Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/github-copilot-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines 52 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the public docs site for response_format

Because this change exposes response_format support to OpenAI-compatible clients, updating only docs/github-copilot-app.md leaves the hosted docs-site reference/guides without the new behavior; users reading the public docs still have no indication that routed openai-chat can accept structured output. Add the corresponding docs-site/ update, including locales if relevant, alongside this docs change.

AGENTS.md reference: AGENTS.md:L224-L225

Useful? React with 👍 / 👎.

error). Other Chat Completions fields, including penalties, `n`, and logprobs,
are not currently supported.

## Troubleshooting

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 @@ -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
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 @@ -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[] = [];
Expand Down
3 changes: 3 additions & 0 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +1667 to +1669

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clear _structuredOutput for routed compaction.

Line 1669 removes options.textFormat, but _structuredOutput remains true. A Kiro-routed compaction request with text.format still reaches the Kiro adapter as structured output. The Kiro adapter rejects that flag, so compaction fails instead of returning the required prose summary.

Delete parsed._structuredOutput in this block. Add a Kiro routed-compaction regression test.

Proposed fix
     delete parsed.options.parallelToolCalls;
     delete parsed.options.textFormat;
+    delete parsed._structuredOutput;
     parsed.context.messages.push({ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/responses/core.ts` around lines 1667 - 1669, Clear
parsed._structuredOutput in the routed compaction normalization block alongside
the existing parsed.options.textFormat removal, ensuring the synthetic
compaction request is sent as plain prose to the Kiro adapter. Add a regression
test covering Kiro-routed compaction with text.format and verify it succeeds
with a prose summary.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip text.format from raw compaction bodies

When routed compaction is sent to a noncanonical openai-responses provider, deleting only parsed.options.textFormat does not change the payload: that adapter's buildRequest starts from parsed._rawBody and buildRoutedCompactionBody only removes tools/tool_choice/parallel. With --output-schema/text.format, the compaction summarizer still receives text.format and can be forced to emit schema JSON instead of the prose summary that the synthetic compaction item needs. Strip text from the raw compaction body as well.

Useful? React with 👍 / 👎.

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
5 changes: 5 additions & 0 deletions tests/kiro-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
51 changes: 51 additions & 0 deletions tests/openai-chat-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> =>
JSON.parse(req.body as string) as Record<string, unknown>;

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();
});
});
24 changes: 24 additions & 0 deletions tests/responses-compaction-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> = [];
globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
bodies.push(JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>);
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<Record<string, unknown>> = [];
globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
Expand Down
38 changes: 38 additions & 0 deletions tests/responses-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading