mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-15 03:12:36 +03:00
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:
2
changelog.d/features/12866-live-catalog-authority.md
Normal file
2
changelog.d/features/12866-live-catalog-authority.md
Normal 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))
|
||||
@@ -25,5 +25,5 @@ export const agyProvider: RegistryEntry = {
|
||||
},
|
||||
models: [...AGY_PUBLIC_MODELS],
|
||||
passthroughModels: true,
|
||||
liveCatalogAuthoritative: false,
|
||||
liveCatalogAuthoritative: true,
|
||||
};
|
||||
|
||||
@@ -25,5 +25,5 @@ export const antigravityProvider: RegistryEntry = {
|
||||
},
|
||||
models: [...ANTIGRAVITY_PUBLIC_MODELS],
|
||||
passthroughModels: true,
|
||||
liveCatalogAuthoritative: false,
|
||||
liveCatalogAuthoritative: true,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -194,6 +194,7 @@ export const codex = {
|
||||
}
|
||||
|
||||
const providerSpecificData = {
|
||||
autoSync: true,
|
||||
workspaceId,
|
||||
workspacePlanType: planType,
|
||||
// Also store the full authInfo for future reference
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
26
src/lib/providerModels/claudeModelsHeaders.ts
Normal file
26
src/lib/providerModels/claudeModelsHeaders.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
12
src/lib/providerModels/copilotDiscoveryToken.ts
Normal file
12
src/lib/providerModels/copilotDiscoveryToken.ts
Normal 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);
|
||||
}
|
||||
31
src/lib/providerModels/discoveryClass.ts
Normal file
31
src/lib/providerModels/discoveryClass.ts
Normal 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";
|
||||
}
|
||||
51
src/lib/providerModels/hardcodedModelsConfigIds.ts
Normal file
51
src/lib/providerModels/hardcodedModelsConfigIds.ts
Normal 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",
|
||||
]);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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*\(/);
|
||||
});
|
||||
|
||||
51
tests/unit/claude-oauth-models-discovery.test.ts
Normal file
51
tests/unit/claude-oauth-models-discovery.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
42
tests/unit/copilot-discovery-token.test.ts
Normal file
42
tests/unit/copilot-discovery-token.test.ts
Normal 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\)/,
|
||||
);
|
||||
});
|
||||
48
tests/unit/discovery-class.test.ts
Normal file
48
tests/unit/discovery-class.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
);
|
||||
|
||||
34
tests/unit/l1-oauth-autosync-default.test.ts
Normal file
34
tests/unit/l1-oauth-autosync-default.test.ts
Normal 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/);
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
203
tests/unit/models-dev-catalog-read-gate.test.ts
Normal file
203
tests/unit/models-dev-catalog-read-gate.test.ts
Normal 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`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user