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 d01b164940bd6782a97b4472887a86fb5978bcf2 Mon Sep 17 00:00:00 2001 From: HaydernCenterpoint Date: Fri, 24 Jul 2026 18:18:08 +0700 Subject: [PATCH 4/4] feat(gui): complete local provider logo coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- gui/public/provider-icons/LICENSE.litellm | 26 +++++ gui/public/provider-icons/LICENSE.lobe-icons | 21 ++++ gui/public/provider-icons/LICENSE.omniroute | 21 ++++ .../provider-icons/LICENSE.simple-icons | 30 ++++++ gui/public/provider-icons/agentrouter.png | Bin 0 -> 9597 bytes gui/public/provider-icons/agnesai.svg | 1 + gui/public/provider-icons/ai21-color.svg | 1 + gui/public/provider-icons/aionlabs-color.svg | 1 + gui/public/provider-icons/api-airforce.svg | 5 + gui/public/provider-icons/arcee-color.svg | 1 + gui/public/provider-icons/baichuan-color.svg | 1 + gui/public/provider-icons/bazaarlink.svg | 7 ++ gui/public/provider-icons/blackbox.png | Bin 0 -> 83811 bytes gui/public/provider-icons/bluesminds.svg | 5 + gui/public/provider-icons/cerebras-color.svg | 1 + gui/public/provider-icons/cohere-color.svg | 1 + gui/public/provider-icons/coze.svg | 1 + gui/public/provider-icons/deepinfra-color.svg | 1 + gui/public/provider-icons/duckduckgo.svg | 1 + gui/public/provider-icons/freemodel-dev.svg | 5 + gui/public/provider-icons/friendli.svg | 1 + gui/public/provider-icons/hackclub.svg | 1 + .../provider-icons/hyperbolic-color.svg | 1 + gui/public/provider-icons/iflytek-color.svg | 1 + gui/public/provider-icons/inference.svg | 1 + gui/public/provider-icons/kilocode.svg | 1 + gui/public/provider-icons/liquid.svg | 1 + gui/public/provider-icons/litellm.png | Bin 0 -> 21723 bytes gui/public/provider-icons/llm7.svg | 1 + gui/public/provider-icons/longcat-color.svg | 1 + gui/public/provider-icons/monsterapi.svg | 5 + gui/public/provider-icons/morph-color.svg | 1 + gui/public/provider-icons/nebius.svg | 1 + gui/public/provider-icons/nlpcloud.svg | 1 + gui/public/provider-icons/nousresearch.svg | 1 + gui/public/provider-icons/novita-color.svg | 1 + gui/public/provider-icons/nscale.png | Bin 0 -> 1713 bytes gui/public/provider-icons/ovh.svg | 1 + gui/public/provider-icons/pollinations.svg | 1 + gui/public/provider-icons/predibase.png | Bin 0 -> 2091 bytes .../provider-icons/provider-generic.svg | 5 + gui/public/provider-icons/publicai.svg | 1 + gui/public/provider-icons/puter.svg | 1 + gui/public/provider-icons/qoder-color.svg | 1 + gui/public/provider-icons/reka.png | Bin 0 -> 2492 bytes gui/public/provider-icons/requesty.svg | 1 + gui/public/provider-icons/sambanova-color.svg | 1 + gui/public/provider-icons/scaleway.svg | 1 + gui/public/provider-icons/sensenova-color.svg | 1 + .../provider-icons/siliconcloud-color.svg | 1 + gui/public/provider-icons/spark-color.svg | 1 + gui/public/provider-icons/stepfun-color.svg | 1 + gui/public/provider-icons/t3-web.svg | 1 + gui/public/provider-icons/tencent-color.svg | 1 + gui/public/provider-icons/together-color.svg | 1 + gui/public/provider-icons/uncloseai.svg | 1 + .../provider-icons/volcengine-color.svg | 1 + gui/public/provider-icons/zhipu-color.svg | 1 + gui/src/components/AddProviderModal.tsx | 7 +- .../provider-catalog/ProviderCatalog.tsx | 2 +- gui/src/provider-icons.ts | 101 ++++++++++++++++-- tests/provider-workspace-data.test.ts | 16 +++ 62 files changed, 288 insertions(+), 10 deletions(-) create mode 100644 gui/public/provider-icons/LICENSE.litellm create mode 100644 gui/public/provider-icons/LICENSE.lobe-icons create mode 100644 gui/public/provider-icons/LICENSE.omniroute create mode 100644 gui/public/provider-icons/LICENSE.simple-icons create mode 100644 gui/public/provider-icons/agentrouter.png create mode 100644 gui/public/provider-icons/agnesai.svg create mode 100644 gui/public/provider-icons/ai21-color.svg create mode 100644 gui/public/provider-icons/aionlabs-color.svg create mode 100644 gui/public/provider-icons/api-airforce.svg create mode 100644 gui/public/provider-icons/arcee-color.svg create mode 100644 gui/public/provider-icons/baichuan-color.svg create mode 100644 gui/public/provider-icons/bazaarlink.svg create mode 100644 gui/public/provider-icons/blackbox.png create mode 100644 gui/public/provider-icons/bluesminds.svg create mode 100644 gui/public/provider-icons/cerebras-color.svg create mode 100644 gui/public/provider-icons/cohere-color.svg create mode 100644 gui/public/provider-icons/coze.svg create mode 100644 gui/public/provider-icons/deepinfra-color.svg create mode 100644 gui/public/provider-icons/duckduckgo.svg create mode 100644 gui/public/provider-icons/freemodel-dev.svg create mode 100644 gui/public/provider-icons/friendli.svg create mode 100644 gui/public/provider-icons/hackclub.svg create mode 100644 gui/public/provider-icons/hyperbolic-color.svg create mode 100644 gui/public/provider-icons/iflytek-color.svg create mode 100644 gui/public/provider-icons/inference.svg create mode 100644 gui/public/provider-icons/kilocode.svg create mode 100644 gui/public/provider-icons/liquid.svg create mode 100644 gui/public/provider-icons/litellm.png create mode 100644 gui/public/provider-icons/llm7.svg create mode 100644 gui/public/provider-icons/longcat-color.svg create mode 100644 gui/public/provider-icons/monsterapi.svg create mode 100644 gui/public/provider-icons/morph-color.svg create mode 100644 gui/public/provider-icons/nebius.svg create mode 100644 gui/public/provider-icons/nlpcloud.svg create mode 100644 gui/public/provider-icons/nousresearch.svg create mode 100644 gui/public/provider-icons/novita-color.svg create mode 100644 gui/public/provider-icons/nscale.png create mode 100644 gui/public/provider-icons/ovh.svg create mode 100644 gui/public/provider-icons/pollinations.svg create mode 100644 gui/public/provider-icons/predibase.png create mode 100644 gui/public/provider-icons/provider-generic.svg create mode 100644 gui/public/provider-icons/publicai.svg create mode 100644 gui/public/provider-icons/puter.svg create mode 100644 gui/public/provider-icons/qoder-color.svg create mode 100644 gui/public/provider-icons/reka.png create mode 100644 gui/public/provider-icons/requesty.svg create mode 100644 gui/public/provider-icons/sambanova-color.svg create mode 100644 gui/public/provider-icons/scaleway.svg create mode 100644 gui/public/provider-icons/sensenova-color.svg create mode 100644 gui/public/provider-icons/siliconcloud-color.svg create mode 100644 gui/public/provider-icons/spark-color.svg create mode 100644 gui/public/provider-icons/stepfun-color.svg create mode 100644 gui/public/provider-icons/t3-web.svg create mode 100644 gui/public/provider-icons/tencent-color.svg create mode 100644 gui/public/provider-icons/together-color.svg create mode 100644 gui/public/provider-icons/uncloseai.svg create mode 100644 gui/public/provider-icons/volcengine-color.svg create mode 100644 gui/public/provider-icons/zhipu-color.svg diff --git a/gui/public/provider-icons/LICENSE.litellm b/gui/public/provider-icons/LICENSE.litellm new file mode 100644 index 000000000..3bfef5bae --- /dev/null +++ b/gui/public/provider-icons/LICENSE.litellm @@ -0,0 +1,26 @@ +Portions of this software are licensed as follows: + +* All content that resides under the "enterprise/" directory of this repository, if that directory exists, is licensed under the license defined in "enterprise/LICENSE". +* Content outside of the above mentioned directories or restrictions above is available under the MIT license as defined below. +--- +MIT License + +Copyright (c) 2023 Berri AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/gui/public/provider-icons/LICENSE.lobe-icons b/gui/public/provider-icons/LICENSE.lobe-icons new file mode 100644 index 000000000..1dd53d2a9 --- /dev/null +++ b/gui/public/provider-icons/LICENSE.lobe-icons @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 LobeHub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/gui/public/provider-icons/LICENSE.omniroute b/gui/public/provider-icons/LICENSE.omniroute new file mode 100644 index 000000000..b5c9f58d0 --- /dev/null +++ b/gui/public/provider-icons/LICENSE.omniroute @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 diegosouzapw + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/gui/public/provider-icons/LICENSE.simple-icons b/gui/public/provider-icons/LICENSE.simple-icons new file mode 100644 index 000000000..99202c3a9 --- /dev/null +++ b/gui/public/provider-icons/LICENSE.simple-icons @@ -0,0 +1,30 @@ +# CC0 1.0 Universal + +## Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an owner) of an original work of authorship and/or a database (each, a Work). + +Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works (Commons) that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the Affirmer), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights (Copyright and Related Rights). Copyright and Related Rights include, but are not limited to, the following: + 1. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; + 2. moral rights retained by the original author(s) and/or performer(s); + 3. publicity and privacy rights pertaining to a persons image or likeness depicted in a Work; + 4. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(i), below; + 5. rights protecting the extraction, dissemination, use and reuse of data in a Work; + 6. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and + 7. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmers Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the Waiver). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmers heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmers express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmers express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmers Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the License). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmers express Statement of Purpose. + +4. Limitations and Disclaimers. + 1. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. + 2. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. + 3. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any persons Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. + 4. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. + +For more information, please see . diff --git a/gui/public/provider-icons/agentrouter.png b/gui/public/provider-icons/agentrouter.png new file mode 100644 index 0000000000000000000000000000000000000000..851556f62db58d4267e096a39d2e9de88e6688c5 GIT binary patch literal 9597 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 aG3a-DviBvqQ0G6M3ZNjPDqSUM7WzNIJEblF literal 0 HcmV?d00001 diff --git a/gui/public/provider-icons/agnesai.svg b/gui/public/provider-icons/agnesai.svg new file mode 100644 index 000000000..a3eedc08e --- /dev/null +++ b/gui/public/provider-icons/agnesai.svg @@ -0,0 +1 @@ +Agnes AI \ No newline at end of file diff --git a/gui/public/provider-icons/ai21-color.svg b/gui/public/provider-icons/ai21-color.svg new file mode 100644 index 000000000..7e62a9517 --- /dev/null +++ b/gui/public/provider-icons/ai21-color.svg @@ -0,0 +1 @@ +AI21 \ No newline at end of file diff --git a/gui/public/provider-icons/aionlabs-color.svg b/gui/public/provider-icons/aionlabs-color.svg new file mode 100644 index 000000000..644734ef4 --- /dev/null +++ b/gui/public/provider-icons/aionlabs-color.svg @@ -0,0 +1 @@ +AionLabs \ No newline at end of file diff --git a/gui/public/provider-icons/api-airforce.svg b/gui/public/provider-icons/api-airforce.svg new file mode 100644 index 000000000..a7e52fc99 --- /dev/null +++ b/gui/public/provider-icons/api-airforce.svg @@ -0,0 +1,5 @@ + + API Airforce + + + diff --git a/gui/public/provider-icons/arcee-color.svg b/gui/public/provider-icons/arcee-color.svg new file mode 100644 index 000000000..dc809fc95 --- /dev/null +++ b/gui/public/provider-icons/arcee-color.svg @@ -0,0 +1 @@ +Arcee \ No newline at end of file diff --git a/gui/public/provider-icons/baichuan-color.svg b/gui/public/provider-icons/baichuan-color.svg new file mode 100644 index 000000000..c44c5f944 --- /dev/null +++ b/gui/public/provider-icons/baichuan-color.svg @@ -0,0 +1 @@ +Baichuan diff --git a/gui/public/provider-icons/bazaarlink.svg b/gui/public/provider-icons/bazaarlink.svg new file mode 100644 index 000000000..3cf817478 --- /dev/null +++ b/gui/public/provider-icons/bazaarlink.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/gui/public/provider-icons/blackbox.png b/gui/public/provider-icons/blackbox.png new file mode 100644 index 0000000000000000000000000000000000000000..e5728840c228419059a31e4f7f09d75d2ada30bf GIT binary patch literal 83811 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�{05jr&qDlZEcg`zL*J_C9 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

|HL8@3?{07aw#VENMJUES-)jvsf# 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 + Bluesminds + + B + diff --git a/gui/public/provider-icons/cerebras-color.svg b/gui/public/provider-icons/cerebras-color.svg new file mode 100644 index 000000000..cf3701e32 --- /dev/null +++ b/gui/public/provider-icons/cerebras-color.svg @@ -0,0 +1 @@ +Cerebras diff --git a/gui/public/provider-icons/cohere-color.svg b/gui/public/provider-icons/cohere-color.svg new file mode 100644 index 000000000..6e5d9b981 --- /dev/null +++ b/gui/public/provider-icons/cohere-color.svg @@ -0,0 +1 @@ +Cohere diff --git a/gui/public/provider-icons/coze.svg b/gui/public/provider-icons/coze.svg new file mode 100644 index 000000000..743f6d6a6 --- /dev/null +++ b/gui/public/provider-icons/coze.svg @@ -0,0 +1 @@ +Coze \ No newline at end of file diff --git a/gui/public/provider-icons/deepinfra-color.svg b/gui/public/provider-icons/deepinfra-color.svg new file mode 100644 index 000000000..565c8095b --- /dev/null +++ b/gui/public/provider-icons/deepinfra-color.svg @@ -0,0 +1 @@ +DeepInfra \ No newline at end of file diff --git a/gui/public/provider-icons/duckduckgo.svg b/gui/public/provider-icons/duckduckgo.svg new file mode 100644 index 000000000..7b51a80e8 --- /dev/null +++ b/gui/public/provider-icons/duckduckgo.svg @@ -0,0 +1 @@ +DuckDuckGo \ No newline at end of file diff --git a/gui/public/provider-icons/freemodel-dev.svg b/gui/public/provider-icons/freemodel-dev.svg new file mode 100644 index 000000000..c155e808a --- /dev/null +++ b/gui/public/provider-icons/freemodel-dev.svg @@ -0,0 +1,5 @@ + + FreeModel Dev + + M + diff --git a/gui/public/provider-icons/friendli.svg b/gui/public/provider-icons/friendli.svg new file mode 100644 index 000000000..f866a9ab9 --- /dev/null +++ b/gui/public/provider-icons/friendli.svg @@ -0,0 +1 @@ +Friendli \ No newline at end of file diff --git a/gui/public/provider-icons/hackclub.svg b/gui/public/provider-icons/hackclub.svg new file mode 100644 index 000000000..ef1fbf19b --- /dev/null +++ b/gui/public/provider-icons/hackclub.svg @@ -0,0 +1 @@ +Hack Club \ No newline at end of file diff --git a/gui/public/provider-icons/hyperbolic-color.svg b/gui/public/provider-icons/hyperbolic-color.svg new file mode 100644 index 000000000..76536c29c --- /dev/null +++ b/gui/public/provider-icons/hyperbolic-color.svg @@ -0,0 +1 @@ +Hyperbolic \ No newline at end of file diff --git a/gui/public/provider-icons/iflytek-color.svg b/gui/public/provider-icons/iflytek-color.svg new file mode 100644 index 000000000..4962d9e16 --- /dev/null +++ b/gui/public/provider-icons/iflytek-color.svg @@ -0,0 +1 @@ +iFlyTekCloud \ No newline at end of file diff --git a/gui/public/provider-icons/inference.svg b/gui/public/provider-icons/inference.svg new file mode 100644 index 000000000..f3366fe75 --- /dev/null +++ b/gui/public/provider-icons/inference.svg @@ -0,0 +1 @@ +Inference \ No newline at end of file diff --git a/gui/public/provider-icons/kilocode.svg b/gui/public/provider-icons/kilocode.svg new file mode 100644 index 000000000..ff0526264 --- /dev/null +++ b/gui/public/provider-icons/kilocode.svg @@ -0,0 +1 @@ +Kilo Code \ No newline at end of file diff --git a/gui/public/provider-icons/liquid.svg b/gui/public/provider-icons/liquid.svg new file mode 100644 index 000000000..5e698b89c --- /dev/null +++ b/gui/public/provider-icons/liquid.svg @@ -0,0 +1 @@ +Liquid \ No newline at end of file diff --git a/gui/public/provider-icons/litellm.png b/gui/public/provider-icons/litellm.png new file mode 100644 index 0000000000000000000000000000000000000000..261b7504da8866b8970f1f02b67077623156e7ff GIT binary patch literal 21723 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 literal 0 HcmV?d00001 diff --git a/gui/public/provider-icons/llm7.svg b/gui/public/provider-icons/llm7.svg new file mode 100644 index 000000000..d60ff2c7d --- /dev/null +++ b/gui/public/provider-icons/llm7.svg @@ -0,0 +1 @@ +LLM77 diff --git a/gui/public/provider-icons/longcat-color.svg b/gui/public/provider-icons/longcat-color.svg new file mode 100644 index 000000000..fde6c8a36 --- /dev/null +++ b/gui/public/provider-icons/longcat-color.svg @@ -0,0 +1 @@ +LongCat \ No newline at end of file diff --git a/gui/public/provider-icons/monsterapi.svg b/gui/public/provider-icons/monsterapi.svg new file mode 100644 index 000000000..e815c0e59 --- /dev/null +++ b/gui/public/provider-icons/monsterapi.svg @@ -0,0 +1,5 @@ + + + + MA + diff --git a/gui/public/provider-icons/morph-color.svg b/gui/public/provider-icons/morph-color.svg new file mode 100644 index 000000000..dbe7c4167 --- /dev/null +++ b/gui/public/provider-icons/morph-color.svg @@ -0,0 +1 @@ +Morph \ No newline at end of file diff --git a/gui/public/provider-icons/nebius.svg b/gui/public/provider-icons/nebius.svg new file mode 100644 index 000000000..eb7a4f5e3 --- /dev/null +++ b/gui/public/provider-icons/nebius.svg @@ -0,0 +1 @@ +Nebius \ No newline at end of file diff --git a/gui/public/provider-icons/nlpcloud.svg b/gui/public/provider-icons/nlpcloud.svg new file mode 100644 index 000000000..9177026b3 --- /dev/null +++ b/gui/public/provider-icons/nlpcloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gui/public/provider-icons/nousresearch.svg b/gui/public/provider-icons/nousresearch.svg new file mode 100644 index 000000000..a0537ebc2 --- /dev/null +++ b/gui/public/provider-icons/nousresearch.svg @@ -0,0 +1 @@ +NousResearch \ No newline at end of file diff --git a/gui/public/provider-icons/novita-color.svg b/gui/public/provider-icons/novita-color.svg new file mode 100644 index 000000000..0658ce0f0 --- /dev/null +++ b/gui/public/provider-icons/novita-color.svg @@ -0,0 +1 @@ +Novita AI \ No newline at end of file diff --git a/gui/public/provider-icons/nscale.png b/gui/public/provider-icons/nscale.png new file mode 100644 index 0000000000000000000000000000000000000000..cb6cdf41ea24051db9a13270fac7401167e383b5 GIT binary patch literal 1713 zcmV;i22S~jP)C0000sP)t-sM{rCJ zKIZ@b{|-Ff4?yt`LGunj><>Zm4?*z{K=BPZ)1R^PaE$N(DAZPS>!-N%%GCBMQs#vZ z7?c12019+cPE!E?|NoT#|L#}LHeM7{000IcNkltfn4429YLCIi43+Ak^T(3WOS5un6Y3UE2bo39i)lO>i~9r9J4n@LGWT)d1Fk zOJ|{*hyMXCU5DIC^dI0~2e@DLj`-gM0JszYVCbiJ$#6gG?EskZ$3Q6BuH`830`WXH;-5K%wut0I~_k1C;s!06jPypwf>HoCQ$pTL48Y zI2b_CPXKTRfTZsL)JXs^2>KZSjs}qRJwUeLK!6#20sw~s%;{SIO)S_6pnk70t4{!S za{#7cFdSiCKLV)z0AP5HHhPM%pifHxdjP;NzFdqiB!DG->VRd~4B+&~m;dThCoJka zfJ9)n1El<&1)~YBF6$Ej`*_$0;Pn&2IE2D3>r)3d1CabZfFD#^|0Mvmy$7=XhyZFO z{{WzOaUl0!q_BhPReb~qUAO^&`|k|UszE;hpatIpRQmn9Nk0RqO#qVqyG7pubRRkZ zW}1qnKIxA{U$^N;2Xs4t{jJu;1ngLqf2)3Wps@td#~AW!?iT$|0Z_m1O7??EZOIe!MQ04k;$TPH2WSXg})kn__&~Ltc44-iYx^&q_b?1e*NqIDBMd z-}q`BE97nnV-lMi<4vEH<(&oWkJkafhE|#djuemts%8!xsdm8l&kYqQ2Nr&cKIr(A zPA7zKA(%dQ01PAx!V*y%L5dSdzQ>6Anw>)uEJzV3s5<@&+5w1s>$+wrTn6q008I7( z8T5RhA;>^L8Y-Y92TBB~5*3DUQ=ULfKOpeac#wcq!e0`F;v%JPSJY=w-;MUmh`@!wSz6CVWbB;u^DhC8ni&EvKcilllc&tAW?m}Dp*J1%xcz$fuX1a zGb0Yf97)43V8nT?%9GoH`M@Tm*pL||t7dubN$!&bt<If0#_NmWp+kVgj|(iV>>Ppd#$a<(Iu>cdEA@J%kU0dgl4 z2ufNRKH$hJ7;ORR1pknKGNO2WDvR(b4YlAQZFEW=U>V4KP{apF`h;L{t`uvBd+^*= zFPx028Ou%p1(XWA6K}QIg8S`+^Gb0xR=pVJBkHjJz@;-*W}d@6j%>Ru3Xo?bp(sgI zS{on^n2s=8#Il3~koDzl7HLI{uMWy6W&xiXbiq>0qCEuW$XKoE*fEZd@FqQZD984|wNJAdyy?>rlb10eJyOI|n_I+a-fs^0cH z(+HS7E=q=pnbHwuI8Avcm@>QfT0GYu_*}@V6Z2xK6(wIR!0guVz;G*H8i2k*uycXu zUUDpj1zOqsjug24-|zp&naC#tT_sJt%78xn{~qvM-2VR;nq5Irfm^@u00000NkvXX Hu0mjf#Zw?u literal 0 HcmV?d00001 diff --git a/gui/public/provider-icons/ovh.svg b/gui/public/provider-icons/ovh.svg new file mode 100644 index 000000000..8e80d0eb8 --- /dev/null +++ b/gui/public/provider-icons/ovh.svg @@ -0,0 +1 @@ +OVH \ No newline at end of file diff --git a/gui/public/provider-icons/pollinations.svg b/gui/public/provider-icons/pollinations.svg new file mode 100644 index 000000000..65b3b6dbd --- /dev/null +++ b/gui/public/provider-icons/pollinations.svg @@ -0,0 +1 @@ +Pollinations \ No newline at end of file diff --git a/gui/public/provider-icons/predibase.png b/gui/public/provider-icons/predibase.png new file mode 100644 index 0000000000000000000000000000000000000000..55ecaed763093f00bda68c33d82887a37726ff27 GIT binary patch literal 2091 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 VKK!Pvr6&LY002ovPDHLkV1kQ+5cL26 literal 0 HcmV?d00001 diff --git a/gui/public/provider-icons/provider-generic.svg b/gui/public/provider-icons/provider-generic.svg new file mode 100644 index 000000000..665d9910d --- /dev/null +++ b/gui/public/provider-icons/provider-generic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/gui/public/provider-icons/publicai.svg b/gui/public/provider-icons/publicai.svg new file mode 100644 index 000000000..d028fc3c9 --- /dev/null +++ b/gui/public/provider-icons/publicai.svg @@ -0,0 +1 @@ +PublicAIP \ No newline at end of file diff --git a/gui/public/provider-icons/puter.svg b/gui/public/provider-icons/puter.svg new file mode 100644 index 000000000..2bd9180ee --- /dev/null +++ b/gui/public/provider-icons/puter.svg @@ -0,0 +1 @@ +P diff --git a/gui/public/provider-icons/qoder-color.svg b/gui/public/provider-icons/qoder-color.svg new file mode 100644 index 000000000..c25f9b5d4 --- /dev/null +++ b/gui/public/provider-icons/qoder-color.svg @@ -0,0 +1 @@ +Qoder \ No newline at end of file diff --git a/gui/public/provider-icons/reka.png b/gui/public/provider-icons/reka.png new file mode 100644 index 0000000000000000000000000000000000000000..000162576afdfc2a891ba28f201aeb042be1bca6 GIT binary patch literal 2492 zcmaJ@2{hZ;8vjR%4zZCbCy_H88r~ZX=fDJ2j2Ar0YiXeKt&$}1d)dPuMmL;&`qFZU6Qv9ZiZ0H zS_&w7H65&{iahgk5jm86{EfiW6=%?UQ8V>ZM>=nPc-`Q3JNfJ$g5A~}wzG+=U@SD# zmJ~;uzEa#X@$uoBw#E_F2&v0EKrX%`qIy|g<=*5^l@oj&v;D7his8wFQGAPxZJ$q* zBlLoT@2*gY4gq%2UDiQ+?%W+#L2?E=v-oE5w%!{RH=TS>#9i}9I5Y9%XW z=S9RrvzFRQSM8DCG& zMoUgO4R0T`3CjG^R8i!a)%uJzu7nHXz|XanOW|H58I%>5UG8JzUSAJ6Qt8j1sXs?r zHwf)&RLhKvw}vbvLz`l6KD{ueuS&zMpMexv+gL)R?NBx!NHJc;^)A0;?>}DgSvEqf zB=m18YUSlCtqW(|zX=!NIA-yyA@AgcG&VUs&O);~ZXpP^|g3eC5nEsjA0+yFAH<-2ofOV_XYi+zFfccp<+};!j2)vtDD4bkR|R z`nmehgG167))c9s!M)9N*Pe^6A}Fa$_9uJ30m*puw zKlbY0Xdh$I8q(;5cbD&+;w0L?pPcF7!LDc!uWnbF<#8!`(-%(KC!U1uKdg0@)s1~D z3BKDi!F98`6lres52jeSA9g&^j6c=XnWV18@1JOb-J`dedEA;67h}!g<8qmJx4>YO zc^%|cTXfINwZI!Fy$`;72>#9l6VuYWN9}3SH@}!j@EpPT{0Kxk9+4i1H^ZB;#WOV5 z{4Jeq?Tu0%u<&w0l#)m#YjQQe?s|r26YQmLsg_j!Nvfavt(w&ahGDW~=y(WF%ifCe zDO+kEa-ZO5fh8yL_2WnNorxs4wE8} zvkM}Cp;NM|9%Hd-(c)z&Gdr++X>~0Re6Q*_c}uj_5F%$ygh2MR8C`It|E6OWe;!6! z^jz-K4Qzk@FU+v!@GD7g9_;*eLhF_s1*qE?YfS}wYvMO-4dS!p` zplcH`%0{_hhLM7dPN8z+@aBLrkq74ZP;8QmYLbsFt7!9v1ACbCsKPgOmN+)uNGa)7 z$s=rC=S{LQYwD-Sj5#rUCdSRy>eobSXIil0%YuoyOG8|`OL5Dy^OlajxFMI<$2`0_ zKhq338)Jb?w(&EGa`JI+8}k+b08=TTXFGrkC3QB*_I%Chy&dz~+#6oT3o1Tt^*v~3 z*RV3*bnhBbWut33dVQ;wy(8aEkM5tCSS|}PYj%A&Ht<=VbAQ``_8Ai4g32G)bzGj` zN{mxYNZRcWE!dysUg(l4blpmP86VnnYAUekiO(rTL@2n0oCwSt^G-aNY#5=^V4f5` zdl|+@0~wS1aQ$e%?SR56|B_H~)lR6z511-{2Kf$4tQkdlP|>cWR?uDU*8o19DN*H1 ze>BF!mYj%foJ!+_o*RN%Ji=7|{&=9i%+yhr=>^Av?Re-vV?frwVi)Gb(PF6B+^dKhbEMpiijn3F+`NWXz^vB&KwV zoL7lCt5H)N{t5G<_1!?}`oXw9j;jGX*%W$v8v)p8P|iU&=!0jyOgE5$ge#j#%|TL! zvY6Smk2*DLVn>3#%XO56JG6^4?d{c4Pr0v12H9|iZKn@TV?I0qMEhB@L24^kcYy4U zakSc=dGPL4ZH&uDf}3nQSaPeDoQ<5jhUGy?k^6{9vv413vOIG Ue|%J*0m=NRequestyR diff --git a/gui/public/provider-icons/sambanova-color.svg b/gui/public/provider-icons/sambanova-color.svg new file mode 100644 index 000000000..1de43be42 --- /dev/null +++ b/gui/public/provider-icons/sambanova-color.svg @@ -0,0 +1 @@ +SambaNova diff --git a/gui/public/provider-icons/scaleway.svg b/gui/public/provider-icons/scaleway.svg new file mode 100644 index 000000000..054f2c010 --- /dev/null +++ b/gui/public/provider-icons/scaleway.svg @@ -0,0 +1 @@ +Scaleway \ No newline at end of file diff --git a/gui/public/provider-icons/sensenova-color.svg b/gui/public/provider-icons/sensenova-color.svg new file mode 100644 index 000000000..17f1d82c5 --- /dev/null +++ b/gui/public/provider-icons/sensenova-color.svg @@ -0,0 +1 @@ +SenseNova \ No newline at end of file diff --git a/gui/public/provider-icons/siliconcloud-color.svg b/gui/public/provider-icons/siliconcloud-color.svg new file mode 100644 index 000000000..d5462b878 --- /dev/null +++ b/gui/public/provider-icons/siliconcloud-color.svg @@ -0,0 +1 @@ +SiliconCloud diff --git a/gui/public/provider-icons/spark-color.svg b/gui/public/provider-icons/spark-color.svg new file mode 100644 index 000000000..50c8faeae --- /dev/null +++ b/gui/public/provider-icons/spark-color.svg @@ -0,0 +1 @@ +Spark \ No newline at end of file diff --git a/gui/public/provider-icons/stepfun-color.svg b/gui/public/provider-icons/stepfun-color.svg new file mode 100644 index 000000000..d4011ed35 --- /dev/null +++ b/gui/public/provider-icons/stepfun-color.svg @@ -0,0 +1 @@ +StepFun diff --git a/gui/public/provider-icons/t3-web.svg b/gui/public/provider-icons/t3-web.svg new file mode 100644 index 000000000..9433d1201 --- /dev/null +++ b/gui/public/provider-icons/t3-web.svg @@ -0,0 +1 @@ +T3 WebT3 \ No newline at end of file diff --git a/gui/public/provider-icons/tencent-color.svg b/gui/public/provider-icons/tencent-color.svg new file mode 100644 index 000000000..98da272a2 --- /dev/null +++ b/gui/public/provider-icons/tencent-color.svg @@ -0,0 +1 @@ +Tencent \ No newline at end of file diff --git a/gui/public/provider-icons/together-color.svg b/gui/public/provider-icons/together-color.svg new file mode 100644 index 000000000..46bf0d4fa --- /dev/null +++ b/gui/public/provider-icons/together-color.svg @@ -0,0 +1 @@ +Together AI diff --git a/gui/public/provider-icons/uncloseai.svg b/gui/public/provider-icons/uncloseai.svg new file mode 100644 index 000000000..d3cd654cc --- /dev/null +++ b/gui/public/provider-icons/uncloseai.svg @@ -0,0 +1 @@ +UncloseAIU diff --git a/gui/public/provider-icons/volcengine-color.svg b/gui/public/provider-icons/volcengine-color.svg new file mode 100644 index 000000000..4b0cd2070 --- /dev/null +++ b/gui/public/provider-icons/volcengine-color.svg @@ -0,0 +1 @@ +Volcengine diff --git a/gui/public/provider-icons/zhipu-color.svg b/gui/public/provider-icons/zhipu-color.svg new file mode 100644 index 000000000..0c6e61cea --- /dev/null +++ b/gui/public/provider-icons/zhipu-color.svg @@ -0,0 +1 @@ +Zhipu \ No newline at end of file diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index 7ebba05ce..58901cabb 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -406,7 +406,12 @@ export default function AddProviderModal({ 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 selectedIcon = preset ? providerIconSrc(preset.id, { + adapter: preset.adapter, + baseUrl: preset.baseUrl, + defaultModel: preset.defaultModel, + models: preset.models, + }) : undefined; const setupUrl = preset?.dashboardUrl ?? preset?.documentationUrl; return ( diff --git a/gui/src/components/provider-catalog/ProviderCatalog.tsx b/gui/src/components/provider-catalog/ProviderCatalog.tsx index edb91df2c..cdf09b30d 100644 --- a/gui/src/components/provider-catalog/ProviderCatalog.tsx +++ b/gui/src/components/provider-catalog/ProviderCatalog.tsx @@ -186,7 +186,7 @@ export default function ProviderCatalog({

{presetsLoading && rows.length === 0 &&
{t("modal.catalogLoading")}
} {tier !== "accounts" && rows.map(p => { - const icon = providerIconSrc(p.id, { adapter: p.adapter, baseUrl: p.baseUrl }); + const icon = providerIconSrc(p.id, { adapter: p.adapter, baseUrl: p.baseUrl, defaultModel: p.defaultModel, models: p.models }); return (