From 0fb6b617756badee42d1cb39696359daae421d02 Mon Sep 17 00:00:00 2001 From: devmello Date: Mon, 3 Aug 2026 21:48:49 -0700 Subject: [PATCH 1/3] feat(responses): carry text.format into parsed request options --- src/responses/parser.ts | 36 +++++++++++++++++++++++--------- src/server/responses/core.ts | 3 +++ src/types.ts | 14 +++++++++++++ tests/kiro-adapter.test.ts | 5 +++++ tests/responses-parser.test.ts | 38 ++++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 10 deletions(-) diff --git a/src/responses/parser.ts b/src/responses/parser.ts index a06cd8aab..6afe4057c 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/responses/core.ts b/src/server/responses/core.ts index 00f85dc1c..b13d1832b 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1664,6 +1664,9 @@ 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. + delete parsed.options.textFormat; parsed.context.messages.push({ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() }); } diff --git a/src/types.ts b/src/types.ts index faceb9968..125081870 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/kiro-adapter.test.ts b/tests/kiro-adapter.test.ts index 2cc49fb38..779816a55 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/responses-parser.test.ts b/tests/responses-parser.test.ts index 576a88966..a6d3da5ab 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", From 0c815524d8d060ead8587a73d8454f4ab0171090 Mon Sep 17 00:00:00 2001 From: devmello Date: Mon, 3 Aug 2026 21:49:47 -0700 Subject: [PATCH 2/3] feat(openai-chat): map text.format onto response_format --- src/adapters/openai-chat.ts | 20 +++++++++++ tests/openai-chat-hardening.test.ts | 51 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index d8fbab0f1..dcdf63d1c 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -823,6 +823,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/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index 3e6f726c5..53823902b 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(); + }); +}); From db284da38a83a18c2f3254489996e3e02bdab7aa Mon Sep 17 00:00:00 2001 From: devmello Date: Mon, 3 Aug 2026 21:52:23 -0700 Subject: [PATCH 3/3] feat(chat): forward response_format to routed openai-chat models --- docs/github-copilot-app.md | 7 ++-- src/server/chat-completions.ts | 4 -- tests/chat-completions-endpoint.test.ts | 48 ++++++++++++++++++---- tests/responses-compaction-routing.test.ts | 24 +++++++++++ 4 files changed, 68 insertions(+), 15 deletions(-) diff --git a/docs/github-copilot-app.md b/docs/github-copilot-app.md index 876f12454..481de76ca 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/server/chat-completions.ts b/src/server/chat-completions.ts index a4489d591..30309aedb 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -131,10 +131,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/tests/chat-completions-endpoint.test.ts b/tests/chat-completions-endpoint.test.ts index 765bc9c51..f22b459ee 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/responses-compaction-routing.test.ts b/tests/responses-compaction-routing.test.ts index d6cd644b4..b5643fcb0 100644 --- a/tests/responses-compaction-routing.test.ts +++ b/tests/responses-compaction-routing.test.ts @@ -418,6 +418,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) => {