Skip to content
Closed
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
2 changes: 1 addition & 1 deletion src/codex/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, c
export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch";
export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation";
export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation";
export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, effectiveSubagentRoster, buildCatalogEntries, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync";
export { ANTHROPIC_RESPONSE_MODEL_ALIAS_CATALOG_KIND, MAX_SPAWN_AGENT_MODEL_OVERRIDES, effectiveSubagentRoster, buildCatalogEntries, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync";
export type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync";
export { accountBoundNativeDisplayName, accountBoundNativeModelSlugs, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models";
53 changes: 49 additions & 4 deletions src/codex/catalog/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,39 @@ import { codexRuntimeStatePath } from "../runtime";
import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models";

export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5;
/** Marker for hidden bare Anthropic rows that heal legacy response-model identities. */
export const ANTHROPIC_RESPONSE_MODEL_ALIAS_CATALOG_KIND = "anthropic-response-model-alias-v1";

function isAnthropicResponseModelAlias(entry: RawEntry): boolean {
return entry.opencodex_catalog_kind === ANTHROPIC_RESPONSE_MODEL_ALIAS_CATALOG_KIND;
}

/**
* Clone canonical Anthropic catalog rows under hidden bare slugs. Older Codex
* sessions can still request the bare id, while new responses always identify
* the canonical provider-qualified row. The marker lets sync and restore remove
* these generated aliases without treating them as user-authored native rows.
*/
function appendAnthropicResponseModelAliases(
entries: RawEntry[],
models: readonly CatalogModel[],
): RawEntry[] {
const result = [...entries];
for (const model of models) {
if (model.provider !== "anthropic" || model.id.includes("/")) continue;
const canonicalSlug = routedSlug(model.provider, model.id);
const canonical = entries.find(entry => entry.slug === canonicalSlug);
if (!canonical) continue;
if (result.some(entry => entry.slug === model.id)) continue;
result.push({
...JSON.parse(JSON.stringify(canonical)) as RawEntry,
slug: model.id,
visibility: "hide",
opencodex_catalog_kind: ANTHROPIC_RESPONSE_MODEL_ALIAS_CATALOG_KIND,
});
}
return result;
}

export type SpawnAgentSurface = "v1" | "v2";

Expand Down Expand Up @@ -396,7 +429,10 @@ export function buildCatalogEntries(
delete entry.prefer_websockets;
}
}
return applyMultiAgentMode(out, multiAgentMode, isMultiAgentV2Enabled());
return appendAnthropicResponseModelAliases(
applyMultiAgentMode(out, multiAgentMode, isMultiAgentV2Enabled()),
goModels,
);
}

export function resetCatalogRuntimeStateForTests(): void {
Expand Down Expand Up @@ -466,6 +502,7 @@ export function mergeCatalogEntriesForSync(
? catalogModels
.filter(m => typeof m.slug === "string"
&& !(m.slug as string).includes("/")
&& !isAnthropicResponseModelAlias(m)
&& m.owned_by !== COMBO_NAMESPACE
&& !goIds.has(m.slug as string)
&& !isUnsupportedOpenAiNativeSlug(m.slug as string))
Expand Down Expand Up @@ -996,11 +1033,16 @@ export function restoreCodexCatalogWithPermit(
const replacementVisibility = visibleAccountReplacementNatives(catalog.models, disabledModels);
const backup = readCatalogBackup(catalogPath);
if (backup && Array.isArray(backup.models)) {
const removed = (catalog.models ?? []).filter(m => typeof m.slug === "string" && m.slug.includes("/")).length;
const removed = (catalog.models ?? []).filter(m =>
(typeof m.slug === "string" && m.slug.includes("/")) || isAnthropicResponseModelAlias(m)
).length;
const backupSlugs = new Set(backup.models.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
const userNativeAdditions = restoreAccountHiddenBareNatives(
(catalog.models ?? []).filter(m =>
typeof m.slug === "string" && !m.slug.includes("/") && !backupSlugs.has(m.slug)
typeof m.slug === "string"
&& !m.slug.includes("/")
&& !backupSlugs.has(m.slug)
&& !isAnthropicResponseModelAlias(m)
),
replacementVisibility,
disabledModels,
Expand All @@ -1017,7 +1059,10 @@ export function restoreCodexCatalogWithPermit(
}
const before = catalog.models.length;
const native = restoreAccountHiddenBareNatives(
catalog.models.filter(m => !(typeof m.slug === "string" && m.slug.includes("/"))),
catalog.models.filter(m =>
!(typeof m.slug === "string" && m.slug.includes("/"))
&& !isAnthropicResponseModelAlias(m)
),
replacementVisibility,
disabledModels,
);
Expand Down
4 changes: 3 additions & 1 deletion src/images/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ class LoopError extends Error {
*/
export interface ImageBridgeDeps {
parsed: OcxParsedRequest;
/** Client-facing Responses model; upstream adapters continue to read parsed.modelId. */
responseModel?: string;
adapter: ProviderAdapter;
incomingMeta: IncomingMeta;
plan?: ImageBridgePlan;
Expand Down Expand Up @@ -900,7 +902,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
}

const sse = bridgeToResponsesSSE(
produce(), parsed.modelId, toolNsMap, freeform, toolSearch, () => {
produce(), deps.responseModel ?? parsed.modelId, toolNsMap, freeform, toolSearch, () => {
internalAbort.abort("client closed responses stream");
}, 2_000,
{
Expand Down
22 changes: 17 additions & 5 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } f
import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget";
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
import { slugsEquivalent } from "../../providers/slug-codec";
import { routedSlug, slugsEquivalent } from "../../providers/slug-codec";
import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
import { isUsageDebugEnabled } from "../../usage/debug";
import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "../request-decompress";
Expand Down Expand Up @@ -1529,6 +1529,7 @@ async function handleResponsesInner(
// upstream for reliability (#875); the answer must then be reframed to SSE
// for streaming clients.
const clientRequestedStream = parsed.stream;
const finalSelectedModelId = route.modelId;
await applyFinalRouteRequestNormalization({
parsed,
route,
Expand All @@ -1538,6 +1539,15 @@ async function handleResponsesInner(
inboundWire,
inboundTransport: options.inboundTransport,
});
// Anthropic routes strip their provider namespace for the upstream Messages
// request. Keep the canonical Codex catalog selector separately so every
// client-facing Responses surface resolves back to the same metadata row.
// Legacy bare selectors are intentionally healed to the namespaced row.
// Other adapters retain their post-normalization identity (including virtual
// model rewrites), exactly as before this repair.
const responseModel = route.provider.adapter === "anthropic"
? routedSlug(route.providerName, finalSelectedModelId)
: route.modelId;
// Attribute local auth/cooldown failures to the public selector too; exact auth may fail before
// the normal post-resolution provider label is assigned.
if (route.codexAccountNamespace) {
Expand Down Expand Up @@ -2388,6 +2398,7 @@ async function handleResponsesInner(
}
const imgResponse = await runWithImageBridge({
parsed, adapter,
responseModel,
incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget },
...(imgPlan ? { plan: imgPlan } : {}),
...(vidPlan ? { videoPlan: vidPlan } : {}),
Expand Down Expand Up @@ -2463,6 +2474,7 @@ async function handleResponsesInner(
parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
const wsResponse = await runWithWebSearch({
parsed, adapter,
responseModel,
incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget },
backend: wsPlan.backend,
forwardProvider: wsPlan.forwardSidecar?.provider,
Expand Down Expand Up @@ -2560,7 +2572,7 @@ async function handleResponsesInner(
eventSource = preflight.stream;
}
const sseStream = bridgeToResponsesSSE(
eventSource, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
eventSource, responseModel, toolNsMap, freeformToolNames, toolSearchToolNames,
() => {
runTurnAbort.abort();
queue.close();
Expand Down Expand Up @@ -2612,7 +2624,7 @@ async function handleResponsesInner(
}
}
let providerState: OcxProviderContinuationState | undefined;
const json = buildResponseJSON(events, parsed.modelId, {
const json = buildResponseJSON(events, responseModel, {
translatorBudget,
replayCacheScope: parsed._clientThreadId ?? "global",
hideThinkingSummary: parsed.options.hideThinkingSummary,
Expand Down Expand Up @@ -3254,7 +3266,7 @@ async function handleResponsesInner(
: initialEventStream;
const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
const sseStream = bridgeToResponsesSSE(
eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
eventStream, responseModel, toolNsMap, freeformToolNames, toolSearchToolNames,
() => upstream.abort(), 2_000,
{
translatorBudget,
Expand Down Expand Up @@ -3314,7 +3326,7 @@ async function handleResponsesInner(
}
const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
let providerState: OcxProviderContinuationState | undefined;
const json = buildResponseJSON(events, parsed.modelId, {
const json = buildResponseJSON(events, responseModel, {
translatorBudget,
replayCacheScope: parsed._clientThreadId ?? "global",
hideThinkingSummary: parsed.options.hideThinkingSummary,
Expand Down
4 changes: 3 additions & 1 deletion src/web-search/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ class LoopError extends Error {
*/
export interface WebSearchLoopDeps {
parsed: OcxParsedRequest;
/** Client-facing Responses model; upstream adapters continue to read parsed.modelId. */
responseModel?: string;
adapter: ProviderAdapter;
incomingMeta: IncomingMeta;
/** Which executor runs searches. Defaults to "openai" so existing callers keep the ChatGPT path (audit F4). */
Expand Down Expand Up @@ -770,7 +772,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
}

const sse = bridgeToResponsesSSE(
produce(), parsed.modelId, toolNsMap, freeform, toolSearch, () => {
produce(), deps.responseModel ?? parsed.modelId, toolNsMap, freeform, toolSearch, () => {
const elapsed = Date.now() - loopT0;
if (executedSearchCount > 0 || searchesExecuted > 0) {
console.warn(`[web-search-loop] cancelled — ${executedSearchCount} real searches, ${searchesExecuted - executedSearchCount} placeholders, ${elapsed}ms`);
Expand Down
29 changes: 29 additions & 0 deletions structure/03_catalog-and-subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,35 @@ The `multi_agent_v2` feature flag and the logical maximum thread count are separ
`multiAgentMode` (`src/codex/features.ts`): the mode decides which surface Codex advertises, while
the flag and thread count decide what the native runtime allows.

## Routed response model identity

Anthropic requests use a provider-qualified Codex selector (for example
`anthropic/claude-sonnet-5`) but the upstream Messages request uses the bare model id. The response
bridge preserves those as separate identities: upstream adapters read the bare `parsed.modelId`,
while every Codex-facing Responses path emits the canonical provider-qualified selector. This
includes the ordinary stream/JSON bridge and the image and web-search loops.

Legacy sessions may still send a bare Anthropic selector. Catalog sync therefore clones each
canonical Anthropic row into a hidden bare compatibility row with identical context, compaction,
modality, and reasoning metadata. The clone carries
`opencodex_catalog_kind = "anthropic-response-model-alias-v1"`; sync drops stale marked rows and
restore removes them without touching user-authored bare catalog entries.

[Decision Log]
- 목적과 의도: keep `response.model` resolvable against the exact Codex catalog metadata row while
preserving Anthropic's bare upstream wire model.
- 기존 구현 및 제약 조건: route normalization overwrites `parsed.modelId` before every response
bridge, and old sessions can resume with a bare selector that has no catalog row.
- 검토한 주요 대안: stop stripping the upstream model, rewrite only the terminal event, or retain a
separate client-facing identity and marker-owned hidden compatibility row.
- 선택한 방식: retain the separate identity and thread it through every bridge; generate only the
built-in Anthropic compatibility aliases and mark them for deterministic cleanup.
- 다른 대안 대신 이 방식을 선택한 이유: upstream behavior remains byte-compatible, all response
events agree on one identity, and restore can distinguish generated aliases from user data.
- 장점, 단점 및 영향: long-context and auto-compaction metadata resolve correctly for new and
resumed sessions; the catalog gains hidden rows, but they never appear in the picker and are
removed with OpenCodex-owned routing state.

## Ultra reasoning level

Ultra is always advertised in the catalog regardless of the `multi_agent_v2` toggle. The v2 toggle
Expand Down
103 changes: 103 additions & 0 deletions tests/anthropic-response-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { saveCredential } from "../src/oauth/store";
import { handleResponses } from "../src/server/responses";
import type { OcxConfig } from "../src/types";

const config = {
port: 0,
defaultProvider: "anthropic",
providers: {
anthropic: {
adapter: "anthropic",
baseUrl: "https://api.anthropic.com",
authMode: "oauth",
models: ["claude-sonnet-5"],
},
},
} as unknown as OcxConfig;

const streamingMessage = [
'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"claude-sonnet-5","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"output_tokens":0}}}\n\n',
'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n',
'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"OK"}}\n\n',
'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n',
'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}\n\n',
'event: message_stop\ndata: {"type":"message_stop"}\n\n',
].join("");

describe("Anthropic Codex-facing response model", () => {
const originalHome = process.env.OPENCODEX_HOME;
let originalFetch: typeof fetch;
let home: string;
let upstreamModels: string[];

beforeEach(async () => {
home = mkdtempSync(join(tmpdir(), "ocx-anthropic-response-model-"));
process.env.OPENCODEX_HOME = home;
await saveCredential("anthropic", {
access: "anthropic-access-test",
refresh: "anthropic-refresh-test",
expires: Date.now() + 3_600_000,
accountId: `response-model-${Date.now()}`,
});
originalFetch = globalThis.fetch;
upstreamModels = [];
globalThis.fetch = (async (_input, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as { model?: string; stream?: boolean };
upstreamModels.push(body.model ?? "");
if (body.stream) {
return new Response(streamingMessage, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
return Response.json({
id: "msg_test",
type: "message",
role: "assistant",
content: [{ type: "text", text: "OK" }],
model: "claude-sonnet-5",
stop_reason: "end_turn",
stop_sequence: null,
usage: { input_tokens: 3, output_tokens: 1 },
});
}) as typeof fetch;
});

afterEach(() => {
globalThis.fetch = originalFetch;
if (originalHome === undefined) delete process.env.OPENCODEX_HOME;
else process.env.OPENCODEX_HOME = originalHome;
rmSync(home, { recursive: true, force: true });
});

test("keeps a provider-qualified selector in streaming Responses output", async () => {
const response = await handleResponses(new Request("http://localhost/v1/responses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "anthropic/claude-sonnet-5", input: "reply OK", stream: true }),
}), config, { model: "", provider: "" });

const text = await response.text();
expect(response.status).toBe(200);
expect(upstreamModels).toEqual(["claude-sonnet-5"]);
expect(text).toContain('"model":"anthropic/claude-sonnet-5"');
expect(text).not.toContain('"model":"claude-sonnet-5"');
});

test("heals a legacy bare selector in non-streaming Responses output", async () => {
const response = await handleResponses(new Request("http://localhost/v1/responses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "claude-sonnet-5", input: "reply OK", stream: false }),
}), config, { model: "", provider: "" });

const json = await response.json() as { model?: string };
expect(response.status).toBe(200);
expect(upstreamModels).toEqual(["claude-sonnet-5"]);
expect(json.model).toBe("anthropic/claude-sonnet-5");
});
});
Loading
Loading