diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 48ba9a0784..1b8446d1f1 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -80,8 +80,8 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `authMode?` | `"key" \| "forward" \| "oauth" \| "local"` | Authentication mode (default `key`). OAuth/subscription credentials are stored outside `config.json`; `local` is limited to providers whose registry entry permits it. | | `codexAccountMode?` | `"pool" \| "direct"` | Canonical `openai` only; defaults to Pool. Direct bypasses pool state. | | `refreshPolicy?` | `"proactive" \| "lazy-only" \| "disabled"` | Override this OAuth provider's Token Guardian policy. | -| `reasoningEfforts?` | `string[]` | Provider-wide Codex reasoning labels to advertise and send. | -| `modelReasoningEfforts?` | `Record` | Per-model labels. An empty list hides effort control. | +| `reasoningEfforts?` | `string[]` | Provider-wide Codex reasoning labels to advertise and send. For `google`-adapter providers, a configured ladder also asserts `thinkingLevel` capability: direct and Vertex non-image requests send the selected effort as `generationConfig.thinkingConfig.thinkingLevel`, while Cloud Code Assist uses its envelope-specific path. | +| `modelReasoningEfforts?` | `Record` | Per-model labels. An empty list hides effort control. As with `reasoningEfforts`, each configured `google`-adapter ladder asserts `thinkingLevel` capability; direct and Vertex non-image requests use the flat Gemini path, while Cloud Code Assist sends it under its request envelope. | | `modelSupportsReasoningSummaries?` | `Record` | Set a model to `false` to stop advertising summaries and strip summary-delivery fields. | | `modelReasoningSummaryDelivery?` | `Record` | Per-model Responses delivery enum; rewrites an existing delivery field. | | `modelAdapters?` | `Record` | Per-model `openai-chat` or `openai-responses` wire override for mixed-wire gateways. Explicit entries beat registry defaults; DeepSeek's preset can select native Responses for `deepseek-v4-flash`, and GitHub Copilot declares Responses-only defaults for its GPT-5 family (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) because those models reject `/chat/completions` for agent traffic. Models without a built-in default (for example `gpt-5.4-nano`) can be opted in here. Single-wire upstream pins and canonical ChatGPT forward reject overrides. | 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/src/adapters/google.ts b/src/adapters/google.ts index 93366a2abe..9f97b6ac11 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -30,7 +30,7 @@ import { type TranslatorBudget, } from "../lib/translator-budget"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; -import { mapReasoningEffort } from "../reasoning-effort"; +import { configuredReasoningEfforts, mapReasoningEffort } from "../reasoning-effort"; // Google-family models (Gemini/Vertex/Antigravity) tend to emit long running commentary between // tool calls. This steers them to keep the BETWEEN-STEP text to one line and reason internally @@ -340,12 +340,22 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (parsed.options.temperature !== undefined) generationConfig.temperature = parsed.options.temperature; if (parsed.options.topP !== undefined) generationConfig.topP = parsed.options.topP; if (parsed.options.stopSequences) generationConfig.stopSequences = parsed.options.stopSequences; - const directFlashThinking = provider.googleMode !== "vertex" - && provider.googleMode !== "cloud-code-assist" - && (parsed.modelId === "gemini-3.5-flash" || parsed.modelId === "gemini-3.6-flash") + // Effort → thinkingLevel follows the configured ladder: any model advertising reasoning + // efforts (registry preset or user config) sends the mapped level, so a picker-selected + // effort actually reaches the wire (gemini-3.1-pro-preview ships a ladder). The original + // gemini-3.5/3.6-flash direct-mode slice stays hardcoded so unladdered configs keep their + // current behavior; Vertex participates only through an explicitly configured ladder (the + // seed google-vertex entry ships none). Image models are excluded — thinkingConfig would + // suppress the responseModalities fallback below. CCA maps effort on its envelope path. + const thinkingEligible = provider.googleMode !== "cloud-code-assist" + && !isImageCapableModel(parsed.modelId) + && (configuredReasoningEfforts(provider, parsed.modelId) !== undefined + || (provider.googleMode !== "vertex" + && (parsed.modelId === "gemini-3.5-flash" || parsed.modelId === "gemini-3.6-flash"))); + const thinkingLevel = thinkingEligible ? mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning) : undefined; - if (directFlashThinking) generationConfig.thinkingConfig = { thinkingLevel: directFlashThinking }; + if (thinkingLevel) generationConfig.thinkingConfig = { thinkingLevel }; if (!generationConfig.thinkingConfig && isImageCapableModel(parsed.modelId)) { generationConfig.responseModalities = ["TEXT", "IMAGE"]; } diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 577d268d2b..7e8aff56c5 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") { + body.response_format = { + type: "json_schema", + json_schema: { + name: textFormat.name ?? "response", + ...(textFormat.description !== undefined ? { description: textFormat.description } : {}), + ...(textFormat.schema !== undefined ? { 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 8d877b66b0..7ef8f98c89 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/google-hardening.test.ts b/tests/google-hardening.test.ts index e1d4e31e5c..c2c4ebcd42 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -314,6 +314,95 @@ describe("google provider hardening", () => { expect(JSON.parse(antigravity.body).request.generationConfig).toBeUndefined(); }); + test("provider-wide effort ladder drives thinkingLevel for a non-image model", async () => { + const direct = createGoogleAdapter(provider({ + reasoningEfforts: ["low", "medium", "high"], + })); + const request = await direct.buildRequest({ + ...parsed(), + modelId: "gemini-3.1-pro-preview", + options: { reasoning: "high" }, + }); + + expect(JSON.parse(request.body).generationConfig.thinkingConfig).toEqual({ thinkingLevel: "high" }); + }); + + test("effort ladder drives thinkingLevel beyond the flash slice", async () => { + const direct = createGoogleAdapter(provider({ + modelReasoningEfforts: { "gemini-3.1-pro-preview": ["low", "medium", "high"] }, + })); + const proHigh = await direct.buildRequest({ + ...parsed(), + modelId: "gemini-3.1-pro-preview", + options: { reasoning: "high" }, + }); + const proMinimal = await direct.buildRequest({ + ...parsed(), + modelId: "gemini-3.1-pro-preview", + options: { reasoning: "minimal" }, + }); + const proUnset = await direct.buildRequest({ + ...parsed(), + modelId: "gemini-3.1-pro-preview", + }); + const unladdered = await direct.buildRequest({ + ...parsed(), + modelId: "gemini-3.5-flash-lite", + options: { reasoning: "high" }, + }); + + expect(JSON.parse(proHigh.body).generationConfig.thinkingConfig).toEqual({ thinkingLevel: "high" }); + // minimal is not on the pro-preview ladder; the clamp lands on the nearest supported tier. + expect(JSON.parse(proMinimal.body).generationConfig.thinkingConfig).toEqual({ thinkingLevel: "low" }); + expect(JSON.parse(proUnset.body).generationConfig).toBeUndefined(); + expect(JSON.parse(unladdered.body).generationConfig).toBeUndefined(); + }); + + test("Vertex sends thinkingLevel only when a ladder is explicitly configured", async () => { + const frozen = createGoogleAdapter(provider({ googleMode: "vertex" })); + const opted = createGoogleAdapter(provider({ + googleMode: "vertex", + modelReasoningEfforts: { "gemini-3-pro": ["low", "medium", "high"] }, + })); + const withoutLadder = await frozen.buildRequest({ + ...parsed(), + modelId: "gemini-3.5-flash", + options: { reasoning: "high" }, + }); + const withLadder = await opted.buildRequest({ + ...parsed(), + modelId: "gemini-3-pro", + options: { reasoning: "high" }, + }); + + expect(JSON.parse(withoutLadder.body).generationConfig).toBeUndefined(); + expect(JSON.parse(withLadder.body).generationConfig.thinkingConfig).toEqual({ thinkingLevel: "high" }); + }); + + test("unladdered direct flash keeps its hardcoded thinking slice", async () => { + const bare = createGoogleAdapter(provider()); + const flash = await bare.buildRequest({ + ...parsed(), + modelId: "gemini-3.6-flash", + options: { reasoning: "medium" }, + }); + + expect(JSON.parse(flash.body).generationConfig.thinkingConfig).toEqual({ thinkingLevel: "medium" }); + }); + + test("image models keep responseModalities even with a provider-wide effort ladder", async () => { + const direct = createGoogleAdapter(provider({ reasoningEfforts: ["low", "high"] })); + const image = await direct.buildRequest({ + ...parsed(), + modelId: "gemini-3.1-flash-image", + options: { reasoning: "high" }, + }); + + const generationConfig = JSON.parse(image.body).generationConfig; + expect(generationConfig.thinkingConfig).toBeUndefined(); + expect(generationConfig.responseModalities).toEqual(["TEXT", "IMAGE"]); + }); + test("publishes audited AI Studio metadata while Vertex stays frozen", () => { const google = PROVIDER_REGISTRY.find(entry => entry.id === "google"); const vertex = PROVIDER_REGISTRY.find(entry => entry.id === "google-vertex"); 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..77a8efa144 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -340,3 +340,61 @@ 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", () => { + const plain = createOpenAIChatAdapter(provider()).buildRequest(parsed()); + + expect(bodyOf(plain).response_format).toBeUndefined(); + }); + + test("preserves a schema-less json_schema response_format", () => { + const schemaless = createOpenAIChatAdapter(provider()).buildRequest({ + ...parsed(), + options: { textFormat: { type: "json_schema", name: "answer" } }, + }); + + expect(bodyOf(schemaless).response_format).toEqual({ + type: "json_schema", + json_schema: { name: "answer" }, + }); + }); +}); 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); + } + }); });