import { CHARM_HYPER_API_BASE_URL, normalizeCharmHyperBaseUrl } from "../wire/charm-hyper"; import { CODEX_BASE_URL, CODEX_CLIENT_VERSION } from "../wire/codex"; import { CURSOR_DEFAULT_BASE_URL } from "../wire/cursor"; import { type AccountScope, factoryDroidModelCacheProviderId } from "../wire/factory-droid"; import { PERSONAL_GITHUB_COPILOT_BASE_URL } from "../wire/github-copilot"; import { SINGULARITYAPI_DEV_API_BASE_URL, SINGULARITYAPI_TECH_API_BASE_URL, normalizeSingularityApiBaseUrl, } from "../wire/singularityapi"; export interface ModelCacheProviderIdOptions extends AccountScope { apiKey?: string; baseUrl?: string; } const CREDENTIAL_SCOPED_MODEL_CACHE_PROVIDERS: Readonly> = { "opencode-go": true, "opencode-zen": true, "github-copilot": true, "muse-code": true, cursor: true, "factory-droid": true, // Both SingularityAPI rosters are issued per key, so the namespace must be // resolved with the credential (`hydrateCredentialScopedModelCaches`) rather // than from the synchronous, credential-less startup read. "singularityapi-dev": true, "singularityapi-tech": true, // Kimi Code's roster is plan-gated and discovery is authoritative, so a // shared namespace would serve one plan's ids to another plan's credential. "kimi-code": true, // Otari's roster is the set of models one workspace enables, and discovery // is authoritative, so a shared namespace would serve one workspace's ids, // prices, and limits to another workspace's key for the full TTL. otari: true, // LiteLLM virtual keys have different /v1/models rosters on the same proxy. litellm: true, }; /** Whether a provider's model-cache namespace requires its resolved credential. */ export function isCredentialScopedModelCacheProvider(providerId: string): boolean { return CREDENTIAL_SCOPED_MODEL_CACHE_PROVIDERS[providerId] === true; } export function getDefaultModelDiscoveryBaseUrl(providerId: string): string | undefined { switch (providerId) { case "charm-hyper": return CHARM_HYPER_API_BASE_URL; case "kimi-code": return "https://api.kimi.com/coding/v1"; case "meta": case "muse-code": return "https://api.meta.ai/v1"; case "ollama": return "http://127.0.0.1:11434"; case "litellm": return Bun.env.LITELLM_BASE_URL ?? "http://localhost:4000/v1"; case "otari": return Bun.env.OTARI_BASE_URL?.trim() || "https://api.otari.ai/api/v1"; case "opencode-go": return "https://opencode.ai/inference/go/v1"; case "opencode-zen": return "https://opencode.ai/zen/v1"; case "vllm": return "http://127.0.0.1:8000/v1"; default: return undefined; } } /** Resolve an Ollama model-cache namespace scoped to the normalized discovery endpoint. */ export function resolveOllamaModelCacheProviderId(providerId: string, baseUrl?: string): string { const defaultBaseUrl = getDefaultModelDiscoveryBaseUrl("ollama")!; let endpoint = defaultBaseUrl; try { const parsed = new URL(baseUrl ?? defaultBaseUrl); const trimmedPath = parsed.pathname.replace(/\/+$/g, ""); const nativePath = trimmedPath.endsWith("/v1") ? trimmedPath.slice(0, -3) : trimmedPath; endpoint = `${parsed.protocol}//${parsed.host}${nativePath}`; } catch { // Malformed URLs fall back during discovery, so share the default endpoint's cache. } return `${providerId}:ollama-models-v1:${Bun.hash(endpoint).toString(36)}`; } /** * The `sub` claim of a JWT access token. Cursor and Kimi Code access tokens * are JWTs that refresh rotates (new `exp`/`iat`/`jti`) for the same account, * so the subject is the stable account scope. Non-JWT keys return undefined. */ function jwtCredentialSubject(apiKey: string): string | undefined { const parts = apiKey.split("."); if (parts.length !== 3 || !parts[1]) return undefined; try { const payload: unknown = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8")); if (typeof payload !== "object" || payload === null) return undefined; const subject = Reflect.get(payload, "sub"); return typeof subject === "string" && subject.length > 0 ? `sub:${subject}` : undefined; } catch { return undefined; } } /** Resolve the cache namespace used by a provider's model-manager options without constructing those options. */ export function resolveModelCacheProviderId(providerId: string, options: ModelCacheProviderIdOptions = {}): string { switch (providerId) { case "openai-codex": { // The backend filters the roster by client version. A Codex-compatible // gateway publishes its own roster; discovery is authoritative, so the // namespace must follow the endpoint. The official backend keeps the // unhashed namespace so existing caches stay valid. const baseUrl = options.baseUrl?.trim().replace(/\/+$/, ""); if (!baseUrl || baseUrl === CODEX_BASE_URL) return `${providerId}:${CODEX_CLIENT_VERSION}`; return `${providerId}:${CODEX_CLIENT_VERSION}:${Bun.hash(baseUrl).toString(36)}`; } case "ollama": return resolveOllamaModelCacheProviderId(providerId, options.baseUrl); case "cursor": { // Cursor catalogs are entitlement-, admin-policy-, and privacy-mode // scoped. A credential switch must never reuse another account's // authoritative model rows. // v3 invalidates zero-price rows written before rich lanes were priced // from the KDL rate card; v4 keys the scope on the token's stable // account subject instead of the rotating JWT; v5 drops rows that still // mark the account default `isProviderDefault`, which startup now honors. const baseUrl = (options.baseUrl ?? CURSOR_DEFAULT_BASE_URL).replace(/\/+$/, ""); const apiKey = options.apiKey ?? ""; const scope = `${jwtCredentialSubject(apiKey) ?? apiKey}\u0000${baseUrl}`; return `cursor:rich-models-v5:${Bun.hash(scope).toString(36)}`; } case "charm-hyper": { // Discovery is authoritative for this gateway, so a warm cache is served // for its full TTL without re-probing: the namespace must follow the // configured endpoint, or a self-hosted proxy keeps serving the canonical // host's roster, capabilities and tariffs until expiry. // // Endpoint-only scope is deliberate. `/v1/models` is public here, so the // roster does not vary by key, and `charm-hyper` is absent from // CREDENTIAL_SCOPED_MODEL_CACHE_PROVIDERS — `ModelRegistry` resolves this // namespace with no credential at all, so hashing one would split it // against the namespace discovery computes and miss forever. // // Normalized through the shared helper because the registry passes the // raw configured value while `charmHyperModelManagerOptions` passes a // `/v1`-suffixed one; both must land on one namespace. return `charm-hyper:models-v1:${Bun.hash(normalizeCharmHyperBaseUrl(options.baseUrl)).toString(36)}`; } case "kimi-code": { // The `/coding/v1/models` roster depends on the subscription plan behind // the credential, and discovery is authoritative: a namespace without the // credential would serve the previous plan's roster for the full TTL, // including ids the current plan cannot call. OAuth access tokens rotate on // refresh, so the scope is the token's account subject, not the token. const baseUrl = options.baseUrl ?? getDefaultModelDiscoveryBaseUrl(providerId)!; const apiKey = options.apiKey ?? ""; const scope = `${jwtCredentialSubject(apiKey) ?? apiKey}\u0000${baseUrl}`; return `kimi-code:models-v1:${Bun.hash(scope).toString(36)}`; } // Per-key rosters (Otari: one workspace's enabled models) behind a // configurable endpoint: scope by both so switching either re-runs discovery. case "muse-code": case "otari": { const baseUrl = options.baseUrl ?? getDefaultModelDiscoveryBaseUrl(providerId)!; const scope = `${options.apiKey ?? ""}\u0000${baseUrl}`; return `${providerId}:models-v1:${Bun.hash(scope).toString(36)}`; } case "singularityapi-dev": case "singularityapi-tech": { // Both products issue their roster per key, and a configured proxy // publishes its own. Discovery is authoritative, so a shared namespace // would serve the previous key's roster for the full 24h TTL — including // ids the current key cannot call. Hashing the pair means switching // either re-runs discovery instead, and the provider-id prefix keeps the // two products from ever reading each other's rows behind one proxy. // // Both call paths must land on one namespace: `ModelRegistry` resolves // this provider through the credential-scoped hydration pass (it is in // CREDENTIAL_SCOPED_MODEL_CACHE_PROVIDERS), while discovery hashes the // `/v1`-suffixed endpoint the matching `singularityApi*ModelManagerOptions` // passes — which is why both normalize through // `normalizeSingularityApiBaseUrl` against their own canonical host. const canonical = providerId === "singularityapi-tech" ? SINGULARITYAPI_TECH_API_BASE_URL : SINGULARITYAPI_DEV_API_BASE_URL; const baseUrl = normalizeSingularityApiBaseUrl(options.baseUrl, canonical); const scope = `${options.apiKey ?? ""}\u0000${baseUrl}`; return `${providerId}:models-v1:${Bun.hash(scope).toString(36)}`; } case "litellm": { const baseUrl = (options.baseUrl ?? getDefaultModelDiscoveryBaseUrl(providerId)!).trim().replace(/\/+$/, ""); // The selectable roster is issued per virtual key; old rich-only // snapshots must not survive the switch to scoped discovery. Bump the // version whenever the LiteLLM mappers change, or warm caches keep // serving pre-change rows for the full TTL (rich-v1…v14 history in git). const scope = `${options.apiKey ?? ""}\u0000${baseUrl}`; return `litellm:scoped-v1:${Bun.hash(scope).toString(36)}`; } case "gmi-cloud": case "siliconflow": case "siliconflow-cn": // models-v1 moves rows enriched before cross-provider reference // isolation out of the legacy bare-provider namespaces (#10932). return `${providerId}:models-v1`; case "opencode-go": case "opencode-zen": { // v3: gateway-first rows cached before stencil enrichment carry null // limits and `reasoning: false`; use a fresh namespace so they refetch. const configuredBaseUrl = options.baseUrl ?? getDefaultModelDiscoveryBaseUrl(providerId)!; const trimmedBaseUrl = configuredBaseUrl.endsWith("/") ? configuredBaseUrl.slice(0, -1) : configuredBaseUrl; const discoveryBaseUrl = trimmedBaseUrl.endsWith("/v1") ? trimmedBaseUrl : `${trimmedBaseUrl}/v1`; const scope = `${options.apiKey ?? ""}\u0000${discoveryBaseUrl}`; return `${providerId}:models-v3:${Bun.hash(scope).toString(36)}`; } case "github-copilot": { // Copilot model specs bake in the plan-specific endpoint (personal vs // Business/Enterprise) resolved from the credential. Discovery writes an // authoritative cache, so `online-if-uncached` serves it for the full // TTL without re-probing. Keying the namespace on the credential means // switching `COPILOT_GITHUB_TOKEN` to a different account misses the // prior endpoint's cache and re-runs discovery instead of hitting the // stale host and 403ing (PR #8510 review). // v2: rows cached before the cross-provider routing strip inherit // Cursor collapsed-family wire ids (e.g. enterprise-only // `gpt-5.6-sol-fast` pinned to `-none-fast`); use a fresh namespace // so they refetch instead of serving the poisoned rows. Listing ids // cannot cover this class — any enterprise-only sibling can carry // another provider's routing — so version the namespace instead. const baseUrl = options.baseUrl ?? PERSONAL_GITHUB_COPILOT_BASE_URL; const scope = `${options.apiKey ?? ""}\u0000${baseUrl}`; return `github-copilot:models-v2:${Bun.hash(scope).toString(36)}`; } case "factory-droid": return factoryDroidModelCacheProviderId(options); case "openrouter": return "openrouter:pseudo-api"; case "vllm": { // v2: qwen3.8 rows cached before the reasoning/template-effort upgrade // carry `reasoning: false` and must be refetched. const baseUrl = options.baseUrl ?? getDefaultModelDiscoveryBaseUrl(providerId)!; return `vllm:models-v2:${Bun.hash(baseUrl).toString(36)}`; } case "devin": // v2: rows cached before Fusion pairings carried their lead uid as // `requestModelId` send the composite uid and fail with // `permission_denied: no API providers are available`. // v3: rows cached before discovery read `ModelInfo.maxTokens` hold a // 200K window on every model. return "devin:models-v3"; default: return providerId; } }