diff --git a/changelog.d/features/12866-live-catalog-authority.md b/changelog.d/features/12866-live-catalog-authority.md new file mode 100644 index 0000000000..3e7e5c7cb7 --- /dev/null +++ b/changelog.d/features/12866-live-catalog-authority.md @@ -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)) diff --git a/open-sse/config/providers/registry/agy/index.ts b/open-sse/config/providers/registry/agy/index.ts index 661f3f8ab7..4a49f9574d 100644 --- a/open-sse/config/providers/registry/agy/index.ts +++ b/open-sse/config/providers/registry/agy/index.ts @@ -25,5 +25,5 @@ export const agyProvider: RegistryEntry = { }, models: [...AGY_PUBLIC_MODELS], passthroughModels: true, - liveCatalogAuthoritative: false, + liveCatalogAuthoritative: true, }; diff --git a/open-sse/config/providers/registry/antigravity/index.ts b/open-sse/config/providers/registry/antigravity/index.ts index c3080b0103..4456e67758 100644 --- a/open-sse/config/providers/registry/antigravity/index.ts +++ b/open-sse/config/providers/registry/antigravity/index.ts @@ -25,5 +25,5 @@ export const antigravityProvider: RegistryEntry = { }, models: [...ANTIGRAVITY_PUBLIC_MODELS], passthroughModels: true, - liveCatalogAuthoritative: false, + liveCatalogAuthoritative: true, }; diff --git a/open-sse/services/usage/antigravity.ts b/open-sse/services/usage/antigravity.ts index 66ceab9311..f251e6d98a 100644 --- a/open-sse/services/usage/antigravity.ts +++ b/open-sse/services/usage/antigravity.ts @@ -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; diff --git a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts index cc45e6b727..72946e44c5 100644 --- a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts +++ b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts @@ -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 { + 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 = 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: { diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 2e6bb736a2..bcf5de3e94 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -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 = { diff --git a/src/app/api/providers/[id]/sync-models/degradedLocalCatalog.ts b/src/app/api/providers/[id]/sync-models/degradedLocalCatalog.ts index dc2d8966aa..80ae370e9f 100644 --- a/src/app/api/providers/[id]/sync-models/degradedLocalCatalog.ts +++ b/src/app/api/providers/[id]/sync-models/degradedLocalCatalog.ts @@ -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) + ); } diff --git a/src/lib/db/models/activeSyncedCatalog.ts b/src/lib/db/models/activeSyncedCatalog.ts index 443b37f894..4e68a17d2b 100644 --- a/src/lib/db/models/activeSyncedCatalog.ts +++ b/src/lib/db/models/activeSyncedCatalog.ts @@ -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 = { + 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(); + 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 { + 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 { const storedProviderId = resolveStoredProviderId(providerId); if (!storedProviderId) { @@ -161,27 +206,13 @@ export async function getActiveSyncedCatalog(providerId: string): Promise 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 { diff --git a/src/lib/oauth/providers/claude.ts b/src/lib/oauth/providers/claude.ts index fbe3af836a..27b81d7fba 100644 --- a/src/lib/oauth/providers/claude.ts +++ b/src/lib/oauth/providers/claude.ts @@ -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; diff --git a/src/lib/oauth/providers/codex.ts b/src/lib/oauth/providers/codex.ts index 6829f0c560..578b9a86fe 100644 --- a/src/lib/oauth/providers/codex.ts +++ b/src/lib/oauth/providers/codex.ts @@ -194,6 +194,7 @@ export const codex = { } const providerSpecificData = { + autoSync: true, workspaceId, workspacePlanType: planType, // Also store the full authInfo for future reference diff --git a/src/lib/oauth/providers/ghe-copilot.ts b/src/lib/oauth/providers/ghe-copilot.ts index 35218e7c05..f540a73394 100644 --- a/src/lib/oauth/providers/ghe-copilot.ts +++ b/src/lib/oauth/providers/ghe-copilot.ts @@ -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, diff --git a/src/lib/oauth/providers/github.ts b/src/lib/oauth/providers/github.ts index 2a2b1739f2..b3e47e1257 100644 --- a/src/lib/oauth/providers/github.ts +++ b/src/lib/oauth/providers/github.ts @@ -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, diff --git a/src/lib/providerModels/claudeModelsHeaders.ts b/src/lib/providerModels/claudeModelsHeaders.ts new file mode 100644 index 0000000000..51853476dd --- /dev/null +++ b/src/lib/providerModels/claudeModelsHeaders.ts @@ -0,0 +1,26 @@ +import { getClaudeCodeVersion } from "@omniroute/open-sse/executors/claudeIdentity.ts"; + +export function buildClaudeModelsHeaders(input: { + accessToken?: string | null; + apiKey?: string | null; +}): Record { + 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, + }; +} diff --git a/src/lib/providerModels/copilotDiscoveryToken.ts b/src/lib/providerModels/copilotDiscoveryToken.ts new file mode 100644 index 0000000000..a7c49d14cc --- /dev/null +++ b/src/lib/providerModels/copilotDiscoveryToken.ts @@ -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); +} diff --git a/src/lib/providerModels/discoveryClass.ts b/src/lib/providerModels/discoveryClass.ts new file mode 100644 index 0000000000..21b345d6be --- /dev/null +++ b/src/lib/providerModels/discoveryClass.ts @@ -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(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"; +} diff --git a/src/lib/providerModels/hardcodedModelsConfigIds.ts b/src/lib/providerModels/hardcodedModelsConfigIds.ts new file mode 100644 index 0000000000..088a9e0274 --- /dev/null +++ b/src/lib/providerModels/hardcodedModelsConfigIds.ts @@ -0,0 +1,51 @@ +/** Keys of PROVIDER_MODELS_CONFIG. Lockstep test in discovery-class.test.ts. */ +export const HARDCODED_MODELS_CONFIG_IDS: ReadonlySet = 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", +]); diff --git a/src/lib/usage/providerLimits/quotaNormalize.ts b/src/lib/usage/providerLimits/quotaNormalize.ts index de1dffefa8..647bb1bb81 100644 --- a/src/lib/usage/providerLimits/quotaNormalize.ts +++ b/src/lib/usage/providerLimits/quotaNormalize.ts @@ -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; @@ -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; } diff --git a/src/shared/constants/codexClient.ts b/src/shared/constants/codexClient.ts index d045191afd..a339097123 100644 --- a/src/shared/constants/codexClient.ts +++ b/src/shared/constants/codexClient.ts @@ -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( diff --git a/src/shared/services/modelSyncScheduler.ts b/src/shared/services/modelSyncScheduler.ts index c63a5e50ac..3812a293ea 100644 --- a/src/shared/services/modelSyncScheduler.ts +++ b/src/shared/services/modelSyncScheduler.ts @@ -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 { /** * 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(), diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index fbd4d1d22c..fb0b76f75a 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -1311,16 +1311,16 @@ "Authorization": "Bearer ", "Content-Type": "application/json", "Openai-Beta": "responses=experimental", - "User-Agent": "codex-cli/0.149.0 (; )", - "Version": "0.149.0", + "User-Agent": "codex-cli/0.153.2 (; )", + "Version": "0.153.2", "X-Codex-Beta-Features": "responses_websockets" }, "nonStream": { "Authorization": "Bearer ", "Content-Type": "application/json", "Openai-Beta": "responses=experimental", - "User-Agent": "codex-cli/0.149.0 (; )", - "Version": "0.149.0", + "User-Agent": "codex-cli/0.153.2 (; )", + "Version": "0.153.2", "X-Codex-Beta-Features": "responses_websockets" }, "oauth": { @@ -1328,8 +1328,8 @@ "Authorization": "Bearer ", "Content-Type": "application/json", "Openai-Beta": "responses=experimental", - "User-Agent": "codex-cli/0.149.0 (; )", - "Version": "0.149.0", + "User-Agent": "codex-cli/0.153.2 (; )", + "Version": "0.153.2", "X-Codex-Beta-Features": "responses_websockets" } }, diff --git a/tests/unit/agentrouter-executor-protocols.test.ts b/tests/unit/agentrouter-executor-protocols.test.ts index 33900b2168..a9b9030d87 100644 --- a/tests/unit/agentrouter-executor-protocols.test.ts +++ b/tests/unit/agentrouter-executor-protocols.test.ts @@ -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); diff --git a/tests/unit/agy-provider.test.ts b/tests/unit/agy-provider.test.ts index 4c7c7152fb..ae049575bd 100644 --- a/tests/unit/agy-provider.test.ts +++ b/tests/unit/agy-provider.test.ts @@ -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). diff --git a/tests/unit/claude-codex-identity-version-sync.test.ts b/tests/unit/claude-codex-identity-version-sync.test.ts index cda482fba9..0c2a60754a 100644 --- a/tests/unit/claude-codex-identity-version-sync.test.ts +++ b/tests/unit/claude-codex-identity-version-sync.test.ts @@ -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( + entries: Record, + fn: () => T | Promise +): Promise { + const previous = new Map(); + 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*\(/); }); diff --git a/tests/unit/claude-oauth-models-discovery.test.ts b/tests/unit/claude-oauth-models-discovery.test.ts new file mode 100644 index 0000000000..443b4eef92 --- /dev/null +++ b/tests/unit/claude-oauth-models-discovery.test.ts @@ -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); +}); diff --git a/tests/unit/client-identity-profiles.test.ts b/tests/unit/client-identity-profiles.test.ts index 6c24c54eaa..698b6d60ea 100644 --- a/tests/unit/client-identity-profiles.test.ts +++ b/tests/unit/client-identity-profiles.test.ts @@ -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"); }); diff --git a/tests/unit/copilot-discovery-token.test.ts b/tests/unit/copilot-discovery-token.test.ts new file mode 100644 index 0000000000..fea874d3d5 --- /dev/null +++ b/tests/unit/copilot-discovery-token.test.ts @@ -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\)/, + ); +}); diff --git a/tests/unit/discovery-class.test.ts b/tests/unit/discovery-class.test.ts new file mode 100644 index 0000000000..f3c2fb9015 --- /dev/null +++ b/tests/unit/discovery-class.test.ts @@ -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); +}); diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index 725d8d4e10..a7814b88f5 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -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"); } ); diff --git a/tests/unit/l1-oauth-autosync-default.test.ts b/tests/unit/l1-oauth-autosync-default.test.ts new file mode 100644 index 0000000000..99486ac38d --- /dev/null +++ b/tests/unit/l1-oauth-autosync-default.test.ts @@ -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/); +}); diff --git a/tests/unit/live-model-catalog-reconciliation-8926.test.ts b/tests/unit/live-model-catalog-reconciliation-8926.test.ts index 5bc1d15c54..b7571e6ca8 100644 --- a/tests/unit/live-model-catalog-reconciliation-8926.test.ts +++ b/tests/unit/live-model-catalog-reconciliation-8926.test.ts @@ -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:`. 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" + ); +}); diff --git a/tests/unit/model-listing-capability-5420.test.ts b/tests/unit/model-listing-capability-5420.test.ts index 8a37f38e33..c15a3bfb2e 100644 --- a/tests/unit/model-listing-capability-5420.test.ts +++ b/tests/unit/model-listing-capability-5420.test.ts @@ -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); + }); }); diff --git a/tests/unit/model-sync-scheduler.test.ts b/tests/unit/model-sync-scheduler.test.ts index c8a6e2bfd4..9d67839cc7 100644 --- a/tests/unit/model-sync-scheduler.test.ts +++ b/tests/unit/model-sync-scheduler.test.ts @@ -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/); +}); diff --git a/tests/unit/models-dev-catalog-read-gate.test.ts b/tests/unit/models-dev-catalog-read-gate.test.ts new file mode 100644 index 0000000000..ed7318c79d --- /dev/null +++ b/tests/unit/models-dev-catalog-read-gate.test.ts @@ -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 | 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` + ); + } +}); diff --git a/tests/unit/provider-models-route-codex.test.ts b/tests/unit/provider-models-route-codex.test.ts index 5a0ea5e792..c63841f460 100644 --- a/tests/unit/provider-models-route-codex.test.ts +++ b/tests/unit/provider-models-route-codex.test.ts @@ -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 () => { diff --git a/tests/unit/reactive-model-sync.test.ts b/tests/unit/reactive-model-sync.test.ts index ee72c67aad..a6803827bd 100644 --- a/tests/unit/reactive-model-sync.test.ts +++ b/tests/unit/reactive-model-sync.test.ts @@ -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/, + ); +}); diff --git a/tests/unit/sync-models-degraded-cached-catalog-9683.test.ts b/tests/unit/sync-models-degraded-cached-catalog-9683.test.ts index beb0fe76c0..e18ccc3e21 100644 --- a/tests/unit/sync-models-degraded-cached-catalog-9683.test.ts +++ b/tests/unit/sync-models-degraded-cached-catalog-9683.test.ts @@ -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" + ); + } +});