From a70e0cc4d720e3a512910f3ea327d2db94fc865e Mon Sep 17 00:00:00 2001 From: Haydern Date: Fri, 24 Jul 2026 00:03:40 +0700 Subject: [PATCH 1/2] add canonical free provider directory --- src/providers/derive.ts | 28 ++++- src/providers/free-directory.ts | 168 +++++++++++++++++++++++++ src/providers/registry.ts | 41 +++++- tests/provider-registry-parity.test.ts | 4 +- 4 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 src/providers/free-directory.ts diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 7277d5e87..22f19a6d4 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -1,5 +1,6 @@ import type { CodexAccountMode, OcxProviderConfig } from "../types"; import { PROVIDER_REGISTRY, type ProviderRegistryEntry } from "./registry"; +import type { ProviderAccessGroup } from "./free-directory"; export interface DerivedKeyLoginProvider { label: string; @@ -67,6 +68,15 @@ export interface DerivedProviderPreset { baseUrlChoices?: Array<{ id: string; label: string; baseUrl?: string }>; /** Immutable canonical provider config seed for the reserved canonical `openai` forward preset. */ provider?: OcxProviderConfig; + accessGroups?: readonly ProviderAccessGroup[]; + supportLevel?: "supported" | "experimental" | "reference"; + verification?: "official" | "primary" | "unverified"; + documentationUrl?: string; + modelsUrl?: string; + discovery?: "live" | "static" | "hybrid" | "unsupported"; + lastVerified?: string; + models?: string[]; + liveModels?: boolean; } export function listRegistryEntries(): readonly ProviderRegistryEntry[] { @@ -125,7 +135,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon export function deriveKeyLoginMap(): Record { const out: Record = {}; for (const entry of PROVIDER_REGISTRY) { - if (entry.authKind !== "key") continue; + if (entry.authKind !== "key" || entry.supportLevel === "reference" || entry.directoryOnly) continue; if (!entry.dashboardUrl) throw new Error(`Registry key provider missing dashboardUrl: ${entry.id}`); out[entry.id] = { label: entry.label, @@ -165,7 +175,7 @@ export function deriveKeyLoginMap(): Record { } export function deriveInitProviders(): DerivedInitProvider[] { - return PROVIDER_REGISTRY.map(entry => ({ + return PROVIDER_REGISTRY.filter(entry => entry.supportLevel !== "reference" && !entry.directoryOnly).map(entry => ({ id: entry.id, label: formatInitLabel(entry), adapter: entry.adapter, @@ -192,7 +202,7 @@ export function deriveOAuthIds(): string[] { export function deriveProviderPresets(): DerivedProviderPreset[] { const presets = PROVIDER_REGISTRY - .filter(entry => entry.featured || entry.authKind === "key" || entry.dashboardPreset) + .filter(entry => entry.featured || entry.authKind === "key" || entry.dashboardPreset || entry.accessGroups?.length) .map(entryToPreset); return [...dedupePresets(presets), customPreset()]; } @@ -231,6 +241,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.freeTier === undefined && seed.freeTier !== undefined) prov.freeTier = seed.freeTier; if (prov.modelSuffixBracketStrip === undefined && seed.modelSuffixBracketStrip !== undefined) prov.modelSuffixBracketStrip = seed.modelSuffixBracketStrip; if (!prov.headers && seed.headers) prov.headers = { ...seed.headers }; + if (prov.googleMode === undefined && seed.googleMode !== undefined) prov.googleMode = seed.googleMode; + if (prov.project === undefined && seed.project !== undefined) prov.project = seed.project; + if (prov.location === undefined && seed.location !== undefined) prov.location = seed.location; } export function deriveFeaturedProviderIds(): string[] { @@ -270,6 +283,15 @@ function entryToPreset(entry: ProviderRegistryEntry): DerivedProviderPreset { ...(entry.keyOptional ? { keyOptional: true } : {}), ...(entry.freeTier ? { freeTier: true } : {}), ...(entry.baseUrlChoices ? { baseUrlChoices: entry.baseUrlChoices.map(c => ({ ...c })) } : {}), + ...(entry.accessGroups ? { accessGroups: [...entry.accessGroups] } : {}), + ...(entry.supportLevel ? { supportLevel: entry.supportLevel } : {}), + ...(entry.verification ? { verification: entry.verification } : {}), + ...(entry.documentationUrl ? { documentationUrl: entry.documentationUrl } : {}), + ...(entry.modelsUrl ? { modelsUrl: entry.modelsUrl } : {}), + ...(entry.discovery ? { discovery: entry.discovery } : {}), + ...(entry.lastVerified ? { lastVerified: entry.lastVerified } : {}), + ...(entry.models ? { models: [...entry.models] } : {}), + ...(entry.liveModels !== undefined ? { liveModels: entry.liveModels } : {}), }; } diff --git a/src/providers/free-directory.ts b/src/providers/free-directory.ts new file mode 100644 index 000000000..3cd4647af --- /dev/null +++ b/src/providers/free-directory.ts @@ -0,0 +1,168 @@ +export type ProviderAccessGroup = + | "recurring-or-keyless" + | "recurring-uncapped" + | "recurring-credit" + | "signup-credit"; + +export const FREE_PROVIDER_ACCESS_GROUPS = { + "recurring-or-keyless": [ + "agy", "aihorde", "api-airforce", "arcee-ai", "bazaarlink", "blackbox", "bluesminds", "cerebras", + "cloudflare-ai", "cohere", "coze", "duckduckgo-web", "felo-web", "friendliai", "gemini", "github-models", + "groq", "hackclub", "huggingchat", "huggingface", "iflytek", "inference-net", "kiro", "liquid", "llm7", + "mistral", "morph", "muse-spark-web", "nara", "navy", "nlpcloud", "ollama-cloud", "opencode", "openrouter", + "ovhcloud", "pollinations", "puter", "qwen-web", "reka", "sambanova", "sparkdesk", "t3-web", "uncloseai", + ], + "recurring-uncapped": [ + "agnes", "ainative", "aion", "baidu", "glm", "glm-cn", "kilo-gateway", "opencode-zen", "requesty", + "routeway", "sealion", "siliconflow", "tencent", + ], + "recurring-credit": ["bytez", "nous-research"], + "signup-credit": [ + "agentrouter", "ai21", "baichuan", "deepinfra", "deepseek", "doubao", "fireworks", "freemodel-dev", "glm-cn", + "hyperbolic", "longcat", "monsterapi", "nebius", "novita", "nscale", "nvidia", "predibase", "publicai", "qoder", + "scaleway", "sensenova", "stepfun", "together", "vertex", + ], +} as const satisfies Record; + +export interface FreeDirectoryProvider { + id: string; + label: string; + adapter: string; + baseUrl: string; + authKind: "oauth" | "key"; + accessGroups: readonly ProviderAccessGroup[]; + supportLevel: "supported" | "experimental" | "reference"; + verification: "official" | "primary" | "unverified"; + documentationUrl?: string; + modelsUrl?: string; + discovery: "live" | "static" | "hybrid" | "unsupported"; + lastVerified: string; + dashboardUrl?: string; + keyOptional?: boolean; + models?: string[]; + liveModels: boolean; + note?: string; + googleMode?: "ai-studio" | "vertex"; +} + +type ConnectableOverride = Partial>; + +const LAST_VERIFIED = "2026-07-23"; +const openAi = (baseUrl: string, dashboardUrl: string, extra: Partial = {}): ConnectableOverride => ({ + baseUrl, + dashboardUrl, + adapter: "openai-chat", + authKind: "key", + supportLevel: "experimental", + verification: "primary", + discovery: "live", + liveModels: true, + ...extra, +}); + +// API roots are limited to documented or primary-source integrations. Consumer-web/session +// providers remain reference-only: this directory never asks users to paste cookies or bypass WAFs. +const CONNECTABLE: Record = { + kiro: { baseUrl: "https://runtime.us-east-1.kiro.dev", dashboardUrl: "https://kiro.dev", adapter: "kiro", authKind: "oauth", supportLevel: "supported", verification: "official", documentationUrl: "https://kiro.dev/docs/cli/authentication/", discovery: "static", liveModels: false }, + aihorde: openAi("https://oai.aihorde.net/v1", "https://aihorde.net", { modelsUrl: "https://oai.aihorde.net/v1/models" }), + "arcee-ai": openAi("https://conductor.arcee.ai/v1", "https://conductor.arcee.ai", { verification: "official", documentationUrl: "https://docs.arcee.ai/" }), + cerebras: openAi("https://api.cerebras.ai/v1", "https://cloud.cerebras.ai/platform/apikeys", { supportLevel: "supported", verification: "official", documentationUrl: "https://inference-docs.cerebras.ai/api-reference/models/list" }), + "cloudflare-ai": openAi("https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1", "https://dash.cloudflare.com/?to=/:account/ai/workers-ai", { supportLevel: "supported", verification: "official", documentationUrl: "https://developers.cloudflare.com/workers-ai/configuration/open-ai-compatibility/", discovery: "static", liveModels: false, models: ["@cf/meta/llama-3.3-70b-instruct-fp8-fast", "@cf/qwen/qwq-32b"] }), + cohere: openAi("https://api.cohere.com/compatibility/v1", "https://dashboard.cohere.com/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.cohere.com/reference/list-models", modelsUrl: "https://api.cohere.com/compatibility/v1/models" }), + friendliai: openAi("https://api.friendli.ai/serverless/v1", "https://suite.friendli.ai", { modelsUrl: "https://api.friendli.ai/serverless/v1/models" }), + gemini: { baseUrl: "https://generativelanguage.googleapis.com", dashboardUrl: "https://aistudio.google.com/apikey", adapter: "google", authKind: "key", supportLevel: "supported", verification: "official", documentationUrl: "https://ai.google.dev/api/models", discovery: "live", liveModels: true, googleMode: "ai-studio", models: ["gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview"] }, + "github-models": openAi("https://models.github.ai/inference", "https://github.com/settings/tokens", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.github.com/en/github-models/prototyping-with-ai-models", discovery: "static", liveModels: false, models: ["openai/gpt-4.1", "meta/llama-4-scout-17b-16e-instruct"] }), + groq: openAi("https://api.groq.com/openai/v1", "https://console.groq.com/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://console.groq.com/docs/api-reference#models" }), + hackclub: openAi("https://ai.hackclub.com/proxy/v1", "https://ai.hackclub.com", { modelsUrl: "https://ai.hackclub.com/proxy/v1/models" }), + huggingface: openAi("https://router.huggingface.co/v1", "https://huggingface.co/settings/tokens", { supportLevel: "supported", verification: "official", documentationUrl: "https://huggingface.co/docs/inference-providers/index", modelsUrl: "https://router.huggingface.co/v1/models" }), + iflytek: openAi("https://spark-api-open.xf-yun.com/v1", "https://console.xfyun.cn", { verification: "official", documentationUrl: "https://www.xfyun.cn/doc/spark/Web.html" }), + liquid: openAi("https://inference.liquid.ai/v1", "https://playground.liquid.ai", { modelsUrl: "https://inference.liquid.ai/v1/models" }), + mistral: openAi("https://api.mistral.ai/v1", "https://console.mistral.ai/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.mistral.ai/api/endpoint/models" }), + morph: openAi("https://api.morphllm.com/v1", "https://morphllm.com", { verification: "official", documentationUrl: "https://docs.morphllm.com/api-reference/introduction" }), + "ollama-cloud": openAi("https://ollama.com/v1", "https://ollama.com/settings/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.ollama.com/cloud", modelsUrl: "https://ollama.com/api/tags" }), + opencode: openAi("https://opencode.ai/zen/v1", "https://opencode.ai/auth", { supportLevel: "supported", verification: "official", documentationUrl: "https://opencode.ai/docs/zen/", modelsUrl: "https://opencode.ai/zen/v1/models" }), + openrouter: openAi("https://openrouter.ai/api/v1", "https://openrouter.ai/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://openrouter.ai/docs/api/api-reference/models/get-models" }), + ovhcloud: openAi("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1", "https://console.ovhcloud.com", { keyOptional: true, modelsUrl: "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models" }), + pollinations: openAi("https://gen.pollinations.ai/v1", "https://pollinations.ai", { keyOptional: true }), + reka: openAi("https://api.reka.ai/v1", "https://platform.reka.ai"), + sambanova: openAi("https://api.sambanova.ai/v1", "https://cloud.sambanova.ai/apis", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.sambanova.ai/cloud/docs/api-reference/models" }), + sparkdesk: openAi("https://spark-api-open.xf-yun.com/v1", "https://console.xfyun.cn", { verification: "official", documentationUrl: "https://www.xfyun.cn/doc/spark/Web.html" }), + agnes: openAi("https://apihub.agnes-ai.com/v1", "https://agnes-ai.com"), + ainative: openAi("https://api.ainative.studio/api/v1", "https://ainative.studio", { modelsUrl: "https://api.ainative.studio/api/v1/models" }), + aion: openAi("https://api.aionlabs.ai/v1", "https://aionlabs.ai", { modelsUrl: "https://api.aionlabs.ai/v1/models" }), + baidu: openAi("https://qianfan.baidubce.com/v2", "https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application", { verification: "official", documentationUrl: "https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Fm2vrveyu", discovery: "static", liveModels: false, models: ["ernie-5.1", "ernie-5.0", "ernie-4.5-turbo-128k"] }), + glm: openAi("https://api.z.ai/api/coding/paas/v4", "https://z.ai/manage-apikey/apikey-list", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.z.ai/guides/overview/quick-start", discovery: "static", liveModels: false, models: ["glm-5"] }), + "glm-cn": openAi("https://open.bigmodel.cn/api/coding/paas/v4", "https://open.bigmodel.cn/usercenter/apikeys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.bigmodel.cn/cn/api/introduction", discovery: "static", liveModels: false, models: ["glm-4.5-flash"] }), + "kilo-gateway": openAi("https://api.kilo.ai/api/gateway", "https://kilo.ai", { modelsUrl: "https://api.kilo.ai/api/gateway/models" }), + "opencode-zen": openAi("https://opencode.ai/zen/v1", "https://opencode.ai/auth", { supportLevel: "supported", verification: "official", documentationUrl: "https://opencode.ai/docs/zen/" }), + requesty: openAi("https://router.requesty.ai/v1", "https://app.requesty.ai", { modelsUrl: "https://router.requesty.ai/v1/models" }), + routeway: openAi("https://api.routeway.ai/v1", "https://routeway.ai", { modelsUrl: "https://api.routeway.ai/v1/models" }), + sealion: openAi("https://api.sea-lion.ai/v1", "https://sea-lion.ai", { discovery: "static", liveModels: false, models: ["aisingapore/Llama-SEA-LION-v3.5-70B-R", "aisingapore/Llama-SEA-LION-v3-70B-IT", "aisingapore/Gemma-SEA-LION-v4-27B-IT", "aisingapore/Qwen-SEA-LION-v4.5-27B-IT", "aisingapore/Qwen-SEA-LION-v4-32B-IT"] }), + siliconflow: openAi("https://api.siliconflow.cn/v1", "https://cloud.siliconflow.cn/account/ak", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.siliconflow.cn/en/api-reference/models/get-model-list" }), + tencent: openAi("https://api.hunyuan.cloud.tencent.com/v1", "https://console.cloud.tencent.com/hunyuan/api-key", { verification: "official", documentationUrl: "https://cloud.tencent.com/document/product/1729/111007", discovery: "static", liveModels: false, models: ["hunyuan-turbos-latest", "hunyuan-t1-latest", "hunyuan-pro", "hunyuan-lite"] }), + bytez: openAi("https://api.bytez.com/models/v2/openai/v1", "https://bytez.com", { verification: "unverified", documentationUrl: "https://docs.bytez.com/", discovery: "static", liveModels: false, models: ["meta-llama/Llama-3.3-70B-Instruct", "mistralai/Mistral-7B-Instruct-v0.3", "Qwen/Qwen2.5-72B-Instruct"], note: "The recurring-credit classification is retained from the requested catalog, but the current reset terms could not be independently verified." }), + "nous-research": openAi("https://inference-api.nousresearch.com/v1", "https://portal.nousresearch.com", { discovery: "static", liveModels: false, models: ["Hermes-4-405B", "Hermes-4-70B"] }), + agentrouter: { baseUrl: "https://agentrouter.org", dashboardUrl: "https://agentrouter.org", adapter: "anthropic", authKind: "key", supportLevel: "experimental", verification: "primary", modelsUrl: "https://agentrouter.org/v1/models", discovery: "live", liveModels: true }, + ai21: openAi("https://api.ai21.com/studio/v1", "https://studio.ai21.com/account/api-key", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.ai21.com/reference/models" }), + baichuan: openAi("https://api.baichuan-ai.com/v1", "https://platform.baichuan-ai.com/console/apikey", { verification: "official" }), + deepinfra: openAi("https://api.deepinfra.com/v1/openai", "https://deepinfra.com/dash/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://deepinfra.com/docs/openai_api" }), + deepseek: openAi("https://api.deepseek.com", "https://platform.deepseek.com/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://api-docs.deepseek.com/api/list-models" }), + doubao: openAi("https://ark.cn-beijing.volces.com/api/v3", "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey", { verification: "official" }), + fireworks: openAi("https://api.fireworks.ai/inference/v1", "https://fireworks.ai/account/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.fireworks.ai/api-reference/list-models", modelsUrl: "https://api.fireworks.ai/v1/accounts/fireworks/models?filter=supports_serverless=true" }), + "freemodel-dev": openAi("https://api.freemodel.dev/v1", "https://freemodel.dev", { modelsUrl: "https://api.freemodel.dev/v1/models" }), + hyperbolic: openAi("https://api.hyperbolic.xyz/v1", "https://app.hyperbolic.xyz/settings", { verification: "official" }), + longcat: openAi("https://api.longcat.chat/openai/v1", "https://longcat.chat", { verification: "official", discovery: "static", liveModels: false, models: ["LongCat-2.0"] }), + monsterapi: openAi("https://api.monsterapi.ai/v1", "https://monsterapi.ai", { verification: "official" }), + nebius: openAi("https://api.tokenfactory.nebius.com/v1", "https://studio.nebius.com", { verification: "official" }), + novita: openAi("https://api.novita.ai/openai/v1", "https://novita.ai/settings/key-management", { supportLevel: "supported", verification: "official", modelsUrl: "https://api.novita.ai/openai/v1/models" }), + nscale: openAi("https://inference.api.nscale.com/v1", "https://console.nscale.com", { verification: "official" }), + nvidia: openAi("https://integrate.api.nvidia.com/v1", "https://build.nvidia.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.api.nvidia.com/nim/reference/llm-apis" }), + publicai: openAi("https://api.publicai.co/v1", "https://publicai.co"), + scaleway: openAi("https://api.scaleway.ai/v1", "https://console.scaleway.com/generative-api", { verification: "official" }), + sensenova: openAi("https://token.sensenova.cn/v1", "https://console.sensenova.cn", { verification: "official" }), + stepfun: openAi("https://api.stepfun.com/v1", "https://platform.stepfun.com", { verification: "official" }), + together: openAi("https://api.together.xyz/v1", "https://api.together.xyz/settings/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.together.ai/reference/models-1" }), + vertex: { baseUrl: "https://aiplatform.googleapis.com", dashboardUrl: "https://console.cloud.google.com/vertex-ai", adapter: "google", authKind: "key", supportLevel: "experimental", verification: "official", documentationUrl: "https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference", discovery: "static", liveModels: false, googleMode: "vertex", models: ["gemini-3.1-pro-preview", "gemini-3.1-flash-lite", "gemini-3-flash-preview"], note: "Vertex accepts an express-mode API key here. ADC users can instead configure project and location through the existing Google Vertex provider." }, +}; + +const LABELS: Record = { + agy: "AGY", aihorde: "AI Horde", "api-airforce": "API Airforce", "arcee-ai": "Arcee AI", + bazaarlink: "BazaarLink", blackbox: "Blackbox", bluesminds: "Bluesminds", "cloudflare-ai": "Cloudflare AI", + coze: "Coze", "duckduckgo-web": "DuckDuckGo Web", "felo-web": "Felo Web", friendliai: "FriendliAI", + gemini: "Google Gemini", "github-models": "GitHub Models", huggingchat: "HuggingChat", huggingface: "Hugging Face", + iflytek: "iFlytek", "inference-net": "Inference.net", llm7: "LLM7", "muse-spark-web": "Muse Spark Web", + nlpcloud: "NLP Cloud", "ollama-cloud": "Ollama Cloud", ovhcloud: "OVHcloud", "qwen-web": "Qwen Web", + "t3-web": "T3 Web", uncloseai: "UncloseAI", ainative: "AI Native", baidu: "Baidu Qianfan", + glm: "Z.AI GLM", "glm-cn": "BigModel GLM (CN)", "kilo-gateway": "Kilo Gateway", "opencode-zen": "OpenCode Zen", + sealion: "SEA-LION", bytez: "Bytez", "nous-research": "Nous Research", agentrouter: "AgentRouter", + ai21: "AI21", baichuan: "Baichuan", deepinfra: "DeepInfra", deepseek: "DeepSeek", doubao: "Doubao", + "freemodel-dev": "FreeModel.dev", nebius: "Nebius", novita: "Novita", nscale: "Nscale", nvidia: "NVIDIA NIM", + publicai: "PublicAI", qoder: "Qoder", sensenova: "SenseNova", stepfun: "StepFun", vertex: "Google Vertex AI", +}; + +const referenceNote = "Reference entry only: no safe documented API integration is enabled. Configure it manually only with provider documentation; consumer-web cookies and anti-bot bypasses are intentionally unsupported."; + +export const FREE_PROVIDER_DIRECTORY: readonly FreeDirectoryProvider[] = Object.values(FREE_PROVIDER_ACCESS_GROUPS) + .flat() + .filter((id, index, ids) => ids.indexOf(id) === index) + .map(id => { + const accessGroups = (Object.entries(FREE_PROVIDER_ACCESS_GROUPS) as [ProviderAccessGroup, readonly string[]][]) + .filter(([, ids]) => ids.includes(id)) + .map(([group]) => group); + const override = CONNECTABLE[id]; + return { + id, + label: LABELS[id] ?? id.split("-").map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" "), + adapter: "openai-chat", + baseUrl: "", + authKind: "key", + accessGroups, + supportLevel: "reference", + verification: "unverified", + discovery: "unsupported", + lastVerified: LAST_VERIFIED, + liveModels: false, + ...(!override ? { note: referenceNote } : {}), + ...override, + } satisfies FreeDirectoryProvider; + }); diff --git a/src/providers/registry.ts b/src/providers/registry.ts index bdf09f52b..5a579d445 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2,6 +2,7 @@ import type { CodexAccountMode, OcxProviderConfig } from "../types"; import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "./kiro-models"; import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS } from "./antigravity-models"; import type { ProviderBaseUrlChoice } from "./base-url-choices"; +import { FREE_PROVIDER_DIRECTORY, type ProviderAccessGroup } from "./free-directory"; import { QWEN_CLOUD_BASE_URL_CHOICES, QWEN_CLOUD_TOKEN_PLAN_BASE_URL, ALIBABA_INTL_BASE_URL_CHOICES, ALIBABA_INTL_TOKEN_PLAN_BASE_URL, @@ -83,6 +84,15 @@ export interface ProviderRegistryEntry { googleMode?: "ai-studio" | "vertex" | "cloud-code-assist"; project?: string; location?: string; + accessGroups?: readonly ProviderAccessGroup[]; + supportLevel?: "supported" | "experimental" | "reference"; + verification?: "official" | "primary" | "unverified"; + documentationUrl?: string; + modelsUrl?: string; + discovery?: "live" | "static" | "hybrid" | "unsupported"; + lastVerified?: string; + /** Catalog-only row; excluded from legacy CLI login/init projections. */ + directoryOnly?: boolean; } export type ProviderConfigSeed = Pick< @@ -333,7 +343,7 @@ const UMANS_MODEL_INPUT_MODALITIES: Record = Object.fromEntrie UMANS_MODELS.map(id => [id, UMANS_TEXT_ONLY_MODELS.includes(id) ? ["text"] : ["text", "image"]]), ); -export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ +const BASE_PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ { id: "openai", label: "OpenAI (Codex login)", @@ -763,6 +773,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.siliconflow.cn/account/ak", + baseUrlChoices: [ + { id: "china-mainland", label: "China mainland", baseUrl: "https://api.siliconflow.cn/v1" }, + { id: "international", label: "International", baseUrl: "https://api.siliconflow.com/v1" }, + ], liveModels: true, note: "OpenAI-compatible live model catalog; reasoning controls vary by model.", }, @@ -1010,6 +1024,31 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ { id: "gitlab-duo", label: "GitLab Duo", baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://gitlab.com/-/user_settings/personal_access_tokens" }, ]; +const BASE_PROVIDER_IDS = new Set(BASE_PROVIDER_REGISTRY.map(entry => entry.id)); +const FREE_DIRECTORY_BY_ID = new Map(FREE_PROVIDER_DIRECTORY.map(entry => [entry.id, entry])); + +export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ + ...BASE_PROVIDER_REGISTRY.map(entry => { + const directory = FREE_DIRECTORY_BY_ID.get(entry.id); + if (!directory) return entry; + return { + ...entry, + accessGroups: directory.accessGroups, + supportLevel: directory.supportLevel, + verification: directory.verification, + ...(directory.documentationUrl ? { documentationUrl: directory.documentationUrl } : {}), + ...(directory.modelsUrl ? { modelsUrl: directory.modelsUrl } : {}), + discovery: directory.discovery, + lastVerified: directory.lastVerified, + }; + }), + ...FREE_PROVIDER_DIRECTORY.filter(entry => !BASE_PROVIDER_IDS.has(entry.id)).map(entry => ({ + ...entry, + dashboardPreset: true, + directoryOnly: true, + })), +]; + export function getProviderRegistryEntry(id: string): ProviderRegistryEntry | undefined { return PROVIDER_REGISTRY.find(entry => entry.id === id); } diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 14d840e8f..b7bc99279 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -416,13 +416,13 @@ describe("provider registry parity", () => { expect(moonshot?.preserveReasoningContentModels).toContain("kimi-k3"); }); - test("LiteLLM is the only registry seed with optional key authentication", () => { + test("optional-key registry seeds are explicit", () => { const litellm = PROVIDER_REGISTRY.find(entry => entry.id === "litellm"); const optionalKeyProviders = PROVIDER_REGISTRY.filter(entry => entry.keyOptional).map(entry => entry.id); expect(litellm?.authKind).toBe("key"); expect(providerConfigSeed(litellm!).keyOptional).toBe(true); - expect(optionalKeyProviders).toEqual(["litellm", "opencode-free", "mimo-free"]); + expect(optionalKeyProviders).toEqual(["litellm", "opencode-free", "mimo-free", "ovhcloud", "pollinations"]); }); test("NVIDIA NIM is free-tier priced but still requires an API key", () => { From c42716deea33d79ea1d301ad3b86575b0b4b0dab Mon Sep 17 00:00:00 2001 From: Haydern Date: Fri, 24 Jul 2026 00:03:57 +0700 Subject: [PATCH 2/2] add safe provider model discovery --- src/codex/catalog/provider-fetch.ts | 66 +++++++-- src/oauth/index.ts | 11 +- src/server/management/provider-routes.ts | 111 ++++++++++++++- tests/google-models-listing.test.ts | 16 ++- tests/provider-directory-routing.test.ts | 138 ++++++++++++++++++ tests/provider-free-directory.test.ts | 170 +++++++++++++++++++++++ tests/provider-model-envelope.test.ts | 19 +++ 7 files changed, 506 insertions(+), 25 deletions(-) create mode 100644 tests/provider-directory-routing.test.ts create mode 100644 tests/provider-free-directory.test.ts create mode 100644 tests/provider-model-envelope.test.ts diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 04556b0b4..2b2722fe0 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -63,14 +63,53 @@ export type ProviderModelsApiItem = { }; }; -export function isProviderModelsApiItems(value: unknown): value is ProviderModelsApiItem[] { - return Array.isArray(value) && value.every(item => - item !== null - && typeof item === "object" - && !Array.isArray(item) - && typeof (item as { id?: unknown }).id === "string" - && (item as { id: string }).id.trim().length > 0 - ); +/** Normalize the common model-catalog envelopes without weakening malformed-response fallback. */ +export function parseProviderModelsApiItems(value: unknown, allowModelsEnvelope = false): ProviderModelsApiItem[] | null { + const rows = Array.isArray(value) + ? value + : value !== null && typeof value === "object" + ? (Array.isArray((value as { data?: unknown }).data) + ? (value as { data: unknown[] }).data + : allowModelsEnvelope && Array.isArray((value as { models?: unknown }).models) + ? (value as { models: unknown[] }).models + : null) + : null; + if (!rows) return null; + + const normalized: ProviderModelsApiItem[] = []; + for (const row of rows) { + if (typeof row === "string" && row.trim()) { + normalized.push({ id: row.trim() }); + continue; + } + if (row === null || typeof row !== "object" || Array.isArray(row)) return null; + const item = row as Record; + const rawId = typeof item.id === "string" ? item.id + : typeof item.name === "string" ? item.name + : typeof item.model === "string" ? item.model + : ""; + const id = rawId.trim() + .replace(/^models\//, "") + .replace(/^accounts\/fireworks\/models\//, ""); + if (!id) return null; + const contextLength = [ + item.context_length, + item.context_window, + item.contextWindow, + item.inputTokenLimit, + item.max_context_length, + item.max_model_len, + ].find(candidate => typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) as number | undefined; + normalized.push({ + id, + ...(typeof item.owned_by === "string" ? { owned_by: item.owned_by } : {}), + ...(contextLength ? { context_length: contextLength } : {}), + ...(item.metadata !== null && typeof item.metadata === "object" && !Array.isArray(item.metadata) + ? { metadata: item.metadata as ProviderModelsApiItem["metadata"] } + : {}), + }); + } + return normalized; } export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined { @@ -326,7 +365,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, return models; } - const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) }); + const res = await fetch(url, { headers, redirect: "error", signal: AbortSignal.timeout(8000) }); if (!res.ok) { const { models, fallback } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status }); console.warn( @@ -352,17 +391,16 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, ); return models; } - const data = json !== null && typeof json === "object" && !Array.isArray(json) - ? (json as { data?: unknown }).data - : undefined; - if (!isProviderModelsApiItems(data)) { + const allowsModelsEnvelope = (prov.adapter === "google" && (prov.googleMode ?? "ai-studio") === "ai-studio") + || new URL(url).pathname.replace(/\/+$/, "").endsWith("/api/tags"); + const items = parseProviderModelsApiItems(json, allowsModelsEnvelope); + if (!items) { const { models, fallback } = failedDiscoveryFallback({ reason: "invalid_response" }); console.warn( `[opencodex] Provider model discovery for "${name}" returned malformed 2xx data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, ); return models; } - const items = data; const live = items.map(m => applyProviderConfigHints(name, prov, { id: m.id, provider: name, diff --git a/src/oauth/index.ts b/src/oauth/index.ts index a8382accc..0cc74bb1b 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -13,7 +13,7 @@ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity" import { loginCursor, refreshCursorToken } from "./cursor"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive"; -import { effectiveGoogleMode } from "../providers/registry"; +import { effectiveGoogleMode, getProviderRegistryEntry } from "../providers/registry"; import { resolveProviderTransport } from "../providers/xai-transport"; import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect"; @@ -436,13 +436,16 @@ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | und providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(providerName) : undefined, ); const headers: Record = { ...(effectiveProvider.headers ?? {}) }; + const registry = getProviderRegistryEntry(providerName); + const sameRegistryEndpoint = registry?.baseUrl.replace(/\/+$/, "") === effectiveProvider.baseUrl.replace(/\/+$/, ""); + const trustedModelsUrl = sameRegistryEndpoint ? registry?.modelsUrl : undefined; if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") { // Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer), // models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 — // enough to list everything without a pageToken loop. Vertex/antigravity keep the // generic branch (they fall back to their static model lists). if (apiKey) headers["x-goog-api-key"] = apiKey; - return { url: `${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`, headers }; + return { url: trustedModelsUrl ?? `${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`, headers }; } if (effectiveProvider.adapter === "anthropic") { headers["anthropic-version"] = "2023-06-01"; @@ -452,10 +455,10 @@ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | und } else if (apiKey) { headers["x-api-key"] = apiKey; } - return { url: `${effectiveProvider.baseUrl}/v1/models?limit=1000`, headers }; + return { url: trustedModelsUrl ?? `${effectiveProvider.baseUrl}/v1/models?limit=1000`, headers }; } if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; - return { url: `${effectiveProvider.baseUrl}/models`, headers }; + return { url: trustedModelsUrl ?? `${effectiveProvider.baseUrl}/models`, headers }; } /** diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 1f848c783..5ce6d4bcd 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { parseProviderModelsApiItems } from "../../codex/catalog/provider-fetch"; import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, @@ -24,7 +25,7 @@ import { import { removeCredential } from "../../oauth/store"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; -import { deriveProviderPresets } from "../../providers/derive"; +import { deriveProviderPresets, providerConfigSeed } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; @@ -406,5 +407,113 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise + choice.baseUrl?.replace(/\/+$/, "") === normalizedSuppliedBaseUrl + ); + const acceptsResolvedBaseUrl = entry.allowBaseUrlOverride === true || matchesResolvedTemplate || matchesOfficialChoice; + if (suppliedBaseUrl && suppliedBaseUrl.replace(/\/+$/, "") !== entry.baseUrl.replace(/\/+$/, "") && !acceptsResolvedBaseUrl) { + return jsonResponse({ error: "provider baseUrl does not match the trusted preset" }, 400); + } + const provider = { + ...providerConfigSeed(entry), + ...(suppliedBaseUrl ? { baseUrl: suppliedBaseUrl } : {}), + ...(supplied.apiKey ? { apiKey: supplied.apiKey } : {}), + ...(supplied.defaultModel ? { defaultModel: supplied.defaultModel } : {}), + ...(supplied.allowPrivateNetwork === true ? { allowPrivateNetwork: true } : {}), + ...(supplied.project ? { project: supplied.project } : {}), + ...(supplied.location ? { location: supplied.location } : {}), + }; + const staticModels = (entry.models ?? []).map(id => ({ + id, + ...(entry.modelContextWindows?.[id] ? { contextWindow: entry.modelContextWindows[id] } : {}), + })); + const destinationError = await providerDestinationResolvedError(presetId, provider); + if (destinationError) return jsonResponse({ error: destinationError }, 400); + if (entry.discovery === "static" || entry.discovery === "unsupported" || entry.liveModels === false) { + return jsonResponse(staticModels.length + ? { ok: true, models: staticModels, source: "static", latencyMs: 0 } + : { ok: false, models: [], source: "static", latencyMs: 0, error: "No static model catalog is available for this preset" }); + } + const { resolveModelsAuthToken, buildModelsRequest } = await import("../../oauth"); + const apiKey = await resolveModelsAuthToken(presetId, provider); + const request = buildModelsRequest(provider, apiKey, presetId); + const modelsDestinationError = await providerDestinationResolvedError(presetId, { baseUrl: request.url }); + if (modelsDestinationError) return jsonResponse({ error: modelsDestinationError }, 400); + const started = Date.now(); + try { + const response = await fetch(request.url, { headers: request.headers, redirect: "error", signal: AbortSignal.timeout(8000) }); + const latencyMs = Date.now() - started; + if (!response.ok) { + const error = `upstream model discovery returned ${response.status}`; + return jsonResponse(staticModels.length + ? { ok: true, models: staticModels, source: "static", latencyMs, error } + : { ok: false, models: [], source: "live", latencyMs, error }); + } + const allowsModelsEnvelope = (entry.adapter === "google" && (entry.googleMode ?? "ai-studio") === "ai-studio") + || new URL(request.url).pathname.replace(/\/+$/, "").endsWith("/api/tags"); + const rows = parseProviderModelsApiItems(await response.json().catch(() => null), allowsModelsEnvelope); + if (!rows) { + const error = "upstream model discovery returned an unexpected shape"; + return jsonResponse(staticModels.length + ? { ok: true, models: staticModels, source: "static", latencyMs, error } + : { ok: false, models: [], source: "live", latencyMs, error }); + } + const uniqueRows = new Map(); + for (const row of rows) { + const current = uniqueRows.get(row.id); + if (!current || (!current.context_length && row.context_length)) uniqueRows.set(row.id, row); + if (uniqueRows.size >= 2000) break; + } + const models = [...uniqueRows.values()].map(row => ({ + id: row.id, + ...(row.context_length ? { contextWindow: row.context_length } : {}), + })); + if (models.length === 0) { + const error = "upstream model discovery returned an empty catalog"; + return jsonResponse(staticModels.length + ? { ok: true, models: staticModels, source: "static", latencyMs, error } + : { ok: false, models: [], source: "live", latencyMs, error }); + } + return jsonResponse({ ok: true, models, source: "live", latencyMs }); + } catch { + const latencyMs = Date.now() - started; + const error = "model discovery request failed"; + return jsonResponse(staticModels.length + ? { ok: true, models: staticModels, source: "static", latencyMs, error } + : { ok: false, models: [], source: "live", latencyMs, error }); + } + } return null; } diff --git a/tests/google-models-listing.test.ts b/tests/google-models-listing.test.ts index f31bd1341..bba1f6475 100644 --- a/tests/google-models-listing.test.ts +++ b/tests/google-models-listing.test.ts @@ -50,7 +50,7 @@ describe("buildModelsRequest google routing", () => { }); describe("google models listing via catalog", () => { - test("treats a { models } 2xx shape as malformed and degrades to the static seed", async () => { + test("normalizes the Google { models } envelope and caches the live catalog", async () => { clearModelCache("google"); const warning = spyOn(console, "warn").mockImplementation(() => {}); const seen: { url: string; headers: Record }[] = []; @@ -77,11 +77,15 @@ describe("google models listing via catalog", () => { expect(seen[0].url).toBe("https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000"); expect(seen[0].headers["x-goog-api-key"]).toBe("gk-123"); const ids = models.filter(m => m.provider === "google").map(m => m.id); - expect(ids).toEqual(["gemini-3.1-pro-preview", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.6-flash"]); - expect(ids).not.toContain("gemini-3-pro"); - expect(ids).not.toContain("gemini-3-flash"); - expect(getStaleCached("google")).toBeNull(); - expect(warning.mock.calls.flat().join(" ")).toContain("google"); + expect(ids).toEqual(["gemini-3-flash", "gemini-3-pro", "text-embedding-004"]); + expect(getStaleCached("google")?.map(model => model.id)).toEqual([ + "gemini-3-pro", + "text-embedding-004", + "gemini-3-flash", + ]); + const warningText = warning.mock.calls.flat().join(" "); + expect(warningText).toContain("omitted configured model ids"); + expect(warningText).not.toContain("malformed 2xx"); } finally { warning.mockRestore(); } diff --git a/tests/provider-directory-routing.test.ts b/tests/provider-directory-routing.test.ts new file mode 100644 index 000000000..065f3df95 --- /dev/null +++ b/tests/provider-directory-routing.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../src/config"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; + +const originalFetch = globalThis.fetch; +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-provider-directory-route-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-provider-directory-route-")); + process.env.OPENCODEX_HOME = testDir; + globalThis.fetch = originalFetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) rmSync(testDir, { recursive: true, force: true }); +}); + +test("a directory-only provider can be saved and routes a chat call through its generic adapter", async () => { + const preset = getProviderRegistryEntry("pollinations"); + expect(preset).toMatchObject({ + directoryOnly: true, + adapter: "openai-chat", + baseUrl: "https://gen.pollinations.ai/v1", + keyOptional: true, + }); + + const upstream: Array<{ url: string; authorization: string | null; body: Record }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = new URL(request.url); + if (url.origin !== "https://gen.pollinations.ai") { + throw new Error(`unexpected upstream request: ${request.method} ${request.url}`); + } + if (url.pathname === "/v1/models") { + return Response.json({ data: [{ id: "test-model" }] }); + } + if (url.pathname === "/v1/chat/completions") { + upstream.push({ + url: request.url, + authorization: request.headers.get("authorization"), + body: await request.clone().json() as Record, + }); + const frames = [ + `data: ${JSON.stringify({ choices: [{ index: 0, delta: { role: "assistant", content: "directory route ok" } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`, + "data: [DONE]\n\n", + ]; + return new Response(frames.join(""), { headers: { "content-type": "text/event-stream" } }); + } + return new Response("unexpected directory provider path", { status: 404 }); + }) as typeof fetch; + + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "seed", + providers: { + seed: { + adapter: "openai-chat", + baseUrl: "https://seed.example/v1", + authMode: "key", + models: ["seed-model"], + liveModels: false, + }, + }, + } as OcxConfig); + + const server = startServer(0); + try { + const addResponse = await originalFetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "pollinations", + setDefault: true, + provider: { + adapter: preset?.adapter, + baseUrl: preset?.baseUrl, + authMode: "key", + apiKey: "directory-secret", + defaultModel: "test-model", + selectedModels: ["test-model"], + }, + }), + }); + expect(addResponse.status).toBe(200); + + const saved = JSON.parse(readFileSync(join(testDir, "config.json"), "utf8")) as OcxConfig; + expect(saved.defaultProvider).toBe("pollinations"); + expect(saved.providers.pollinations).toMatchObject({ + adapter: "openai-chat", + baseUrl: "https://gen.pollinations.ai/v1", + apiKey: "directory-secret", + keyOptional: true, + defaultModel: "test-model", + selectedModels: ["test-model"], + }); + + const chatResponse = await originalFetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "pollinations/test-model", + stream: false, + messages: [{ role: "user", content: "hello" }], + }), + }); + expect(chatResponse.status).toBe(200); + expect(await chatResponse.json()).toMatchObject({ + object: "chat.completion", + model: "pollinations/test-model", + choices: [{ message: { role: "assistant", content: "directory route ok" }, finish_reason: "stop" }], + }); + expect(upstream).toHaveLength(1); + expect(upstream[0]).toMatchObject({ + url: "https://gen.pollinations.ai/v1/chat/completions", + authorization: "Bearer directory-secret", + body: { model: "test-model" }, + }); + } finally { + await server.stop(true); + } +}, 10_000); diff --git a/tests/provider-free-directory.test.ts b/tests/provider-free-directory.test.ts new file mode 100644 index 000000000..5d6ea26c3 --- /dev/null +++ b/tests/provider-free-directory.test.ts @@ -0,0 +1,170 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { buildModelsRequest } from "../src/oauth"; +import { deriveProviderPresets, enrichProviderFromRegistry } from "../src/providers/derive"; +import { FREE_PROVIDER_ACCESS_GROUPS, FREE_PROVIDER_DIRECTORY } from "../src/providers/free-directory"; +import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../src/providers/registry"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const config: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "openai", + providers: {}, +}; + +function discover(presetId: string, provider: OcxProviderConfig): Promise { + const url = new URL("http://127.0.0.1:10100/api/provider-presets/discover"); + return handleManagementAPI(new Request(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ presetId, provider }), + }), url, config); +} + +describe("free provider directory", () => { + test("encodes the exact four groups, one 81-provider union, and only the declared overlap", () => { + expect(FREE_PROVIDER_ACCESS_GROUPS["recurring-or-keyless"]).toHaveLength(43); + expect(FREE_PROVIDER_ACCESS_GROUPS["recurring-uncapped"]).toHaveLength(13); + expect(FREE_PROVIDER_ACCESS_GROUPS["recurring-credit"]).toHaveLength(2); + expect(FREE_PROVIDER_ACCESS_GROUPS["signup-credit"]).toHaveLength(24); + expect(FREE_PROVIDER_DIRECTORY).toHaveLength(81); + expect(new Set(FREE_PROVIDER_DIRECTORY.map(row => row.id)).size).toBe(81); + const memberships = FREE_PROVIDER_DIRECTORY.filter(row => row.accessGroups.length > 1); + expect(memberships.map(row => row.id)).toEqual(["glm-cn"]); + expect(memberships[0]?.accessGroups).toEqual(["recurring-uncapped", "signup-credit"]); + expect(FREE_PROVIDER_DIRECTORY.filter(row => row.discovery === "static" && !row.models?.length).map(row => row.id)).toEqual(["kiro"]); + }); + + test("merges requested ids into the registry once and exposes all 81 as presets", () => { + const requested = new Set(FREE_PROVIDER_DIRECTORY.map(row => row.id)); + expect(PROVIDER_REGISTRY.filter(row => requested.has(row.id))).toHaveLength(81); + expect(new Set(PROVIDER_REGISTRY.map(row => row.id)).size).toBe(PROVIDER_REGISTRY.length); + expect(deriveProviderPresets().filter(row => row.accessGroups?.length)).toHaveLength(81); + expect(PROVIDER_REGISTRY.filter(row => row.accessGroups?.length && row.discovery === "static" && !row.models?.length)).toEqual([]); + expect(getProviderRegistryEntry("pollinations")?.keyOptional).toBe(true); + expect(getProviderRegistryEntry("siliconflow")?.baseUrlChoices).toEqual([ + { id: "china-mainland", label: "China mainland", baseUrl: "https://api.siliconflow.cn/v1" }, + { id: "international", label: "International", baseUrl: "https://api.siliconflow.com/v1" }, + ]); + for (const directory of FREE_PROVIDER_DIRECTORY) { + const registered = getProviderRegistryEntry(directory.id); + expect({ adapter: registered?.adapter, baseUrl: registered?.baseUrl, authKind: registered?.authKind }) + .toEqual({ adapter: directory.adapter, baseUrl: directory.baseUrl, authKind: directory.authKind }); + } + }); + + test("buildModelsRequest uses a trusted modelsUrl only for the canonical endpoint", () => { + const cohere = getProviderRegistryEntry("cohere")!; + const canonical = buildModelsRequest({ adapter: cohere.adapter, baseUrl: cohere.baseUrl, authMode: "key" }, "secret", "cohere"); + expect(canonical.url).toBe("https://api.cohere.com/compatibility/v1/models"); + const custom = buildModelsRequest({ adapter: cohere.adapter, baseUrl: "https://proxy.example/v1", authMode: "key" }, "secret", "cohere"); + expect(custom.url).toBe("https://proxy.example/v1/models"); + }); + + test("registry enrichment preserves the Vertex transport mode used by saved presets", () => { + const provider: OcxProviderConfig = { + adapter: "google", + baseUrl: "https://aiplatform.googleapis.com", + authMode: "key", + apiKey: "secret", + }; + enrichProviderFromRegistry("vertex", provider); + expect(provider.googleMode).toBe("vertex"); + }); + + test("blocks reference-only presets before any network request", async () => { + let called = false; + globalThis.fetch = (() => { called = true; return Promise.reject(new Error("unexpected")); }) as typeof fetch; + const response = await discover("duckduckgo-web", { adapter: "openai-chat", baseUrl: "https://duckduckgo.com", authMode: "key" }); + expect(response?.status).toBe(400); + expect(await response?.json()).toEqual({ error: "reference-only provider presets cannot be connected automatically" }); + expect(called).toBe(false); + }); + + test("only accepts resolved template URLs that stay on the trusted preset path", async () => { + let called = false; + globalThis.fetch = (() => { called = true; return Promise.reject(new Error("unexpected")); }) as typeof fetch; + for (const baseUrl of [ + "https://attacker.example/client/v4/accounts/account/ai/v1", + "https://api.cloudflare.com/client/v4/accounts/../ai/v1", + "https://api.cloudflare.com/client/v4/accounts/%2e%2e/ai/v1", + ]) { + const response = await discover("cloudflare-ai", { + adapter: "openai-chat", + baseUrl, + authMode: "key", + apiKey: "secret", + }); + expect(response?.status).toBe(400); + expect(await response?.json()).toEqual({ error: "provider baseUrl does not match the trusted preset" }); + } + expect(called).toBe(false); + + const resolved = await discover("cloudflare-ai", { + adapter: "openai-chat", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/account-id/ai/v1", + authMode: "key", + apiKey: "secret", + }); + expect(resolved?.status).toBe(200); + expect(await resolved?.json()).toMatchObject({ ok: true, source: "static" }); + expect(called).toBe(false); + }); + + test("returns normalized live models without persisting the provider", async () => { + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe("https://api.cohere.com/compatibility/v1/models"); + expect(init?.redirect).toBe("error"); + return Response.json({ data: [{ id: "command-a", context_window: 256000 }, { id: "command-a" }] }); + }) as typeof fetch; + const response = await discover("cohere", { adapter: "openai-chat", baseUrl: "https://api.cohere.com/compatibility/v1", authMode: "key", apiKey: "secret" }); + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ ok: true, source: "live", models: [{ id: "command-a", contextWindow: 256000 }] }); + expect(config.providers.cohere).toBeUndefined(); + }); + + test("accepts only SiliconFlow's two official regional endpoints for discovery", async () => { + let calls = 0; + globalThis.fetch = (async input => { + calls += 1; + expect(String(input)).toBe("https://api.siliconflow.com/v1/models"); + return Response.json({ data: [{ id: "Qwen/Qwen3-Coder" }] }); + }) as typeof fetch; + const international = await discover("siliconflow", { + adapter: "openai-chat", + baseUrl: "https://api.siliconflow.com/v1", + authMode: "key", + apiKey: "secret", + }); + expect(international?.status).toBe(200); + expect(await international?.json()).toMatchObject({ ok: true, source: "live", models: [{ id: "Qwen/Qwen3-Coder" }] }); + + const arbitrary = await discover("siliconflow", { + adapter: "openai-chat", + baseUrl: "https://siliconflow-proxy.example/v1", + authMode: "key", + apiKey: "secret", + }); + expect(arbitrary?.status).toBe(400); + expect(await arbitrary?.json()).toEqual({ error: "provider baseUrl does not match the trusted preset" }); + expect(calls).toBe(1); + }); + + test("falls back to static models and reports a sanitized live error", async () => { + globalThis.fetch = (async () => new Response("credential secret should never leak", { status: 503 })) as typeof fetch; + const response = await discover("openrouter", { adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authMode: "key", apiKey: "secret" }); + const json = await response?.json() as { ok: boolean; source: string; models: unknown[]; error: string }; + expect(json.ok).toBe(true); + expect(json.source).toBe("static"); + expect(json.models.length).toBeGreaterThan(0); + expect(json.error).toBe("upstream model discovery returned 503"); + expect(JSON.stringify(json)).not.toContain("secret"); + }); +}); diff --git a/tests/provider-model-envelope.test.ts b/tests/provider-model-envelope.test.ts new file mode 100644 index 000000000..cd3af7ca8 --- /dev/null +++ b/tests/provider-model-envelope.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { parseProviderModelsApiItems } from "../src/codex/catalog/provider-fetch"; + +describe("provider model catalog envelopes", () => { + test("normalizes OpenAI, Google/Ollama, and top-level catalog shapes", () => { + expect(parseProviderModelsApiItems({ data: [{ id: "openai-model" }] })?.map(model => model.id)) + .toEqual(["openai-model"]); + expect(parseProviderModelsApiItems({ models: [{ name: "models/gemini-flash", inputTokenLimit: 1_000_000 }] }, true)) + .toEqual([{ id: "gemini-flash", context_length: 1_000_000 }]); + expect(parseProviderModelsApiItems([{ id: "accounts/fireworks/models/llama" }])?.map(model => model.id)) + .toEqual(["llama"]); + }); + + test("rejects malformed rows instead of making an empty catalog authoritative", () => { + expect(parseProviderModelsApiItems({ models: [{ displayName: "missing id" }] }, true)).toBeNull(); + expect(parseProviderModelsApiItems({ models: [{ name: " " }] }, true)).toBeNull(); + expect(parseProviderModelsApiItems({ models: [{ name: "models/gemini-flash" }] })).toBeNull(); + }); +});