Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>` | 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<string, string[]>` | 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<string, boolean>` | Set a model to `false` to stop advertising summaries and strip summary-delivery fields. |
| `modelReasoningSummaryDelivery?` | `Record<string, "sequential" \| "sequential_cutoff" \| "concurrent" \| "concurrent_cutoff">` | Per-model Responses delivery enum; rewrites an existing delivery field. |
| `modelAdapters?` | `Record<string, string>` | 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. |
Expand Down
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 15 additions & 5 deletions src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"];
}
Expand Down
20 changes: 20 additions & 0 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 26 additions & 10 deletions src/responses/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, unknown> } : {}),
...(typeof f.strict === "boolean" ? { strict: f.strict } : {}),
};
}
4 changes: 0 additions & 4 deletions src/server/chat-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
9 changes: 9 additions & 0 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).text;
}
parsed.context.messages.push({ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() });
}

Expand Down
14 changes: 14 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
strict?: boolean;
};
}

export type OcxMessagePhase = "commentary" | "final_answer";
Expand Down
48 changes: 40 additions & 8 deletions tests/chat-completions-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand Down
Loading
Loading