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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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