Files
OmniRoute/open-sse/services/responseModelEcho.ts
Diego Rodrigues de Sa e Souza 1045e57aa1 feat(codex): echo requested effort-suffixed model id in Responses payloads (#3697) (#6820)
* feat(codex): echo requested effort-suffixed model id in Responses payloads (#3697)

Codex CLI compatibility shim: the Responses API response.created/
response.in_progress/response.completed payloads now carry a `model`
field (previously absent), and for Codex-CLI-originated requests it
echoes the client-requested effort-suffixed model id (e.g.
gpt-5.5-xhigh) instead of the bare upstream id (gpt-5.5), so the Codex
CLI status line/model button shows the active reasoning effort.

- openai-responses.ts translator threads the upstream model into the
  Responses event objects (additive, omitted when unknown).
- New isCodexOriginatedHeaders() (codexIdentity.ts) reuses PR #3481's
  originator/User-Agent detection, header-based so it still fires when
  a combo routes codex/gpt-5.5-xhigh to a non-codex upstream.
- chatCore's existing opt-in #1311 echoModel pipeline now also fires
  automatically for Codex clients on the Responses API, regardless of
  the echoRequestedModelName setting.
- responseModelEcho.ts now also rewrites the nested response.model
  field the Responses API uses (previously only top-level model).
- /v1/models keeps returning models: [] for Codex (unchanged, #3481).

Regression guard: tests/unit/codex-effort-model-echo-3697.test.ts.

Closes #3697

* chore(merge): re-sync with release/v3.8.47 (restore CHANGELOG, keep own bullet)

* chore(merge): re-sync with release/v3.8.47; move changelog bullet to changelog.d fragment (merge-storm proof)
2026-07-11 07:39:09 -03:00

106 lines
4.2 KiB
TypeScript

/**
* #1311: echo the client-requested model/alias name back in the response.
*
* When a request uses an alias or combo (e.g. `claude-sonnet-cx` → `cx/gpt-5.5`),
* OmniRoute forwards the upstream model name (`gpt-5.5`) in the response `model`
* field. Strict clients (e.g. Claude Desktop) validate that the response model
* matches the request and reject the mismatch with a 401. This opt-in helper
* rewrites the `model` field back to the name the client asked for.
*
* The behavior is gated by a global setting (`echoRequestedModelName`, default off),
* so the default response stays byte-for-byte unchanged.
*/
/**
* Rewrite the top-level `model` field of a parsed response object (Chat Completions
* JSON or an OpenAI SSE chunk) to `echoModel`. Mutates and returns `obj`. No-op when
* `echoModel` is falsy or `obj` has no string `model` field.
*
* #3697: the Responses API nests `model` one level down — `{ type: "response.completed",
* response: { model, ... } }` — so also rewrite `obj.response.model` when present. This is
* what lets the Codex CLI compatibility shim echo the requested effort-suffixed model id
* (e.g. `gpt-5.5-xhigh`) in `response.created`/`response.completed` payloads.
*/
export function echoModelInObject(obj: unknown, echoModel: string | null | undefined): unknown {
if (!echoModel) return obj;
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
const rec = obj as Record<string, unknown>;
if (typeof rec.model === "string") {
rec.model = echoModel;
}
const nested = rec.response;
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
const nestedRec = nested as Record<string, unknown>;
if (typeof nestedRec.model === "string") {
nestedRec.model = echoModel;
}
}
}
return obj;
}
/**
* Rewrite the `model` field inside a single SSE line. Only `data: {json}` lines that
* carry a string `model` are rewritten; `data: [DONE]`, comments, event lines, and
* unparseable payloads pass through untouched.
*/
export function echoModelInSseLine(line: string, echoModel: string | null | undefined): string {
if (!echoModel) return line;
if (!line.startsWith("data:")) return line;
const payload = line.slice(5).trim();
if (!payload || payload === "[DONE]" || payload[0] !== "{") return line;
try {
const parsed = JSON.parse(payload) as Record<string, unknown>;
let changed = false;
if (typeof parsed.model === "string") {
parsed.model = echoModel;
changed = true;
}
// #3697: Responses API events nest `model` under `response.model` — see
// echoModelInObject for the shape.
const nested = parsed.response;
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
const nestedRec = nested as Record<string, unknown>;
if (typeof nestedRec.model === "string") {
nestedRec.model = echoModel;
changed = true;
}
}
if (!changed) return line;
return `data: ${JSON.stringify(parsed)}`;
} catch {
return line;
}
}
/**
* A TransformStream that rewrites the `model` field in every SSE `data:` chunk of a
* UTF-8 byte stream to `echoModel`. Buffers across chunk boundaries so a `data:` frame
* split across two reads is still rewritten correctly. Used as the final pipe stage of
* the streaming response when the echo setting is on.
*/
export function createModelEchoTransform(echoModel: string | null | undefined): TransformStream {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let buffer = "";
return new TransformStream({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
// Emit complete lines; keep the trailing partial line in the buffer.
const lastNewline = buffer.lastIndexOf("\n");
if (lastNewline === -1) return;
const ready = buffer.slice(0, lastNewline + 1);
buffer = buffer.slice(lastNewline + 1);
const rewritten = ready
.split("\n")
.map((line) => echoModelInSseLine(line, echoModel))
.join("\n");
controller.enqueue(encoder.encode(rewritten));
},
flush(controller) {
const tail = buffer + decoder.decode();
if (tail) controller.enqueue(encoder.encode(echoModelInSseLine(tail, echoModel)));
},
});
}