diff --git a/.changeset/audio-binary-output.md b/.changeset/audio-binary-output.md new file mode 100644 index 0000000000..d845649fdb --- /dev/null +++ b/.changeset/audio-binary-output.md @@ -0,0 +1,12 @@ +--- +"cf": patch +--- + +Prepare generated commands to preserve audio API responses + +Recognize `audio/*` responses as binary data in the command generator and its +operation descriptors, including schemas without `format: binary`. Future commands +generated for audio endpoints will write the exact bytes to stdout so they can be +redirected to a file. No commands generated from the currently pinned OpenAPI +specification declare audio responses, so this change does not alter the behavior +of any currently shipped command. diff --git a/packages/cli/generator/plugin/emit/index.ts b/packages/cli/generator/plugin/emit/index.ts index cd846b1c24..159e9a302a 100644 --- a/packages/cli/generator/plugin/emit/index.ts +++ b/packages/cli/generator/plugin/emit/index.ts @@ -182,7 +182,10 @@ export function outputKind(op: OperationInfo): OutputKind { ); } if ( - success.content.some((c) => BINARY.includes(c) || c.startsWith("image/")) + success.content.some( + (c) => + BINARY.includes(c) || c.startsWith("image/") || c.startsWith("audio/") + ) ) { return "binary"; } diff --git a/packages/cli/generator/plugin/operation.ts b/packages/cli/generator/plugin/operation.ts index 18d55ea1fb..0ae871e56f 100644 --- a/packages/cli/generator/plugin/operation.ts +++ b/packages/cli/generator/plugin/operation.ts @@ -705,7 +705,8 @@ function wire(method: Method, info: Omit): Wire { ct === "application/octet-stream" || ct === "application/pdf" || ct === "application/zip" || - ct.startsWith("image/"); + ct.startsWith("image/") || + ct.startsWith("audio/"); if (binary) { noResponse = false; responseType = "binary-response"; diff --git a/packages/cli/src/__tests__/commands/ai-run.test.ts b/packages/cli/src/__tests__/commands/ai-run.test.ts index fc0c9bcbd9..017458ed95 100644 --- a/packages/cli/src/__tests__/commands/ai-run.test.ts +++ b/packages/cli/src/__tests__/commands/ai-run.test.ts @@ -135,12 +135,12 @@ describe("cf ai run", () => { }); } - function rawRunHandler(bytes: Uint8Array) { + function rawRunHandler(bytes: Uint8Array, contentType: string) { return http.post( /\/ai\/run$/, () => new HttpResponse(bytes, { - headers: { "content-type": "audio/wav" }, + headers: { "content-type": contentType }, }) ); } @@ -168,18 +168,22 @@ describe("cf ai run", () => { expect(stdout()).toContain("hi"); }); - it("writes non-JSON model output as exact bytes", async () => { - const bytes = Uint8Array.from([82, 73, 70, 70, 0, 255, 128]); - const write = vi - .spyOn(process.stdout, "write") - .mockImplementation(() => true); - server.use(schemaHandler(), rawRunHandler(bytes)); + it.each(["audio/mpeg", "audio/wav", "image/png", "application/octet-stream"])( + "writes %s model output as exact bytes", + async (contentType) => { + const bytes = Uint8Array.from([82, 73, 70, 70, 0, 255, 128]); + const write = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + server.use(schemaHandler(), rawRunHandler(bytes, contentType)); - const { exitCode } = await run(MODEL, "--prompt", "hello"); + const { exitCode } = await run(MODEL, "--prompt", "hello"); - expect(exitCode).toBe(0); - expect(write).toHaveBeenCalledWith(Buffer.from(bytes)); - }); + expect(exitCode).toBe(0); + expect(write).toHaveBeenCalledWith(Buffer.from(bytes)); + expect(stdout()).toBe(""); + } + ); it("parses array and object flags as JSON values", async () => { server.use( diff --git a/packages/cli/src/__tests__/generator/plugin/emit.test.ts b/packages/cli/src/__tests__/generator/plugin/emit.test.ts index db8d99f8fb..3b983868d5 100644 --- a/packages/cli/src/__tests__/generator/plugin/emit.test.ts +++ b/packages/cli/src/__tests__/generator/plugin/emit.test.ts @@ -172,47 +172,50 @@ describe("bodyless raw response emission", () => { ); }); -describe("protobuf response emission", () => { - it("writes profile bytes directly while sending the JSON request body", () => { - const op = operation( - "post", - "/accounts/{account_id}/workers/workers/{worker_id}/versions/{version_id}/profile", - { - pathParams: [ - param("account_id"), - param("worker_id"), - param("version_id"), - ], - bodyParams: [ - bodyParam("duration_ms", "number", ["duration_ms"], { - required: true, - }), - ], - hasRequestBody: true, - requestContentTypes: ["application/json"], - requestBodyRequired: ["duration_ms"], - responses: [["200", { content: ["application/vnd.google.protobuf"] }]], - } - ); - const ctx = context( - method("profile", "workers.versions.profile"), - "workers", - op - ); - expect(ctx.outputKind).toBe("binary"); - const output = handler(ctx).join("\n"); - expect(output).toContain("fetchRawBytes("); - expect(output).toContain("method: 'POST'"); - expect(output).toContain("body: JSON.stringify(bodyData)"); - expect(output).toContain( - "body: Object.keys(bodyData).length > 0 ? bodyData : undefined" - ); - expect(output).toContain("contentType: 'application/json'"); - expect(output).toContain( - "writeRawOutput(argv.text === true ? __cfRawBytes.toString('utf-8') : __cfRawBytes)" - ); - expect(output).not.toContain("formatOutput("); - }); +describe("binary response emission", () => { + it.each(["application/vnd.google.protobuf", "audio/mpeg", "audio/wav"])( + "writes %s bytes directly while sending the JSON request body", + (contentType) => { + const op = operation( + "post", + "/accounts/{account_id}/workers/workers/{worker_id}/versions/{version_id}/profile", + { + pathParams: [ + param("account_id"), + param("worker_id"), + param("version_id"), + ], + bodyParams: [ + bodyParam("duration_ms", "number", ["duration_ms"], { + required: true, + }), + ], + hasRequestBody: true, + requestContentTypes: ["application/json"], + requestBodyRequired: ["duration_ms"], + responses: [["200", { content: [contentType] }]], + } + ); + const ctx = context( + method("profile", "workers.versions.profile"), + "workers", + op + ); + expect(ctx.outputKind).toBe("binary"); + const output = handler(ctx).join("\n"); + expect(output).toContain("fetchRawBytes("); + expect(output).toContain("method: 'POST'"); + expect(output).toContain("body: JSON.stringify(bodyData)"); + expect(output).toContain( + "body: Object.keys(bodyData).length > 0 ? bodyData : undefined" + ); + expect(output).toContain("contentType: 'application/json'"); + expect(output).toContain( + "writeRawOutput(argv.text === true ? __cfRawBytes.toString('utf-8') : __cfRawBytes)" + ); + expect(output).not.toContain("formatOutput("); + } + ); }); describe("variant prompts", () => { diff --git a/packages/cli/src/__tests__/generator/plugin/forge-model.test.ts b/packages/cli/src/__tests__/generator/plugin/forge-model.test.ts index ad8ba0749d..1b9fe72f28 100644 --- a/packages/cli/src/__tests__/generator/plugin/forge-model.test.ts +++ b/packages/cli/src/__tests__/generator/plugin/forge-model.test.ts @@ -393,6 +393,47 @@ describe("the forge model", () => { expect(code).toContain(".conflicts('id', ['name'])"); }); + it.each(["audio/mpeg", "audio/wav"])( + "preserves %s responses without a binary schema format", + (contentType) => { + const files = generateFiles( + doc({ + "/speech": { + post: { + "x-fern-sdk-group-name": "speech", + "x-fern-sdk-method-name": "create", + summary: "Generate speech", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { prompt: { type: "string" } }, + }, + }, + }, + }, + responses: { + "200": { + description: "Audio bytes", + content: { [contentType]: { schema: { type: "string" } } }, + }, + }, + }, + }, + }) + ); + const command = files.get("speech/create.ts") ?? ""; + expect(command).toContain("fetchRawBytes("); + expect(command).toContain("method: 'POST'"); + expect(command).toContain("writeRawOutput("); + expect(command).not.toContain("formatOutput("); + expect(operation(files, "speech/create")).toContain( + 'response: "binary-response"' + ); + } + ); + it("describes the operation a typed call sends", () => { const textGet = doc({ "/accounts/{account_id}/things/{thing_id}": { diff --git a/packages/cli/src/__tests__/generator/plugin/output-kind.test.ts b/packages/cli/src/__tests__/generator/plugin/output-kind.test.ts index f9b21993ae..01b11f7193 100644 --- a/packages/cli/src/__tests__/generator/plugin/output-kind.test.ts +++ b/packages/cli/src/__tests__/generator/plugin/output-kind.test.ts @@ -8,6 +8,9 @@ describe("outputKind", () => { ["application/octet-stream", "binary"], ["application/zip", "binary"], ["image/png", "binary"], + ["audio/mpeg", "binary"], + ["audio/wav", "binary"], + ["audio/ogg", "binary"], ["text/plain", "text"], ["text/csv", "text"], ["application/json", "json"],