fix(responses): default text.format for openai-compatible responses providers (#5101)

Base-reds fixed (#5117); default text.format + file-size rebaseline. Integrated into release/v3.8.38.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-26 17:13:19 -03:00
committed by GitHub
parent 135b5daaa5
commit cfaba4410f
4 changed files with 76 additions and 1 deletions

View File

@@ -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)
---

View File

@@ -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

View File

@@ -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<T>(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<string, unknown>;
const text = record.text;
if (!text || typeof text !== "object" || Array.isArray(text)) return body;
const textRecord = text as Record<string, unknown>;
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

View File

@@ -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<string, unknown>;
const text = result.text as Record<string, unknown>;
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<string, unknown>;
const text = result.text as Record<string, unknown>;
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<string, unknown>;
const text = result.text as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const text = result.text as Record<string, unknown>;
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<string, unknown>;
const text = result.text as Record<string, unknown>;
assert.equal(text.format, undefined);
});