From a70e0cc4d720e3a512910f3ea327d2db94fc865e Mon Sep 17 00:00:00 2001 From: Haydern Date: Fri, 24 Jul 2026 00:03:40 +0700 Subject: [PATCH 1/4] 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/4] 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(); + }); +}); From 60046f85afaa59a0212e9361c593255c35109ed3 Mon Sep 17 00:00:00 2001 From: Haydern Date: Fri, 24 Jul 2026 00:04:12 +0700 Subject: [PATCH 3/4] build provider model loader UI --- gui/src/components/AddProviderModal.tsx | 313 +++++++++++++++--- .../provider-catalog/ProviderCatalog.tsx | 248 ++++++++------ .../provider-catalog/provider-presets.ts | 55 +++ gui/src/i18n/de.ts | 37 +++ gui/src/i18n/en.ts | 37 +++ gui/src/i18n/ja.ts | 37 +++ gui/src/i18n/ko.ts | 37 +++ gui/src/i18n/ru.ts | 37 +++ gui/src/i18n/zh.ts | 37 +++ gui/src/provider-icons.ts | 7 + gui/src/styles/provider-catalog.css | 157 ++++++--- gui/tests/provider-catalog.test.ts | 58 ++++ gui/tests/provider-discovery-race.test.tsx | 165 +++++++++ 13 files changed, 1028 insertions(+), 197 deletions(-) create mode 100644 gui/tests/provider-catalog.test.ts create mode 100644 gui/tests/provider-discovery-race.test.tsx diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index efd84a35b..7ebba05ce 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { IconX, IconLock, IconKey, IconExternal } from "../icons"; import { useT } from "../i18n"; +import { providerIconSrc } from "../provider-icons"; import { buildProviderPostBody, codexPresetDescriptionKey, @@ -12,7 +13,7 @@ import { oauthTosRisk } from "../oauth-tos-risk"; import OAuthTosWarningModal from "./OAuthTosWarningModal"; import ProviderCatalog from "./provider-catalog/ProviderCatalog"; import type { AccountLoginRow, AccountLoginStatus } from "./provider-catalog/ProviderCatalog"; -import type { CatalogPreset } from "./provider-catalog/provider-presets"; +import { isPresetActionable, type CatalogPreset } from "./provider-catalog/provider-presets"; import { baseUrlForChoice, matchChoiceId, resolvedBaseUrlForChoice } from "../base-url-choice"; export type ProviderConfig = ProviderPayload; @@ -69,6 +70,13 @@ export default function AddProviderModal({ const [usageRank, setUsageRank] = useState>({}); const [endpointChoice, setEndpointChoice] = useState("custom"); const [oauthTosPending, setOauthTosPending] = useState(null); + const [discoveryBusy, setDiscoveryBusy] = useState(false); + const [discoveryError, setDiscoveryError] = useState(""); + const [discoverySource, setDiscoverySource] = useState<"live" | "static" | "">(""); + const [discoveredModels, setDiscoveredModels] = useState>([]); + const [selectedModels, setSelectedModels] = useState([]); + const discoveryRequestRef = useRef(0); + const discoveryAbortRef = useRef(null); const aliveRef = useRef(true); const loadedPresetsRef = useRef(false); const previousFocusRef = useRef(null); @@ -88,14 +96,35 @@ export default function AddProviderModal({ } return () => { aliveRef.current = false; + discoveryRequestRef.current += 1; + discoveryAbortRef.current?.abort(); + discoveryAbortRef.current = null; previousFocusRef.current?.focus(); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only open hook }, []); useEffect(() => { const onKey = (e: KeyboardEvent) => { - // Child ToS warning owns Escape while it is open. - if (e.key === "Escape" && !oauthTosPending) onClose(); + // Child ToS warning owns keyboard navigation while it is open. + if (e.key === "Escape" && !oauthTosPending) { + onClose(); + return; + } + if (oauthTosPending) return; + if (e.key !== "Tab" || !dialogRef.current) return; + const focusable = Array.from(dialogRef.current.querySelectorAll( + "a[href], input:not([disabled]), button:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])" + )).filter(node => node.offsetParent !== null); + const first = focusable[0]; + const last = focusable.at(-1); + if (!first || !last) return; + if (e.shiftKey && (document.activeElement === first || !dialogRef.current.contains(document.activeElement))) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); @@ -131,7 +160,15 @@ export default function AddProviderModal({ return key ? t(key) : candidate.note; }; + const invalidateDiscoveryRequest = () => { + discoveryRequestRef.current += 1; + discoveryAbortRef.current?.abort(); + discoveryAbortRef.current = null; + setDiscoveryBusy(false); + }; + const choosePreset = (p: Preset) => { + invalidateDiscoveryRequest(); setPreset(p); const choiceId = matchChoiceId(p.baseUrlChoices, p.baseUrl); setEndpointChoice(choiceId); @@ -143,7 +180,7 @@ export default function AddProviderModal({ : p.baseUrl, authMode: p.auth, apiKey: "", - defaultModel: p.defaultModel ?? "", + defaultModel: p.defaultModel ?? p.models?.[0] ?? "", allowPrivateNetwork: false, }); setError(""); @@ -152,9 +189,15 @@ export default function AddProviderModal({ setManualCode(""); setManualCodeMsg(""); setManualCodeOk(true); + setDiscoveryError(""); + setDiscoverySource(p.models?.length ? "static" : ""); + const staticModels = (p.models ?? []).map(id => ({ id })); + setDiscoveredModels(staticModels); + setSelectedModels(staticModels.map(model => model.id)); }; const back = () => { + invalidateDiscoveryRequest(); setPreset(null); setForm(null); setEndpointChoice("custom"); @@ -164,6 +207,61 @@ export default function AddProviderModal({ setManualCode(""); setManualCodeMsg(""); setManualCodeOk(true); + setDiscoveryError(""); + setDiscoverySource(""); + setDiscoveredModels([]); + setSelectedModels([]); + }; + + const providerPostBody = () => { + if (!form || !preset) return null; + const resolvedBaseUrl = preset.baseUrlChoices?.length + ? resolvedBaseUrlForChoice(preset.baseUrlChoices, endpointChoice, form.baseUrl) + : form.baseUrl.trim(); + return buildProviderPostBody(preset, { ...form, baseUrl: resolvedBaseUrl }); + }; + + const discoverModels = async () => { + const postBody = providerPostBody(); + if (!postBody || !preset || preset.supportLevel === "reference") return; + const requestId = discoveryRequestRef.current + 1; + discoveryRequestRef.current = requestId; + discoveryAbortRef.current?.abort(); + const controller = new AbortController(); + discoveryAbortRef.current = controller; + setDiscoveryBusy(true); + setDiscoveryError(""); + try { + const res = await fetch(`${apiBase}/api/provider-presets/discover`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ presetId: preset.id, provider: postBody.provider }), + signal: controller.signal, + }); + const data = await res.json().catch(() => ({})) as { + ok?: boolean; + error?: string; + source?: "live" | "static"; + models?: Array; + }; + if (requestId !== discoveryRequestRef.current || controller.signal.aborted) return; + if (!res.ok) throw new Error(data.error || t("modal.failedStatus", { status: res.status })); + const models = (data.models ?? []).map(model => typeof model === "string" ? { id: model } : model).filter(model => model.id); + if (data.ok === false && models.length === 0) throw new Error(data.error || t("modal.discoveryFailed")); + setDiscoveredModels(models); + setSelectedModels(models.map(model => model.id)); + setDiscoverySource(data.source ?? (preset.discovery === "static" ? "static" : "live")); + if (data.error) setDiscoveryError(data.error); + if (models[0]) setForm(current => current && !current.defaultModel ? { ...current, defaultModel: models[0]!.id } : current); + } catch (cause) { + if (requestId !== discoveryRequestRef.current || controller.signal.aborted) return; + setDiscoveryError(cause instanceof Error ? cause.message : t("modal.networkError")); + } finally { + if (requestId === discoveryRequestRef.current) { + discoveryAbortRef.current = null; + setDiscoveryBusy(false); + } + } }; const submit = async () => { @@ -187,10 +285,16 @@ export default function AddProviderModal({ setSaving(true); setError(""); try { + const selectedSubset = selectedModels.length > 0 && selectedModels.length < discoveredModels.length + ? selectedModels + : undefined; const res = await fetch(`${apiBase}/api/providers`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(postBody), + body: JSON.stringify({ + ...postBody, + provider: selectedSubset ? { ...postBody.provider, selectedModels: selectedSubset } : postBody.provider, + }), }); if (!res.ok) { const d = await res.json().catch(() => ({})); @@ -301,35 +405,41 @@ export default function AddProviderModal({ const isCustom = preset?.id === "custom"; const isLocal = form?.authMode === "local"; const isReservedForward = preset ? isReservedCodexForwardPreset(preset) : false; + const isReference = preset ? !isPresetActionable(preset) : false; + const selectedIcon = preset ? providerIconSrc(preset.id, { adapter: preset.adapter, baseUrl: preset.baseUrl }) : undefined; + const setupUrl = preset?.dashboardUrl ?? preset?.documentationUrl; return ( <> -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>
-

{preset ? t("modal.addNamed", { label: preset.label }) : t("modal.add")}

+

{t("modal.add")}

- {!preset ? ( - choosePreset(p)} - onSelectCustom={() => choosePreset(fallbackPresets[0]!)} - accountRows={accountRows} - accountStatus={accountStatus} - busyProvider={accountBusy} - onLogin={onAccountLogin} - onCancelLogin={onAccountCancelLogin} - onLogout={onAccountLogout} - /> - ) : form && ( + choosePreset(p)} + onSelectCustom={() => choosePreset(fallbackPresets[0]!)} + accountRows={accountRows} + accountStatus={accountStatus} + busyProvider={accountBusy} + onLogin={onAccountLogin} + onCancelLogin={onAccountCancelLogin} + onLogout={onAccountLogout} + onClearSelection={back} + selectedPreset={preset} + standalone={initialCustom && preset?.id === "custom"} + detail={preset && form ? ( preset.auth === "oauth" && form.authMode === "oauth" ? ( // OAuth login pane -
+
+ +
{preset.note ?? t("modal.oauthDefaultNote")}
{oauthSupported.includes(preset.oauthProvider ?? "") ? (
) : ( // API key / Codex-forward / free-tier form -
- {!isReservedForward && !isCustom && !isLocal && !preset.keyOptional && preset.note && ( -
- {t("modal.setupGuide")} -
    +
    + + + {!isReservedForward && !isCustom && !isLocal && ( +
    + {t("modal.setupGuide")} +
    1. - {t("modal.setupStep1Prefix")}{" "} - - {t("modal.setupDashboardLink", { label: preset.label })} - {" "} - {t("modal.setupStep1Suffix")} + {setupUrl ? <> + {t("modal.setupStep1Prefix")}{" "} + {t("modal.setupDashboardLink", { label: preset.label })} + {!preset.keyOptional && !isReference ? <> {t("modal.setupStep1Suffix")} : null} + : t("modal.setupReviewProvider", { label: preset.label })}
    2. -
    3. {t("modal.setupStep2")}
    4. -
    5. {t("modal.setupStep3")}
    6. + {isReference ?
    7. {t("modal.setupReferenceStep")}
    8. : preset.keyOptional ? <> +
    9. {t("modal.setupKeylessStep")}
    10. +
    11. {t("modal.setupDiscoverStep")}
    12. + : <> +
    13. {t("modal.setupStep2")}
    14. +
    15. {t("modal.setupStep3")}
    16. + }
    - {preset.note &&
    {preset.note}
    } + {preset.note &&
    {preset.note}
    } {/\{[^}]*\}/.test(form.baseUrl) && (
    {t("modal.baseUrlPlaceholderHint")}
    )} -
+
)} + {isReference &&
{t("modal.referenceExplanation")}
} + {!isReference && <> setForm({ ...form, name: e.target.value })} placeholder={t("modal.namePlaceholder")} /> {dup &&
{t("modal.duplicateWarn", { name: form.name.trim() })}
} {!isReservedForward && <> - { + invalidateDiscoveryRequest(); + setForm({ ...form, adapter: e.target.value }); + }}> {["openai-responses", "openai-chat", "anthropic", "google", "azure-openai", "cursor"].map(a => )} @@ -442,6 +564,7 @@ export default function AddProviderModal({ value={endpointChoice} onChange={e => { const id = e.target.value; + invalidateDiscoveryRequest(); setEndpointChoice(id); setForm({ ...form, @@ -453,6 +576,8 @@ export default function AddProviderModal({ @@ -464,7 +589,10 @@ export default function AddProviderModal({ setForm({ ...form, baseUrl: e.target.value })} + onChange={e => { + invalidateDiscoveryRequest(); + setForm({ ...form, baseUrl: e.target.value }); + }} placeholder={t("modal.baseUrlPlaceholder")} /> @@ -472,12 +600,18 @@ export default function AddProviderModal({ ) : ( - setForm({ ...form, baseUrl: e.target.value })} placeholder={t("modal.baseUrlPlaceholder")} /> + { + invalidateDiscoveryRequest(); + setForm({ ...form, baseUrl: e.target.value }); + }} placeholder={t("modal.baseUrlPlaceholder")} /> )} {!isReservedForward && ( )} @@ -505,23 +639,78 @@ export default function AddProviderModal({ )} - setForm({ ...form, apiKey: e.target.value })} placeholder={t("modal.apiKeyPlaceholder")} /> + { + invalidateDiscoveryRequest(); + setForm({ ...form, apiKey: e.target.value }); + }} placeholder={t("modal.apiKeyPlaceholder")} /> )} {!isReservedForward && setForm({ ...form, defaultModel: e.target.value })} placeholder={t("modal.defaultModelPlaceholder")} /> } + {!isReservedForward && !isCustom && ( +
+
+
+ {t("modal.modelDiscovery")} +
+ {discoverySource ? t(discoverySource === "live" ? "modal.modelsLive" : "modal.modelsStatic") : t("modal.modelDiscoveryHint")} +
+
+ +
+ {discoveryError &&
{discoveryError}
} + {discoveredModels.length > 0 && ( +
+ {discoveredModels.map(model => { + const checked = selectedModels.includes(model.id); + return ( + + ); + })} +
+ )} +
+ )} + } {error &&
{error}
} -
- - {preset.auth === "oauth" && } -
+
+ {!isReference && } + {preset.auth === "oauth" && } +
) - )} + ) : undefined} + />
{oauthTosPending && ( @@ -550,3 +739,29 @@ function Field({ label, children }: { label: string; children: React.ReactNode } ); } + +function ProviderDetailHeader({ preset, icon }: { preset: Preset; icon?: string }) { + const t = useT(); + const docs = preset.documentationUrl ?? preset.dashboardUrl; + return ( +
+ {icon ? : preset.label.slice(0, 1)} +
+

{preset.label}

+ {preset.id} +
+ {preset.accessGroups?.map(group => ( + + {t(`modal.group.${group}`)} + + ))} + {preset.supportLevel && + {t(preset.supportLevel === "reference" ? "modal.badge.reference" : preset.supportLevel === "experimental" ? "modal.badge.experimental" : "modal.badge.supported")} + } + {preset.verification && {t(`modal.verification.${preset.verification}`)}} +
+
+ {docs && {t("modal.providerDocs")}} +
+ ); +} diff --git a/gui/src/components/provider-catalog/ProviderCatalog.tsx b/gui/src/components/provider-catalog/ProviderCatalog.tsx index 47e1201b3..edb91df2c 100644 --- a/gui/src/components/provider-catalog/ProviderCatalog.tsx +++ b/gui/src/components/provider-catalog/ProviderCatalog.tsx @@ -1,15 +1,16 @@ -/** - * ProviderCatalog — the browse surface of the add-provider modal: Accounts / - * Free / Paid tabs over a single searchable scroll list, and account login - * rows on the Accounts tab. Presentational: presets/usage arrive via props; - * view state (tab, query) lives here; selection lifts up. - */ -import { useMemo, useState } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import { useT } from "../../i18n"; +import { providerIconSrc } from "../../provider-icons"; import { + ACCESS_GROUPS, + accessGroupCounts, bucketPresets, + curatedPresets, + filterByAccessGroup, filterPresets, + freeCatalogSections, type CatalogPreset, + type ProviderAccessGroup, } from "./provider-presets"; export type AccountLoginStatus = { loggedIn: boolean; email?: string; error?: string; needsReauth?: boolean }; @@ -18,11 +19,11 @@ export type AccountLoginRow = { label: string; kind: "oauth" | "key" | "codex"; statusLabel?: string; - /** Optional deep-link for codex/account-pool management. */ href?: string; }; export type CatalogTier = "accounts" | "free" | "paid"; +type FreeCatalogGroup = ProviderAccessGroup | "all" | "existing"; export default function ProviderCatalog({ presets, @@ -37,6 +38,10 @@ export default function ProviderCatalog({ onLogin, onCancelLogin, onLogout, + onClearSelection, + selectedPreset, + detail, + standalone = false, }: { presets: CatalogPreset[]; usageRank?: Record; @@ -44,31 +49,47 @@ export default function ProviderCatalog({ initialTier?: CatalogTier; onSelectPreset: (preset: CatalogPreset) => void; onSelectCustom: () => void; - /** Accounts-tab login rows; empty (default) degrades to preset-only rendering. */ accountRows?: AccountLoginRow[]; accountStatus?: Record; busyProvider?: string | null; onLogin?: (provider: string) => void; onCancelLogin?: (provider: string) => void; onLogout?: (provider: string) => void; + onClearSelection?: () => void; + selectedPreset?: CatalogPreset | null; + detail?: ReactNode; + standalone?: boolean; }) { const t = useT(); const [tier, setTier] = useState(initialTier); const [query, setQuery] = useState(""); + const [group, setGroup] = useState("all"); const catalog = useMemo(() => presets.filter(p => p.id !== "custom"), [presets]); - - /** Usage-ranked order: requests desc, then label (050a sortPresets is the no-usage fallback). */ const ranked = useMemo(() => [...catalog].sort((a, b) => { - const ra = usageRank[a.id] ?? 0; - const rb = usageRank[b.id] ?? 0; - if (rb !== ra) return rb - ra; - return a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) || a.id.localeCompare(b.id); + const difference = (usageRank[b.id] ?? 0) - (usageRank[a.id] ?? 0); + return difference || a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) || a.id.localeCompare(b.id); }), [catalog, usageRank]); - const buckets = useMemo(() => bucketPresets(ranked), [ranked]); - const tierList = buckets[tier]; + const curated = useMemo(() => curatedPresets(ranked), [ranked]); + const counts = useMemo(() => accessGroupCounts(curated), [curated]); + const freeSections = useMemo(() => freeCatalogSections(ranked), [ranked]); + const freeDirectory = freeSections.directory; + const tierList = tier === "free" + ? group === "all" ? freeDirectory : group === "existing" ? freeSections.existing : filterByAccessGroup(curated, group) + : buckets[tier]; const rows = useMemo(() => filterPresets(tierList, query), [tierList, query]); + const filteredAccountRows = useMemo(() => { + const normalized = query.trim().toLowerCase(); + return normalized + ? accountRows.filter(row => row.label.toLowerCase().includes(normalized) || row.id.toLowerCase().includes(normalized)) + : accountRows; + }, [accountRows, query]); + const searchCount = tier === "accounts" ? accountRows.length + : tier === "free" && group === "all" ? freeDirectory.length + : tierList.length; + + if (standalone) return
{detail}
; const badges = (p: CatalogPreset) => { const auth = p.codexAccountMode === "direct" ? {t("modal.badge.direct")} @@ -76,108 +97,141 @@ export default function ProviderCatalog({ : p.auth === "oauth" ? {t("modal.badge.oauth")} : p.auth === "forward" ? {t("modal.badge.codexLogin")} : p.auth === "local" ? {t("modal.badge.local")} - : p.keyOptional ? null // keyless free: the Free badge alone says it all + : p.keyOptional ? null : {t("modal.badge.apiKey")}; - // Free pricing is orthogonal to auth: NVIDIA (freeTier + key required) shows BOTH - // the Free badge and the API-key badge — free pricing never hides a key requirement. - const free = (p.freeTier || p.keyOptional) && p.auth === "key" - ? {t("modal.badge.free")} - : null; - return <>{free}{auth}; + const access = p.accessGroups?.includes("recurring-or-keyless") + ? {t("modal.badge.recurring")} + : (p.freeTier || p.keyOptional) && p.auth === "key" + ? {t("modal.badge.free")} + : null; + const support = p.supportLevel === "reference" + ? {t("modal.badge.reference")} + : p.supportLevel === "experimental" + ? {t("modal.badge.experimental")} + : null; + return <>{access}{auth}{support}; }; return (
-
+
{ + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + const tabs = Array.from(event.currentTarget.querySelectorAll('[role="tab"]')); + const current = tabs.indexOf(document.activeElement as HTMLButtonElement); + const next = event.key === 'Home' ? 0 + : event.key === 'End' ? tabs.length - 1 + : event.key === 'ArrowLeft' ? (current - 1 + tabs.length) % tabs.length + : (current + 1) % tabs.length; + event.preventDefault(); + tabs[next]?.focus(); + tabs[next]?.click(); + }} + > {(["accounts", "free", "paid"] as const).map(candidate => ( ))}
- {tier === "accounts" && ( -
- {t("modal.accountsHint")} -
- )} +
+ - setQuery(e.target.value)} - placeholder={t("modal.search")} - /> +
+
+ { setQuery(e.target.value); onClearSelection?.(); }} + placeholder={t("modal.searchCount", { count: searchCount })} + aria-label={t("modal.searchCount", { count: searchCount })} + /> +
-
- {presetsLoading && rows.length === 0 && ( -
{t("modal.catalogLoading")}
- )} - {tier !== "accounts" && rows.map(p => ( - - ))} - {tier !== "accounts" && !presetsLoading && rows.length === 0 && ( -
{t("modal.noMatch")}
- )} +
+ {presetsLoading && rows.length === 0 &&
{t("modal.catalogLoading")}
} + {tier !== "accounts" && rows.map(p => { + const icon = providerIconSrc(p.id, { adapter: p.adapter, baseUrl: p.baseUrl }); + return ( + + ); + })} + {tier !== "accounts" && !presetsLoading && rows.length === 0 &&
{t("modal.noMatch")}
} - {tier === "accounts" && accountRows.map(row => { - const status = accountStatus[row.id]; - const busy = busyProvider === row.id; - const loggedIn = !!status?.loggedIn; - const statusText = loggedIn - ? (status?.email ?? row.statusLabel ?? t("modal.accountLoggedIn")) - : (status?.error ?? row.statusLabel ?? t("modal.accountLoggedOut")); - return ( -
-
-
{row.label}
-
{statusText}
-
-
- {row.kind === "key" ? null : row.kind === "codex" ? ( - <> - {loggedIn && ( - {t("modal.accountManage")} - )} - {onLogin && ( - + {tier === "accounts" && filteredAccountRows.map(row => { + const status = accountStatus[row.id]; + const busy = busyProvider === row.id; + const loggedIn = !!status?.loggedIn; + const statusText = loggedIn ? (status.email ?? row.statusLabel ?? t("modal.accountLoggedIn")) : (status?.error ?? row.statusLabel ?? t("modal.accountLoggedOut")); + return ( +
+ {row.label}{statusText} + + {row.kind === "key" ? null : row.kind === "codex" ? <> + {loggedIn && {t("modal.accountManage")}} + {onLogin && } + : loggedIn ? ( + onLogout && + ) : busy ? ( + onCancelLogin && + ) : ( + onLogin && )} - - ) : loggedIn ? ( - onLogout && - ) : busy ? ( - onCancelLogin && - ) : ( - onLogin && - )} -
-
- ); - })} - {tier === "accounts" && accountRows.length === 0 && !presetsLoading && ( -
{t("modal.noMatch")}
- )} -
+ +
+ ); + })} + {tier === "accounts" && filteredAccountRows.length === 0 && !presetsLoading &&
{t("modal.noMatch")}
} +
+
-
-
- {tier !== "accounts" && ( - - )} +
+ {tier === "accounts" + ?
{t("modal.tab.accounts")}{t("modal.accountsHint")}
+ : detail ??
{t("modal.selectProvider")}{t("modal.selectProviderHint")}
} +
); diff --git a/gui/src/components/provider-catalog/provider-presets.ts b/gui/src/components/provider-catalog/provider-presets.ts index 42d6d4c7f..cea689d22 100644 --- a/gui/src/components/provider-catalog/provider-presets.ts +++ b/gui/src/components/provider-catalog/provider-presets.ts @@ -9,6 +9,14 @@ import { providerTier, type ProviderTier, type WorkspaceProvider } from "../../provider-workspace/catalog"; import type { ProviderPayload } from "../../provider-payload"; +export type ProviderAccessGroup = + | "recurring-or-keyless" + | "recurring-uncapped" + | "recurring-credit" + | "signup-credit"; + +export type ProviderSupportLevel = "supported" | "experimental" | "reference"; + /** Row shape returned by GET /api/provider-presets (mirrors DerivedProviderPreset). */ export interface CatalogPreset { id: string; @@ -33,6 +41,53 @@ export interface CatalogPreset { baseUrlChoices?: Array<{ id: string; label: string; baseUrl?: string }>; codexAccountMode?: "direct" | "pool"; provider?: ProviderPayload; + accessGroups?: readonly ProviderAccessGroup[]; + supportLevel?: ProviderSupportLevel; + verification?: "official" | "primary" | "unverified"; + documentationUrl?: string; + modelsUrl?: string; + discovery?: "live" | "static" | "hybrid" | "unsupported"; + lastVerified?: string; + models?: string[]; + liveModels?: boolean; +} + +export const ACCESS_GROUPS: ProviderAccessGroup[] = [ + "recurring-or-keyless", + "recurring-uncapped", + "recurring-credit", + "signup-credit", +]; + +/** Curated free-directory rows. A provider may belong to more than one group. */ +export function curatedPresets(presets: CatalogPreset[]): CatalogPreset[] { + return presets.filter(p => (p.accessGroups?.length ?? 0) > 0); +} + +export function accessGroupCounts(presets: CatalogPreset[]): Record { + const counts = Object.fromEntries(ACCESS_GROUPS.map(group => [group, 0])) as Record; + for (const preset of curatedPresets(presets)) { + for (const group of new Set(preset.accessGroups)) counts[group] += 1; + } + return counts; +} + +export function filterByAccessGroup(presets: CatalogPreset[], group: ProviderAccessGroup | "all"): CatalogPreset[] { + const curated = curatedPresets(presets); + return group === "all" ? curated : curated.filter(p => p.accessGroups?.includes(group)); +} + +/** Keep the requested directory at 81 while retaining legacy free/local presets in a separate lane. */ +export function freeCatalogSections(presets: CatalogPreset[]): { directory: CatalogPreset[]; existing: CatalogPreset[] } { + const directory = curatedPresets(presets); + const free = bucketPresets(presets).free; + if (directory.length === 0) return { directory: free, existing: [] }; + const directoryIds = new Set(directory.map(preset => preset.id)); + return { directory, existing: free.filter(preset => !directoryIds.has(preset.id)) }; +} + +export function isPresetActionable(preset: CatalogPreset): boolean { + return preset.supportLevel !== "reference"; } /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 6cade5be0..4feb196e0 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -554,6 +554,8 @@ export const de = { "modal.endpoint": "Endpunkt", "modal.endpoint.tokenPlan": "Token-Plan", "modal.endpoint.payAsYouGo": "Pay as you go", + "modal.endpoint.chinaMainland": "Festlandchina", + "modal.endpoint.international": "International", "modal.endpoint.custom": "Benutzerdefiniert", "modal.defaultModel": "Standardmodell (optional)", "modal.allowPrivateNetwork": "Lokales/privates Netzwerk erlauben", @@ -997,6 +999,41 @@ export const de = { "time.minutesAgo": "vor {n} Min.", "time.hoursAgo": "vor {n} Std.", "time.daysAgo": "vor {n} T.", + "modal.catalogModes": "Zugriffsarten der Anbieter", + "modal.accessGroups": "Kostenlose Zugriffsgruppen", + "modal.providerDirectory": "Anbieterverzeichnis", + "modal.searchCount": "{count} Anbieter durchsuchen", + "modal.group.all": "Alle Anbieter", + "modal.group.recurring-or-keyless": "Regelmäßig / ohne Schlüssel", + "modal.group.recurring-uncapped": "Kein öffentliches Token-Limit", + "modal.group.recurring-credit": "Regelmäßiges Guthaben", + "modal.group.signup-credit": "Startguthaben", + "modal.group.existing": "Vorhandene kostenlose & lokale", + "modal.paidHint": "Kostenpflichtige Anbieter und Anbieter mit einmaligem Startguthaben durchsuchen.", + "modal.selectProvider": "Anbieter auswählen", + "modal.selectProviderHint": "Wähle einen Anbieter, um die Einrichtung anzusehen und Modelle zu suchen.", + "modal.badge.recurring": "Regelmäßig kostenlos", + "modal.badge.reference": "Referenz", + "modal.badge.experimental": "Experimentell", + "modal.badge.supported": "Unterstützt", + "modal.verification.official": "Offizielle Dokumentation", + "modal.verification.primary": "Primärquelle", + "modal.verification.unverified": "Nicht verifiziert", + "modal.providerDocs": "Anbieterdokumentation", + "modal.referenceExplanation": "Dieser Eintrag dient nur als Referenz. Automatische Aufrufe und die Modellsuche bleiben deaktiviert, bis ein dokumentierter Transportweg verifiziert wurde.", + "modal.modelDiscovery": "Modellsuche", + "modal.modelDiscoveryHint": "Verfügbare Modelle für diese Einrichtung suchen.", + "modal.discoverModels": "Modelle suchen", + "modal.discovering": "Suche läuft…", + "modal.modelsLive": "Live-Antwort des Anbieters", + "modal.modelsStatic": "Statischer Katalog", + "modal.discoveredModels": "Gefundene Modelle", + "modal.discoveryFailed": "Für diese Einrichtung konnten keine Modelle gefunden werden.", + "modal.backToDirectory": "Zurück zum Anbieterverzeichnis", + "modal.setupReviewProvider": "Einrichtungsdokumentation für {label} prüfen.", + "modal.setupReferenceStep": "Nutze diesen Eintrag als Einrichtungshilfe, bis verifizierte Aufrufunterstützung verfügbar ist.", + "modal.setupKeylessStep": "Bestätige den Endpunkt unten; ein API-Schlüssel ist optional.", + "modal.setupDiscoverStep": "Modelle suchen und ein Standardmodell auswählen.", "modal.noMatch": "Kein Treffer.", "modal.oauthDefaultNote": "Mit deinem Konto anmelden — kein API-Schlüssel nötig.", "modal.oauthComingSoon": "OAuth-Login für {label} kommt im nächsten Update. Nutze vorerst einen API-Schlüssel.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 81bc40d28..a3d8b32b9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -611,6 +611,8 @@ export const en = { "modal.endpoint": "Endpoint", "modal.endpoint.tokenPlan": "Token plan", "modal.endpoint.payAsYouGo": "Pay as you go", + "modal.endpoint.chinaMainland": "China mainland", + "modal.endpoint.international": "International", "modal.endpoint.custom": "Custom", "modal.defaultModel": "Default model (optional)", "modal.allowPrivateNetwork": "Allow local/private network", @@ -840,6 +842,41 @@ export const en = { "time.minutesAgo": "{n}m ago", "time.hoursAgo": "{n}h ago", "time.daysAgo": "{n}d ago", + "modal.catalogModes": "Provider access modes", + "modal.accessGroups": "Free access groups", + "modal.providerDirectory": "Provider directory", + "modal.searchCount": "Search {count} providers", + "modal.group.all": "All providers", + "modal.group.recurring-or-keyless": "Recurring / keyless", + "modal.group.recurring-uncapped": "No public token cap", + "modal.group.recurring-credit": "Recurring credits", + "modal.group.signup-credit": "Signup credits", + "modal.group.existing": "Existing free & local", + "modal.paidHint": "Browse paid providers and providers with one-time signup credit.", + "modal.selectProvider": "Select a provider", + "modal.selectProviderHint": "Choose a provider to view setup and discover models.", + "modal.badge.recurring": "Free recurring", + "modal.badge.reference": "Reference", + "modal.badge.experimental": "Experimental", + "modal.badge.supported": "Supported", + "modal.verification.official": "Official docs", + "modal.verification.primary": "Primary source", + "modal.verification.unverified": "Unverified", + "modal.providerDocs": "Provider docs", + "modal.referenceExplanation": "This entry is reference-only. Automated calling and model discovery remain disabled until a documented transport has been verified.", + "modal.modelDiscovery": "Model discovery", + "modal.modelDiscoveryHint": "Find available models for this setup.", + "modal.discoverModels": "Discover models", + "modal.discovering": "Discovering…", + "modal.modelsLive": "Live provider response", + "modal.modelsStatic": "Static catalog", + "modal.discoveredModels": "Discovered models", + "modal.discoveryFailed": "No models could be discovered for this setup.", + "modal.backToDirectory": "Back to provider directory", + "modal.setupReviewProvider": "Review the {label} setup documentation.", + "modal.setupReferenceStep": "Use this entry as setup guidance until verified calling support is available.", + "modal.setupKeylessStep": "Confirm the endpoint below; an API key is optional.", + "modal.setupDiscoverStep": "Discover and choose a default model.", "modal.noMatch": "No match.", "modal.oauthDefaultNote": "Log in with your account — no API key needed.", "modal.oauthComingSoon": "OAuth login for {label} arrives in the next update. Use an API key for now.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e7dfd58c1..3c0cceddb 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -576,6 +576,8 @@ export const ja: Record = { "modal.endpoint": "エンドポイント", "modal.endpoint.tokenPlan": "トークンプラン", "modal.endpoint.payAsYouGo": "従量課金", + "modal.endpoint.chinaMainland": "中国本土", + "modal.endpoint.international": "国際", "modal.endpoint.custom": "カスタム", "modal.defaultModel": "デフォルトモデル(任意)", "modal.allowPrivateNetwork": "ローカル/プライベートネットワークを許可", @@ -795,6 +797,41 @@ export const ja: Record = { "time.minutesAgo": "{n}分前", "time.hoursAgo": "{n}時間前", "time.daysAgo": "{n}日前", + "modal.catalogModes": "プロバイダーのアクセス方式", + "modal.accessGroups": "無料アクセスのグループ", + "modal.providerDirectory": "プロバイダーディレクトリ", + "modal.searchCount": "{count} 件のプロバイダーを検索", + "modal.group.all": "すべてのプロバイダー", + "modal.group.recurring-or-keyless": "定期無料 / キー不要", + "modal.group.recurring-uncapped": "公開トークン上限なし", + "modal.group.recurring-credit": "定期付与クレジット", + "modal.group.signup-credit": "登録時クレジット", + "modal.group.existing": "既存の無料・ローカル", + "modal.paidHint": "有料プロバイダーと、登録時に一度だけクレジットが付与されるプロバイダーを参照します。", + "modal.selectProvider": "プロバイダーを選択", + "modal.selectProviderHint": "プロバイダーを選択して、設定の確認とモデルの検出を行います。", + "modal.badge.recurring": "定期無料", + "modal.badge.reference": "参考情報", + "modal.badge.experimental": "試験的", + "modal.badge.supported": "対応済み", + "modal.verification.official": "公式ドキュメント", + "modal.verification.primary": "一次情報", + "modal.verification.unverified": "未検証", + "modal.providerDocs": "プロバイダーのドキュメント", + "modal.referenceExplanation": "この項目は参考情報のみです。文書化された通信方式が検証されるまで、自動呼び出しとモデル検出は無効です。", + "modal.modelDiscovery": "モデル検出", + "modal.modelDiscoveryHint": "この設定で利用可能なモデルを検索します。", + "modal.discoverModels": "モデルを検出", + "modal.discovering": "検出中…", + "modal.modelsLive": "プロバイダーからのライブ応答", + "modal.modelsStatic": "静的カタログ", + "modal.discoveredModels": "検出されたモデル", + "modal.discoveryFailed": "この設定でモデルを検出できませんでした。", + "modal.backToDirectory": "プロバイダーディレクトリに戻る", + "modal.setupReviewProvider": "{label} の設定ドキュメントを確認します。", + "modal.setupReferenceStep": "検証済みの呼び出しに対応するまで、この項目を設定ガイドとして利用してください。", + "modal.setupKeylessStep": "下記のエンドポイントを確認します。API キーは任意です。", + "modal.setupDiscoverStep": "モデルを検出して、既定のモデルを選択します。", "modal.noMatch": "一致なし。", "modal.oauthDefaultNote": "アカウントでログイン — API キー不要です。", "modal.oauthComingSoon": "{label} の OAuth ログインは次回の更新で対応予定です。今は API キーをお使いください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index df1797959..bff685846 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -573,6 +573,8 @@ export const ko: Record = { "modal.endpoint": "엔드포인트", "modal.endpoint.tokenPlan": "토큰 플랜", "modal.endpoint.payAsYouGo": "종량제", + "modal.endpoint.chinaMainland": "중국 본토", + "modal.endpoint.international": "국제", "modal.endpoint.custom": "사용자 지정", "modal.defaultModel": "기본 모델(선택)", "modal.allowPrivateNetwork": "로컬/사설 네트워크 허용", @@ -1019,6 +1021,41 @@ export const ko: Record = { "time.minutesAgo": "{n}분 전", "time.hoursAgo": "{n}시간 전", "time.daysAgo": "{n}일 전", + "modal.catalogModes": "프로바이더 접근 방식", + "modal.accessGroups": "무료 접근 그룹", + "modal.providerDirectory": "프로바이더 디렉터리", + "modal.searchCount": "프로바이더 {count}개 검색", + "modal.group.all": "모든 프로바이더", + "modal.group.recurring-or-keyless": "정기 무료 / 키 불필요", + "modal.group.recurring-uncapped": "공개 토큰 한도 없음", + "modal.group.recurring-credit": "정기 크레딧", + "modal.group.signup-credit": "가입 크레딧", + "modal.group.existing": "기존 무료 및 로컬", + "modal.paidHint": "유료 프로바이더와 가입 시 일회성 크레딧을 제공하는 프로바이더를 찾아보세요.", + "modal.selectProvider": "프로바이더 선택", + "modal.selectProviderHint": "프로바이더를 선택해 설정을 확인하고 모델을 검색하세요.", + "modal.badge.recurring": "정기 무료", + "modal.badge.reference": "참고 자료", + "modal.badge.experimental": "실험적", + "modal.badge.supported": "지원됨", + "modal.verification.official": "공식 문서", + "modal.verification.primary": "1차 출처", + "modal.verification.unverified": "검증되지 않음", + "modal.providerDocs": "프로바이더 문서", + "modal.referenceExplanation": "이 항목은 참고용입니다. 문서화된 전송 방식이 검증될 때까지 자동 호출과 모델 검색은 비활성화됩니다.", + "modal.modelDiscovery": "모델 검색", + "modal.modelDiscoveryHint": "이 설정에서 사용 가능한 모델을 찾습니다.", + "modal.discoverModels": "모델 검색", + "modal.discovering": "검색 중…", + "modal.modelsLive": "프로바이더 실시간 응답", + "modal.modelsStatic": "정적 카탈로그", + "modal.discoveredModels": "검색된 모델", + "modal.discoveryFailed": "이 설정에서 모델을 찾지 못했습니다.", + "modal.backToDirectory": "프로바이더 디렉터리로 돌아가기", + "modal.setupReviewProvider": "{label} 설정 문서를 확인하세요.", + "modal.setupReferenceStep": "검증된 호출 지원이 제공될 때까지 이 항목을 설정 안내로 사용하세요.", + "modal.setupKeylessStep": "아래 엔드포인트를 확인하세요. API 키는 선택 사항입니다.", + "modal.setupDiscoverStep": "모델을 검색하고 기본 모델을 선택하세요.", "modal.noMatch": "일치 항목 없음.", "modal.oauthDefaultNote": "계정으로 로그인 — API 키 불필요.", "modal.oauthComingSoon": "{label} OAuth 로그인은 다음 업데이트에 제공됩니다. 지금은 API 키를 사용하세요.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 148af25f6..5577e1577 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -611,6 +611,8 @@ export const ru: Record = { "modal.endpoint": "Конечная точка", "modal.endpoint.tokenPlan": "Пакет токенов", "modal.endpoint.payAsYouGo": "Оплата по факту", + "modal.endpoint.chinaMainland": "Материковый Китай", + "modal.endpoint.international": "Международный", "modal.endpoint.custom": "Своя", "modal.defaultModel": "Модель по умолчанию (необязательно)", "modal.allowPrivateNetwork": "Разрешить локальную/частную сеть", @@ -840,6 +842,41 @@ export const ru: Record = { "time.minutesAgo": "{n} мин назад", "time.hoursAgo": "{n} ч назад", "time.daysAgo": "{n} дн. назад", + "modal.catalogModes": "Режимы доступа к провайдерам", + "modal.accessGroups": "Группы бесплатного доступа", + "modal.providerDirectory": "Каталог провайдеров", + "modal.searchCount": "Поиск среди {count} провайдеров", + "modal.group.all": "Все провайдеры", + "modal.group.recurring-or-keyless": "Регулярно / без ключа", + "modal.group.recurring-uncapped": "Без публичного лимита токенов", + "modal.group.recurring-credit": "Регулярные кредиты", + "modal.group.signup-credit": "Кредиты за регистрацию", + "modal.group.existing": "Существующие бесплатные и локальные", + "modal.paidHint": "Просматривайте платных провайдеров и провайдеров с разовым кредитом за регистрацию.", + "modal.selectProvider": "Выберите провайдера", + "modal.selectProviderHint": "Выберите провайдера, чтобы просмотреть настройку и найти модели.", + "modal.badge.recurring": "Регулярно бесплатно", + "modal.badge.reference": "Справка", + "modal.badge.experimental": "Экспериментально", + "modal.badge.supported": "Поддерживается", + "modal.verification.official": "Официальная документация", + "modal.verification.primary": "Первичный источник", + "modal.verification.unverified": "Не проверено", + "modal.providerDocs": "Документация провайдера", + "modal.referenceExplanation": "Эта запись предназначена только для справки. Автоматические вызовы и поиск моделей останутся отключёнными, пока не будет проверен документированный способ подключения.", + "modal.modelDiscovery": "Поиск моделей", + "modal.modelDiscoveryHint": "Найдите модели, доступные для этой настройки.", + "modal.discoverModels": "Найти модели", + "modal.discovering": "Поиск…", + "modal.modelsLive": "Ответ провайдера в реальном времени", + "modal.modelsStatic": "Статический каталог", + "modal.discoveredModels": "Найденные модели", + "modal.discoveryFailed": "Не удалось найти модели для этой настройки.", + "modal.backToDirectory": "Вернуться к каталогу провайдеров", + "modal.setupReviewProvider": "Ознакомьтесь с документацией по настройке {label}.", + "modal.setupReferenceStep": "Используйте эту запись как руководство по настройке, пока не появится поддержка проверенных вызовов.", + "modal.setupKeylessStep": "Подтвердите конечную точку ниже; API-ключ необязателен.", + "modal.setupDiscoverStep": "Найдите модели и выберите модель по умолчанию.", "modal.noMatch": "Ничего не найдено.", "modal.oauthDefaultNote": "Войдите со своим аккаунтом — API-ключ не нужен.", "modal.oauthComingSoon": "Вход через OAuth для {label} появится в следующем обновлении. Пока используйте API-ключ.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e6414158f..9764bc991 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -573,6 +573,8 @@ export const zh: Record = { "modal.endpoint": "端点", "modal.endpoint.tokenPlan": "Token 套餐", "modal.endpoint.payAsYouGo": "按量付费", + "modal.endpoint.chinaMainland": "中国大陆", + "modal.endpoint.international": "国际", "modal.endpoint.custom": "自定义", "modal.defaultModel": "默认模型(可选)", "modal.allowPrivateNetwork": "允许本地/私有网络", @@ -1019,6 +1021,41 @@ export const zh: Record = { "time.minutesAgo": "{n} 分钟前", "time.hoursAgo": "{n} 小时前", "time.daysAgo": "{n} 天前", + "modal.catalogModes": "提供商访问方式", + "modal.accessGroups": "免费访问分组", + "modal.providerDirectory": "提供商目录", + "modal.searchCount": "搜索 {count} 个提供商", + "modal.group.all": "所有提供商", + "modal.group.recurring-or-keyless": "定期免费 / 无需密钥", + "modal.group.recurring-uncapped": "无公开 Token 上限", + "modal.group.recurring-credit": "定期赠送额度", + "modal.group.signup-credit": "注册赠送额度", + "modal.group.existing": "现有免费与本地", + "modal.paidHint": "浏览付费提供商及提供一次性注册赠送额度的提供商。", + "modal.selectProvider": "选择提供商", + "modal.selectProviderHint": "选择提供商以查看设置并发现模型。", + "modal.badge.recurring": "定期免费", + "modal.badge.reference": "参考资料", + "modal.badge.experimental": "实验性", + "modal.badge.supported": "已支持", + "modal.verification.official": "官方文档", + "modal.verification.primary": "第一方来源", + "modal.verification.unverified": "未验证", + "modal.providerDocs": "提供商文档", + "modal.referenceExplanation": "此条目仅供参考。在已记录的传输方式通过验证前,自动调用和模型发现功能将保持禁用。", + "modal.modelDiscovery": "模型发现", + "modal.modelDiscoveryHint": "查找此设置可用的模型。", + "modal.discoverModels": "发现模型", + "modal.discovering": "正在发现…", + "modal.modelsLive": "提供商实时响应", + "modal.modelsStatic": "静态目录", + "modal.discoveredModels": "已发现的模型", + "modal.discoveryFailed": "无法为此设置发现模型。", + "modal.backToDirectory": "返回提供商目录", + "modal.setupReviewProvider": "查看 {label} 的设置文档。", + "modal.setupReferenceStep": "在经过验证的调用支持可用前,请将此条目用作设置指南。", + "modal.setupKeylessStep": "确认下方端点;API 密钥为可选项。", + "modal.setupDiscoverStep": "发现模型并选择默认模型。", "modal.noMatch": "无匹配。", "modal.oauthDefaultNote": "使用账户登录 — 无需 API 密钥。", "modal.oauthComingSoon": "{label} 的 OAuth 登录将在下次更新提供。请先使用 API 密钥。", diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index ec9dfcb66..b5790eaaf 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -4,17 +4,21 @@ const PROVIDER_ICON_ALIASES: Record = { "azure-openai": "openai.svg", chatgpt: "openai.svg", "cloudflare-ai-gateway": "cloudflare-ai-gateway-color.svg", + "cloudflare-ai": "cloudflare-ai-gateway-color.svg", "cloudflare-workers-ai": "cloudflare-ai-gateway-color.svg", cursor: "cursor-color.svg", deepseek: "deepseek-color.svg", firepass: "firepass-color.svg", fireworks: "fireworks-color.svg", github: "github-copilot-color.svg", + "github-models": "github-copilot-color.svg", "github-copilot": "copilot-color.svg", "gitlab-duo": "gitlab-duo-color.svg", google: "gemini-color.svg", + gemini: "gemini-color.svg", "google-antigravity": "antigravity-color.svg", "google-vertex": "gemini-color.svg", + vertex: "gemini-color.svg", groq: "groq-color.svg", huggingface: "huggingface-color.svg", kimi: "kimi-color.svg", @@ -29,14 +33,17 @@ const PROVIDER_ICON_ALIASES: Record = { openai: "openai.svg", "openai-apikey": "openai.svg", "opencode-free": "opencode.svg", + opencode: "opencode.svg", "opencode-go": "opencode.svg", "opencode-zen": "opencode.svg", openrouter: "openrouter-color.svg", qianfan: "qianfan-color.svg", + baidu: "qianfan-color.svg", alibaba: "alibaba-color.svg", "alibaba-token-plan": "alibaba-color.svg", "alibaba-token-plan-intl": "alibaba-color.svg", "qwen-cloud": "qwen-portal-color.svg", + "qwen-web": "qwen-portal-color.svg", "vercel-ai-gateway": "vercel-ai-gateway-color.svg", vllm: "vllm-color.svg", xai: "grok-color.svg", diff --git a/gui/src/styles/provider-catalog.css b/gui/src/styles/provider-catalog.css index e12d5b199..3c9f71bde 100644 --- a/gui/src/styles/provider-catalog.css +++ b/gui/src/styles/provider-catalog.css @@ -1,67 +1,122 @@ -/* Add-provider catalog: tabs, browse rows, account rows (WP050b). */ +.provider-loader-overlay { padding: 24px 16px; } -.provider-catalog { - display: flex; - flex-direction: column; - gap: 10px; +.modal-card.provider-loader-card { + width: min(1400px, calc(100vw - 32px)); + max-width: none; + height: min(860px, calc(100dvh - 48px)); + max-height: none; + padding: 0; + overflow: hidden; } -.provider-catalog-tabs { - display: flex; - gap: 4px; +.provider-loader-card > .modal-head { + min-height: 62px; + margin: 0; + padding: 12px 18px; border-bottom: 1px solid var(--border); } +.provider-catalog { display: flex; flex-direction: column; min-height: 0; height: calc(100% - 62px); } +.provider-catalog-standalone { padding: 20px; overflow-y: auto; } +.provider-catalog-standalone .provider-detail-form { width: min(720px, 100%); margin: 0 auto; } +.provider-catalog-tabs { display: flex; gap: 4px; padding: 0 18px; border-bottom: 1px solid var(--border); } .provider-catalog-tab { - appearance: none; - background: none; - border: none; - border-bottom: 2px solid transparent; - padding: 8px 12px; - color: var(--muted); - font: inherit; - cursor: pointer; -} - -.provider-catalog-tab.active { - color: var(--fg); - border-bottom-color: var(--accent); -} - -.provider-catalog-accounts-hint { - padding: 2px 2px 0; + appearance: none; background: none; border: 0; border-bottom: 2px solid transparent; + min-height: 42px; padding: 8px 12px; color: var(--muted); font: inherit; cursor: pointer; } - -.provider-catalog-search { - width: 100%; +.provider-catalog-tab.active { color: var(--text); border-bottom-color: var(--accent); } +.provider-catalog-workspace { display: grid; grid-template-columns: 220px minmax(300px, 390px) minmax(420px, 1fr); min-height: 0; flex: 1; } +.provider-catalog-groups { display: flex; flex-direction: column; gap: 4px; padding: 14px 10px; border-right: 1px solid var(--border); min-width: 0; } +.provider-catalog-group { + display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; + min-height: 42px; padding: 8px 10px; border: 1px solid transparent; border-radius: var(--radius-sm); + background: transparent; color: var(--muted); font: inherit; font-size: var(--text-control); text-align: left; cursor: pointer; } - -.provider-catalog-rows { - display: flex; - flex-direction: column; - gap: 6px; - max-height: 360px; - overflow-y: auto; +.provider-catalog-group span { overflow-wrap: anywhere; } +.provider-catalog-group strong { font-size: var(--text-label); font-variant-numeric: tabular-nums; } +.provider-catalog-group:hover { background: var(--raised); color: var(--text); } +.provider-catalog-group.active { background: var(--accent-soft); border-color: var(--accent-ring); color: var(--text); } +.provider-catalog-custom { margin: auto 8px 4px; align-self: flex-start; } +.provider-catalog-accounts-hint { padding: 10px; line-height: var(--leading-body); } +.provider-catalog-browser { display: flex; flex-direction: column; min-width: 0; border-right: 1px solid var(--border); } +.provider-catalog-search-wrap { padding: 14px; border-bottom: 1px solid var(--border-soft); } +.provider-catalog-search { width: 100%; } +.provider-catalog-rows { display: flex; flex-direction: column; gap: 7px; padding: 10px; overflow-y: auto; min-height: 0; } +.provider-catalog-row { + display: flex; align-items: center; gap: 10px; width: 100%; min-height: 58px; padding: 8px 10px; + border: 1px solid var(--border); border-radius: var(--radius-sm); background: transparent; + color: var(--text); font: inherit; text-align: left; cursor: pointer; } - -.provider-catalog-badges { - display: flex; - gap: 4px; - align-items: center; - flex-shrink: 0; +.provider-catalog-row:hover { background: var(--raised); } +.provider-catalog-row.active { border-color: var(--accent); background: var(--accent-soft); } +.provider-catalog-icon { + display: grid; place-items: center; flex: 0 0 34px; width: 34px; height: 34px; + border-radius: var(--radius-sm); background: var(--raised); font-weight: var(--weight-semibold); } - -.provider-catalog-empty { - padding: 8px; +.provider-catalog-icon img { width: 23px; height: 23px; object-fit: contain; } +.provider-catalog-row-copy { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 2px; } +.provider-catalog-row-copy .title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-control); font-weight: var(--weight-semibold); } +.provider-catalog-row-copy .sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: var(--text-label); } +.provider-catalog-badges { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; flex: 0 0 auto; max-width: 46%; } +.provider-catalog-empty { padding: 10px; } +.provider-catalog-account-row { cursor: default; } +.provider-catalog-detail { min-width: 0; padding: 20px 24px 24px; overflow-y: auto; } +.provider-catalog-detail-empty { display: grid; place-content: center; gap: 7px; height: 100%; color: var(--muted); text-align: center; } +.provider-catalog-detail-empty strong { color: var(--text); font-size: var(--text-subtitle); } +.provider-detail-form { display: flex; flex-direction: column; gap: 12px; } +.provider-detail-mobile-back { display: none; align-self: flex-start; } +.provider-detail-header { display: flex; align-items: flex-start; gap: 12px; padding-bottom: 14px; border-bottom: 1px solid var(--border); } +.provider-detail-header .provider-catalog-icon { flex-basis: 42px; width: 42px; height: 42px; } +.provider-detail-header .provider-catalog-icon img { width: 29px; height: 29px; } +.provider-detail-title { flex: 1; min-width: 0; } +.provider-detail-title h4 { margin: 0 0 2px; font-size: var(--text-subtitle); } +.provider-detail-meta { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 7px; } +.provider-detail-docs { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; font-size: var(--text-label); } +.provider-detail-docs svg { width: 14px; height: 14px; flex: 0 0 auto; } +.provider-detail-guide { margin: 0; padding: 12px 14px; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); background: var(--raised); } +.provider-detail-guide ol { margin: 8px 0 0; padding-left: 20px; color: var(--muted); font-size: var(--text-label); line-height: var(--leading-relaxed); } +.provider-detail-reference { padding: 10px 12px; border: 1px solid var(--amber); border-radius: var(--radius-sm); background: var(--amber-soft); color: var(--amber); font-size: var(--text-label); line-height: var(--leading-body); } +.provider-model-discovery { display: grid; gap: 8px; padding-top: 4px; border-top: 1px solid var(--border-soft); } +.provider-model-discovery-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.provider-model-discovery-head strong { font-size: var(--text-control); } +.provider-model-source { color: var(--muted); font-size: var(--text-label); } +.provider-model-list { display: grid; max-height: 210px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); } +.provider-model-row { display: flex; align-items: center; gap: 9px; padding: 8px 10px; font-size: var(--text-label); cursor: pointer; } +.provider-model-row + .provider-model-row { border-top: 1px solid var(--border-soft); } +.provider-model-row:hover { background: var(--raised); } +.provider-detail-actions { + position: sticky; bottom: -24px; z-index: 2; display: flex; align-items: center; gap: 8px; + margin: 2px -24px -24px; padding: 14px 24px; border-top: 1px solid var(--border); + background: color-mix(in oklab, var(--surface) 94%, transparent); backdrop-filter: blur(12px); } +.provider-detail-actions .provider-detail-spacer { flex: 1; } -.provider-catalog-account-row .sub { - overflow: hidden; - text-overflow: ellipsis; +@media (max-width: 1050px) { + .provider-catalog-workspace { grid-template-columns: 180px minmax(260px, 320px) minmax(0, 1fr); } + .provider-catalog-detail { padding: 18px; } } -.provider-catalog-footer { - display: flex; - gap: 8px; - align-items: center; +@media (max-width: 760px) { + .provider-loader-overlay { padding: 12px 10px; } + .modal-card.provider-loader-card { width: calc(100vw - 20px); height: calc(100dvh - 24px); } + .provider-loader-card > .modal-head { min-height: 56px; padding: 8px 12px; } + .provider-catalog { height: calc(100% - 56px); } + .provider-catalog-tabs { padding: 0 8px; overflow-x: auto; } + .provider-catalog-tab { min-width: max-content; min-height: 44px; } + .provider-catalog-workspace { display: grid; grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } + .provider-catalog-groups { flex-direction: row; gap: 6px; padding: 8px; overflow-x: auto; border: 0; border-bottom: 1px solid var(--border); } + .provider-catalog-group { display: flex; min-width: max-content; min-height: 38px; } + .provider-catalog-custom { margin: 0 4px; min-width: max-content; align-self: center; } + .provider-catalog-browser { border: 0; } + .provider-catalog-search-wrap { padding: 10px; } + .provider-catalog-detail { display: none; padding: 14px; } + .provider-catalog-workspace.has-selection .provider-catalog-groups, + .provider-catalog-workspace.has-selection .provider-catalog-browser { display: none; } + .provider-catalog-workspace.has-selection .provider-catalog-detail { display: block; } + .provider-catalog-workspace.is-accounts .provider-catalog-detail { display: none; } + .provider-catalog-badges .badge-muted { display: none; } + .provider-detail-header { flex-wrap: wrap; } + .provider-detail-mobile-back { display: inline-flex; } + .provider-detail-docs { width: 100%; } + .provider-detail-actions { bottom: -14px; margin: 2px -14px -14px; padding: 12px 14px; } } diff --git a/gui/tests/provider-catalog.test.ts b/gui/tests/provider-catalog.test.ts new file mode 100644 index 000000000..5454957eb --- /dev/null +++ b/gui/tests/provider-catalog.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test"; +import { + accessGroupCounts, + curatedPresets, + filterByAccessGroup, + filterPresets, + freeCatalogSections, + isPresetActionable, + type CatalogPreset, +} from "../src/components/provider-catalog/provider-presets"; +import { FREE_PROVIDER_DIRECTORY } from "../../src/providers/free-directory"; +import { deriveProviderPresets } from "../../src/providers/derive"; + +const rows: CatalogPreset[] = FREE_PROVIDER_DIRECTORY.map(row => ({ + id: row.id, + label: row.label, + adapter: row.adapter, + baseUrl: row.baseUrl, + auth: row.authKind, + accessGroups: row.accessGroups, + supportLevel: row.supportLevel, +})); + +test("curated directory keeps 81 unique providers and counts glm-cn in both groups", () => { + expect(curatedPresets(rows)).toHaveLength(81); + expect(accessGroupCounts(rows)).toEqual({ + "recurring-or-keyless": 43, + "recurring-uncapped": 13, + "recurring-credit": 2, + "signup-credit": 24, + }); + expect(filterByAccessGroup(rows, "recurring-uncapped").some(row => row.id === "glm-cn")).toBe(true); + expect(filterByAccessGroup(rows, "signup-credit").some(row => row.id === "glm-cn")).toBe(true); +}); + +test("actual preset projection keeps the 81-row directory and seven legacy free providers reachable", () => { + const presets: CatalogPreset[] = deriveProviderPresets().filter(row => row.id !== "custom").map(row => ({ + id: row.id, + label: row.label, + adapter: row.adapter, + baseUrl: row.baseUrl, + auth: row.auth, + keyOptional: row.keyOptional, + freeTier: row.freeTier, + accessGroups: row.accessGroups, + })); + const sections = freeCatalogSections(presets); + expect(sections.directory).toHaveLength(81); + expect(sections.existing.map(row => row.id)).toEqual([ + "ollama", "vllm", "lm-studio", "litellm", "opencode-free", "mimo-free", "cloudflare-workers-ai", + ]); +}); + +test("search matches provider label/id and reference presets are not actionable", () => { + expect(filterPresets(rows, "google gemini").map(row => row.id)).toEqual(["gemini"]); + expect(isPresetActionable(rows.find(row => row.id === "duckduckgo-web")!)).toBe(false); + expect(isPresetActionable(rows.find(row => row.id === "groq")!)).toBe(true); +}); diff --git a/gui/tests/provider-discovery-race.test.tsx b/gui/tests/provider-discovery-race.test.tsx new file mode 100644 index 000000000..f519ddeec --- /dev/null +++ b/gui/tests/provider-discovery-race.test.tsx @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import AddProviderModal from "../src/components/AddProviderModal"; +import { LanguageProvider } from "../src/i18n/provider"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +const originalFetch = globalThis.fetch; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +test("stale discovery responses cannot overwrite a changed endpoint or provider", async () => { + const discoveryResolvers: Array<(response: Response) => void> = []; + const deferredDiscoveries: Promise[] = []; + const discoverySignals: Array = []; + + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/oauth/providers")) return Promise.resolve(Response.json({ providers: [] })); + if (url.endsWith("/api/usage?range=30d")) return Promise.resolve(Response.json({ providers: [] })); + if (url.endsWith("/api/provider-presets")) { + return Promise.resolve(Response.json({ + providers: [ + { + id: "alpha", + label: "Alpha", + adapter: "openai-chat", + baseUrl: "https://alpha-cn.example/v1", + auth: "key", + baseUrlChoices: [ + { id: "china-mainland", label: "China mainland", baseUrl: "https://alpha-cn.example/v1" }, + { id: "international", label: "International", baseUrl: "https://alpha-global.example/v1" }, + ], + accessGroups: ["recurring-or-keyless"], + supportLevel: "supported", + verification: "official", + discovery: "live", + liveModels: true, + }, + { + id: "beta", + label: "Beta", + adapter: "openai-chat", + baseUrl: "https://beta.example/v1", + auth: "key", + accessGroups: ["recurring-or-keyless"], + supportLevel: "supported", + verification: "official", + discovery: "static", + liveModels: false, + models: ["beta-static"], + defaultModel: "beta-static", + }, + ], + })); + } + if (url.endsWith("/api/provider-presets/discover")) { + const requestIndex = deferredDiscoveries.length; + discoverySignals.push(init?.signal ?? undefined); + expect(JSON.parse(String(init?.body))).toMatchObject({ + presetId: "alpha", + provider: { + baseUrl: requestIndex === 0 ? "https://alpha-cn.example/v1" : "https://alpha-global.example/v1", + }, + }); + const deferred = new Promise(resolve => { discoveryResolvers.push(resolve); }); + deferredDiscoveries.push(deferred); + return deferred; + } + return Promise.reject(new Error(`unexpected fetch: ${url}`)); + }) as typeof fetch; + + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + let root: Root; + + await act(async () => { + root = createRoot(container); + root.render( + + {}} + onAdded={() => {}} + /> + , + ); + }); + await act(async () => { + await new Promise(resolve => window.setTimeout(resolve, 0)); + }); + + const button = (label: string) => [...container.querySelectorAll("button")] + .find(candidate => candidate.textContent?.includes(label)); + + await act(async () => { button("Alpha")!.click(); }); + await act(async () => { button("Discover models")!.click(); }); + expect(discoverySignals[0]?.aborted).toBe(false); + + const endpointSelect = [...container.querySelectorAll("label")] + .find(label => label.textContent?.includes("Endpoint")) + ?.querySelector("select"); + expect(endpointSelect).toBeDefined(); + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLSelectElement.prototype, "value")! + .set!.call(endpointSelect, "international"); + endpointSelect!.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }); + expect(endpointSelect?.value).toBe("international"); + expect(discoverySignals[0]?.aborted).toBe(true); + + await act(async () => { + discoveryResolvers[0]!(Response.json({ ok: true, source: "live", models: [{ id: "alpha-cn-stale" }] })); + await deferredDiscoveries[0]; + await Promise.resolve(); + }); + expect(container.textContent).not.toContain("alpha-cn-stale"); + + await act(async () => { button("Discover models")!.click(); }); + expect(discoverySignals[1]?.aborted).toBe(false); + + await act(async () => { button("Beta")!.click(); }); + expect(discoverySignals[1]?.aborted).toBe(true); + expect(container.textContent).toContain("beta-static"); + + await act(async () => { + discoveryResolvers[1]!(Response.json({ ok: true, source: "live", models: [{ id: "alpha-global-stale" }] })); + await deferredDiscoveries[1]; + await Promise.resolve(); + }); + + expect(container.textContent).toContain("Beta"); + expect(container.textContent).toContain("beta-static"); + expect(container.textContent).not.toContain("alpha-global-stale"); + const defaultModelInput = [...container.querySelectorAll("label")] + .find(label => label.textContent?.includes("Default model")) + ?.querySelector("input"); + expect(defaultModelInput?.value).toBe("beta-static"); + + await act(async () => { root.unmount(); }); +}); From 23c47671bf27a648349d9fecd2136c7b040ea295 Mon Sep 17 00:00:00 2001 From: HaydernCenterpoint Date: Fri, 24 Jul 2026 18:18:30 +0700 Subject: [PATCH 4/4] feat: filter OpenRouter discovery to free models Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- gui/src/components/AddProviderModal.tsx | 19 ++++++++-- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/styles/provider-catalog.css | 1 + src/codex/catalog/provider-fetch.ts | 12 ++++++ src/server/management/provider-routes.ts | 43 ++++++++++++++------- tests/provider-free-directory.test.ts | 48 +++++++++++++++++++++++- tests/provider-model-envelope.test.ts | 4 ++ 12 files changed, 115 insertions(+), 18 deletions(-) diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index 7ebba05ce..3d10f8d7d 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -75,6 +75,7 @@ export default function AddProviderModal({ const [discoverySource, setDiscoverySource] = useState<"live" | "static" | "">(""); const [discoveredModels, setDiscoveredModels] = useState>([]); const [selectedModels, setSelectedModels] = useState([]); + const [openRouterFreeOnly, setOpenRouterFreeOnly] = useState(false); const discoveryRequestRef = useRef(0); const discoveryAbortRef = useRef(null); const aliveRef = useRef(true); @@ -194,6 +195,7 @@ export default function AddProviderModal({ const staticModels = (p.models ?? []).map(id => ({ id })); setDiscoveredModels(staticModels); setSelectedModels(staticModels.map(model => model.id)); + setOpenRouterFreeOnly(false); }; const back = () => { @@ -211,6 +213,7 @@ export default function AddProviderModal({ setDiscoverySource(""); setDiscoveredModels([]); setSelectedModels([]); + setOpenRouterFreeOnly(false); }; const providerPostBody = () => { @@ -221,7 +224,7 @@ export default function AddProviderModal({ return buildProviderPostBody(preset, { ...form, baseUrl: resolvedBaseUrl }); }; - const discoverModels = async () => { + const discoverModels = async (freeOnly = openRouterFreeOnly) => { const postBody = providerPostBody(); if (!postBody || !preset || preset.supportLevel === "reference") return; const requestId = discoveryRequestRef.current + 1; @@ -235,7 +238,7 @@ export default function AddProviderModal({ const res = await fetch(`${apiBase}/api/provider-presets/discover`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ presetId: preset.id, provider: postBody.provider }), + body: JSON.stringify({ presetId: preset.id, provider: postBody.provider, ...(preset.id === "openrouter" ? { freeOnly } : {}) }), signal: controller.signal, }); const data = await res.json().catch(() => ({})) as { @@ -252,7 +255,7 @@ export default function AddProviderModal({ setSelectedModels(models.map(model => model.id)); setDiscoverySource(data.source ?? (preset.discovery === "static" ? "static" : "live")); if (data.error) setDiscoveryError(data.error); - if (models[0]) setForm(current => current && !current.defaultModel ? { ...current, defaultModel: models[0]!.id } : current); + if (models[0]) setForm(current => current && (freeOnly || !current.defaultModel) ? { ...current, defaultModel: models[0]!.id } : current); } catch (cause) { if (requestId !== discoveryRequestRef.current || controller.signal.aborted) return; setDiscoveryError(cause instanceof Error ? cause.message : t("modal.networkError")); @@ -662,6 +665,16 @@ export default function AddProviderModal({ {discoveryBusy ? t("modal.discovering") : t("modal.discoverModels")}
+ {preset.id === "openrouter" && ( + + )} {discoveryError &&
{discoveryError}
} {discoveredModels.length > 0 && (
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 4feb196e0..31c190473 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1024,6 +1024,7 @@ export const de = { "modal.modelDiscovery": "Modellsuche", "modal.modelDiscoveryHint": "Verfügbare Modelle für diese Einrichtung suchen.", "modal.discoverModels": "Modelle suchen", + "modal.openRouterFreeOnly": "Nur kostenlose OpenRouter-Modelle verwenden", "modal.discovering": "Suche läuft…", "modal.modelsLive": "Live-Antwort des Anbieters", "modal.modelsStatic": "Statischer Katalog", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index a3d8b32b9..66c1575d1 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -867,6 +867,7 @@ export const en = { "modal.modelDiscovery": "Model discovery", "modal.modelDiscoveryHint": "Find available models for this setup.", "modal.discoverModels": "Discover models", + "modal.openRouterFreeOnly": "Use free OpenRouter models only", "modal.discovering": "Discovering…", "modal.modelsLive": "Live provider response", "modal.modelsStatic": "Static catalog", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3c0cceddb..620d10074 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -822,6 +822,7 @@ export const ja: Record = { "modal.modelDiscovery": "モデル検出", "modal.modelDiscoveryHint": "この設定で利用可能なモデルを検索します。", "modal.discoverModels": "モデルを検出", + "modal.openRouterFreeOnly": "OpenRouter の無料モデルのみを使用", "modal.discovering": "検出中…", "modal.modelsLive": "プロバイダーからのライブ応答", "modal.modelsStatic": "静的カタログ", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index bff685846..d826e3bce 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1046,6 +1046,7 @@ export const ko: Record = { "modal.modelDiscovery": "모델 검색", "modal.modelDiscoveryHint": "이 설정에서 사용 가능한 모델을 찾습니다.", "modal.discoverModels": "모델 검색", + "modal.openRouterFreeOnly": "무료 OpenRouter 모델만 사용", "modal.discovering": "검색 중…", "modal.modelsLive": "프로바이더 실시간 응답", "modal.modelsStatic": "정적 카탈로그", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 5577e1577..3106c60eb 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -867,6 +867,7 @@ export const ru: Record = { "modal.modelDiscovery": "Поиск моделей", "modal.modelDiscoveryHint": "Найдите модели, доступные для этой настройки.", "modal.discoverModels": "Найти модели", + "modal.openRouterFreeOnly": "Использовать только бесплатные модели OpenRouter", "modal.discovering": "Поиск…", "modal.modelsLive": "Ответ провайдера в реальном времени", "modal.modelsStatic": "Статический каталог", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 9764bc991..63aa17a12 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1046,6 +1046,7 @@ export const zh: Record = { "modal.modelDiscovery": "模型发现", "modal.modelDiscoveryHint": "查找此设置可用的模型。", "modal.discoverModels": "发现模型", + "modal.openRouterFreeOnly": "仅使用免费的 OpenRouter 模型", "modal.discovering": "正在发现…", "modal.modelsLive": "提供商实时响应", "modal.modelsStatic": "静态目录", diff --git a/gui/src/styles/provider-catalog.css b/gui/src/styles/provider-catalog.css index 3c9f71bde..278eff636 100644 --- a/gui/src/styles/provider-catalog.css +++ b/gui/src/styles/provider-catalog.css @@ -80,6 +80,7 @@ .provider-model-discovery-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } .provider-model-discovery-head strong { font-size: var(--text-control); } .provider-model-source { color: var(--muted); font-size: var(--text-label); } +.provider-model-free-filter { display: inline-flex; align-items: center; gap: 7px; width: fit-content; color: var(--text); font-size: var(--text-label); cursor: pointer; } .provider-model-list { display: grid; max-height: 210px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); } .provider-model-row { display: flex; align-items: center; gap: 9px; padding: 8px 10px; font-size: var(--text-label); cursor: pointer; } .provider-model-row + .provider-model-row { border-top: 1px solid var(--border-soft); } diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 2b2722fe0..638b80f54 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -57,6 +57,10 @@ export type ProviderModelsApiItem = { owned_by?: string; context_length?: number; max_model_len?: number; + pricing?: { + prompt: string; + completion: string; + }; metadata?: { capabilities?: Record; limits?: Record; @@ -100,10 +104,18 @@ export function parseProviderModelsApiItems(value: unknown, allowModelsEnvelope item.max_context_length, item.max_model_len, ].find(candidate => typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) as number | undefined; + const rawPricing = item.pricing !== null && typeof item.pricing === "object" && !Array.isArray(item.pricing) + ? item.pricing as Record + : null; + const pricing = rawPricing && typeof rawPricing.prompt === "string" && rawPricing.prompt.trim() + && typeof rawPricing.completion === "string" && rawPricing.completion.trim() + ? { prompt: rawPricing.prompt, completion: rawPricing.completion } + : undefined; normalized.push({ id, ...(typeof item.owned_by === "string" ? { owned_by: item.owned_by } : {}), ...(contextLength ? { context_length: contextLength } : {}), + ...(pricing ? { pricing } : {}), ...(item.metadata !== null && typeof item.metadata === "object" && !Array.isArray(item.metadata) ? { metadata: item.metadata as ProviderModelsApiItem["metadata"] } : {}), diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 5ce6d4bcd..6a2a2a33c 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -409,12 +409,14 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise model.id.endsWith(":free")) + : staticModels; 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 } + return jsonResponse(fallbackModels.length + ? { ok: true, models: fallbackModels, 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"); @@ -477,8 +482,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise 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 } + return jsonResponse(fallbackModels.length + ? { ok: true, models: fallbackModels, source: "static", latencyMs, error } : { ok: false, models: [], source: "live", latencyMs, error }); } const uniqueRows = new Map(); @@ -496,22 +501,34 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise= 2000) break; } - const models = [...uniqueRows.values()].map(row => ({ + const filteredRows = body.freeOnly === true + ? [...uniqueRows.values()].filter(row => { + const prompt = Number(row.pricing?.prompt); + const completion = Number(row.pricing?.completion); + return row.pricing !== undefined + && Number.isFinite(prompt) && prompt >= 0 + && Number.isFinite(completion) && completion >= 0 + && prompt === 0 && completion === 0; + }) + : [...uniqueRows.values()]; + const models = filteredRows.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 } + const error = body.freeOnly === true + ? "OpenRouter currently reports no free models" + : "upstream model discovery returned an empty catalog"; + return jsonResponse(fallbackModels.length + ? { ok: true, models: fallbackModels, 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 } + return jsonResponse(fallbackModels.length + ? { ok: true, models: fallbackModels, source: "static", latencyMs, error } : { ok: false, models: [], source: "live", latencyMs, error }); } } diff --git a/tests/provider-free-directory.test.ts b/tests/provider-free-directory.test.ts index 5d6ea26c3..4ae9928e9 100644 --- a/tests/provider-free-directory.test.ts +++ b/tests/provider-free-directory.test.ts @@ -19,12 +19,12 @@ const config: OcxConfig = { providers: {}, }; -function discover(presetId: string, provider: OcxProviderConfig): Promise { +function discover(presetId: string, provider: OcxProviderConfig, freeOnly?: boolean): 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 }), + body: JSON.stringify({ presetId, provider, ...(freeOnly === undefined ? {} : { freeOnly }) }), }), url, config); } @@ -130,6 +130,50 @@ describe("free provider directory", () => { expect(config.providers.cohere).toBeUndefined(); }); + test("filters OpenRouter discovery to models with complete valid zero pricing", async () => { + globalThis.fetch = (async () => Response.json({ data: [ + { id: "paid/model", pricing: { prompt: "0.000001", completion: "0.000002" } }, + { id: "free/by-price", context_length: 128000, pricing: { prompt: "0", completion: "0" } }, + { id: "not-free/by-suffix:free" }, + { id: "not-free/partial-price", pricing: { prompt: "0" } }, + { id: "not-free/invalid-price", pricing: { prompt: "free", completion: "0" } }, + { id: "not-free/negative-price", pricing: { prompt: "-1", completion: "0" } }, + { id: "not-free/blank-price", pricing: { prompt: "", completion: "" } }, + ] })) as typeof fetch; + const response = await discover("openrouter", { + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + authMode: "key", + apiKey: "secret", + }, true); + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ + ok: true, + source: "live", + models: [{ id: "free/by-price", contextWindow: 128000 }], + }); + }); + + test("validates freeOnly at the discovery boundary", async () => { + const url = new URL("http://127.0.0.1:10100/api/provider-presets/discover"); + const invalidType = await handleManagementAPI(new Request(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ presetId: "openrouter", provider: {}, freeOnly: "yes" }), + }), url, config); + expect(invalidType?.status).toBe(400); + expect(await invalidType?.json()).toEqual({ error: "freeOnly must be a boolean" }); + + const unsupported = await discover("cohere", { + adapter: "openai-chat", + baseUrl: "https://api.cohere.com/compatibility/v1", + authMode: "key", + apiKey: "secret", + }, true); + expect(unsupported?.status).toBe(400); + expect(await unsupported?.json()).toEqual({ error: "freeOnly is only supported for OpenRouter" }); + }); + test("accepts only SiliconFlow's two official regional endpoints for discovery", async () => { let calls = 0; globalThis.fetch = (async input => { diff --git a/tests/provider-model-envelope.test.ts b/tests/provider-model-envelope.test.ts index cd3af7ca8..fbd3eac91 100644 --- a/tests/provider-model-envelope.test.ts +++ b/tests/provider-model-envelope.test.ts @@ -5,6 +5,10 @@ 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({ data: [{ id: "free-model", pricing: { prompt: "0", completion: "0" } }] })?.[0]?.pricing) + .toEqual({ prompt: "0", completion: "0" }); + expect(parseProviderModelsApiItems({ data: [{ id: "partial-price", pricing: { prompt: "0" } }] })?.[0]?.pricing) + .toBeUndefined(); 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))