feat(models): live account catalog for Claude, Codex, Copilot, AGY (#12866)

Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437, ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

O ponto que sustenta a PR é o `models.dev` virar overlay de preço em vez de fonte de catálogo. Um catálogo estático que sobrevive à conta já ter listado ids mais novos é o tipo de defeito que só aparece quando o modelo novo é justamente o que se quer usar.

Nota de integração: `activeSyncedCatalog.ts` colidiu com o #12934 (união dos `customModels` do picker no catálogo de despacho). Como você extraiu o bloco original para `loadConnectionCatalog`, os dois se compõem: a união dos irmãos agy/antigravity primeiro, o `unionCustomModels` por cima. Revalidei com `custom-models-live-catalog-12597`, `live-model-catalog-reconciliation-8926`, `sync-models-degraded-cached-catalog-9683`, `models-dev-catalog-read-gate`, `discovery-class`, `reactive-model-sync` e `l1-oauth-autosync-default` juntos — 48/48 — mais typecheck:core limpo.

Sobre o `autoSync` padrão em Claude/Codex/Copilot com scheduler de 6h: passei isso pelo dono antes de mergear e a decisão foi manter como está.
This commit is contained in:
Bob.Hou
2026-09-07 08:02:59 -04:00
committed by GitHub
parent aa35d460dc
commit ebdbd2c67d
36 changed files with 880 additions and 82 deletions

View File

@@ -0,0 +1,2 @@
- **feat(models):** Account live listings become the chat catalog source for Claude, Codex, Copilot, and AGY; public metadata only fills prices on IDs those accounts already list. ([#12866](https://github.com/diegosouzapw/OmniRoute/pull/12866))
- **fix(models):** Union `agy` and `antigravity` live catalogs so `agy/gemini-3.8-flash-high` is not rejected after the prefix folds to `antigravity`. ([#12866](https://github.com/diegosouzapw/OmniRoute/pull/12866))

View File

@@ -25,5 +25,5 @@ export const agyProvider: RegistryEntry = {
},
models: [...AGY_PUBLIC_MODELS],
passthroughModels: true,
liveCatalogAuthoritative: false,
liveCatalogAuthoritative: true,
};

View File

@@ -25,5 +25,5 @@ export const antigravityProvider: RegistryEntry = {
},
models: [...ANTIGRAVITY_PUBLIC_MODELS],
passthroughModels: true,
liveCatalogAuthoritative: false,
liveCatalogAuthoritative: true,
};

View File

@@ -20,7 +20,7 @@ import {
isDiscoverableAntigravityModelId,
toClientAntigravityQuotaModelId,
} from "../../config/antigravityModelAliases.ts";
import { isUserCallableAgyModelId } from "../../config/agyModels.ts";
import { isDiscoverableAgyModelId } from "../../config/agyModels.ts";
import { getDbInstance } from "@/lib/db/core";
import {
applyAntigravityClientProfileHeaders,
@@ -645,7 +645,7 @@ export async function getAntigravityUsage(
!modelKey ||
info.isInternal === true ||
!(provider === "agy"
? isUserCallableAgyModelId(modelKey)
? isDiscoverableAgyModelId(modelKey)
: isDiscoverableAntigravityModelId(modelKey)) ||
Object.keys(quotaInfo).length === 0
) {
@@ -698,7 +698,7 @@ export async function getAntigravityUsage(
if (
quotas[modelKey] ||
!(provider === "agy"
? isUserCallableAgyModelId(modelKey)
? isDiscoverableAgyModelId(modelKey)
: isDiscoverableAntigravityModelId(modelKey))
) {
continue;

View File

@@ -7,6 +7,7 @@ import {
} from "@omniroute/open-sse/config/grokBuild.ts";
import { getAntigravityContentHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts";
import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser";
import { buildClaudeModelsHeaders } from "@/lib/providerModels/claudeModelsHeaders";
import {
CLINE_MODELS_ENDPOINT,
CLINEPASS_MODELS_ENDPOINT,
@@ -103,10 +104,12 @@ export function parsePerplexitySonarModels(data: any): any[] {
(model: any) => typeof model?.id === "string" && /^sonar(-|$)/.test(model.id)
);
}
type ProviderModelsHeaderContext = {
export type ProviderModelsHeaderContext = {
authType?: string;
providerSpecificData?: unknown;
email?: string | null;
accessToken?: string | null;
apiKey?: string | null;
};
export type ProviderModelsConfigEntry = {
@@ -124,6 +127,20 @@ export type ProviderModelsConfigEntry = {
parseResponse: (data: any) => any;
};
export function assembleProviderModelsHeaders(
config: ProviderModelsConfigEntry,
token: string,
context?: ProviderModelsHeaderContext,
): Record<string, string> {
const headers = config.buildHeaders
? config.buildHeaders(token, context)
: { ...config.headers };
if (!config.buildHeaders && config.authHeader && !config.authQuery) {
headers[config.authHeader] = (config.authPrefix || "") + token;
}
return headers;
}
const DASHSCOPE_TEXT_MODELS_CONFIG: ProviderModelsConfigEntry = {
url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models",
method: "GET",
@@ -387,10 +404,14 @@ export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> =
url: "https://api.anthropic.com/v1/models",
method: "GET",
headers: {
"Anthropic-Version": "2023-06-01",
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
authHeader: "x-api-key",
buildHeaders: (_token, context) =>
buildClaudeModelsHeaders({
accessToken: context?.accessToken,
apiKey: context?.apiKey,
}),
parseResponse: (data) => data.data || [],
},
gemini: {

View File

@@ -92,6 +92,7 @@ import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLease
import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent";
import { fetchCursorAvailableModels } from "@/lib/providerModels/cursorAvailableModels";
import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog";
import { resolveCopilotDiscoveryToken } from "@/lib/providerModels/copilotDiscoveryToken";
import {
type JsonRecord,
asRecord,
@@ -117,6 +118,7 @@ import { isNamedOpenAIStyleProvider } from "./discovery/providerSets";
import { buildStaleEncryptionKeyResponse } from "./staleEncryptionGuard";
import {
type ProviderModelsConfigEntry,
assembleProviderModelsHeaders,
PROVIDER_MODELS_CONFIG,
} from "./discovery/providerModelsConfig";
import {
@@ -1336,14 +1338,6 @@ export async function GET(
return buildApiDiscoveryResponse(normalizeSapModelsResponse(await response.json()));
}
if (provider === "claude") {
return buildResponse({
provider,
connectionId,
models: getStaticModelsForProvider("claude") || [],
});
}
if (provider === "cursor") {
const cachedResponse = maybeReturnCachedDiscovery();
if (cachedResponse) return cachedResponse;
@@ -1650,8 +1644,10 @@ export async function GET(
// the exchanged token; only DISCOVERY needs the raw token.) This mirrors the
// Copilot CLI + Hermes "de-gate model discovery" fix. Exchanged token stays
// as a fallback for connections that only captured that.
const copilotToken =
toNonEmptyString(accessToken) || toNonEmptyString(psd.copilotToken) || null;
const copilotToken = resolveCopilotDiscoveryToken({
accessToken,
copilotToken: psd.copilotToken,
});
const discovery = await fetchGitHubCopilotModels({
token: copilotToken,
@@ -1697,8 +1693,10 @@ export async function GET(
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
const psd = asRecord(connection.providerSpecificData);
const copilotToken =
toNonEmptyString(psd.copilotToken) || toNonEmptyString(accessToken) || null;
const copilotToken = resolveCopilotDiscoveryToken({
accessToken,
copilotToken: psd.copilotToken,
});
// endpoints.api serves the real chat model catalog; endpoints.proxy only
// has NES/autocomplete models. Prefer the api host, fall back to proxy for
// legacy connections that predate copilotApiUrl capture.
@@ -2157,10 +2155,13 @@ export async function GET(
}
if (githubCatalogModels && githubCatalogModels.length > 0) {
return buildApiDiscoveryResponse(
finalizeCodexCatalog(githubCatalogModels),
"Codex live catalog unavailable — using GitHub model catalog"
);
return buildResponse({
provider,
connectionId,
models: finalizeCodexCatalog(githubCatalogModels),
source: "github_catalog",
warning: "Codex live catalog unavailable — using GitHub model catalog",
});
}
if (cachedDiscoveryModels.length > 0) {
@@ -2292,12 +2293,8 @@ export async function GET(
}
// Build headers
const headers = config.buildHeaders
? config.buildHeaders(token, connection)
: { ...config.headers };
if (!config.buildHeaders && config.authHeader && !config.authQuery) {
headers[config.authHeader] = (config.authPrefix || "") + token;
}
const headerContext = { ...connection, accessToken, apiKey };
const headers = assembleProviderModelsHeaders(config, token, headerContext);
// Make request (with pagination for providers that use nextPageToken, e.g. Gemini)
const fetchOptions: any = {

View File

@@ -48,6 +48,23 @@ export function isDegradedCachedCatalog(modelsData: {
return typeof modelsData?.warning === "string" && modelsData.warning.trim().length > 0;
}
/**
* Codex live-empty GET returns `{ source: "github_catalog", warning: "…" }`
* via `buildResponse`. That is a display fallback of public models.json, not
* an authoritative discovery. Same discriminator as cache: source match + a
* non-empty warning. Without this, Import/Sync and boot ModelSync would persist
* the public catalog as the synced source of truth (spec 3.7).
*/
export function isDegradedGithubCatalog(modelsData: {
source?: unknown;
warning?: unknown;
}): boolean {
const source =
typeof modelsData?.source === "string" ? modelsData.source.trim().toLowerCase() : "";
if (source !== "github_catalog") return false;
return typeof modelsData?.warning === "string" && modelsData.warning.trim().length > 0;
}
/**
* Either degraded shape. Model-sync must refuse to treat these as a successful
* discovery: persisting them would silently pin a stale catalog and hide the
@@ -58,5 +75,9 @@ export function isDegradedDiscovery(modelsData: {
intentional?: unknown;
warning?: unknown;
}): boolean {
return isDegradedLocalCatalog(modelsData) || isDegradedCachedCatalog(modelsData);
return (
isDegradedLocalCatalog(modelsData) ||
isDegradedCachedCatalog(modelsData) ||
isDegradedGithubCatalog(modelsData)
);
}

View File

@@ -58,6 +58,34 @@ function resolveStoredProviderId(aliasOrId: string): string {
return normalized;
}
/**
* Distinct stored provider ids that share an account family.
* Credential lookup already pairs these in PROVIDER_SEARCH_PAIRS (#8779);
* live catalogs are keyed `provider:connectionId`, so the same pair must
* union here. parseModel folds `agy/` → `antigravity`, but CLI-card rows
* persist catalogs under `agy:` and the IDE card under `antigravity:`.
*/
const CATALOG_SIBLING_IDS: Record<string, string[]> = {
antigravity: ["agy"],
agy: ["antigravity"],
};
function catalogLookupIds(storedProviderId: string): string[] {
const siblings = CATALOG_SIBLING_IDS[storedProviderId] || [];
return [storedProviderId, ...siblings.filter((id) => id !== storedProviderId)];
}
function unionModels(groups: SyncedAvailableModel[][]): SyncedAvailableModel[] {
const models = new Map<string, SyncedAvailableModel>();
for (const group of groups) {
for (const model of group) {
if (!model?.id || models.has(model.id)) continue;
models.set(model.id, model);
}
}
return Array.from(models.values());
}
function readConnectionRef(connection: unknown): ProviderConnectionRef | null {
if (!connection || typeof connection !== "object") return null;
@@ -154,6 +182,23 @@ async function unionCustomModels(
* non-empty usable catalog. Missing, empty, malformed, or unavailable state
* fails open to the static registry.
*/
async function loadConnectionCatalog(storedProviderId: string): Promise<SyncedAvailableModel[]> {
const [connections, modelsByConnection] = await Promise.all([
getRawProviderConnections({ provider: storedProviderId, isActive: true }, undefined, undefined, [
"id",
"provider",
]),
getSyncedAvailableModelsByConnection(storedProviderId),
]);
const activeConnectionIds = connections
.map(readConnectionRef)
.filter((connection): connection is ProviderConnectionRef => connection !== null)
.map((connection) => connection.id);
return collectModelsForConnections(modelsByConnection, activeConnectionIds);
}
export async function getActiveSyncedCatalog(providerId: string): Promise<ActiveSyncedCatalog> {
const storedProviderId = resolveStoredProviderId(providerId);
if (!storedProviderId) {
@@ -161,27 +206,13 @@ export async function getActiveSyncedCatalog(providerId: string): Promise<Active
}
try {
const [connections, modelsByConnection] = await Promise.all([
getRawProviderConnections(
{ provider: storedProviderId, isActive: true },
undefined,
undefined,
["id", "provider"]
),
getSyncedAvailableModelsByConnection(storedProviderId),
]);
const activeConnectionIds = connections
.map(readConnectionRef)
.filter((connection): connection is ProviderConnectionRef => connection !== null)
.map((connection) => connection.id);
const lookupIds = catalogLookupIds(storedProviderId);
const siblingCatalogs = await Promise.all(lookupIds.map(loadConnectionCatalog));
// #12866 unions the agy/antigravity sibling catalogs; #12934 then overlays the
// picker-added customModels so dispatch admits the same rows the picker REST shows.
const models = enrichCursorCatalog(
storedProviderId,
await unionCustomModels(
storedProviderId,
collectModelsForConnections(modelsByConnection, activeConnectionIds)
)
await unionCustomModels(storedProviderId, unionModels(siblingCatalogs))
);
if (models.length > 0) {
return {

View File

@@ -143,6 +143,7 @@ export const claude = {
const providerSpecificData: any = {
// Generated once at provisioning; preserved across token refresh.
cliUserID: crypto.randomBytes(32).toString("hex"),
autoSync: true,
};
if (bs.account_uuid) providerSpecificData.accountUUID = bs.account_uuid;
if (bs.organization_uuid) providerSpecificData.organizationUUID = bs.organization_uuid;

View File

@@ -194,6 +194,7 @@ export const codex = {
}
const providerSpecificData = {
autoSync: true,
workspaceId,
workspacePlanType: planType,
// Also store the full authInfo for future reference

View File

@@ -102,6 +102,7 @@ export const gheCopilot = {
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: {
autoSync: true,
gheUrl: extra?.gheUrl,
copilotApiUrl: extra?.copilotApiUrl || extra?.copilotToken?.endpoints?.api,
copilotProxyUrl: extra?.copilotProxyUrl || extra?.copilotToken?.endpoints?.proxy,

View File

@@ -79,6 +79,7 @@ export const github = {
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: {
autoSync: true,
copilotToken: extra?.copilotToken?.token,
copilotTokenExpiresAt: extra?.copilotToken?.expires_at,
githubUserId: extra?.userInfo?.id,

View File

@@ -0,0 +1,26 @@
import { getClaudeCodeVersion } from "@omniroute/open-sse/executors/claudeIdentity.ts";
export function buildClaudeModelsHeaders(input: {
accessToken?: string | null;
apiKey?: string | null;
}): Record<string, string> {
const accessToken = typeof input.accessToken === "string" ? input.accessToken.trim() : "";
const apiKey = typeof input.apiKey === "string" ? input.apiKey.trim() : "";
const common = {
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
};
if (accessToken) {
const scheme = "Bearer";
return {
...common,
Authorization: [scheme, accessToken].join(" "),
"anthropic-beta": "oauth-2025-04-20",
"User-Agent": `claude-cli/${getClaudeCodeVersion()} (external, cli)`,
};
}
return {
...common,
"x-api-key": apiKey,
};
}

View File

@@ -0,0 +1,12 @@
function nonEmpty(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
export function resolveCopilotDiscoveryToken(input: {
accessToken?: unknown;
copilotToken?: unknown;
}): string | null {
return nonEmpty(input.accessToken) || nonEmpty(input.copilotToken);
}

View File

@@ -0,0 +1,31 @@
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry";
import { providerUsesCuratedModelsOnly } from "@/lib/providers/modelListingCapability";
import { HARDCODED_MODELS_CONFIG_IDS } from "./hardcodedModelsConfigIds.ts";
export type DiscoveryClass = "account-live" | "openai-compat" | "static-only";
export const ACCOUNT_LIVE_PROVIDER_IDS = [
"claude",
"codex",
"github",
"ghe-copilot",
"agy",
"antigravity",
"gemini",
"cursor",
"cu",
"grok-cli",
] as const;
const ACCOUNT_LIVE = new Set<string>(ACCOUNT_LIVE_PROVIDER_IDS);
export function getDiscoveryClass(providerId: string): DiscoveryClass {
const id = providerId.trim().toLowerCase();
if (!id) return "static-only";
if (providerUsesCuratedModelsOnly(id)) return "static-only";
if (ACCOUNT_LIVE.has(id)) return "account-live";
if (HARDCODED_MODELS_CONFIG_IDS.has(id) || Boolean(getRegistryEntry(id)?.modelsUrl)) {
return "openai-compat";
}
return "static-only";
}

View File

@@ -0,0 +1,51 @@
/** Keys of PROVIDER_MODELS_CONFIG. Lockstep test in discovery-class.test.ts. */
export const HARDCODED_MODELS_CONFIG_IDS: ReadonlySet<string> = new Set([
"agentrouter",
"aimlapi",
"alibaba",
"alibaba-cn",
"anthropic",
"antigravity",
"blackbox",
"cerebras",
"chutes",
"clarifai",
"claude",
"cline",
"clinepass",
"cloudflare-ai",
"cohere",
"command-code",
"deepseek",
"fenayai",
"fireworks",
"gemini",
"gitlawb",
"gitlawb-gmi",
"glm-cn",
"grok-cli",
"groq",
"huggingface",
"kilo-gateway",
"kilocode",
"kimi",
"kimi-coding",
"kimi-coding-apikey",
"mistral",
"nebius",
"nvidia",
"ollama-cloud",
"openai",
"opencode-go",
"opencode-zen",
"openference",
"openference-api",
"openrouter",
"openvecta",
"perplexity",
"qwen-cloud",
"synthetic",
"thebai",
"together",
"xai",
]);

View File

@@ -2,7 +2,7 @@ import {
isUserCallableAntigravityModelId,
toClientAntigravityModelId,
} from "@omniroute/open-sse/config/antigravityModelAliases.ts";
import { isUserCallableAgyModelId } from "@omniroute/open-sse/config/agyModels.ts";
import { isDiscoverableAgyModelId } from "@omniroute/open-sse/config/agyModels.ts";
type JsonRecord = Record<string, unknown>;
@@ -13,7 +13,7 @@ export function isRecord(value: unknown): value is JsonRecord {
export function isUsageQuotaKeyAllowed(provider: string, quotaKey: string): boolean {
if (quotaKey === "credits" || quotaKey === "models") return true;
if (provider === "antigravity") return isUserCallableAntigravityModelId(quotaKey);
if (provider === "agy") return isUserCallableAgyModelId(quotaKey);
if (provider === "agy") return isDiscoverableAgyModelId(quotaKey);
return true;
}

View File

@@ -1,9 +1,9 @@
// Kept in lockstep with the codex CLI actually installed in the OmniRoute image
// (bin/omniroute-fix.Containerfile installs `codex` latest; app-server runtime is
// 0.149.0 as of 2026-08-22). When the image's codex is bumped, refresh this so the
// fingerprint OpenAI sees from the OAuth/Responses face matches the real client
// version. Overridable per-deployment via the CODEX_CLIENT_VERSION env.
export const DEFAULT_CODEX_CLIENT_VERSION = "0.149.0";
// Kept in lockstep with the `@openai/codex@x.y.z` pin in the root Dockerfile
// (the CLI installed in the OmniRoute image). When that image pin is bumped,
// refresh this so the fingerprint OpenAI sees from the OAuth/Responses face
// matches the real client version. Overridable per-deployment via
// CODEX_CLIENT_VERSION.
export const DEFAULT_CODEX_CLIENT_VERSION = "0.153.2";
export const CODEX_CLI_RS_ORIGINATOR = "codex_cli_rs";
export function getCodexCliRsHeaders(

View File

@@ -3,7 +3,7 @@
*
* Automatically refreshes model lists for provider connections that have
* autoSync enabled in their providerSpecificData, at a configurable
* interval (default: 24h).
* interval (default: 6h).
*
* Pattern mirrors cloudSyncScheduler.ts for consistency.
*/
@@ -14,7 +14,7 @@ import { getSettings, updateSettings } from "@/lib/db/settings";
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
import { getRuntimePorts } from "@/lib/runtime/ports";
const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
export const DEFAULT_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
const MODEL_SYNC_SETTING_KEY = "model_sync_last_run";
const MODEL_SYNC_INTERNAL_AUTH_HEADER = "x-model-sync-internal-auth";
@@ -270,7 +270,7 @@ async function runSyncCycle(apiBaseUrl: string): Promise<void> {
/**
* Start the model sync scheduler.
* @param apiBaseUrl — internal base URL to call OmniRoute's own API
* @param intervalMs — sync interval in milliseconds (default: 24h)
* @param intervalMs — sync interval in milliseconds (default: 6h)
*/
export function startModelSyncScheduler(
apiBaseUrl = getModelSyncInternalBaseUrl(),

View File

@@ -1311,16 +1311,16 @@
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"Openai-Beta": "responses=experimental",
"User-Agent": "codex-cli/0.149.0 (<OS>; <ARCH>)",
"Version": "0.149.0",
"User-Agent": "codex-cli/0.153.2 (<OS>; <ARCH>)",
"Version": "0.153.2",
"X-Codex-Beta-Features": "responses_websockets"
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"Openai-Beta": "responses=experimental",
"User-Agent": "codex-cli/0.149.0 (<OS>; <ARCH>)",
"Version": "0.149.0",
"User-Agent": "codex-cli/0.153.2 (<OS>; <ARCH>)",
"Version": "0.153.2",
"X-Codex-Beta-Features": "responses_websockets"
},
"oauth": {
@@ -1328,8 +1328,8 @@
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"Openai-Beta": "responses=experimental",
"User-Agent": "codex-cli/0.149.0 (<OS>; <ARCH>)",
"Version": "0.149.0",
"User-Agent": "codex-cli/0.153.2 (<OS>; <ARCH>)",
"Version": "0.153.2",
"X-Codex-Beta-Features": "responses_websockets"
}
},

View File

@@ -3,6 +3,7 @@ import test from "node:test";
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
import { getClaudeCodeUserAgent } from "../../src/shared/constants/claudeCodeClient.ts";
import { DEFAULT_CODEX_CLIENT_VERSION } from "../../src/shared/constants/codexClient.ts";
test("AgentRouter default dispatch uses the Claude Code wire image and x-api-key auth", async () => {
const originalFetch = globalThis.fetch;
@@ -84,7 +85,7 @@ test("AgentRouter OpenAI Chat dispatch uses Codex identity without Claude-only b
assert.equal(captured.url, "https://agentrouter.org/v1/chat/completions");
assert.equal(captured.headers.get("authorization"), "Bearer test-agentrouter-key");
assert.equal(captured.headers.get("x-api-key"), null);
assert.equal(captured.headers.get("user-agent"), "codex_cli_rs/0.149.0");
assert.equal(captured.headers.get("user-agent"), `codex_cli_rs/${DEFAULT_CODEX_CLIENT_VERSION}`);
assert.equal(captured.headers.get("originator"), "codex_cli_rs");
assert.equal(captured.headers.get("x-app"), null);
assert.equal(captured.headers.get("anthropic-version"), null);
@@ -130,7 +131,7 @@ test("AgentRouter OpenAI Responses dispatch uses the Responses endpoint and Code
assert.ok(captured);
assert.equal(captured.url, "https://agentrouter.org/v1/responses");
assert.equal(captured.headers.get("authorization"), "Bearer test-agentrouter-key");
assert.equal(captured.headers.get("user-agent"), "codex_cli_rs/0.149.0");
assert.equal(captured.headers.get("user-agent"), `codex_cli_rs/${DEFAULT_CODEX_CLIENT_VERSION}`);
assert.equal(captured.headers.get("originator"), "codex_cli_rs");
assert.equal(captured.headers.get("x-app"), null);
assert.equal(captured.headers.get("anthropic-beta"), null);

View File

@@ -121,6 +121,18 @@ test("agy live discovery accepts new chat models while excluding tab-completion
assert.equal(isDiscoverableAgyModelId(""), false);
});
const quotaNormalize = await import("../../src/lib/usage/providerLimits/quotaNormalize.ts");
test("test 9: agy live catalog is authoritative; quota keys use discoverable denylist", () => {
assert.equal(REGISTRY.agy.liveCatalogAuthoritative, true);
assert.equal(REGISTRY.antigravity.liveCatalogAuthoritative, true);
const { isUsageQuotaKeyAllowed } = quotaNormalize;
assert.equal(isDiscoverableAgyModelId("gemini-new-live-tier"), true);
assert.equal(isUsageQuotaKeyAllowed("agy", "gemini-new-live-tier"), true);
assert.equal(isUserCallableAgyModelId("gemini-new-live-tier"), false);
assert.equal(isUsageQuotaKeyAllowed("agy", "tab_flash_lite_preview"), false);
});
test("agy token refresh is wired on the Google (non-rotating) refresh path", () => {
assert.equal(supportsTokenRefresh("agy"), true);
// Same 15-minute proactive lead as antigravity (Google refresh tokens are permanent).

View File

@@ -12,6 +12,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const id = await import("../../open-sse/executors/claudeIdentity.ts");
const hdr = await import("../../open-sse/config/anthropicHeaders.ts");
@@ -57,9 +59,71 @@ test("Claude CLI wire versions match the captured 2.1.258 binary", () => {
assert.equal(hdr.CLAUDE_CLI_BILLING_VERSION, canonical.CLAUDE_CODE_CLIENT_BILLING_VERSION);
});
test("Codex client is pinned to the captured 0.149.0 release", () => {
assert.equal(codexCfg.getCodexClientVersion(), "0.149.0");
assert.equal(codexCfg.getCodexUserAgent(), "codex-cli/0.149.0 (Windows 10.0.26200; x64)");
assert.equal(codexCfg.getCodexDefaultHeaders().Version, "0.149.0");
assert.equal(codexCfg.getCodexCliRsHeaders()["User-Agent"], "codex_cli_rs/0.149.0");
async function withEnv<T>(
entries: Record<string, string | undefined>,
fn: () => T | Promise<T>
): Promise<T> {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(entries)) {
previous.set(key, process.env[key]);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
try {
return await fn();
} finally {
for (const [key, value] of previous.entries()) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
test("Codex client version locksteps Dockerfile @openai/codex", () => {
const dockerfile = fs.readFileSync(path.join(process.cwd(), "Dockerfile"), "utf8");
const match = dockerfile.match(/@openai\/codex@([0-9]+\.[0-9]+\.[0-9]+)/);
assert.ok(match, "Dockerfile must pin @openai/codex@x.y.z");
const pinned = match[1];
assert.notEqual(pinned, "0.149.0");
assert.equal(codexCfg.DEFAULT_CODEX_CLIENT_VERSION, pinned);
assert.equal(codexCfg.getCodexClientVersion(), pinned);
assert.equal(codexCfg.getCodexDefaultHeaders().Version, pinned);
assert.equal(
codexCfg.getCodexCliRsHeaders()["User-Agent"],
`codex_cli_rs/${pinned}`,
);
});
test("Codex client version env override still wins", async () => {
await withEnv({ CODEX_CLIENT_VERSION: "0.99.0" }, () => {
assert.equal(codexCfg.getCodexClientVersion(), "0.99.0");
assert.equal(codexCfg.getCodexDefaultHeaders().Version, "0.99.0");
});
});
test("test 7: live-empty GitHub catalog path does not call persist", () => {
const src = fs.readFileSync(
path.join(process.cwd(), "src/app/api/providers/[id]/models/route.ts"),
"utf8",
);
// The githubCatalogModels fallback must use buildResponse, not buildApiDiscoveryResponse.
const idx = src.indexOf("Codex live catalog unavailable — using GitHub model catalog");
assert.ok(idx > 0);
const start = src.lastIndexOf("if (githubCatalogModels", idx);
const end = src.indexOf("if (cachedDiscoveryModels", idx);
assert.ok(start > 0 && end > start);
const window = src.slice(start, end);
assert.match(window, /buildResponse\s*\(/);
assert.doesNotMatch(window, /buildApiDiscoveryResponse\s*\(/);
const liveIdx = src.lastIndexOf("if (liveModels && liveModels.length > 0)");
assert.ok(liveIdx > 0 && liveIdx < start);
const liveWindow = src.slice(liveIdx, start);
assert.match(liveWindow, /buildApiDiscoveryResponse\s*\(/);
});

View File

@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { buildClaudeModelsHeaders } from "../../src/lib/providerModels/claudeModelsHeaders.ts";
import {
assembleProviderModelsHeaders,
PROVIDER_MODELS_CONFIG,
} from "../../src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts";
const OAUTH = "oauth-access-token-fixture";
const KEY = "sk-ant-api-key-fixture";
test("test 4: OAuth headers are RFC 6750 Authorization, not x-api-key", () => {
const headers = assembleProviderModelsHeaders(PROVIDER_MODELS_CONFIG.claude, OAUTH, {
accessToken: OAUTH,
apiKey: KEY,
});
assert.match(headers.Authorization ?? "", /^Bearer /);
assert.equal(headers.Authorization, ["Bearer", OAUTH].join(" "));
assert.equal(headers["anthropic-beta"], "oauth-2025-04-20");
assert.equal(headers["anthropic-version"] ?? headers["Anthropic-Version"], "2023-06-01");
assert.equal(headers["x-api-key"], undefined);
assert.ok(!("x-api-key" in headers));
});
test("test 5: API key headers set x-api-key only", () => {
const headers = assembleProviderModelsHeaders(PROVIDER_MODELS_CONFIG.claude, KEY, {
accessToken: "",
apiKey: KEY,
});
assert.equal(headers["x-api-key"], KEY);
assert.equal(headers.Authorization, undefined);
assert.ok(!("Authorization" in headers));
});
test("test 6: route.ts has no claude static early return", () => {
const src = fs.readFileSync(
path.join(process.cwd(), "src/app/api/providers/[id]/models/route.ts"),
"utf8",
);
assert.doesNotMatch(
src,
/if\s*\(\s*provider\s*===\s*"claude"\s*\)[\s\S]{0,400}getStaticModelsForProvider\(\s*"claude"/,
);
});
test("mutation: OAuth context without buildHeaders would set x-api-key (helper contract)", () => {
const direct = buildClaudeModelsHeaders({ accessToken: OAUTH, apiKey: KEY });
assert.equal(direct["x-api-key"], undefined);
});

View File

@@ -19,6 +19,7 @@ const {
} = await import("../../src/shared/constants/clientIdentityProfiles.ts");
const { isForbiddenCustomHeaderName } =
await import("../../src/shared/constants/upstreamHeaders.ts");
const { DEFAULT_CODEX_CLIENT_VERSION } = await import("../../src/shared/constants/codexClient.ts");
const { DefaultExecutor } = await import("../../open-sse/executors/default.ts");
const core = await import("../../src/lib/db/core.ts");
@@ -43,7 +44,7 @@ test("getClientIdentityProfileHeaders: known CLI profiles expose their preset he
assert.equal(claudeCli["X-App"], "cli");
const codexCli = getClientIdentityProfileHeaders("codex-cli");
assert.equal(codexCli["User-Agent"], "codex_cli_rs/0.149.0");
assert.equal(codexCli["User-Agent"], `codex_cli_rs/${DEFAULT_CODEX_CLIENT_VERSION}`);
assert.equal(codexCli.originator, "codex_cli_rs");
const geminiCli = getClientIdentityProfileHeaders("gemini-cli");
@@ -80,7 +81,10 @@ test("a selected profile's headers land in providerSpecificData.customHeaders",
customHeaders: { ...profileHeaders, "X-Operator-Set": "keep-me" },
};
assert.equal(providerSpecificData.customHeaders["User-Agent"], "codex_cli_rs/0.149.0");
assert.equal(
providerSpecificData.customHeaders["User-Agent"],
`codex_cli_rs/${DEFAULT_CODEX_CLIENT_VERSION}`
);
assert.equal(providerSpecificData.customHeaders.originator, "codex_cli_rs");
assert.equal(providerSpecificData.customHeaders["X-Operator-Set"], "keep-me");
});

View File

@@ -0,0 +1,42 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { resolveCopilotDiscoveryToken } from "../../src/lib/providerModels/copilotDiscoveryToken.ts";
test("test 8: raw accessToken wins over exchanged copilotToken", () => {
assert.equal(
resolveCopilotDiscoveryToken({
accessToken: "gho_raw",
copilotToken: "tid_exchanged",
}),
"gho_raw",
);
assert.equal(
resolveCopilotDiscoveryToken({ accessToken: "", copilotToken: "tid_exchanged" }),
"tid_exchanged",
);
assert.equal(resolveCopilotDiscoveryToken({ accessToken: " ", copilotToken: " " }), null);
});
test("test 8: github and ghe discovery both prefer raw accessToken via helper", () => {
const source = fs.readFileSync(
path.join(process.cwd(), "src/app/api/providers/[id]/models/route.ts"),
"utf8",
);
const helperSrc = fs.readFileSync(
path.join(process.cwd(), "src/lib/providerModels/copilotDiscoveryToken.ts"),
"utf8",
);
assert.doesNotMatch(helperSrc, /src\/app\/api/);
const calls = source.match(/resolveCopilotDiscoveryToken\(\s*\{[\s\S]*?\}\s*\)/g) ?? [];
assert.equal(calls.length, 2, "github and ghe branches must both call the helper");
for (const call of calls) {
assert.match(call, /accessToken/);
assert.match(call, /copilotToken:\s*psd\.copilotToken/);
}
assert.doesNotMatch(
source,
/toNonEmptyString\(psd\.copilotToken\)\s*\|\|\s*toNonEmptyString\(accessToken\)/,
);
});

View File

@@ -0,0 +1,48 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
ACCOUNT_LIVE_PROVIDER_IDS,
getDiscoveryClass,
} from "../../src/lib/providerModels/discoveryClass.ts";
import { HARDCODED_MODELS_CONFIG_IDS } from "../../src/lib/providerModels/hardcodedModelsConfigIds.ts";
import { PROVIDER_MODELS_CONFIG } from "../../src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts";
import { getRegistryEntry } from "../../open-sse/config/providerRegistry.ts";
const L1 = [
"claude",
"codex",
"github",
"ghe-copilot",
"agy",
"antigravity",
"gemini",
"cursor",
"cu",
"grok-cli",
] as const;
test("test 1: L1 ids are account-live", () => {
assert.equal(ACCOUNT_LIVE_PROVIDER_IDS.length, 10);
for (const id of L1) {
assert.equal(getDiscoveryClass(id), "account-live", id);
}
assert.equal(getDiscoveryClass("CLAUDE"), "account-live");
});
test("test 2: curated web providers are static-only", () => {
for (const id of ["kimi-web", "chatgpt-web", "zai-web"]) {
assert.equal(getDiscoveryClass(id), "static-only", id);
}
});
test("test 3: modelsUrl gateway is openai-compat; unknown is static-only", () => {
assert.ok(getRegistryEntry("minimax")?.modelsUrl);
assert.equal(getDiscoveryClass("minimax"), "openai-compat");
assert.equal(getDiscoveryClass("no-such-provider-xyz"), "static-only");
});
test("hardcoded config keys lockstep with PROVIDER_MODELS_CONFIG", () => {
const fromModule = [...HARDCODED_MODELS_CONFIG_IDS].sort();
const fromConfig = Object.keys(PROVIDER_MODELS_CONFIG).sort();
assert.deepEqual(fromModule, fromConfig);
});

View File

@@ -184,10 +184,10 @@ test("CodexExecutor.buildHeaders binds workspace ids and disables SSE accept for
assert.equal(standardHeaders.Authorization, "Bearer codex-token");
assert.equal(standardHeaders.Accept, "text/event-stream");
assert.equal(standardHeaders["chatgpt-account-id"], "workspace-1");
assert.equal(standardHeaders.Version, "0.149.0");
assert.equal(standardHeaders.Version, "0.153.2");
assert.equal(standardHeaders["Openai-Beta"], "responses=experimental");
assert.equal(standardHeaders["X-Codex-Beta-Features"], "responses_websockets");
assert.equal(standardHeaders["User-Agent"], "codex-cli/0.149.0 (Windows 10.0.26200; x64)");
assert.equal(standardHeaders["User-Agent"], "codex-cli/0.153.2 (Windows 10.0.26200; x64)");
assert.equal(compactHeaders.Accept, "application/json");
});
@@ -213,7 +213,7 @@ test("CodexExecutor.buildHeaders honors safe env overrides for Version and User-
},
() => {
const headers = executor.buildHeaders({ accessToken: "codex-token" }, true);
assert.equal(headers.Version, "0.149.0");
assert.equal(headers.Version, "0.153.2");
assert.equal(headers["User-Agent"], "custom-codex/9.9.9");
}
);

View File

@@ -0,0 +1,34 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { claude } from "../../src/lib/oauth/providers/claude.ts";
import { codex } from "../../src/lib/oauth/providers/codex.ts";
import { github } from "../../src/lib/oauth/providers/github.ts";
import { gheCopilot } from "../../src/lib/oauth/providers/ghe-copilot.ts";
import { cursor } from "../../src/lib/oauth/providers/cursor.ts";
test("test 8: new OAuth mapTokens set autoSync true for L1 four", () => {
const tokens = { access_token: "t", refresh_token: "r", expires_in: 3600, scope: "s" };
assert.equal(claude.mapTokens(tokens, null).providerSpecificData.autoSync, true);
assert.equal(codex.mapTokens(tokens, {}).providerSpecificData.autoSync, true);
assert.equal(github.mapTokens(tokens, {}).providerSpecificData.autoSync, true);
assert.equal(gheCopilot.mapTokens(tokens, {}).providerSpecificData.autoSync, true);
});
test("test 8: Claude mapTokens always emits providerSpecificData.autoSync even if bootstrap is empty", () => {
const tokens = { access_token: "t", refresh_token: "r", expires_in: 3600, scope: "s" };
const mapped = claude.mapTokens(tokens, null);
assert.equal(mapped.providerSpecificData != null, true);
assert.equal(mapped.providerSpecificData.autoSync, true);
});
test("test 8: cursor / grok-cli mapTokens stay without autoSync default", () => {
const mapped = cursor.mapTokens({ accessToken: "t" });
assert.equal(mapped.providerSpecificData.autoSync, undefined);
const grokSrc = fs.readFileSync(
path.join(process.cwd(), "src/lib/oauth/providers/grok-cli.ts"),
"utf8",
);
assert.doesNotMatch(grokSrc, /autoSync:\s*true/);
});

View File

@@ -201,3 +201,31 @@ test("#8926: partial passthrough discovery remains non-authoritative", async ()
["gpt-5.6-luna"]
);
});
test("#12866: agy CLI catalog is visible after parseModel folds the prefix to antigravity", async () => {
// Production shape: combo steps are `agy/gemini-3.8-flash-high` on CLI-card
// rows. parseModel canonicalizes `agy/` → `antigravity` (#8013), then live
// authority looks up the IDE catalog keyed `antigravity:<id>`. Those two
// catalogs are distinct stored ids — CLI has flash-high, IDE does not —
// so the request 400s even though the pinned agy connection serves the model.
await seedProviderCatalog("agy", "agy-cli-catalog-12866", ["gemini-3.8-flash-high"]);
await seedProviderCatalog("antigravity", "antigravity-ide-catalog-12866", [
"gemini-3.8-flash-tiered",
]);
const resolved = await getModelInfo("agy/gemini-3.8-flash-high");
assert.equal(resolved.errorType, undefined, resolved.errorMessage);
assert.equal(resolved.model, "gemini-3.8-flash-high");
assert.ok(
resolved.provider === "agy" || resolved.provider === "antigravity",
`expected agy/antigravity, got ${resolved.provider}`
);
const antigravityCatalog = await getActiveSyncedCatalog("antigravity");
assert.equal(
antigravityCatalog.models.some((model) => model.id === "gemini-3.8-flash-high"),
true,
"antigravity live lookup must union the sibling agy CLI catalog"
);
});

View File

@@ -55,4 +55,10 @@ describe("providerUsesExclusiveSyncedListing", () => {
assert.equal(providerUsesExclusiveSyncedListing("openai"), false);
assert.equal(providerUsesExclusiveSyncedListing(""), false);
});
it("test 10: exclusive listing stays cursor-only; claude is not cursor", () => {
assert.equal(providerUsesExclusiveSyncedListing("claude"), false);
assert.equal(providerUsesExclusiveSyncedListing("codex"), false);
assert.equal(providerUsesExclusiveSyncedListing("agy"), false);
});
});

View File

@@ -441,3 +441,24 @@ test("modelSyncScheduler skips empty cycles and tolerates failing sync requests"
timers.restore();
}
});
test("test 12: default interval is 6h; env hours override; no-arg uses default", async () => {
const source = fs.readFileSync(
path.join(process.cwd(), "src/shared/services/modelSyncScheduler.ts"),
"utf8",
);
assert.match(source, /DEFAULT_INTERVAL_MS\s*=\s*6\s*\*\s*60\s*\*\s*60\s*\*\s*1000/);
assert.doesNotMatch(source, /DEFAULT_INTERVAL_MS\s*=\s*24\s*\*\s*60\s*\*\s*60\s*\*\s*1000/);
assert.match(source, /intervalMs\s*=\s*DEFAULT_INTERVAL_MS/);
const { DEFAULT_INTERVAL_MS } = await import("../../src/shared/services/modelSyncScheduler.ts");
assert.equal(DEFAULT_INTERVAL_MS, 6 * 60 * 60 * 1000);
});
test("test 12: MODEL_SYNC_INTERVAL_HOURS still wins over default", () => {
const source = fs.readFileSync(
path.join(process.cwd(), "src/shared/services/modelSyncScheduler.ts"),
"utf8",
);
assert.match(source, /MODEL_SYNC_INTERVAL_HOURS/);
assert.match(source, /envHours \* 60 \* 60 \* 1000/);
});

View File

@@ -0,0 +1,203 @@
/**
* Test 11 — C-layer read gate.
*
* models.dev is a pricing overlay, not a catalog source. `models_dev_pricing`
* may be full (including IDs this account never synced). The /v1/models row
* set must not grow from those keys. Forbidden: filtering the pricing cache
* to make this pass.
*
* Source guard is mandatory. The catalog row-set assertion runs when
* getUnifiedModelsResponse is reachable under DATA_DIR isolation; if the
* auth wall returns 401, the skip reason is documented in this file and the
* source guard still covers "does not fabricate catalog rows".
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-models-dev-read-gate-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "models-dev-read-gate-secret";
process.env.REQUIRE_API_KEY = "false";
const SYNCED_MODEL_ID = "claude-opus-4-6";
const UNVERIFIED_MODEL_ID = "unverified-market-id-not-synced";
const SYNCED_INPUT = 15;
const SYNCED_OUTPUT = 75;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const modelsDev = await import("../../src/lib/modelsDevSync.ts");
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
type CatalogRow = {
id: string;
owned_by?: string;
root?: string | null;
pricing?: Record<string, number> | null;
};
function readModelsDevSyncSources(): string[] {
const files = ["src/lib/modelsDevSync.ts"];
const dir = "src/lib/modelsDevSync";
if (fs.existsSync(dir)) {
for (const name of fs.readdirSync(dir)) {
if (name.endsWith(".ts")) files.push(path.join(dir, name));
}
}
return files;
}
function rowMatchesUnverified(row: CatalogRow): boolean {
const id = row.id ?? "";
const root = row.root ?? "";
return (
id === UNVERIFIED_MODEL_ID ||
id.endsWith(`/${UNVERIFIED_MODEL_ID}`) ||
id.endsWith(UNVERIFIED_MODEL_ID) ||
root === UNVERIFIED_MODEL_ID
);
}
function rowMatchesSynced(row: CatalogRow): boolean {
const id = row.id ?? "";
const root = row.root ?? "";
return (
id === SYNCED_MODEL_ID ||
id.endsWith(`/${SYNCED_MODEL_ID}`) ||
root === SYNCED_MODEL_ID
);
}
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
v1ModelsCatalog.__resetCatalogBuilderRunsForTest();
}
async function seedClaudeSyncedAndFullPricingCache() {
const connection = await providersDb.createProviderConnection({
provider: "claude",
authType: "oauth",
name: "claude-read-gate",
apiKey: "",
accessToken: "claude-read-gate-access-token",
isActive: true,
testStatus: "active",
providerSpecificData: {},
});
await modelsDb.replaceSyncedAvailableModelsForConnection(
"claude",
(connection as { id: string }).id,
[{ id: SYNCED_MODEL_ID, name: "Claude Opus 4.6", source: "imported" }]
);
// Full overlay: a live-synced id AND a market id this connection never
// discovered. Do not filter this map to make the catalog assertion pass.
modelsDev.saveModelsDevPricing({
claude: {
[SYNCED_MODEL_ID]: { input: SYNCED_INPUT, output: SYNCED_OUTPUT },
[UNVERIFIED_MODEL_ID]: { input: 1, output: 2 },
},
});
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
test("test 11: modelsDevSync does not insert synced_models rows", () => {
for (const file of readModelsDevSyncSources()) {
const src = fs.readFileSync(file, "utf8");
assert.doesNotMatch(
src,
/replaceSyncedAvailableModels/,
`${file} must not write synced catalog rows from models.dev`
);
assert.doesNotMatch(
src,
/insertCustomModel/,
`${file} must not insert custom models from models.dev`
);
}
});
test("test 11: models.dev pricing cache may contain unverified ids (read gate, not write-side intersection)", async () => {
await seedClaudeSyncedAndFullPricingCache();
const pricing = modelsDev.getModelsDevPricing();
assert.ok(pricing.claude, "pricing overlay must keep the claude provider key");
assert.equal(pricing.claude[SYNCED_MODEL_ID]?.input, SYNCED_INPUT);
assert.equal(pricing.claude[UNVERIFIED_MODEL_ID]?.input, 1);
assert.equal(
pricing.claude[UNVERIFIED_MODEL_ID]?.output,
2,
"unverified market ids stay in models_dev_pricing; do not filter cache keys"
);
});
test("test 11: getUnifiedModelsResponse does not list unverified models.dev ids", async (t) => {
await seedClaudeSyncedAndFullPricingCache();
const pricing = modelsDev.getModelsDevPricing();
assert.ok(
pricing.claude?.[UNVERIFIED_MODEL_ID],
"precondition: pricing overlay still contains the unsynced market id"
);
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
new Request("http://127.0.0.1/v1/models")
);
if (response.status === 401 || response.status === 403) {
// Auth wall: keep the source guard (test above) as the "does not fabricate
// catalog rows" coverage. Do not weaken that guard, and do not filter
// models_dev_pricing keys to paper over a missing row-set assertion.
t.skip(
`catalog row-set skipped: getUnifiedModelsResponse returned ${response.status} (auth wall). ` +
"Source guard still covers modelsDevSync not writing synced_models / custom_models."
);
return;
}
assert.equal(
response.status,
200,
`expected catalog 200, got ${response.status}: ${(await response.clone().text()).slice(0, 500)}`
);
const body = (await response.json()) as { data: CatalogRow[] };
assert.ok(Array.isArray(body.data), "catalog body.data must be an array");
const leaked = body.data.filter(rowMatchesUnverified);
assert.deepEqual(
leaked.map((row) => row.id),
[],
`catalog must not list unverified models.dev id ${UNVERIFIED_MODEL_ID}`
);
const syncedRows = body.data.filter(rowMatchesSynced);
assert.ok(
syncedRows.length > 0,
`catalog must still list the live-synced id ${SYNCED_MODEL_ID}`
);
for (const row of syncedRows) {
if (row.pricing == null) continue;
const keys = Object.keys(row.pricing);
assert.ok(
keys.length > 0,
`synced row ${row.id} advertised pricing but the object was empty`
);
}
});

View File

@@ -4,6 +4,8 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { getCodexClientVersion } from "../../open-sse/config/codexClient.ts";
const TEST_DATA_DIR = fs.mkdtempSync(
path.join(os.tmpdir(), "omniroute-provider-model-routes-codex-")
);
@@ -159,11 +161,11 @@ test("provider models route merges live Codex models with the local catalog then
assert.equal(body.discoveredCandidateCount, undefined);
assert.deepEqual(seenRequests, [
{
url: "https://chatgpt.com/backend-api/codex/models?client_version=0.149.0",
url: `https://chatgpt.com/backend-api/codex/models?client_version=${getCodexClientVersion()}`,
authorization: "Bearer codex-access-token",
workspaceId: "account-123",
originator: "codex_cli_rs",
userAgent: "codex-cli/0.149.0 (Windows 10.0.26200; x64)",
userAgent: `codex-cli/${getCodexClientVersion()} (Windows 10.0.26200; x64)`,
},
{
url: "https://raw.githubusercontent.com/openai/codex/refs/heads/main/codex-rs/models-manager/models.json",
@@ -281,7 +283,7 @@ test("provider models route uses the GitHub Codex catalog when live discovery fa
assert.equal(response.status, 200);
assert.equal(body.provider, "codex");
assert.equal(body.source, "api");
assert.equal(body.source, "github_catalog");
assert.equal(body.intentional, undefined);
assert.equal(body.warning, "Codex live catalog unavailable — using GitHub model catalog");
assert.equal(body.discoveredCandidateCount, undefined);
@@ -293,6 +295,8 @@ test("provider models route uses the GitHub Codex catalog when live discovery fa
[...modelIds].some((id) => String(id).startsWith("gpt-5.4")),
false
);
const syncedModels = await modelsDb.getSyncedAvailableModelsForConnection("codex", connection.id);
assert.equal(syncedModels.length, 0, "GitHub models.json must not persist into synced catalog");
});
test("provider models route returns cached Codex models when refresh discovery fails", async () => {

View File

@@ -8,6 +8,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const {
maybeTriggerReactiveModelSync,
@@ -141,3 +143,45 @@ test("cleanup restores the default loopback sync implementation", () => {
__resetReactiveModelSyncForTests();
assert.ok(true);
});
test("test 13: claude/codex/github do not trigger reactive sync", () => {
__resetReactiveModelSyncForTests();
const calls = installCountingSync();
assert.equal(maybeTriggerReactiveModelSync("claude", "conn-claude"), false);
assert.equal(maybeTriggerReactiveModelSync("codex", "conn-codex"), false);
assert.equal(maybeTriggerReactiveModelSync("github", "conn-github"), false);
assert.equal(calls.length, 0);
});
test("test 13: antigravity executor still calls maybeTriggerReactiveModelSync", () => {
const src = fs.readFileSync(
path.join(process.cwd(), "open-sse/executors/antigravity/executeAttempt.ts"),
"utf8",
);
assert.match(src, /maybeTriggerReactiveModelSync\(\s*provider,\s*credentials\.connectionId\s*\)/);
});
test("test 14: claude live non-200 uses catalog fallback, not empty 502", () => {
const src = fs.readFileSync(
path.join(process.cwd(), "src/app/api/providers/[id]/models/route.ts"),
"utf8",
);
// Task 2 deleted the claude static early return. Claude is a
// PROVIDER_MODELS_CONFIG live provider and must land in generic live.
assert.doesNotMatch(
src,
/if\s*\(\s*provider\s*===\s*"claude"\s*\)[\s\S]{0,400}getStaticModelsForProvider\(\s*"claude"/,
);
const assembleIdx = src.lastIndexOf("assembleProviderModelsHeaders");
assert.ok(assembleIdx >= 0, "generic live must assemble provider-models headers");
const tail = src.slice(assembleIdx);
// Generic live 401/non-200: warning + cached/local catalog, not a 502 empty body.
assert.match(
tail,
/if\s*\(\s*!response\.ok\s*\)[\s\S]{0,400}buildDiscoveryFallbackResponse\(\s*\)/,
);
assert.doesNotMatch(
tail,
/if\s*\(\s*!response\.ok\s*\)[\s\S]{0,500}status:\s*502/,
);
});

View File

@@ -119,3 +119,43 @@ test("#9683: the sync-models route gates on the combined predicate", async () =>
"the narrow predicate must no longer be the route's only gate"
);
});
// ── github_catalog is display fallback, not persist ────────────────────────
// Codex live-empty GET returns `{ source: "github_catalog", warning: "…" }`
// via buildResponse. That payload must be refuse-to-sync, same class as #9683
// cache fallback: live discovery failed, public models.json is not authoritative.
const GITHUB_CATALOG_FALLBACK = {
source: "github_catalog",
warning: "Codex live catalog unavailable — using GitHub model catalog",
};
test("github_catalog with a warning is a degraded discovery", () => {
assert.equal(isDegradedDiscovery(GITHUB_CATALOG_FALLBACK), true);
assert.equal(
isDegradedDiscovery({
source: " GITHUB_CATALOG ",
warning: GITHUB_CATALOG_FALLBACK.warning,
}),
true,
"source match is case-insensitive like cache"
);
});
test("healthy api and warning-less cache stay successful discoveries", () => {
assert.equal(isDegradedDiscovery({ source: "api" }), false);
assert.equal(isDegradedDiscovery({ source: "api", warning: "anything" }), false);
assert.equal(isDegradedDiscovery(HEALTHY_CACHE), false);
assert.equal(
isDegradedDiscovery({ source: "github_catalog" }),
false,
"github_catalog without a warning is not the Codex live-empty fallback"
);
for (const blank of ["", " "]) {
assert.equal(
isDegradedDiscovery({ source: "github_catalog", warning: blank }),
false,
"a blank warning is not a degradation signal"
);
}
});