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)
);
}