Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,7 @@ const configSchema = z.object({
// parse: a hand-edited typo must never trip the backup-and-defaults repair
// path below and wipe providers/pool accounts. Warning emitted in loadConfig.
streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined),
blockedModelRedirects: z.record(z.string(), z.string()).optional().catch(undefined),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject blank redirect targets

For a hand-edited mapping such as { "gpt-5.6-terra": "" } or a whitespace-only target, this schema accepts the value; routeResult then produces an empty/invalid upstream model ID, and an empty value also retains the original route reason because it is falsy. Validate both keys and values with trimming and a non-empty constraint so malformed optional configuration degrades safely rather than breaking matching requests.

Useful? React with 👍 / 👎.

// Same degrade-don't-reject rationale as the fields above: a hand-edited
// non-string must not trip the backup-and-defaults repair path. Unset then
// takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot).
Expand Down
17 changes: 17 additions & 0 deletions src/lib/shadow-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@
*/
export const DEFAULT_SHADOW_SOURCE_MODELS = ["gpt-5.6-luna"] as const;

/**
* Optional blocked model redirects at the shared routing layer.
* When `blockedModelRedirects` is configured (e.g. `{ "gpt-5.6-terra": "gpt-5.6-luna" }`),
* requests targeting those models are rewritten to the substitute model with
* routeReason "blocked-model-redirect".
* Returns undefined when not configured or the model is not in the redirect map.
*/
export function resolveBlockedModelRedirect(
config: { blockedModelRedirects?: Record<string, string> } | undefined,
modelId: string,
): string | undefined {
if (!config?.blockedModelRedirects || typeof config.blockedModelRedirects !== "object") {
return undefined;
}
return config.blockedModelRedirects[modelId];
}

/** Normalize a persisted `sourceModels` override; falls back to the defaults. */
export function shadowSourceModels(configured?: unknown): string[] {
const configuredStrings = Array.isArray(configured)
Expand Down
26 changes: 16 additions & 10 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
} from "./providers/openai-tiers";
import { decodeRoutedModelIdOrThrow, encodeRoutedModelId } from "./providers/slug-codec";
import { resolveModelAlias } from "./providers/default-aliases";
import { resolveBlockedModelRedirect } from "./lib/shadow-call";
import { getStaleCached } from "./codex/model-cache";
import { codexAccountNamespaceEntries } from "./codex/account-namespaces";
import {
Expand Down Expand Up @@ -506,19 +507,23 @@ function isBareOpenAiFamilyModel(modelId: string): boolean {
}

function routeResult(
config: OcxConfig | undefined,
providerName: string,
provider: OcxProviderConfig,
modelId: string,
routeKind: RouteDecisionKind,
routeReason: string,
): RouteResult {
const redirected = resolveBlockedModelRedirect(config, modelId);
const effectiveModelId = redirected ?? modelId;
Comment on lines +517 to +518

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route the replacement through provider selection

When a redirect points to a model owned by another provider—for example, gpt-5.6-terra to anthropic/claude-opus-4routeResult changes only modelId after the source provider has already been selected. The request therefore sends the replacement model to the OpenAI provider instead of the configured Anthropic provider, causing an upstream failure or dispatching to the wrong backend. Resolve the replacement as a fresh concrete route, or explicitly restrict and validate redirects to models on the same provider.

Useful? React with 👍 / 👎.

const effectiveRouteReason = redirected ? "blocked-model-redirect" : routeReason;
Comment on lines +517 to +519

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve redirect metadata through policy and combo routes

When a policy or combo selects a blocked model, this redirect runs in the recursive concrete route, but routeModelInternal subsequently overwrites its reason with policy-selected or combo-pick; policy routes also retain an evaluation trace naming the blocked source model. Consequently usage logs report Terra as selected while Luna is actually executed and never expose the advertised blocked-model-redirect reason. Propagate the effective model and redirect reason into the outer decision trace instead of discarding them.

Useful? React with 👍 / 👎.

const codexAccountMode = providerCodexAccountMode(providerName, provider);
return {
providerName,
provider: routedProviderConfig(providerName, provider),
modelId,
modelId: effectiveModelId,
routeKind,
routeReason,
routeReason: effectiveRouteReason,
...(codexAccountMode ? { codexAccountMode } : {}),
};
}
Expand Down Expand Up @@ -621,7 +626,7 @@ function routeModelInternal(
throw new NoEnabledOpenAiProviderError(nativeModelId);
}
return {
...routeResult(OPENAI_CODEX_PROVIDER_ID, provider, nativeModelId, "explicit-account", "account-namespace"),
...routeResult(config, OPENAI_CODEX_PROVIDER_ID, provider, nativeModelId, "explicit-account", "account-namespace"),
// Exact account injection uses the pool credential machinery even when the canonical
// provider is globally Direct. The fixed id bypasses pool selection entirely.
codexAccountMode: "pool",
Expand Down Expand Up @@ -666,7 +671,7 @@ function routeModelInternal(
// itself a known model (e.g. orcarouter/auto). Route it whole instead of stripping to the
// remainder, which would send a bare `auto` the upstream cannot resolve.
if (known.includes(modelId)) {
return routeResult(provName, prov, modelId, "explicit-provider", "explicit-provider-namespace");
return routeResult(config, provName, prov, modelId, "explicit-provider", "explicit-provider-namespace");
}
// Codex-facing alias ids (`provider/vendor-model`) decode back to the native
// slash id via an exact known-id lookup; raw full-slash selectors keep working.
Expand All @@ -676,6 +681,7 @@ function routeModelInternal(
? decoded
: resolveModelAlias(config, prov, known, requestedModel) ?? decoded;
return routeResult(
config,
provName,
prov,
nativeModel,
Expand All @@ -689,15 +695,15 @@ function routeModelInternal(
if (isBareOpenAiFamilyModel(modelId)) {
const provider = config.providers[OPENAI_CODEX_PROVIDER_ID];
if (provider && provider.disabled !== true) {
return routeResult(OPENAI_CODEX_PROVIDER_ID, provider, modelId, "native", "native-family");
return routeResult(config, OPENAI_CODEX_PROVIDER_ID, provider, modelId, "native", "native-family");
}
throw new NoEnabledOpenAiProviderError(modelId);
}

for (const [provName, prov] of activeProviderEntries(config)) {
if (prov.defaultModel === modelId
|| (typeof prov.defaultModel === "string" && encodeRoutedModelId(prov.defaultModel) === modelId)) {
return routeResult(provName, prov, prov.defaultModel as string, "explicit-provider", "configured-default-model");
return routeResult(config, provName, prov, prov.defaultModel as string, "explicit-provider", "configured-default-model");
}
}

Expand All @@ -708,7 +714,7 @@ function routeModelInternal(
if (prov.models && Array.isArray(prov.models)) {
const hit = (prov.models as string[]).find(id => id === modelId || encodeRoutedModelId(id) === modelId);
if (hit !== undefined) {
return routeResult(provName, prov, hit, "explicit-provider", "configured-model-list");
return routeResult(config, provName, prov, hit, "explicit-provider", "configured-model-list");
}
}
}
Expand All @@ -728,7 +734,7 @@ function routeModelInternal(
}
if (aliasMatches[0]) {
const match = aliasMatches[0];
return routeResult(match.provider, config.providers[match.provider], match.model, "explicit-provider", "model-alias");
return routeResult(config, match.provider, config.providers[match.provider], match.model, "explicit-provider", "model-alias");
}

if (config.defaultProvider === LEGACY_CHATGPT_PROVIDER_ID) {
Expand All @@ -737,7 +743,7 @@ function routeModelInternal(
if (hasOwnProvider(config.providers, config.defaultProvider)) {
const defaultProv = config.providers[config.defaultProvider];
if (defaultProv.disabled === true) throw new Error(`Default provider is disabled: ${config.defaultProvider}`);
return routeResult(config.defaultProvider, defaultProv, modelId, "default-provider", "default-provider");
return routeResult(config, config.defaultProvider, defaultProv, modelId, "default-provider", "default-provider");
}

throw new Error(`No provider configured for model: ${modelId}`);
Expand Down Expand Up @@ -786,7 +792,7 @@ function routeByKnownModelPattern(config: OcxConfig, modelId: string): RouteResu
);
if (matchingProvider) {
const [provName, prov] = matchingProvider;
return routeResult(provName, prov, modelId, "explicit-provider", "model-pattern");
return routeResult(config, provName, prov, modelId, "explicit-provider", "model-pattern");
}
// Deliberately no "first provider with an Anthropic adapter" fallback here. Picking by
// object insertion order, without checking `models`, `selectedModels`, `disabledModels` or
Expand Down
30 changes: 19 additions & 11 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,17 +436,25 @@ export interface OcxConfig {
* commit messages, skill orchestration) to a user-chosen model. Default intercepted
* source models: gpt-5.4-mini (older clients) and gpt-5.6-luna (Codex 0.145.0+).
* Opt-in; disabled by default. Matching requests preserve their configured reasoning effort.
* All requests for configured shadow source models are intercepted regardless of request kind,
* except when the replacement intersects the same provider+model source set.
*/
shadowCallIntercept?: {
/** When true, requests for known shadow/helper source models are rewritten to the configured model. */
enabled?: boolean;
/** Replacement model id (e.g. "gpt-5.5"). */
model?: string;
/** Optional override of intercepted source-model prefixes (default: gpt-5.4-mini, gpt-5.6-luna). */
sourceModels?: string[];
};
* All requests for configured shadow source models are intercepted regardless of request kind,
* except when the replacement intersects the same provider+model source set.
*/
shadowCallIntercept?: {
/** When true, requests for known shadow/helper source models are rewritten to the configured model. */
enabled?: boolean;
/** Replacement model id (e.g. "gpt-5.5"). */
model?: string;
/** Optional override of intercepted source-model prefixes (default: gpt-5.4-mini, gpt-5.6-luna). */
sourceModels?: string[];
};
/**
* Optional map of blocked model IDs to their replacement model IDs.
* When configured, incoming requests targeting a blocked model (including
* account-namespaced and concrete routes) are redirected to the replacement
* model at the shared routing layer with routeReason "blocked-model-redirect".
* Unset or omitted by default.
*/
blockedModelRedirects?: Record<string, string>;
Comment on lines +450 to +457

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the new redirect configuration

This adds a user-facing, opt-in configuration key, but the commit contains no docs-site/ update explaining its syntax, supported source/target forms, or interactions with account, combo, and policy routing, leaving operators without a supported way to discover or configure the feature. Add the configuration to the relevant English documentation and ensure translated pages do not contradict it.

AGENTS.md reference: AGENTS.md:L340-L341

Useful? React with 👍 / 👎.

/**
* 3-state multi-agent surface override:
* - "v1": force ALL models to v1 surface (override upstream pins)
Expand Down
86 changes: 86 additions & 0 deletions tests/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,3 +613,89 @@ describe("routeModel backfills google wire mode from the registry", () => {
expect(routed.providerName).toBe("fallbackProvider");
});
});

describe("routeModel blocked model redirect", () => {
test("routes gpt-5.6-terra normally when blockedModelRedirects is unset", () => {
const config: OcxConfig = {
port: 10100,
defaultProvider: "openai",
providers: {
openai: {
adapter: "openai-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
},
},
};

const routed = routeModel(config, "gpt-5.6-terra");
expect(routed).toMatchObject({
providerName: "openai",
modelId: "gpt-5.6-terra",
routeKind: "native",
routeReason: "native-family",
});
});

test("opt-in intercepts gpt-5.6-terra and rewrites to gpt-5.6-luna with blocked-model-redirect reason", () => {
const config: OcxConfig = {
port: 10100,
defaultProvider: "openai",
blockedModelRedirects: {
"gpt-5.6-terra": "gpt-5.6-luna",
},
providers: {
openai: {
adapter: "openai-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
},
},
};

const routed = routeModel(config, "gpt-5.6-terra");
expect(routed).toMatchObject({
providerName: "openai",
modelId: "gpt-5.6-luna",
routeKind: "native",
routeReason: "blocked-model-redirect",
});
expect(routed.routeDecision?.selected).toMatchObject({
model: "gpt-5.6-luna",
reason: "blocked-model-redirect",
});
expect(routed.routeDecision?.requestedModel).toBe("gpt-5.6-terra");
});

test("opt-in intercepts account-namespaced gpt-5.6-terra and rewrites to gpt-5.6-luna", () => {
const config: OcxConfig = {
port: 10100,
defaultProvider: "openai",
blockedModelRedirects: {
"gpt-5.6-terra": "gpt-5.6-luna",
},
providers: {
openai: {
adapter: "openai-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
codexAccountMode: "direct",
},
},
codexAccountNamespaces: { side: "side-account-id" },
};

const routed = routeModel(config, "side/gpt-5.6-terra");
expect(routed).toMatchObject({
providerName: "openai",
modelId: "gpt-5.6-luna",
routeKind: "explicit-account",
routeReason: "blocked-model-redirect",
codexAccountId: "side-account-id",
codexAccountNamespace: "side",
});
expect(routed.routeDecision?.selected).toMatchObject({
model: "gpt-5.6-luna",
accountRef: "side",
reason: "blocked-model-redirect",
});
expect(routed.routeDecision?.requestedModel).toBe("side/gpt-5.6-terra");
});
});
Loading