diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6bfadaa1..d9c3caa2f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(api): sanitize error responses on seven management routes (Rule #12 hardening)** — `cli-tools/backups`, `cli-tools/guide-settings/[toolId]`, `logs/export`, `models/catalog`, `providers/test-batch`, `settings/import-json` and `usage/proxy-logs` no longer return raw `error.message`; they wrap caught errors in `sanitizeErrorMessage(...)`, and the routes are removed from the `check-error-helper` allowlist. (thanks @JxnLexn) - **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). - **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). +- **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) --- diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index ccd570650f..f8889d2970 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,6 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "_rebaseline_2026_06_26_5101_responses_textformat": "PR #5101 own growth: open-sse/executors/default.ts 859->876 (+17 = defaultResponsesTextFormat — fills the Responses-API default text.format for openai-compatible-*responses* providers so LM-Studio-style upstreams stop 400ing; guarded, never overwrites an existing format). Irreducible executor chokepoint next to applyJsonSchemaFallback; covered by tests/unit/responses-default-text-format.test.ts.", "_rebaseline_2026_06_26_5117_basereds": "Base-red repair #5117 own growth + sibling drift: open-sse/handlers/responseSanitizer.ts 1122->1139 (+17 = synthesize an output[] message item from an output_text-only Responses body so the answer is not dropped/false-502'd; #4942 regression, covered by tests/unit/response-sanitizer.test.ts). src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx 866->868 (+2 sibling drift from a separately-merged PR, already on the release tip; rebaselined here to green the file-size gate).", "_rebaseline_2026_06_26_leva5": "Leva 5 (26 PRs, release/v3.8.38, tip 7b6a9ed33): own growth from merged PRs — cursor.ts 1474->1577 (#4912 composer inline tool-calls wiring), kiro.ts 814->944 (#4911 inline-thinking splitter wiring), videoGeneration.ts 1083->1265 (#5051 Alibaba DashScope handler), default.ts 835->859 (#4727 client_metadata strip), base.ts 1470->1475 (#4846 anthropic header normalize call), chat.ts 1552->1560 (#5064 self-inflicted-timeout cooldown skip). All covered by per-PR tests; growth is localized feature/fix code next to existing branches.", "_rebaseline_2026_06_26_rc17b_leva4": "Leva 4 (22 PRs): drift dos commits definidores. route.ts (#4931 DGrid), settings.ts (#4852/#4856), chat.ts (#4852/#4858/#4870), antigravity.ts (#4941 retry), codex.ts (#4849), request/openai-responses.ts (#4859/#4862), response/openai-responses.ts (#4862/#4906 dense+custom-tool), kiro.ts NOVO>800 (#4855), openai-to-claude.ts NOVO>800 (#4853); testes: executor-codex (#4849), translator-openai-responses-req (#4859).", @@ -232,7 +233,7 @@ "src/shared/validation/schemas.ts": 2523, "src/sse/handlers/chat.ts": 1560, "src/sse/services/auth.ts": 2336, - "open-sse/executors/default.ts": 859, + "open-sse/executors/default.ts": 876, "open-sse/translator/request/openai-responses.ts": 902, "open-sse/executors/kiro.ts": 944, "open-sse/translator/request/openai-to-claude.ts": 805 diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 5a855e332a..f866e73786 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -620,6 +620,22 @@ export class DefaultExecutor extends BaseExecutor { return { ...record, messages, response_format: { type: "json_object" } } as T; } + // Some Responses-compatible upstreams (e.g. LM Studio) reject a request whose + // `text` is an object missing `text.format` with a 400 missing_required_parameter. + // The Responses API default for that field is { type: "text" }, so default it + // for openai-compatible "responses" providers before forwarding upstream. + defaultResponsesTextFormat(body: T): T { + if (!this.provider?.startsWith?.("openai-compatible-")) return body; + if (!this.provider.includes("responses")) return body; + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const record = body as Record; + const text = record.text; + if (!text || typeof text !== "object" || Array.isArray(text)) return body; + const textRecord = text as Record; + if (textRecord.format !== undefined) return body; + return { ...record, text: { ...textRecord, format: { type: "text" } } } as T; + } + /** * For compatible providers, the model name is already clean by the time * it reaches the executor (chatCore sets body.model = modelInfo.model, @@ -632,6 +648,7 @@ export class DefaultExecutor extends BaseExecutor { const cleanedBody = super.transformRequest(model, body, stream, credentials); let withDefaults = applyProviderRequestDefaults(cleanedBody, this.config.requestDefaults); withDefaults = this.applyJsonSchemaFallback(withDefaults); + withDefaults = this.defaultResponsesTextFormat(withDefaults); // Port of decolua/9router commit d652300e: // Cerebras returns 400 (wrong_api_format) and Mistral returns 422 diff --git a/tests/unit/responses-default-text-format.test.ts b/tests/unit/responses-default-text-format.test.ts new file mode 100644 index 0000000000..92f29a88e6 --- /dev/null +++ b/tests/unit/responses-default-text-format.test.ts @@ -0,0 +1,56 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DefaultExecutor } from "../../open-sse/executors/default.ts"; + +const RESPONSES_PROVIDER = "openai-compatible-responses-lmstudio"; +const CHAT_PROVIDER = "openai-compatible-chat-1234"; + +test("DefaultExecutor: defaults text.format to { type: 'text' } for openai-compatible-responses when text.format is absent", () => { + const executor = new DefaultExecutor(RESPONSES_PROVIDER); + const body = { input: "Say OK", text: { verbosity: "medium" } }; + const result = executor.transformRequest("llama-3", body, false, {}) as Record; + const text = result.text as Record; + assert.deepEqual(text.format, { type: "text" }); + assert.equal(text.verbosity, "medium"); +}); + +test("DefaultExecutor: defaults text.format when text is an empty object", () => { + const executor = new DefaultExecutor(RESPONSES_PROVIDER); + const body = { input: "Say OK", text: {} }; + const result = executor.transformRequest("llama-3", body, false, {}) as Record; + const text = result.text as Record; + assert.deepEqual(text.format, { type: "text" }); +}); + +test("DefaultExecutor: does not overwrite an existing text.format", () => { + const executor = new DefaultExecutor(RESPONSES_PROVIDER); + const fmt = { type: "json_schema", name: "x", schema: { type: "object" } }; + const body = { input: "Say OK", text: { format: fmt } }; + const result = executor.transformRequest("llama-3", body, false, {}) as Record; + const text = result.text as Record; + assert.deepEqual(text.format, fmt); +}); + +test("DefaultExecutor: no-op when there is no text field", () => { + const executor = new DefaultExecutor(RESPONSES_PROVIDER); + const body = { input: "Say OK" }; + const result = executor.transformRequest("llama-3", body, false, {}) as Record; + assert.equal(result.text, undefined); +}); + +test("DefaultExecutor: does not default text.format for non-responses openai-compatible provider", () => { + const executor = new DefaultExecutor(CHAT_PROVIDER); + const body = { messages: [{ role: "user", content: "hi" }], text: { verbosity: "medium" } }; + const result = executor.transformRequest("llama-3", body, false, {}) as Record; + const text = result.text as Record; + assert.equal(text.format, undefined); +}); + +test("DefaultExecutor: does not default text.format for non-openai-compatible provider (Codex guard)", () => { + const executor = new DefaultExecutor("openai"); + const body = { messages: [{ role: "user", content: "hi" }], text: { verbosity: "medium" } }; + const result = executor.transformRequest("gpt-4o", body, false, {}) as Record; + const text = result.text as Record; + assert.equal(text.format, undefined); +});