diff --git a/open-sse/services/codexVerbosity.ts b/open-sse/services/codexVerbosity.ts index 8b530038c6..96cc441faa 100644 --- a/open-sse/services/codexVerbosity.ts +++ b/open-sse/services/codexVerbosity.ts @@ -9,9 +9,8 @@ * * This helper runs on the translated path (before the allowlist) and folds whichever * shape arrived into a single, validated `text:{verbosity}`. `text` is then added to - * the allowlist so the hint survives. Non-verbosity `text` keys (e.g. a stray - * `text.format`) are intentionally dropped — they were already stripped by the - * pre-existing allowlist, so this preserves the status quo while adding verbosity. + * the allowlist so the hint survives. Other Responses `text` keys, such as + * `text.format` for structured output, are preserved. * * Ref: OpenAI GPT-5 docs (text.verbosity), Azure Foundry reasoning guide. */ @@ -33,8 +32,8 @@ function normalizeLevel(value: unknown): string | undefined { /** * Mutates `body` in place: resolves verbosity from `text.verbosity` (Responses) or * the top-level `verbosity` (Chat Completions, which takes precedence when both are - * present), drops the Chat-only top-level field, and collapses `text` to - * `{verbosity}` when valid or removes it otherwise. + * present), drops the Chat-only top-level field, and preserves any other existing + * Responses `text` configuration. */ export function normalizeCodexVerbosity(body: Record): void { const textRecord = asRecord(body.text); @@ -45,8 +44,15 @@ export function normalizeCodexVerbosity(body: Record): void { delete body.verbosity; + const nextText = textRecord ? { ...textRecord } : {}; if (verbosity) { - body.text = { verbosity }; + nextText.verbosity = verbosity; + } else { + delete nextText.verbosity; + } + + if (Object.keys(nextText).length > 0) { + body.text = nextText; } else { delete body.text; } diff --git a/open-sse/translator/request/openai-responses/toResponses.ts b/open-sse/translator/request/openai-responses/toResponses.ts index 5cb12419f5..d52608496f 100644 --- a/open-sse/translator/request/openai-responses/toResponses.ts +++ b/open-sse/translator/request/openai-responses/toResponses.ts @@ -17,6 +17,28 @@ import { normalizeResponsesReasoningEffort, } from "./helpers.ts"; +// Chat Completions `response_format: { type: "json_schema" }` → Responses API `text.format`. +// Merges into any existing `result.text` (e.g. verbosity) so structured-output schemas from +// Chat clients survive the translation to the Responses/Codex upstream (#5933). +function mapChatResponseFormatToResponsesText(body: JsonRecord, result: JsonRecord): void { + const responseFormat = toRecord(body.response_format); + if (responseFormat.type !== "json_schema") return; + + const jsonSchema = toRecord(responseFormat.json_schema); + if (jsonSchema.schema === undefined) return; + + const existingText = toRecord(result.text); + const format: JsonRecord = { + type: "json_schema", + name: toString(jsonSchema.name, "codex_output_schema"), + schema: jsonSchema.schema, + }; + if (jsonSchema.description !== undefined) format.description = jsonSchema.description; + if (jsonSchema.strict !== undefined) format.strict = jsonSchema.strict; + + result.text = { ...existingText, format }; +} + export function openaiToOpenAIResponsesRequest( model: unknown, body: unknown, @@ -301,6 +323,7 @@ export function openaiToOpenAIResponsesRequest( result.max_output_tokens = root.max_tokens; } if (root.top_p !== undefined) result.top_p = root.top_p; + mapChatResponseFormatToResponsesText(root, result); // GPT-5 verbosity: Chat Completions `verbosity` → Responses `text.verbosity`. const chatVerbosity = normalizeVerbosity(root.verbosity); if (chatVerbosity) { diff --git a/tests/unit/codex-verbosity.test.ts b/tests/unit/codex-verbosity.test.ts index b23814c04d..7869f5aea9 100644 --- a/tests/unit/codex-verbosity.test.ts +++ b/tests/unit/codex-verbosity.test.ts @@ -37,10 +37,19 @@ test("invalid verbosity is dropped and text removed", () => { assert.equal(body.verbosity, undefined); }); -test("no verbosity + stray non-verbosity text → text removed (status quo)", () => { - const body: Record = { text: { format: { type: "json" } }, input: [] }; +test("no verbosity + Responses text.format is preserved", () => { + const format = { type: "json_schema", name: "schema", schema: { type: "object" } }; + const body: Record = { text: { format }, input: [] }; normalizeCodexVerbosity(body); - assert.equal(body.text, undefined); + assert.deepEqual(body.text, { format }); +}); + +test("verbosity is merged with existing Responses text.format", () => { + const format = { type: "json_schema", name: "schema", schema: { type: "object" } }; + const body: Record = { verbosity: "low", text: { format }, input: [] }; + normalizeCodexVerbosity(body); + assert.deepEqual(body.text, { format, verbosity: "low" }); + assert.equal(body.verbosity, undefined); }); test("verbosity is normalized case-insensitively", () => { diff --git a/tests/unit/translator-openai-responses-req.test.ts b/tests/unit/translator-openai-responses-req.test.ts index 705ff6c38f..87e5c97f43 100644 --- a/tests/unit/translator-openai-responses-req.test.ts +++ b/tests/unit/translator-openai-responses-req.test.ts @@ -393,6 +393,81 @@ test("Chat -> Responses converts messages, tool calls, tool outputs, tools and p assert.equal((result as any).top_p, 0.9); }); +test("Chat -> Responses converts json_schema response_format to text.format", () => { + const schema = { + type: "object", + additionalProperties: false, + properties: { answer: { type: "string" } }, + required: ["answer"], + }; + + const result = openaiToOpenAIResponsesRequest( + "gpt-5.2-codex", + { + messages: [{ role: "user", content: "Return the answer as JSON" }], + response_format: { + type: "json_schema", + json_schema: { + name: "answer_schema", + description: "A structured answer", + strict: true, + schema, + }, + }, + }, + false, + null + ) as any; + + assert.deepEqual(result.text, { + format: { + type: "json_schema", + name: "answer_schema", + description: "A structured answer", + strict: true, + schema, + }, + }); + assert.equal(result.response_format, undefined); +}); + +test("Chat -> Responses uses response_format over nonstandard chat text.format", () => { + const result = openaiToOpenAIResponsesRequest( + "gpt-5.2-codex", + { + messages: [{ role: "user", content: "Return JSON" }], + text: { + format: { type: "json_schema", name: "nonstandard", schema: { type: "object" } }, + verbosity: "low", + }, + response_format: { + type: "json_schema", + json_schema: { name: "chat", schema: { type: "object", properties: {} } }, + }, + }, + false, + null + ) as any; + + assert.deepEqual(result.text, { + format: { type: "json_schema", name: "chat", schema: { type: "object", properties: {} } }, + }); +}); + +test("Chat -> Responses ignores nonstandard chat text.format without response_format", () => { + const result = openaiToOpenAIResponsesRequest( + "gpt-5.2-codex", + { + messages: [{ role: "user", content: "Return JSON" }], + text: { format: { type: "json_schema", name: "nonstandard", schema: { type: "object" } } }, + }, + false, + null + ) as any; + + assert.equal(result.text, undefined); +}); + test("Responses round-trip preserves store and previous_response_id when opt-in is enabled", () => { const credentials = { providerSpecificData: {