diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index bc65fc4bbc..5e0996ef2b 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -138,6 +138,13 @@ non-empty `messages` array. It translates system, user, assistant, and tool mess Responses items; translates function tools, tool choice, images, reasoning effort, and supported response formats; runs the normal Responses routing pipeline; then translates the result back. +Structured output is part of that translation: `response_format` with `json_object` or +`json_schema` is forwarded to routed `openai-chat` models. On `POST /v1/responses` the +equivalent request field is `text.format`: native Responses routes preserve it in the raw +Responses body, and it is translated to `response_format` when the model routes to an +`openai-chat` provider. A backend without structured-output support returns its own error +instead of the proxy rejecting the request locally. + Non-streaming output has `object: "chat.completion"`. Streaming output uses SSE objects with `object: "chat.completion.chunk"`, choice deltas, a terminal choice with `finish_reason`, and `data: [DONE]`. Tool-call and usage information are translated back where the source events carry diff --git a/docs/github-copilot-app.md b/docs/github-copilot-app.md index 876f12454d..481de76caf 100644 --- a/docs/github-copilot-app.md +++ b/docs/github-copilot-app.md @@ -50,9 +50,10 @@ existing providers, routing, OAuth, and sidecars apply. The compatibility surface supports `model`, `messages`, `stream`, function tools and tool choice, token limits, temperature/top-p/stop, reasoning effort, parallel tool calls, prompt cache keys, metadata, and `response_format` on native Responses -routes. Routed `openai-chat` models reject `response_format` with HTTP 400 because -their structured-output support is not verified. Other Chat Completions fields, -including penalties, `n`, and logprobs, are not currently supported. +routes and routed `openai-chat` models (`json_object` and `json_schema` are +forwarded as-is; a backend without structured-output support returns its own +error). Other Chat Completions fields, including penalties, `n`, and logprobs, +are not currently supported. ## Troubleshooting diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 577d268d2b..fe459616ae 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -818,6 +818,26 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (provider.promptCacheKey && parsed.options.promptCacheKey !== undefined) { body.prompt_cache_key = parsed.options.promptCacheKey; } + // Responses `text.format` -> chat `response_format`. json_object maps 1:1; json_schema + // re-nests the flattened Responses fields under `json_schema` — the exact inverse of + // responseFormatToText in src/chat/inbound.ts. Forwarded unconditionally (like `stop`): + // response_format is a first-class Chat Completions field, it is only present when the + // caller explicitly asked for structured output, and a backend that rejects it should + // fail loud rather than silently return prose the caller will try to JSON.parse. + const textFormat = parsed.options.textFormat; + if (textFormat?.type === "json_object") { + body.response_format = { type: "json_object" }; + } else if (textFormat?.type === "json_schema" && textFormat.schema !== undefined) { + body.response_format = { + type: "json_schema", + json_schema: { + name: textFormat.name ?? "response", + ...(textFormat.description !== undefined ? { description: textFormat.description } : {}), + schema: textFormat.schema, + ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}), + }, + }; + } if (tools) { // Default-ON for chat-completions providers (user decision 260709): the buffered diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 5ed43ba381..8bb40769a3 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1048,7 +1048,8 @@ function stripInputImagesDeep(value: unknown): unknown { */ function buildRoutedCompactionBody(body: unknown): unknown { if (!isPlainObject(body)) return body; - const { tools: _tools, tool_choice: _toolChoice, parallel_tool_calls: _parallel, ...rest } = body; + // `text` goes with the tool fields: the summary must be prose, not schema-constrained JSON. + const { tools: _tools, tool_choice: _toolChoice, parallel_tool_calls: _parallel, text: _text, ...rest } = body; const input = Array.isArray(body.input) ? body.input : []; const kept = input.filter(item => !isPlainObject(item) // `additional_tools` is how Codex Desktop's responses-lite shape carries tools; diff --git a/src/responses/parser.ts b/src/responses/parser.ts index a06cd8aabc..6afe4057c4 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -668,9 +668,12 @@ export function parseRequest(body: unknown): OcxParsedRequest { ...(data.tools as unknown[] ?? []), ...loadedToolSpecs, ]); - // Detect structured-output mode (Responses `text.format`) so the web-search sidecar can render its - // tool_result as JSON rather than prose that could corrupt the model's schema-constrained answer. - const structuredOutput = detectStructuredOutput(data.text); + // Capture structured-output mode (Responses `text.format`): the format object rides + // options.textFormat for adapters whose wire has an equivalent (openai-chat response_format), + // while the `_structuredOutput` flag keeps the web-search sidecar rendering its tool_result + // as JSON rather than prose that could corrupt the model's schema-constrained answer. + const textFormat = parseTextFormat(data.text); + if (textFormat) options.textFormat = textFormat; return { modelId: data.model, @@ -682,17 +685,30 @@ export function parseRequest(body: unknown): OcxParsedRequest { ...(replayedInputPrefixLength > 0 ? { _replayPrefixLen: replayedInputPrefixLength } : {}), ...(webSearch ? { _webSearch: webSearch } : {}), ...(imageGen ? { _imageGeneration: imageGen } : {}), - ...(structuredOutput ? { _structuredOutput: true } : {}), + ...(textFormat ? { _structuredOutput: true } : {}), ...(compactionRequest ? { _compactionRequest: true } : {}), ...(contextCompactionBoundary ? { _contextCompactionBoundary: true } : {}), }; } -/** True when the Responses `text.format` requests structured output (json_schema or json_object). */ -function detectStructuredOutput(text: unknown): boolean { - if (!isObj(text)) return false; +/** + * The Responses `text.format` object when it requests structured output (json_schema or + * json_object), undefined otherwise. Acceptance is identical to the boolean detector this + * replaces; unknown or malformed formats are ignored, never rejected, so the native + * passthrough keeps forwarding whatever the caller sent via `_rawBody`. + */ +function parseTextFormat(text: unknown): OcxRequestOptions["textFormat"] { + if (!isObj(text)) return undefined; const format = (text as { format?: unknown }).format; - if (!isObj(format)) return false; - const t = (format as { type?: unknown }).type; - return t === "json_schema" || t === "json_object"; + if (!isObj(format)) return undefined; + const f = format as { type?: unknown; name?: unknown; description?: unknown; schema?: unknown; strict?: unknown }; + if (f.type === "json_object") return { type: "json_object" }; + if (f.type !== "json_schema") return undefined; + return { + type: "json_schema", + ...(typeof f.name === "string" ? { name: f.name } : {}), + ...(typeof f.description === "string" ? { description: f.description } : {}), + ...(isObj(f.schema) ? { schema: f.schema as Record } : {}), + ...(typeof f.strict === "boolean" ? { strict: f.strict } : {}), + }; } diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 3ea74e9724..20a621b4a2 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -133,10 +133,6 @@ async function handleChatCompletionsWithBudget( } else if (internalBody.store === undefined) { internalBody.store = false; } - if (route.provider.adapter === "openai-chat" && internalBody.text !== undefined) { - if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" }); - return chatCompletionsErrorResponse(400, "response_format is not supported for routed openai-chat models"); - } if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") { const raw = chatBody as Rec; const parts: string[] = []; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 2d00ab0c43..465f3ef452 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1741,6 +1741,15 @@ async function handleResponsesInner( delete parsed._webSearch; delete parsed.options.toolChoice; delete parsed.options.parallelToolCalls; + // The compaction turn is a plain prose summary; a surviving structured-output format + // would force schema-constrained JSON into the synthetic compaction item. The flag and + // the raw `text` controls go too: Kiro's capability guard reads both and would reject + // the turn outright, and the key-mode openai-responses adapter builds from _rawBody. + delete parsed.options.textFormat; + delete parsed._structuredOutput; + if (parsed._rawBody && typeof parsed._rawBody === "object") { + delete (parsed._rawBody as Record).text; + } parsed.context.messages.push({ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() }); } diff --git a/src/types.ts b/src/types.ts index d38fbc1896..6ac4342725 100644 --- a/src/types.ts +++ b/src/types.ts @@ -233,6 +233,20 @@ export interface OcxRequestOptions { frequencyPenalty?: number; /** Responses prompt-cache affinity key. Passthrough preserves it via _rawBody; routed adapters do not consume it unless their upstream wire supports it. */ promptCacheKey?: string; + /** + * Responses `text.format` (json_schema / json_object), preserved for adapters whose + * upstream wire has an equivalent. The openai-chat adapter re-nests it as chat + * `response_format`, the exact inverse of responseFormatToText in src/chat/inbound.ts. + * The native passthrough ignores it (it forwards `_rawBody.text` verbatim) and Kiro + * keeps rejecting structured output via `_structuredOutput`. + */ + textFormat?: { + type: "json_schema" | "json_object"; + name?: string; + description?: string; + schema?: Record; + strict?: boolean; + }; } export type OcxMessagePhase = "commentary" | "final_answer"; diff --git a/tests/chat-completions-endpoint.test.ts b/tests/chat-completions-endpoint.test.ts index 765bc9c51a..f22b459ee6 100644 --- a/tests/chat-completions-endpoint.test.ts +++ b/tests/chat-completions-endpoint.test.ts @@ -467,8 +467,8 @@ test("responsesSseToChatCompletionsSse delivers the first frame before a macrota await reader.cancel(); }); -test("POST /v1/chat/completions rejects response_format for routed openai-chat", async () => { - const upstream = mockChatUpstream(); +test("POST /v1/chat/completions forwards response_format to routed openai-chat", async () => { + const { server: upstream, captured } = mockChatUpstreamCapturing(); saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); const server = startServer(0); try { @@ -477,15 +477,47 @@ test("POST /v1/chat/completions rejects response_format for routed openai-chat", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "mock/test-model", - stream: false, + stream: true, messages: [{ role: "user", content: "hi" }], - response_format: { type: "json_object" }, + response_format: { type: "json_schema", json_schema: { name: "answer", schema: { type: "object" }, strict: true } }, }), }); - expect(response.status).toBe(400); - const json = await response.json() as { error: { message: string; type: string } }; - expect(json.error.message).toContain("response_format"); - expect(json.error.type).toBe("invalid_request_error"); + expect(response.status).toBe(200); + await response.text(); + // Round trip: chat nested -> internal flat text.format -> re-nested on the wire, byte-identical. + expect(captured.length).toBe(1); + expect(captured[0]!.response_format).toEqual({ + type: "json_schema", + json_schema: { name: "answer", schema: { type: "object" }, strict: true }, + }); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + +test("POST /v1/responses carries text.format onto the routed chat wire", async () => { + const { server: upstream, captured } = mockChatUpstreamCapturing(); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", + stream: true, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], + text: { format: { type: "json_schema", name: "answer", schema: { type: "object" }, strict: true } }, + }), + }); + expect(response.status).toBe(200); + await response.text(); + expect(captured.length).toBe(1); + expect(captured[0]!.response_format).toEqual({ + type: "json_schema", + json_schema: { name: "answer", schema: { type: "object" }, strict: true }, + }); } finally { await server.stop(true); upstream.stop(true); diff --git a/tests/kiro-adapter.test.ts b/tests/kiro-adapter.test.ts index 2cc49fb382..779816a559 100644 --- a/tests/kiro-adapter.test.ts +++ b/tests/kiro-adapter.test.ts @@ -844,6 +844,11 @@ describe("kiro adapter — buildRequest", () => { } as OcxParsedRequest)).rejects.toThrow(/Kiro (supports only|does not support)/); } + await expect(createKiroAdapter(provider).buildRequest({ + ...parsedWith([{ role: "user", content: "hi" }], [bashTool]), + _structuredOutput: true, + } as OcxParsedRequest)).rejects.toThrow("Kiro does not support Responses text controls or structured output"); + const none = { ...parsedWith([{ role: "user", content: "hi" }], [bashTool]), options: { toolChoice: "none" } } as OcxParsedRequest; const current = JSON.parse((await createKiroAdapter(provider).buildRequest(none)).body).conversationState.currentMessage.userInputMessage; expect(current.userInputMessageContext?.tools).toBeUndefined(); diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index 3e6f726c5e..53823902b5 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -340,3 +340,54 @@ describe("openai-chat max output defaults", () => { expect(body.thinking_budget).toBe(15_000); }); }); + +describe("openai-chat response_format emission", () => { + const bodyOf = (req: { body?: unknown }): Record => + JSON.parse(req.body as string) as Record; + + test("maps textFormat json_object onto response_format", () => { + const req = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + options: { textFormat: { type: "json_object" } }, + }); + + expect(bodyOf(req).response_format).toEqual({ type: "json_object" }); + }); + + test("re-nests textFormat json_schema as chat response_format", () => { + const req = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + options: { + textFormat: { type: "json_schema", name: "answer", description: "shape", schema: { type: "object" }, strict: true }, + }, + }); + + expect(bodyOf(req).response_format).toEqual({ + type: "json_schema", + json_schema: { name: "answer", description: "shape", schema: { type: "object" }, strict: true }, + }); + }); + + test("defaults the json_schema name when the Responses form omits it", () => { + const req = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + options: { textFormat: { type: "json_schema", schema: { type: "object" } } }, + }); + + expect(bodyOf(req).response_format).toEqual({ + type: "json_schema", + json_schema: { name: "response", schema: { type: "object" } }, + }); + }); + + test("omits response_format without a textFormat option or without a schema", () => { + const plain = createOpenAIChatAdapter(provider()).buildRequest(parsed()); + const schemaless = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + options: { textFormat: { type: "json_schema", name: "answer" } }, + }); + + expect(bodyOf(plain).response_format).toBeUndefined(); + expect(bodyOf(schemaless).response_format).toBeUndefined(); + }); +}); diff --git a/tests/responses-compaction-routing.test.ts b/tests/responses-compaction-routing.test.ts index 4aedbe110c..9a5bec4eee 100644 --- a/tests/responses-compaction-routing.test.ts +++ b/tests/responses-compaction-routing.test.ts @@ -398,7 +398,9 @@ describe("routed compaction for key-mode openai-responses (#422)", () => { }) as typeof fetch; const res = await handleResponses( - compactionRequest(baseCompactionBody()), + compactionRequest(baseCompactionBody({ + text: { format: { type: "json_schema", name: "answer", schema: { type: "object" } } }, + })), keyProviderConfig(), { model: "", provider: "" }, ); @@ -411,6 +413,8 @@ describe("routed compaction for key-mode openai-responses (#422)", () => { expect(sent.tools).toBeUndefined(); expect(sent.tool_choice).toBeUndefined(); expect(sent.parallel_tool_calls).toBeUndefined(); + // The summarizer must stay prose: a surviving text.format would force schema JSON. + expect(sent.text).toBeUndefined(); expect(JSON.stringify(input)).toContain("CONTEXT CHECKPOINT COMPACTION"); const json = await res.json() as { output?: Array<{ type?: string }> }; @@ -418,6 +422,30 @@ describe("routed compaction for key-mode openai-responses (#422)", () => { expect(compactionItems.length).toBe(1); }); + test("routed chat compaction drops the structured-output format", async () => { + const bodies: Array> = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return jsonResponse({ + choices: [{ index: 0, message: { role: "assistant", content: "handoff summary" }, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + }); + }) as typeof fetch; + + const res = await handleResponses( + compactionRequest(baseCompactionBody({ text: { format: { type: "json_object" } } })), + keyProviderConfig({ adapter: "openai-chat" }), + { model: "", provider: "" }, + ); + + expect(bodies.length).toBe(1); + // The compaction turn is a prose summary; the caller's structured-output request must not + // constrain it (core.ts routedCompaction deletes options.textFormat). + expect(bodies[0]!.response_format).toBeUndefined(); + const json = await res.json() as { output?: Array<{ type?: string }> }; + expect((json.output ?? []).filter(item => item.type === "compaction").length).toBe(1); + }); + test("strips additional_tools even when top-level tools are absent", async () => { const bodies: Array> = []; globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { diff --git a/tests/responses-parser.test.ts b/tests/responses-parser.test.ts index 576a889661..a6d3da5ab3 100644 --- a/tests/responses-parser.test.ts +++ b/tests/responses-parser.test.ts @@ -143,6 +143,44 @@ describe("Responses parser", () => { expect(parsed.options.promptCacheKey).toBe("project-cache-v1"); }); + test("carries text.format json_schema into options.textFormat and flags structured output", () => { + const parsed = parseRequest({ + model: "gpt-5.5", + input: "structured", + stream: true, + text: { format: { type: "json_schema", name: "answer", description: "shape", schema: { type: "object" }, strict: true } }, + }); + + expect(parsed.options.textFormat).toEqual({ + type: "json_schema", + name: "answer", + description: "shape", + schema: { type: "object" }, + strict: true, + }); + expect(parsed._structuredOutput).toBe(true); + }); + + test("carries text.format json_object and ignores the plain text format", () => { + const jsonObject = parseRequest({ + model: "gpt-5.5", + input: "structured", + stream: true, + text: { format: { type: "json_object" } }, + }); + const plain = parseRequest({ + model: "gpt-5.5", + input: "prose", + stream: true, + text: { format: { type: "text" } }, + }); + + expect(jsonObject.options.textFormat).toEqual({ type: "json_object" }); + expect(jsonObject._structuredOutput).toBe(true); + expect(plain.options.textFormat).toBeUndefined(); + expect(plain._structuredOutput).toBeUndefined(); + }); + test("preserves input_image blocks from function_call_output", () => { const parsed = parseRequest({ model: "kiro/claude-sonnet-4.5", diff --git a/tests/server-kiro-completion-e2e.test.ts b/tests/server-kiro-completion-e2e.test.ts index 803b884cab..78ed9c639c 100644 --- a/tests/server-kiro-completion-e2e.test.ts +++ b/tests/server-kiro-completion-e2e.test.ts @@ -216,4 +216,37 @@ describe("Kiro completion through public server endpoints", () => { upstream.server.stop(true); } }); + + test("routed compaction with text.format summarizes instead of tripping the capability guard", async () => { + const upstream = scriptedKiroUpstream([ + [textFrame("Compaction summary of the earlier turns.")], + ]); + saveConfig(kiroConfig(upstream.server.url.toString())); + const proxy = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", proxy.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "kiro-test/gpt-5.6-sol", + stream: false, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "earlier turn" }] }, + { type: "compaction_trigger" }, + ], + // Routed compaction must strip the structured-output request; before the strip, + // Kiro's capability guard rejected the whole turn as unsupported text controls. + text: { format: { type: "json_schema", name: "answer", schema: { type: "object" } } }, + }), + }); + + expect(response.status).toBe(200); + const json = await response.json() as { output?: Array<{ type?: string }> }; + expect((json.output ?? []).filter(item => item.type === "compaction")).toHaveLength(1); + expect(upstream.requests).toHaveLength(1); + } finally { + await proxy.stop(true); + upstream.server.stop(true); + } + }); });