From ce5be2051414689209831c2059c82fc68445d86a Mon Sep 17 00:00:00 2001 From: Haydern Date: Fri, 24 Jul 2026 00:03:40 +0700 Subject: [PATCH 01/11] 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 5f073a2bf6a819a9921739f344aaaa9ee0484365 Mon Sep 17 00:00:00 2001 From: Haydern Date: Fri, 24 Jul 2026 00:03:57 +0700 Subject: [PATCH 02/11] 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 74c5d7cb0..1ac57c353 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -64,14 +64,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 { @@ -334,7 +373,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, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status }); if (shouldLog) { @@ -364,10 +403,10 @@ 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, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); if (shouldLog) { console.warn( @@ -376,7 +415,6 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, } 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 845323ca91029abcce1ad44dce2b51ba8d32b4fe Mon Sep 17 00:00:00 2001 From: Haydern Date: Fri, 24 Jul 2026 00:04:12 +0700 Subject: [PATCH 03/11] 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 6c69b33ee..b34d73534 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -574,6 +574,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", @@ -1025,6 +1027,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 ed890ecfd..758f10582 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -632,6 +632,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", @@ -861,6 +863,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 114cd7b22..8fa2dd44d 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -597,6 +597,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": "ローカル/プライベートネットワークを許可", @@ -816,6 +818,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 7d8754059..6d0d9ec20 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -593,6 +593,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": "로컬/사설 네트워크 허용", @@ -1047,6 +1049,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 a74b75a9e..14b270a56 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -632,6 +632,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": "Разрешить локальную/частную сеть", @@ -861,6 +863,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 81a2c961e..21a9394b0 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -593,6 +593,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": "允许本地/私有网络", @@ -1047,6 +1049,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 c3c395864658890356648c32565557a163ed0bb2 Mon Sep 17 00:00:00 2001 From: HaydernCenterpoint Date: Sat, 25 Jul 2026 09:05:42 +0700 Subject: [PATCH 04/11] fix: reject redirects during provider tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/server/management/provider-routes.ts | 2 +- tests/provider-connection-test.test.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 5ce6d4bcd..ab1014458 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -292,7 +292,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { }); test("a live 200 with model data reports ok:true with the count", async () => { - globalThis.fetch = (async () => new Response(JSON.stringify({ data: [{ id: "m-1" }, { id: "m-2" }] }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as typeof fetch; + let redirect: RequestRedirect | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + redirect = init?.redirect; + return new Response(JSON.stringify({ data: [{ id: "m-1" }, { id: "m-2" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; const config = baseConfig({ live: { adapter: "openai-chat", baseUrl: "https://api.example.test/v1", apiKey: "sk-live" }, }); const { body } = await probe(config, "live"); expect(body.ok).toBe(true); expect(body.models).toBe(2); + expect(redirect).toBe("error"); }); test("Google's models-array response shape is accepted (x-goog-api-key path)", async () => { From a6054ac290ae303ff2b275656eccdd8d9cfc1f84 Mon Sep 17 00:00:00 2001 From: HaydernCenterpoint Date: Fri, 24 Jul 2026 18:16:56 +0700 Subject: [PATCH 05/11] feat(routing): add evidence-based smart modes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- gui/src/pages/Models.tsx | 58 +++++++++ src/combos/index.ts | 5 + src/combos/smart-routing.ts | 180 ++++++++++++++++++++++++++ src/server/management/combo-routes.ts | 35 ++++- tests/combo-management-api.test.ts | 53 ++++++++ tests/smart-routing.test.ts | 62 +++++++++ 6 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 src/combos/smart-routing.ts create mode 100644 tests/smart-routing.test.ts diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 953e05af0..2e2cbf032 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -46,6 +46,8 @@ interface ShadowCallData { model: string; } +type SmartRoutingMode = "intelligence" | "balance" | "cost"; + const CAP_OPTIONS = Array.from({ length: 18 }, (_, i) => 100_000 + i * 50_000); // 100k … 950k const CAP_OPTION_SET = new Set(CAP_OPTIONS); const CUSTOM_OPTION = "custom"; @@ -126,6 +128,8 @@ export default function Models({ apiBase, viewMode }: { apiBase: string; viewMod // an API error must never masquerade as "no combos configured"). const [combos, setCombos] = useState(null); const [combosError, setCombosError] = useState(false); + const [smartRoutingBusy, setSmartRoutingBusy] = useState(false); + const [smartRoutingMode, setSmartRoutingMode] = useState("balance"); const [combosOpen, setCombosOpen] = useState(() => { try { return localStorage.getItem("ocx-models-combos-open") === "1"; } catch { return false; } }); @@ -141,6 +145,29 @@ export default function Models({ apiBase, viewMode }: { apiBase: string; viewMod }); }; + const applySmartRouting = async () => { + setSmartRoutingBusy(true); + try { + const response = await fetch(`${apiBase}/api/smart-routing`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: smartRoutingMode }), + }); + const payload = await response.json() as { error?: string }; + if (!response.ok) throw new Error(payload.error || String(response.status)); + const comboResponse = await fetch(`${apiBase}/api/combos`); + if (!comboResponse.ok) throw new Error(String(comboResponse.status)); + setCombos(parseComboList(await comboResponse.json())); + setOk(true); + setStatus(t("models.smartRoutingApplied", { mode: t(`models.smartRouting_${smartRoutingMode}` as TKey) })); + } catch (error) { + setOk(false); + setStatus(error instanceof Error && error.message ? error.message : t("models.smartRoutingFailed")); + } finally { + setSmartRoutingBusy(false); + } + }; + useEffect(() => { let cancelled = false; fetch(`${apiBase}/api/combos`) @@ -917,6 +944,37 @@ export default function Models({ apiBase, viewMode }: { apiBase: string; viewMod const combosBlock = ( <> +
+
+
+ {t("models.smartRoutingTitle")} +
{t("models.smartRoutingHint")}
+
+ +
+
+ {(["intelligence", "balance", "cost"] as const).map(mode => { + const combo = combos?.find(item => item.id === `auto-${mode}`); + return ( + + ); + })} +
+
+ {combos !== null && !combosError && combos.length === 0 && (
diff --git a/src/combos/index.ts b/src/combos/index.ts index 63b234b8c..6b6547c41 100644 --- a/src/combos/index.ts +++ b/src/combos/index.ts @@ -38,3 +38,8 @@ export { concreteComboRequestBody, resetComboEffortWarningStateForTests, } from "./request"; +export { + buildSmartRoutingCombo, + SMART_ROUTING_MODES, + type SmartRoutingMode, +} from "./smart-routing"; diff --git a/src/combos/smart-routing.ts b/src/combos/smart-routing.ts new file mode 100644 index 000000000..03a7f6662 --- /dev/null +++ b/src/combos/smart-routing.ts @@ -0,0 +1,180 @@ +import type { CatalogModel } from "../codex/catalog"; +import { + getJawcodeModelMetadata, + getJawcodeModelMetadataCaseInsensitive, + resolveJawcodeProvider, +} from "../generated/jawcode-model-metadata"; +import type { OcxComboConfig, OcxConfig } from "../types"; +import { slugEquals } from "../providers/slug-codec"; +import { findExpectedPriceOverlay } from "../usage/expected-prices"; + +export type SmartRoutingMode = "intelligence" | "balance" | "cost"; + +export const SMART_ROUTING_MODES: readonly SmartRoutingMode[] = ["intelligence", "balance", "cost"]; +export const SMART_ROUTING_PROFILE_VERIFIED_AT = "2026-07-24"; + +interface CapabilityProfile { + pattern: RegExp; + coding: number; + agent: number; + tools: number; + reasoning: number; + instruction: number; + confidence: number; +} + +interface RankedModel { + model: CatalogModel; + quality: number; + cost?: number; + costScore: number; + utility: number; +} + +// Scores are broad coding-agent priors, not raw benchmark averages. They intentionally keep +// model capability independent from provider-specific price and availability. +const CAPABILITY_PROFILES: readonly CapabilityProfile[] = [ + { pattern: /(?:^|\/)claude-fable-5(?:$|[-:])/, coding: .98, agent: .98, tools: .96, reasoning: .97, instruction: .94, confidence: .94 }, + { pattern: /(?:^|\/)claude-sonnet-5(?:$|[-:])/, coding: .91, agent: .92, tools: .94, reasoning: .91, instruction: .94, confidence: .86 }, + { pattern: /(?:^|\/)claude-opus-4-8(?:$|[-:])/, coding: .92, agent: .94, tools: .94, reasoning: .95, instruction: .93, confidence: .93 }, + { pattern: /(?:^|\/)claude-opus-4-[67](?:$|[-:])/, coding: .84, agent: .91, tools: .91, reasoning: .93, instruction: .91, confidence: .90 }, + { pattern: /(?:^|\/)gpt-5\.5(?:$|[-:])/, coding: .91, agent: .97, tools: .94, reasoning: .96, instruction: .92, confidence: .92 }, + { pattern: /(?:^|\/)gpt-5\.3-codex(?:$|[-:])/, coding: .94, agent: .93, tools: .93, reasoning: .91, instruction: .92, confidence: .82 }, + { pattern: /(?:^|\/)gpt-5\.6-sol(?:$|[-:])/, coding: .94, agent: .95, tools: .95, reasoning: .97, instruction: .94, confidence: .78 }, + { pattern: /(?:^|\/)gpt-5\.6-terra(?:$|[-:])/, coding: .87, agent: .88, tools: .92, reasoning: .90, instruction: .92, confidence: .74 }, + { pattern: /(?:^|\/)gpt-5\.6-luna(?:$|[-:])/, coding: .71, agent: .70, tools: .86, reasoning: .72, instruction: .87, confidence: .72 }, + { pattern: /(?:^|\/)grok-4\.5(?:$|[-:])/, coding: .91, agent: .96, tools: .92, reasoning: .91, instruction: .89, confidence: .94 }, + { pattern: /(?:^|\/)grok-4\.(?:3|20)(?:$|[-:])/, coding: .79, agent: .83, tools: .87, reasoning: .85, instruction: .86, confidence: .76 }, + { pattern: /(?:^|\/)gemini-3\.6-flash(?:$|[-:])/, coding: .84, agent: .89, tools: .92, reasoning: .86, instruction: .90, confidence: .82 }, + { pattern: /(?:^|\/)gemini-3\.5-flash-lite(?:$|[-:])/, coding: .67, agent: .68, tools: .82, reasoning: .69, instruction: .84, confidence: .78 }, + { pattern: /(?:^|\/)gemini-3\.5-flash(?:$|[-:])/, coding: .80, agent: .84, tools: .89, reasoning: .82, instruction: .88, confidence: .80 }, + { pattern: /(?:^|\/)deepseek-v4-pro(?:$|[-:])/, coding: .97, agent: .91, tools: .94, reasoning: .96, instruction: .90, confidence: .83 }, + { pattern: /(?:^|\/)deepseek-v4-flash(?:$|[-:])/, coding: .89, agent: .85, tools: .91, reasoning: .88, instruction: .88, confidence: .78 }, + { pattern: /(?:^|\/)glm-5\.2(?:$|[-:\[])/, coding: .91, agent: .94, tools: .97, reasoning: .94, instruction: .90, confidence: .84 }, + { pattern: /(?:^|\/)kimi-k3(?:$|[-:\[])/, coding: .92, agent: .93, tools: .95, reasoning: .94, instruction: .91, confidence: .82 }, + { pattern: /(?:^|\/)kimi-k2\.7-code(?:$|[-:])/, coding: .91, agent: .90, tools: .94, reasoning: .90, instruction: .90, confidence: .85 }, + { pattern: /(?:^|\/)kimi-k2\.6(?:$|[-:])/, coding: .83, agent: .86, tools: .91, reasoning: .88, instruction: .89, confidence: .80 }, + { pattern: /(?:^|\/)minimax-m3(?:$|[-:])/, coding: .82, agent: .84, tools: .88, reasoning: .83, instruction: .87, confidence: .77 }, + { pattern: /(?:^|\/)minimax-m2\.7(?:$|[-:])/, coding: .77, agent: .79, tools: .85, reasoning: .79, instruction: .84, confidence: .76 }, + { pattern: /(?:^|\/)qwen3\.[5-8](?:$|[-:\[])/, coding: .84, agent: .82, tools: .88, reasoning: .88, instruction: .87, confidence: .68 }, + { pattern: /(?:^|\/)devstral-(?:2512|latest)(?:$|[-:])/, coding: .82, agent: .83, tools: .87, reasoning: .79, instruction: .86, confidence: .62 }, + { pattern: /(?:^|\/)mistral-(?:large|medium)(?:$|[-:])/, coding: .75, agent: .76, tools: .85, reasoning: .80, instruction: .86, confidence: .60 }, + { pattern: /(?:^|\/)command-a-plus(?:$|[-:])/, coding: .70, agent: .78, tools: .89, reasoning: .75, instruction: .88, confidence: .75 }, + { pattern: /(?:^|\/)nvidia-nemotron-3-(?:super|ultra)(?:$|[-:])/, coding: .84, agent: .85, tools: .88, reasoning: .90, instruction: .85, confidence: .76 }, + { pattern: /(?:^|\/)longcat-2\.0(?:$|[-:])/, coding: .84, agent: .86, tools: .88, reasoning: .87, instruction: .85, confidence: .72 }, + { pattern: /(?:^|\/)llama-4-maverick(?:$|[-:])/, coding: .70, agent: .70, tools: .80, reasoning: .76, instruction: .84, confidence: .69 }, + { pattern: /(?:^|\/)hermes-4-(?:405b|70b)(?:$|[-:])/, coding: .74, agent: .64, tools: .76, reasoning: .87, instruction: .84, confidence: .70 }, + { pattern: /(?:^|\/)lfm2-24b-a2b(?:$|[-:])/, coding: .28, agent: .50, tools: .78, reasoning: .42, instruction: .77, confidence: .88 }, +]; + +function metadata(provider: string, modelId: string) { + const resolved = resolveJawcodeProvider(provider); + for (const candidate of new Set([provider, resolved].filter((value): value is string => Boolean(value)))) { + const found = getJawcodeModelMetadata(candidate, modelId) + ?? getJawcodeModelMetadataCaseInsensitive(candidate, modelId); + if (found) return found; + } + return undefined; +} + +function modelCost(config: OcxConfig, model: CatalogModel): number | undefined { + const provider = config.providers[model.provider]; + if (provider?.authMode === "local") return 0; + const metaCost = metadata(model.provider, model.id)?.cost; + const overlay = findExpectedPriceOverlay(model.provider, model.id)?.cost4; + const cost = overlay + ?? (metaCost && Object.values(metaCost).some(value => value > 0) ? metaCost : undefined); + if (!cost || !Object.values(cost).some(value => value > 0)) return undefined; + return cost.input + cost.output * 2; +} + +function fallbackProfile(model: CatalogModel): Omit { + const meta = metadata(model.provider, model.id); + const reasoning = (model.reasoningEfforts?.length ?? 0) > 0 || meta?.reasoning === true; + return { + coding: reasoning ? .56 : .48, + agent: reasoning ? .55 : .47, + tools: .50, + reasoning: reasoning ? .62 : .43, + instruction: .55, + confidence: .32, + }; +} + +function modelQuality(model: CatalogModel, mode: SmartRoutingMode): number { + const id = model.id.toLowerCase(); + const profile = CAPABILITY_PROFILES.find(candidate => candidate.pattern.test(id)) ?? fallbackProfile(model); + const broad = profile.coding * .34 + + profile.agent * .27 + + profile.tools * .18 + + profile.reasoning * .14 + + profile.instruction * .07; + const uncertainty = (1 - profile.confidence) * (mode === "intelligence" ? .20 : mode === "balance" ? .12 : .08); + return Math.max(0, broad - uncertainty); +} + +function costScores(costs: readonly number[]): (cost: number | undefined) => number { + const known = [...costs].sort((a, b) => a - b); + if (known.length === 0) return () => 0; + const low = known[Math.floor((known.length - 1) * .1)] ?? known[0]!; + const high = known[Math.ceil((known.length - 1) * .9)] ?? known.at(-1)!; + const min = Math.log(low + .1); + const range = Math.max(.01, Math.log(high + .1) - min); + return cost => cost === undefined ? 0 : 1 - Math.min(1, Math.max(0, (Math.log(cost + .1) - min) / range)); +} + +function utility(mode: SmartRoutingMode, quality: number, costScore: number): number { + if (mode === "intelligence") return quality * .93 + costScore * .07; + if (mode === "balance") return quality * .65 + costScore * .35; + return quality * .34 + costScore * .66; +} + +export function buildSmartRoutingCombo( + mode: SmartRoutingMode, + models: readonly CatalogModel[], + config: OcxConfig, +): OcxComboConfig | null { + const eligible = models + .filter(model => model.provider !== "combo" && config.providers[model.provider]?.disabled !== true) + .filter(model => config.providers[model.provider] !== undefined) + .filter(model => !(config.disabledModels ?? []).some(disabled => slugEquals(disabled, model.provider, model.id))) + .filter(model => (model.contextWindow ?? metadata(model.provider, model.id)?.contextWindow ?? 32_000) >= 32_000) + .map(model => ({ model, quality: modelQuality(model, mode), cost: modelCost(config, model) })); + const scoreCost = costScores(eligible.flatMap(candidate => candidate.cost === undefined ? [] : [candidate.cost])); + const scored = eligible.map(candidate => { + const costScore = scoreCost(candidate.cost); + return { ...candidate, costScore, utility: utility(mode, candidate.quality, costScore) }; + }); + const bestQuality = Math.max(0, ...scored.map(candidate => candidate.quality)); + const capable = mode === "cost" + ? scored.filter(candidate => candidate.cost !== undefined && candidate.quality >= Math.max(.48, bestQuality - .18)) + : scored; + const byProvider = new Map(); + for (const candidate of capable) { + const current = byProvider.get(candidate.model.provider); + if (!current || candidate.utility > current.utility + || (candidate.utility === current.utility && candidate.quality > current.quality) + || (candidate.utility === current.utility && candidate.quality === current.quality + && candidate.model.id.localeCompare(current.model.id) < 0)) { + byProvider.set(candidate.model.provider, candidate); + } + } + const ranked = [...byProvider.values()] + .sort((a, b) => b.utility - a.utility + || b.quality - a.quality + || a.model.provider.localeCompare(b.model.provider) + || a.model.id.localeCompare(b.model.id)) + .slice(0, 8); + if (ranked.length === 0) return null; + const top = ranked[0]!.utility; + return { + strategy: "round-robin", + stickyLimit: 1, + defaultEffort: mode === "intelligence" ? "high" : mode === "balance" ? "medium" : "low", + targets: ranked.map(candidate => ({ + provider: candidate.model.provider, + model: candidate.model.id, + weight: Math.max(1, Math.round(10 * candidate.utility / top)), + })), + }; +} diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index 564bb22fa..d05d02e72 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -1,7 +1,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 { catalogModelSlug, filterCatalogVisibleModels, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, @@ -75,6 +75,39 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise candidate === body.mode) + : undefined; + if (!mode) { + return jsonResponse({ error: `mode must be one of: ${SMART_ROUTING_MODES.join(", ")}` }, 400); + } + const models = filterCatalogVisibleModels(await fetchAllModels(config), config); + const combo = buildSmartRoutingCombo(mode, models, config); + if (!combo) { + const error = mode === "cost" + ? "no enabled models with verified pricing can be routed in cost mode" + : `no enabled models can be routed in ${mode} mode`; + return jsonResponse({ error }, 400); + } + const id = `auto-${mode}`; + config.combos = { ...(config.combos ?? {}), [id]: combo }; + saveConfig(config); + clearComboSelectionState(id); + clearComboTargetCooldowns(id); + await refreshCodexCatalogBestEffort(); + return jsonResponse({ + success: true, + mode, + id, + model: `combo/${id}`, + targets: combo.targets, + }); + } + if (url.pathname === "/api/combos" && req.method === "PUT") { let rawBody: unknown; try { rawBody = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); } diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts index 5bbf5a0af..d0e7649a1 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -153,6 +153,59 @@ afterEach(() => { }); describe("combo management API", () => { + test("smart routing builds the selected mode even when cost pricing is unavailable", async () => { + await withTempHome(async () => { + const config = baseConfig({ + providers: { + a: { + adapter: "openai-chat", + baseUrl: "https://a.example/v1", + apiKey: "key", + liveModels: false, + models: ["unknown-reasoning-model"], + }, + b: { + adapter: "openai-chat", + baseUrl: "https://b.example/v1", + apiKey: "key", + liveModels: false, + models: ["unknown-agent-model"], + }, + }, + defaultProvider: "a", + combos: { manual: VALID_COMBO, "auto-balance": VALID_COMBO }, + }); + saveConfig(config); + let refreshes = 0; + + const response = await comboApi(config, "POST", "/api/smart-routing", { mode: "intelligence" }, async () => { refreshes += 1; }); + const body = await responseJson(response) as { id?: string }; + + expect(response?.status).toBe(200); + expect(body.id).toBe("auto-intelligence"); + expect(Object.keys(config.combos ?? {}).sort()).toEqual(["auto-balance", "auto-intelligence", "manual"]); + expect(config.combos?.["auto-intelligence"]?.strategy).toBe("round-robin"); + expect(config.combos?.["auto-balance"]).toEqual(VALID_COMBO); + expect(refreshes).toBe(1); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).combos["auto-intelligence"]).toBeDefined(); + }); + }); + + test("smart routing rejects an invalid mode without changing config", async () => { + await withTempHome(async () => { + const config = baseConfig(); + saveConfig(config); + const before = readFileSync(getConfigPath(), "utf8"); + + const response = await comboApi(config, "POST", "/api/smart-routing", { mode: "fastest" }); + const body = await responseJson(response); + + expect(response?.status).toBe(400); + expect(body.error).toBe("mode must be one of: intelligence, balance, cost"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + }); + }); + test("bare alias precedence yields back to a non-OpenAI selector after rename and deletion", async () => { await withTempHome(async () => { const selector = "deepseek/deepseek-chat"; diff --git a/tests/smart-routing.test.ts b/tests/smart-routing.test.ts new file mode 100644 index 000000000..87598eb57 --- /dev/null +++ b/tests/smart-routing.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { buildSmartRoutingCombo } from "../src/combos"; +import type { CatalogModel } from "../src/codex/catalog"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { adapter: "openai-chat", baseUrl: "https://example.com", apiKey: "x", ...overrides }; +} + +function config(): OcxConfig { + return { + port: 10100, + defaultProvider: "anthropic", + providers: { + anthropic: provider(), + google: provider(), + xai: provider(), + local: provider({ authMode: "local" }), + unknown: provider({ freeTier: true }), + disabled: provider({ disabled: true }), + }, + disabledModels: ["xai/grok-4.5"], + }; +} + +const models: CatalogModel[] = [ + { provider: "anthropic", id: "claude-fable-5", contextWindow: 1_000_000 }, + { provider: "anthropic", id: "claude-sonnet-5", contextWindow: 1_000_000 }, + { provider: "google", id: "gemini-3.6-flash", contextWindow: 1_000_000 }, + { provider: "google", id: "gemini-3.5-flash-lite", contextWindow: 1_000_000 }, + { provider: "xai", id: "grok-4.5", contextWindow: 500_000 }, + { provider: "local", id: "claude-sonnet-5", contextWindow: 1_000_000 }, + { provider: "unknown", id: "future-reasoner", reasoningEfforts: ["high"], contextWindow: 256_000 }, + { provider: "disabled", id: "deepseek-v4-pro", contextWindow: 1_000_000 }, + { provider: "combo", id: "existing" }, + { provider: "missing", id: "not-installed" }, +]; + +describe("smart routing", () => { + test("uses benchmark-informed capability, provider cost, and conservative unknown handling", () => { + const intelligence = buildSmartRoutingCombo("intelligence", models, config()); + const balance = buildSmartRoutingCombo("balance", models, config()); + const cost = buildSmartRoutingCombo("cost", models, config()); + + expect(intelligence!.targets.find(target => target.provider === "anthropic")?.model).toBe("claude-fable-5"); + expect(intelligence!.targets).toContainEqual(expect.objectContaining({ provider: "local", model: "claude-sonnet-5" })); + expect(balance!.targets.find(target => target.provider === "google")?.model).toBe("gemini-3.6-flash"); + expect(cost!.targets[0]).toMatchObject({ provider: "local", model: "claude-sonnet-5", weight: 10 }); + expect(cost!.targets.some(target => target.provider === "unknown")).toBe(false); + expect(cost!.targets.some(target => target.provider === "xai")).toBe(false); + expect(new Set(intelligence!.targets.map(target => target.provider)).size).toBe(intelligence!.targets.length); + expect(intelligence!.targets.every(target => Number.isInteger(target.weight) && target.weight! > 0)).toBe(true); + }); + + test("does not treat a remote free-tier flag as zero-cost pricing", () => { + const onlyUnknown: CatalogModel[] = [ + { provider: "unknown", id: "claude-sonnet-5", contextWindow: 1_000_000 }, + ]; + expect(buildSmartRoutingCombo("cost", onlyUnknown, config())).toBeNull(); + expect(buildSmartRoutingCombo("intelligence", onlyUnknown, config())).not.toBeNull(); + }); +}); From 709e708ad5abf21a6cf5f37dacc50ba195220f29 Mon Sep 17 00:00:00 2001 From: HaydernCenterpoint Date: Fri, 24 Jul 2026 18:16:42 +0700 Subject: [PATCH 06/11] fix(gui): allow provider catalog scrolling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- gui/src/styles/provider-catalog.css | 2 +- gui/tests/provider-catalog.test.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/gui/src/styles/provider-catalog.css b/gui/src/styles/provider-catalog.css index 3c9f71bde..ccc8b16ae 100644 --- a/gui/src/styles/provider-catalog.css +++ b/gui/src/styles/provider-catalog.css @@ -38,7 +38,7 @@ .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-browser { display: flex; flex-direction: column; min-width: 0; min-height: 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; } diff --git a/gui/tests/provider-catalog.test.ts b/gui/tests/provider-catalog.test.ts index 5454957eb..331dcd9c4 100644 --- a/gui/tests/provider-catalog.test.ts +++ b/gui/tests/provider-catalog.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; import { accessGroupCounts, curatedPresets, @@ -56,3 +57,9 @@ test("search matches provider label/id and reference presets are not actionable" expect(isPresetActionable(rows.find(row => row.id === "duckduckgo-web")!)).toBe(false); expect(isPresetActionable(rows.find(row => row.id === "groq")!)).toBe(true); }); + +test("provider browser lets its row list scroll inside the modal", () => { + const css = readFileSync(new URL("../src/styles/provider-catalog.css", import.meta.url), "utf8"); + expect(css).toMatch(/\.provider-catalog-browser\s*\{[^}]*min-height:\s*0;/); + expect(css).toMatch(/\.provider-catalog-rows\s*\{[^}]*overflow-y:\s*auto;/); +}); From 05a44833a09f3ac243a4c532e51ebebc1a1d2e66 Mon Sep 17 00:00:00 2001 From: HaydernCenterpoint Date: Fri, 24 Jul 2026 18:18:08 +0700 Subject: [PATCH 07/11] 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 (
} + {kind === "account" && isBusy && } + + {kind === "account" && loginInfo?.provider === name && (loginInfo.url || loginInfo.instructions || loginInfo.deviceCode || isBusy) && ( + + {loginInfo.deviceCode && ( + + {t("prov.deviceCode")} + {loginInfo.deviceCode} + + + )} + + {loginInfo.url && {t("prov.didntOpen")}} + + {loginInfo.instructions && !loginInfo.deviceCode && {loginInfo.instructions}} + + + setManualCode(e.target.value)} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); void submitManualCode(name); } }} placeholder={t("prov.pasteRedirect")} aria-label={t("prov.pasteRedirect")} disabled={manualCodeBusy} /> + + + {manualCodeMsg || t("prov.pasteRedirectHint")} + + )} +
+ ); + }; + return ( -
-
+
+
- {t("prov.accountLogin")} + {t("prov.connections")}
-
- {oauthProviders.length === 0 && keyProviders.length === 0 && ( - {t("prov.noOauth")} - )} - {oauthProviders.map(p => { - const st = oauthStatus[p] ?? { loggedIn: false }; - const isBusy = busy === p; - const icon = providerIconSrc(p); - return ( -
- - {icon && } - {p} - - - - {st.loggedIn ? ( - {st.email ?? t("prov.loggedIn")} - ) : ( - {t("prov.notLoggedIn")} - )} - - - {st.loggedIn ? ( - - ) : isBusy ? ( - - ) : ( - - )} - - {loginInfo?.provider === p && (loginInfo.url || loginInfo.instructions || loginInfo.deviceCode || isBusy) && ( - - {loginInfo.deviceCode && ( - - {t("prov.deviceCode")} - {loginInfo.deviceCode} - - - )} - - {loginInfo.url && {t("prov.didntOpen")}} - - {loginInfo.instructions && !loginInfo.deviceCode && {loginInfo.instructions}} - {isBusy && ( - - )} - - - setManualCode(e.target.value)} - onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); void submitManualCode(p); } }} - placeholder={t("prov.pasteRedirect")} - aria-label={t("prov.pasteRedirect")} - disabled={manualCodeBusy} - /> - - - {manualCodeMsg || t("prov.pasteRedirectHint")} - - )} -
- ); - })} - {keyProviders.map(name => { - const provider = config?.providers[name]; - const icon = providerIconSrc(name); - const keylessFree = provider?.keyOptional === true && !provider?.hasApiKey; - const missingOpenAiKey = name === "openai-apikey" && !provider?.hasApiKey; - return ( -
- - {icon && } - {name} - - - - {missingOpenAiKey ? t("prov.openaiApiMissing") : keylessFree ? t("modal.badge.free") : t("prov.hasApiKey")} - - - {missingOpenAiKey && } - -
- ); - })} +
+
+
+ {t("pws.tab.accounts")} + +
+ {connectedAccounts.length > 0 ? ( +
{connectedAccounts.map(name => renderProviderRow(name, "account"))}
+ ) : {t("prov.noConnections")}} +
+
+
+ {t("pws.apiKeys")} + +
+ {keyProviders.length > 0 ? ( +
{keyProviders.map(name => renderProviderRow(name, "key"))}
+ ) : {t("prov.noConnections")}} +
+
+
{t("modal.customProvider")}
+ +
); diff --git a/gui/src/components/providers/ProviderCardList.tsx b/gui/src/components/providers/ProviderCardList.tsx index f10a35a48..2fe5655c0 100644 --- a/gui/src/components/providers/ProviderCardList.tsx +++ b/gui/src/components/providers/ProviderCardList.tsx @@ -1,9 +1,10 @@ import type { Dispatch, SetStateAction } from "react"; import type { TFn } from "../../i18n"; import type { AccountQuota } from "../../codex-quota-utils"; -import QuotaBars from "../QuotaBars"; +import QuotaBars, { buildQuotaRows } from "../QuotaBars"; import { IconChevron, IconLock, IconPlus, IconPower, IconTrash } from "../../icons"; import { oauthAccountDisplayLabel } from "../../provider-workspace/auth"; +import { formatTokenCount, type ProviderUsageTotals } from "../../provider-workspace/usage"; interface Config { port: number; @@ -20,6 +21,7 @@ export interface ProviderCardListProps { t: TFn; config: Config; quotaReports: Record; + usageTotals: Record; accountSets: Record; keyPools: Record; openAccounts: Record; @@ -46,7 +48,7 @@ export interface ProviderCardListProps { } export function ProviderCardList({ - t, config, quotaReports, accountSets, keyPools, openAccounts, addingKeyFor, + t, config, quotaReports, usageTotals, accountSets, keyPools, openAccounts, addingKeyFor, newKeyValue, busy, modeBusy, activeAccountNeedsReauth, setOpenAccounts, setAddingKeyFor, setNewKeyValue, loginOAuth, requestLoginOAuth, setOpenAiAccountMode, setProviderDisabled, removeProvider, switchAccount, @@ -54,14 +56,17 @@ export function ProviderCardList({ resolvedOpenAiAccountMode, }: ProviderCardListProps) { return ( -
-
+
+
{t("prov.port")}: {config.port} · {t("prov.default")}: {config.defaultProvider}
+
{Object.entries(config.providers).map(([name, prov]) => { const isDefault = name === config.defaultProvider; const isDisabled = prov.disabled === true; const quota = quotaReports[name]?.quota ?? null; + const hasQuotaRows = buildQuotaRows(quota, null, t).length > 0; + const totalTokens = usageTotals[name]?.totalTokens; const icon = providerIconSrc(name); const accountSet = prov.authMode === "oauth" ? accountSets[name] : undefined; const isKeyAuth = prov.authMode !== "oauth" && prov.authMode !== "forward"; @@ -154,7 +159,17 @@ export function ProviderCardList({
- {quota && } + {(quota || (!hasQuotaRows && totalTokens !== undefined)) && ( +
+ {quota && } + {!hasQuotaRows && totalTokens !== undefined && ( +
+ {t("dash.tokens30d")} + {formatTokenCount(totalTokens)} +
+ )} +
+ )} {showAccounts && ( <>
); } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index b34d73534..ddf700494 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -201,6 +201,8 @@ export const de = { "dash.updateStatus.succeeded": "Update abgeschlossen.", "dash.updateStatus.failed": "Update fehlgeschlagen.", "prov.subtitle": "Konfiguriere die Upstream-Anbieter, die opencodex in Codex routet. Melde dich mit einem Konto an, füge einen Anbieter hinzu oder bearbeite die Rohkonfiguration.", + "prov.connections": "Verbundene Anbieter", + "prov.noConnections": "Noch keine Anbieter verbunden. Verwende Anbieter hinzufügen, um einen zu verbinden.", "prov.add": "Anbieter hinzufügen", "prov.editJson": "JSON bearbeiten", "prov.accountLogin": "Konto-Login", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 758f10582..a8b6d980d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -215,6 +215,8 @@ export const en = { // providers "prov.subtitle": "Configure the upstream providers opencodex routes into Codex. Log in with an account, add a provider, or edit the raw config.", + "prov.connections": "Connected providers", + "prov.noConnections": "No providers are connected yet. Use Add Provider to connect one.", "prov.add": "Add Provider", "prov.editJson": "Edit JSON", "prov.accountLogin": "Account login", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 8fa2dd44d..8f2b387d2 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -215,6 +215,8 @@ export const ja: Record = { // providers "prov.subtitle": "opencodex が Codex にルーティングする上流プロバイダーを設定します。アカウントでログインするか、プロバイダーを追加、または生の設定を編集します。", + "prov.connections": "接続済みプロバイダー", + "prov.noConnections": "接続済みのプロバイダーはありません。プロバイダーを追加から接続してください。", "prov.add": "プロバイダーを追加", "prov.editJson": "JSON を編集", "prov.accountLogin": "アカウントログイン", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 6d0d9ec20..8b8b30122 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -209,6 +209,8 @@ export const ko: Record = { // providers "prov.subtitle": "opencodex가 Codex로 라우팅하는 업스트림 프로바이더를 설정합니다. 계정으로 로그인하거나, 프로바이더를 추가하거나, 원본 설정을 편집하세요.", + "prov.connections": "연결된 프로바이더", + "prov.noConnections": "연결된 프로바이더가 없습니다. 프로바이더 추가에서 연결하세요.", "prov.add": "프로바이더 추가", "prov.editJson": "JSON 편집", "prov.accountLogin": "계정 로그인", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 14b270a56..0c925a0a0 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -215,6 +215,8 @@ export const ru: Record = { // providers "prov.subtitle": "Настройте вышестоящих провайдеров, которых opencodex маршрутизирует в Codex. Войдите в аккаунт, добавьте провайдера или отредактируйте конфигурацию вручную.", + "prov.connections": "Подключенные провайдеры", + "prov.noConnections": "Нет подключенных провайдеров. Используйте «Добавить провайдера» для подключения.", "prov.add": "Добавить провайдера", "prov.editJson": "Редактировать JSON", "prov.accountLogin": "Вход в аккаунт", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 21a9394b0..54572119d 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -209,6 +209,8 @@ export const zh: Record = { // providers "prov.subtitle": "配置 opencodex 路由到 Codex 的上游提供方。使用账户登录、添加提供方,或编辑原始配置。", + "prov.connections": "已连接的提供方", + "prov.noConnections": "尚未连接提供方。请使用“添加提供方”进行连接。", "prov.add": "添加提供方", "prov.editJson": "编辑 JSON", "prov.accountLogin": "账户登录", diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 590b63a33..fb09e7c65 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -56,6 +56,7 @@ export default function Providers({ apiBase, viewMode }: { apiBase: string; view const [oauthProviders, setOauthProviders] = useState([]); const [oauthStatus, setOauthStatus] = useState>({}); const [quotaReports, setQuotaReports] = useState>({}); + const [usageTotals, setUsageTotals] = useState>({}); const [busy, setBusy] = useState(null); const [modeBusy, setModeBusy] = useState(false); const [loginInfo, setLoginInfo] = useState<{ provider: string; url?: string; instructions?: string; deviceCode?: string } | null>(null); @@ -156,6 +157,18 @@ export default function Providers({ apiBase, viewMode }: { apiBase: string; view } }, [apiBase]); + useEffect(() => { + let cancelled = false; + fetch(`${apiBase}/api/usage?range=30d`) + .then(r => r.ok ? r.json() : null) + .then((data: { providers?: Array<{ provider: string; requests: number; totalTokens?: number }> } | null) => { + if (cancelled || !data) return; + setUsageTotals(Object.fromEntries((data.providers ?? []).map(row => [row.provider, { requests: row.requests, totalTokens: row.totalTokens }]))); + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [apiBase]); + const fetchCodexActiveReauth = useCallback(async () => { const generation = ++codexReauthGenerationRef.current; try { @@ -558,7 +571,7 @@ export default function Providers({ apiBase, viewMode }: { apiBase: string; view

{t("nav.providers")}

- +
{status && {status}} @@ -695,7 +708,7 @@ export default function Providers({ apiBase, viewMode }: { apiBase: string; view ) : ( <> - + )} @@ -707,14 +720,15 @@ export default function Providers({ apiBase, viewMode }: { apiBase: string; view { setAddIntent(intent); setAdding(true); }} /> {editing ? ( @@ -726,7 +740,7 @@ export default function Providers({ apiBase, viewMode }: { apiBase: string; view /> ) : ( { if (busy) void cancelLoginOAuth(busy); setAdding(false); + setAddIntent(null); }} - onAdded={(name) => { setAdding(false); notify(t("prov.added", { name, cmd: "ocx sync" }), true); fetchConfig(); fetchOauth(); fetchProviderQuotas(true); setModelsRefreshToken(n => n + 1); }} + onAdded={(name) => { setAdding(false); setAddIntent(null); notify(t("prov.added", { name, cmd: "ocx sync" }), true); fetchConfig(); fetchOauth(); fetchProviderQuotas(true); setModelsRefreshToken(n => n + 1); }} accountRows={addModalAccountRows} accountStatus={accountLoginStatus} accountBusy={busy} diff --git a/gui/src/styles.css b/gui/src/styles.css index c57f9ce13..4c06e70c5 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -343,6 +343,7 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } .main { min-width: 0; } .main-inner { max-width: 980px; margin: 0 auto; padding: 32px 36px 64px; } +.main-inner.main-inner--providers { max-width: 1440px; } .main-inner.main-inner--combos { max-width: none; margin: 0; @@ -936,19 +937,24 @@ table.logs-table { min-width: 960px; } .list-row .sub { font-size: var(--text-label); color: var(--muted); margin-top: 2px; } /* provider config card (Providers page) */ -.prov-card { overflow: hidden; } -.prov-card-main { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 15px 16px; flex-wrap: wrap; } +.prov-card { display: grid; grid-template-rows: minmax(0, 1fr) auto minmax(27px, auto); height: 100%; overflow: hidden; } +.provider-list-meta { margin-bottom: 8px; } +.provider-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; align-items: stretch; } +.prov-card-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; min-height: 128px; padding: 12px 14px 10px; } .prov-card-disabled { opacity: 0.62; } -.prov-card-info { display: flex; align-items: flex-start; gap: 11px; min-width: 0; flex: 1 1 360px; } +.prov-card-info { display: flex; align-items: flex-start; gap: 11px; min-width: 0; } .prov-card-copy { min-width: 0; flex: 1; } -.prov-title { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; flex-wrap: wrap; min-width: 0; } +.prov-title { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; flex-wrap: wrap; min-width: 0; } .provider-icon { width: 31px; height: 31px; border-radius: var(--radius-xs); flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; background: var(--raised); border: 1px solid var(--border-soft); color: var(--text); } .provider-icon img { width: 19px; height: 19px; object-fit: contain; display: block; } .prov-meta { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; min-width: 0; } .prov-meta > span { min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.provider-quota { padding-left: 58px; } -.provider-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; } -.openai-mode-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px; } +.prov-card-resource { min-height: 31px; margin: 0 14px 8px 55px; padding-top: 7px; border-top: 1px solid var(--border-soft); } +.provider-quota { padding: 0; } +.provider-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; flex-shrink: 0; } +.provider-actions .btn { white-space: nowrap; } +.openai-mode-row { display: grid; grid-template-columns: max-content minmax(0, 1fr); align-items: center; gap: 8px; margin-top: 9px; } +.openai-mode-control { justify-self: start; } .openai-mode-control .usage-segmented-btn { min-width: 76px; } .link-btn { background: none; border: none; color: var(--accent-hover); font: inherit; font-size: var(--text-control); cursor: pointer; padding: 6px 2px; text-decoration: underline; } .link-btn svg { width: 14px; height: 14px; flex-shrink: 0; } @@ -996,9 +1002,21 @@ button.prov-account-row.active { cursor: default; } .prov-account-keyform:hover { background: none; } .prov-account-keyform .input-sm { flex: 1 1 auto; min-width: 0; padding: 4px 8px; font-size: var(--text-label); height: var(--control-sm); } -/* account login grid (Providers page): shared name | status | action columns across rows */ +/* connected providers: compact account/key groups with shared row columns */ +.provider-connections { margin-bottom: 16px; padding: 0; overflow: hidden; border-color: var(--border); } +.provider-connections-head { min-height: 44px; padding: 0 16px; border-bottom: 1px solid var(--border-soft); background: var(--raised); } +.provider-connection-groups { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1.25fr) minmax(160px, .5fr); } +.provider-connection-group { min-width: 0; padding: 14px 16px 16px; } +.provider-connection-groups > .muted { grid-column: 1 / -1; padding: 12px 14px 14px; } +.provider-connection-group + .provider-connection-group { border-left: 1px solid var(--border-soft); } +.provider-connection-group > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; color: var(--text); font-size: var(--text-control); font-weight: var(--weight-semibold); } +.provider-connection-group > header > span:last-child { font-family: var(--font-code); color: var(--faint); } +.provider-connection-add { margin: -5px -5px -5px auto; white-space: nowrap; } +.provider-connection-add svg, .provider-custom-add svg { width: 14px; height: 14px; } +.provider-connection-group--custom { display: flex; flex-direction: column; justify-content: space-between; background: var(--raised); } +.provider-custom-add { width: 100%; justify-content: center; border: 1px dashed var(--border); min-height: 36px; white-space: nowrap; } .oauth-grid { display: grid; grid-template-columns: minmax(140px, max-content) minmax(0, 1fr) max-content; column-gap: 16px; row-gap: 10px; align-items: center; } -.oauth-row { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: center; min-height: 30px; } +.oauth-row { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: center; min-height: 28px; } .oauth-name { display: inline-flex; align-items: center; gap: 8px; font-size: var(--text-control); font-weight: var(--weight-semibold); min-width: 0; } .oauth-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .provider-icon-sm { width: 24px; height: 24px; border-radius: var(--radius-xs); } @@ -1013,6 +1031,14 @@ button.prov-account-row.active { cursor: default; } .oauth-device-code { font-size: 20px; font-weight: 800; letter-spacing: .14em; color: var(--text); user-select: all; } .oauth-login-paste { display: flex; gap: 8px; align-items: center; width: 100%; } .oauth-login-paste .input { flex: 1; min-width: 0; font-size: var(--text-label); padding: 6px 10px; } +.quota-summary { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 16px; padding: 0; } +.quota-summary-item { display: inline-grid; grid-template-columns: max-content max-content minmax(0, max-content) max-content; align-items: baseline; gap: 5px; color: var(--muted); font-size: var(--text-caption); } +.quota-summary-item strong { color: var(--text); font-family: var(--font-code); font-weight: var(--weight-semibold); } +.quota-summary-item--warn strong { color: var(--amber); } +.quota-summary-reset { color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.quota-summary-state { color: var(--amber); font-weight: var(--weight-semibold); } +.provider-usage-fallback { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; color: var(--muted); font-size: var(--text-caption); } +.provider-usage-fallback strong { color: var(--text); font-family: var(--font-code); font-weight: var(--weight-semibold); } /* responsive: compact sticky top bar (menu + brand + stop) with the desktop sidebar reused as an off-canvas drawer — 10 destinations no longer fit an always-visible @@ -1068,16 +1094,27 @@ button.prov-account-row.active { cursor: default; } .stat-row > .stat { min-width: 100px; } .tbl { min-width: 460px; } /* keep columns readable; .tbl-wrap scrolls horizontally */ .usage-cards { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } - .provider-quota { padding-left: 16px; } + .provider-card-grid { grid-template-columns: 1fr; } + .prov-card-main { grid-template-columns: 1fr; min-height: 0; } + .provider-actions { justify-content: flex-start; padding-left: 42px; } + .prov-card-resource { margin-left: 56px; } .prov-meta { display: grid; grid-template-columns: max-content minmax(0, 1fr); width: 100%; } .prov-meta .chip ~ span:not(:last-child) { display: none; } /* account login grid: let name/status truncate with ellipsis instead of wrapping */ .oauth-grid { grid-template-columns: minmax(0, auto) minmax(0, 1fr) max-content; column-gap: 8px; } + .provider-connection-groups { grid-template-columns: 1fr; } + .provider-connection-group + .provider-connection-group { border-left: 0; border-top: 1px solid var(--border-soft); } /* language menu: the drawer scrolls (overflow-y) so the beside-placement would be clipped at the drawer edge — open upward from the foot row instead. */ .sidebar .lang-toggle .select-dropdown-beside { top: auto; bottom: calc(100% + 6px); left: 0; right: auto; } } +@media (max-width: 980px) { + .provider-card-grid { grid-template-columns: 1fr; } + .provider-connection-groups { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .provider-connection-group--custom { grid-column: 1 / -1; border-left: 0 !important; border-top: 1px solid var(--border-soft); } +} + .usage-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 8px; } .usage-cards .stat-value { font-size: var(--text-title); font-weight: var(--weight-semibold); margin-top: 4px; } .usage-head { flex-wrap: wrap; } diff --git a/gui/tests/provider-catalog.test.ts b/gui/tests/provider-catalog.test.ts index 331dcd9c4..eb0874965 100644 --- a/gui/tests/provider-catalog.test.ts +++ b/gui/tests/provider-catalog.test.ts @@ -63,3 +63,57 @@ test("provider browser lets its row list scroll inside the modal", () => { expect(css).toMatch(/\.provider-catalog-browser\s*\{[^}]*min-height:\s*0;/); expect(css).toMatch(/\.provider-catalog-rows\s*\{[^}]*overflow-y:\s*auto;/); }); + +test("provider connections only show connected accounts and configured API keys", () => { + const panel = readFileSync(new URL("../src/components/providers/OAuthPanel.tsx", import.meta.url), "utf8"); + const pools = readFileSync(new URL("../src/hooks/useProviderAccountPools.ts", import.meta.url), "utf8"); + expect(panel).toContain("oauthStatus[provider]?.loggedIn || busy === provider"); + expect(panel).toContain('provider.authMode === "forward"'); + expect(panel).toContain('href="#codex-auth"'); + expect(panel).toContain('t("pws.tab.accounts")'); + expect(panel).toContain('t("pws.apiKeys")'); + expect(pools).toMatch(/p\.hasApiKey\s*&&\s*p\.authMode\s*!==\s*"oauth"\s*&&\s*p\.authMode\s*!==\s*"forward"/); +}); + +test("provider cards retain compact visual quota bars", () => { + const cards = readFileSync(new URL("../src/components/providers/ProviderCardList.tsx", import.meta.url), "utf8"); + const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); + expect(cards).toContain('className="provider-quota"'); + expect(cards).not.toContain('layout="summary"'); + expect(css).toMatch(/\.quota-row\s*\{[^}]*grid-template-columns:/); + expect(css).toMatch(/\.bar\s*\{[^}]*height:/); +}); + +test("provider cards keep content and resource rows aligned", () => { + const cards = readFileSync(new URL("../src/components/providers/ProviderCardList.tsx", import.meta.url), "utf8"); + const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); + expect(cards).toContain('className="prov-card-resource"'); + expect(css).toMatch(/\.prov-card\s*\{[^}]*grid-template-rows:\s*minmax\(0, 1fr\) auto minmax\(27px, auto\);/); + expect(css).toMatch(/\.provider-card-grid\s*\{[^}]*align-items:\s*stretch;/); + expect(css).toMatch(/@media \(max-width: 760px\)[\s\S]*\.prov-card-main\s*\{[^}]*grid-template-columns:\s*1fr;/); +}); + +test("classic provider workspace exposes connection intents and a compact provider grid", () => { + const panel = readFileSync(new URL("../src/components/providers/OAuthPanel.tsx", import.meta.url), "utf8"); + const cards = readFileSync(new URL("../src/components/providers/ProviderCardList.tsx", import.meta.url), "utf8"); + const page = readFileSync(new URL("../src/pages/Providers.tsx", import.meta.url), "utf8"); + const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); + expect(panel).toContain('onAddProvider({ tier: "accounts" })'); + expect(panel).toContain('onAddProvider({ tier: "paid" })'); + expect(panel).toContain('onAddProvider({ custom: true })'); + expect(cards).toContain('className="provider-card-grid"'); + expect(cards).toContain('t("dash.tokens30d")'); + expect(page).toContain('fetch(`${apiBase}/api/usage?range=30d`)'); + expect(page).toContain('initialTier={addIntent?.tier}'); + expect(page).toContain('initialCustom={addIntent?.custom}'); + expect(css).toMatch(/\.provider-card-grid\s*\{[^}]*grid-template-columns:\s*repeat\(2,/); + expect(css).toMatch(/@media \(max-width: 760px\)[\s\S]*\.provider-card-grid\s*\{[^}]*grid-template-columns:\s*1fr;/); +}); + +test("providers page uses the wide content shell", () => { + const app = readFileSync(new URL("../src/App.tsx", import.meta.url), "utf8"); + const styles = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); + + expect(app).toContain('page === "providers" ? " main-inner--providers" : ""'); + expect(styles).toContain(".main-inner.main-inner--providers { max-width: 1440px; }"); +}); From dcea56f2e9b7bcc1e9beceb45a761720a4aa946d Mon Sep 17 00:00:00 2001 From: HaydernCenterpoint Date: Fri, 24 Jul 2026 18:18:30 +0700 Subject: [PATCH 09/11] feat: filter OpenRouter discovery to free models Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- gui/src/components/AddProviderModal.tsx | 19 ++++++++-- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/styles/provider-catalog.css | 1 + src/codex/catalog/provider-fetch.ts | 12 ++++++ src/server/management/provider-routes.ts | 43 ++++++++++++++------- tests/provider-free-directory.test.ts | 48 +++++++++++++++++++++++- tests/provider-model-envelope.test.ts | 4 ++ 12 files changed, 115 insertions(+), 18 deletions(-) diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index 58901cabb..f2d330c64 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -75,6 +75,7 @@ export default function AddProviderModal({ const [discoverySource, setDiscoverySource] = useState<"live" | "static" | "">(""); const [discoveredModels, setDiscoveredModels] = useState>([]); const [selectedModels, setSelectedModels] = useState([]); + const [openRouterFreeOnly, setOpenRouterFreeOnly] = useState(false); const discoveryRequestRef = useRef(0); const discoveryAbortRef = useRef(null); const aliveRef = useRef(true); @@ -194,6 +195,7 @@ export default function AddProviderModal({ const staticModels = (p.models ?? []).map(id => ({ id })); setDiscoveredModels(staticModels); setSelectedModels(staticModels.map(model => model.id)); + setOpenRouterFreeOnly(false); }; const back = () => { @@ -211,6 +213,7 @@ export default function AddProviderModal({ setDiscoverySource(""); setDiscoveredModels([]); setSelectedModels([]); + setOpenRouterFreeOnly(false); }; const providerPostBody = () => { @@ -221,7 +224,7 @@ export default function AddProviderModal({ return buildProviderPostBody(preset, { ...form, baseUrl: resolvedBaseUrl }); }; - const discoverModels = async () => { + const discoverModels = async (freeOnly = openRouterFreeOnly) => { const postBody = providerPostBody(); if (!postBody || !preset || preset.supportLevel === "reference") return; const requestId = discoveryRequestRef.current + 1; @@ -235,7 +238,7 @@ export default function AddProviderModal({ const res = await fetch(`${apiBase}/api/provider-presets/discover`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ presetId: preset.id, provider: postBody.provider }), + body: JSON.stringify({ presetId: preset.id, provider: postBody.provider, ...(preset.id === "openrouter" ? { freeOnly } : {}) }), signal: controller.signal, }); const data = await res.json().catch(() => ({})) as { @@ -252,7 +255,7 @@ export default function AddProviderModal({ setSelectedModels(models.map(model => model.id)); setDiscoverySource(data.source ?? (preset.discovery === "static" ? "static" : "live")); if (data.error) setDiscoveryError(data.error); - if (models[0]) setForm(current => current && !current.defaultModel ? { ...current, defaultModel: models[0]!.id } : current); + if (models[0]) setForm(current => current && (freeOnly || !current.defaultModel) ? { ...current, defaultModel: models[0]!.id } : current); } catch (cause) { if (requestId !== discoveryRequestRef.current || controller.signal.aborted) return; setDiscoveryError(cause instanceof Error ? cause.message : t("modal.networkError")); @@ -667,6 +670,16 @@ export default function AddProviderModal({ {discoveryBusy ? t("modal.discovering") : t("modal.discoverModels")}
+ {preset.id === "openrouter" && ( + + )} {discoveryError &&
{discoveryError}
} {discoveredModels.length > 0 && (
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index ddf700494..f7614054b 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1054,6 +1054,7 @@ export const de = { "modal.modelDiscovery": "Modellsuche", "modal.modelDiscoveryHint": "Verfügbare Modelle für diese Einrichtung suchen.", "modal.discoverModels": "Modelle suchen", + "modal.openRouterFreeOnly": "Nur kostenlose OpenRouter-Modelle verwenden", "modal.discovering": "Suche läuft…", "modal.modelsLive": "Live-Antwort des Anbieters", "modal.modelsStatic": "Statischer Katalog", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index a8b6d980d..be7eaa82d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -890,6 +890,7 @@ export const en = { "modal.modelDiscovery": "Model discovery", "modal.modelDiscoveryHint": "Find available models for this setup.", "modal.discoverModels": "Discover models", + "modal.openRouterFreeOnly": "Use free OpenRouter models only", "modal.discovering": "Discovering…", "modal.modelsLive": "Live provider response", "modal.modelsStatic": "Static catalog", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 8f2b387d2..5b2ca79d7 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -845,6 +845,7 @@ export const ja: Record = { "modal.modelDiscovery": "モデル検出", "modal.modelDiscoveryHint": "この設定で利用可能なモデルを検索します。", "modal.discoverModels": "モデルを検出", + "modal.openRouterFreeOnly": "OpenRouter の無料モデルのみを使用", "modal.discovering": "検出中…", "modal.modelsLive": "プロバイダーからのライブ応答", "modal.modelsStatic": "静的カタログ", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 8b8b30122..092f791b7 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1076,6 +1076,7 @@ export const ko: Record = { "modal.modelDiscovery": "모델 검색", "modal.modelDiscoveryHint": "이 설정에서 사용 가능한 모델을 찾습니다.", "modal.discoverModels": "모델 검색", + "modal.openRouterFreeOnly": "무료 OpenRouter 모델만 사용", "modal.discovering": "검색 중…", "modal.modelsLive": "프로바이더 실시간 응답", "modal.modelsStatic": "정적 카탈로그", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 0c925a0a0..f8c02741d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -890,6 +890,7 @@ export const ru: Record = { "modal.modelDiscovery": "Поиск моделей", "modal.modelDiscoveryHint": "Найдите модели, доступные для этой настройки.", "modal.discoverModels": "Найти модели", + "modal.openRouterFreeOnly": "Использовать только бесплатные модели OpenRouter", "modal.discovering": "Поиск…", "modal.modelsLive": "Ответ провайдера в реальном времени", "modal.modelsStatic": "Статический каталог", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 54572119d..ab68c424f 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1076,6 +1076,7 @@ export const zh: Record = { "modal.modelDiscovery": "模型发现", "modal.modelDiscoveryHint": "查找此设置可用的模型。", "modal.discoverModels": "发现模型", + "modal.openRouterFreeOnly": "仅使用免费的 OpenRouter 模型", "modal.discovering": "正在发现…", "modal.modelsLive": "提供商实时响应", "modal.modelsStatic": "静态目录", diff --git a/gui/src/styles/provider-catalog.css b/gui/src/styles/provider-catalog.css index ccc8b16ae..c1e467e06 100644 --- a/gui/src/styles/provider-catalog.css +++ b/gui/src/styles/provider-catalog.css @@ -80,6 +80,7 @@ .provider-model-discovery-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } .provider-model-discovery-head strong { font-size: var(--text-control); } .provider-model-source { color: var(--muted); font-size: var(--text-label); } +.provider-model-free-filter { display: inline-flex; align-items: center; gap: 7px; width: fit-content; color: var(--text); font-size: var(--text-label); cursor: pointer; } .provider-model-list { display: grid; max-height: 210px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); } .provider-model-row { display: flex; align-items: center; gap: 9px; padding: 8px 10px; font-size: var(--text-label); cursor: pointer; } .provider-model-row + .provider-model-row { border-top: 1px solid var(--border-soft); } diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 1ac57c353..864944554 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -58,6 +58,10 @@ export type ProviderModelsApiItem = { owned_by?: string; context_length?: number; max_model_len?: number; + pricing?: { + prompt: string; + completion: string; + }; metadata?: { capabilities?: Record; limits?: Record; @@ -101,10 +105,18 @@ export function parseProviderModelsApiItems(value: unknown, allowModelsEnvelope item.max_context_length, item.max_model_len, ].find(candidate => typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) as number | undefined; + const rawPricing = item.pricing !== null && typeof item.pricing === "object" && !Array.isArray(item.pricing) + ? item.pricing as Record + : null; + const pricing = rawPricing && typeof rawPricing.prompt === "string" && rawPricing.prompt.trim() + && typeof rawPricing.completion === "string" && rawPricing.completion.trim() + ? { prompt: rawPricing.prompt, completion: rawPricing.completion } + : undefined; normalized.push({ id, ...(typeof item.owned_by === "string" ? { owned_by: item.owned_by } : {}), ...(contextLength ? { context_length: contextLength } : {}), + ...(pricing ? { pricing } : {}), ...(item.metadata !== null && typeof item.metadata === "object" && !Array.isArray(item.metadata) ? { metadata: item.metadata as ProviderModelsApiItem["metadata"] } : {}), diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index ab1014458..0a73e7f29 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -409,12 +409,14 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise model.id.endsWith(":free")) + : staticModels; const destinationError = await providerDestinationResolvedError(presetId, provider); if (destinationError) return jsonResponse({ error: destinationError }, 400); if (entry.discovery === "static" || entry.discovery === "unsupported" || entry.liveModels === false) { - return jsonResponse(staticModels.length - ? { ok: true, models: staticModels, source: "static", latencyMs: 0 } + return jsonResponse(fallbackModels.length + ? { ok: true, models: fallbackModels, source: "static", latencyMs: 0 } : { ok: false, models: [], source: "static", latencyMs: 0, error: "No static model catalog is available for this preset" }); } const { resolveModelsAuthToken, buildModelsRequest } = await import("../../oauth"); @@ -477,8 +482,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise null), allowsModelsEnvelope); if (!rows) { const error = "upstream model discovery returned an unexpected shape"; - return jsonResponse(staticModels.length - ? { ok: true, models: staticModels, source: "static", latencyMs, error } + return jsonResponse(fallbackModels.length + ? { ok: true, models: fallbackModels, source: "static", latencyMs, error } : { ok: false, models: [], source: "live", latencyMs, error }); } const uniqueRows = new Map(); @@ -496,22 +501,34 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise= 2000) break; } - const models = [...uniqueRows.values()].map(row => ({ + const filteredRows = body.freeOnly === true + ? [...uniqueRows.values()].filter(row => { + const prompt = Number(row.pricing?.prompt); + const completion = Number(row.pricing?.completion); + return row.pricing !== undefined + && Number.isFinite(prompt) && prompt >= 0 + && Number.isFinite(completion) && completion >= 0 + && prompt === 0 && completion === 0; + }) + : [...uniqueRows.values()]; + const models = filteredRows.map(row => ({ id: row.id, ...(row.context_length ? { contextWindow: row.context_length } : {}), })); if (models.length === 0) { - const error = "upstream model discovery returned an empty catalog"; - return jsonResponse(staticModels.length - ? { ok: true, models: staticModels, source: "static", latencyMs, error } + const error = body.freeOnly === true + ? "OpenRouter currently reports no free models" + : "upstream model discovery returned an empty catalog"; + return jsonResponse(fallbackModels.length + ? { ok: true, models: fallbackModels, source: "static", latencyMs, error } : { ok: false, models: [], source: "live", latencyMs, error }); } return jsonResponse({ ok: true, models, source: "live", latencyMs }); } catch { const latencyMs = Date.now() - started; const error = "model discovery request failed"; - return jsonResponse(staticModels.length - ? { ok: true, models: staticModels, source: "static", latencyMs, error } + return jsonResponse(fallbackModels.length + ? { ok: true, models: fallbackModels, source: "static", latencyMs, error } : { ok: false, models: [], source: "live", latencyMs, error }); } } diff --git a/tests/provider-free-directory.test.ts b/tests/provider-free-directory.test.ts index 5d6ea26c3..4ae9928e9 100644 --- a/tests/provider-free-directory.test.ts +++ b/tests/provider-free-directory.test.ts @@ -19,12 +19,12 @@ const config: OcxConfig = { providers: {}, }; -function discover(presetId: string, provider: OcxProviderConfig): Promise { +function discover(presetId: string, provider: OcxProviderConfig, freeOnly?: boolean): Promise { const url = new URL("http://127.0.0.1:10100/api/provider-presets/discover"); return handleManagementAPI(new Request(url, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ presetId, provider }), + body: JSON.stringify({ presetId, provider, ...(freeOnly === undefined ? {} : { freeOnly }) }), }), url, config); } @@ -130,6 +130,50 @@ describe("free provider directory", () => { expect(config.providers.cohere).toBeUndefined(); }); + test("filters OpenRouter discovery to models with complete valid zero pricing", async () => { + globalThis.fetch = (async () => Response.json({ data: [ + { id: "paid/model", pricing: { prompt: "0.000001", completion: "0.000002" } }, + { id: "free/by-price", context_length: 128000, pricing: { prompt: "0", completion: "0" } }, + { id: "not-free/by-suffix:free" }, + { id: "not-free/partial-price", pricing: { prompt: "0" } }, + { id: "not-free/invalid-price", pricing: { prompt: "free", completion: "0" } }, + { id: "not-free/negative-price", pricing: { prompt: "-1", completion: "0" } }, + { id: "not-free/blank-price", pricing: { prompt: "", completion: "" } }, + ] })) as typeof fetch; + const response = await discover("openrouter", { + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + authMode: "key", + apiKey: "secret", + }, true); + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ + ok: true, + source: "live", + models: [{ id: "free/by-price", contextWindow: 128000 }], + }); + }); + + test("validates freeOnly at the discovery boundary", async () => { + const url = new URL("http://127.0.0.1:10100/api/provider-presets/discover"); + const invalidType = await handleManagementAPI(new Request(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ presetId: "openrouter", provider: {}, freeOnly: "yes" }), + }), url, config); + expect(invalidType?.status).toBe(400); + expect(await invalidType?.json()).toEqual({ error: "freeOnly must be a boolean" }); + + const unsupported = await discover("cohere", { + adapter: "openai-chat", + baseUrl: "https://api.cohere.com/compatibility/v1", + authMode: "key", + apiKey: "secret", + }, true); + expect(unsupported?.status).toBe(400); + expect(await unsupported?.json()).toEqual({ error: "freeOnly is only supported for OpenRouter" }); + }); + test("accepts only SiliconFlow's two official regional endpoints for discovery", async () => { let calls = 0; globalThis.fetch = (async input => { diff --git a/tests/provider-model-envelope.test.ts b/tests/provider-model-envelope.test.ts index cd3af7ca8..fbd3eac91 100644 --- a/tests/provider-model-envelope.test.ts +++ b/tests/provider-model-envelope.test.ts @@ -5,6 +5,10 @@ describe("provider model catalog envelopes", () => { test("normalizes OpenAI, Google/Ollama, and top-level catalog shapes", () => { expect(parseProviderModelsApiItems({ data: [{ id: "openai-model" }] })?.map(model => model.id)) .toEqual(["openai-model"]); + expect(parseProviderModelsApiItems({ data: [{ id: "free-model", pricing: { prompt: "0", completion: "0" } }] })?.[0]?.pricing) + .toEqual({ prompt: "0", completion: "0" }); + expect(parseProviderModelsApiItems({ data: [{ id: "partial-price", pricing: { prompt: "0" } }] })?.[0]?.pricing) + .toBeUndefined(); expect(parseProviderModelsApiItems({ models: [{ name: "models/gemini-flash", inputTokenLimit: 1_000_000 }] }, true)) .toEqual([{ id: "gemini-flash", context_length: 1_000_000 }]); expect(parseProviderModelsApiItems([{ id: "accounts/fireworks/models/llama" }])?.map(model => model.id)) From 4e16f317cd0f0faea00a4ab38634bc9c99dea0c7 Mon Sep 17 00:00:00 2001 From: HaydernCenterpoint Date: Sat, 25 Jul 2026 09:32:50 +0700 Subject: [PATCH 10/11] fix(gui): localize smart routing controls Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- gui/src/i18n/de.ts | 10 ++++++++++ gui/src/i18n/en.ts | 10 ++++++++++ gui/src/i18n/ja.ts | 10 ++++++++++ gui/src/i18n/ko.ts | 10 ++++++++++ gui/src/i18n/ru.ts | 10 ++++++++++ gui/src/i18n/zh.ts | 10 ++++++++++ 6 files changed, 60 insertions(+) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index f7614054b..6ff20d174 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -403,6 +403,16 @@ export const de = { "models.allowlistLabel": "Nur ausgewählte", "models.allowlistHint": "Nur geprüfte Modelle gehen in den Katalog (leer = alle). Nützlich für Anbieter mit tausenden Modellen.", "models.selectedCount": "{n} ausgewählt", + "models.orderHint": "Reihenfolge in der Modellauswahl: Subagents-Auswahl (in der festgelegten Reihenfolge) → übrige geroutete Modelle alphabetisch nach Anbieter, dann Modell-ID → native Modelle. Sichtbarkeitsschalter filtern nur; sie ändern diese Reihenfolge nicht.", + "models.smartRoutingTitle": "Intelligentes Routing", + "models.smartRoutingHint": "Erstellt aus deinen aktivierten Modellen drei automatische Routen für Intelligenz, ausgewogene Nutzung und Kosten.", + "models.smartRoutingApply": "Intelligente Routen anwenden", + "models.smartRoutingApplying": "Wird angewendet…", + "models.smartRoutingApplied": "Intelligentes Routing für {mode} angewendet.", + "models.smartRoutingFailed": "Intelligentes Routing konnte nicht angewendet werden.", + "models.smartRouting_intelligence": "Intelligenz", + "models.smartRouting_balance": "ausgewogene Nutzung", + "models.smartRouting_cost": "Kosten", "sub.subtitle": "Codex {cmd} bewirbt nur die ersten 5 Modelle (nach Priorität) als Overrides. Wähle hier bis zu 5 — natives gpt oder geroutet — und opencodex setzt ihre Katalog-Priorität, sodass genau diese führen. Jedes andere Modell bleibt über seinen exakten Namen aufrufbar; dies steuert nur die Anzeige.", "sub.featured": "Empfohlen", "sub.orderHint": "Die hier gewählte und angezeigte Reihenfolge bestimmt die Plätze 1–5 oben in der Codex-Modellauswahl und die Standard-Modellkandidaten für {cmd}.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index be7eaa82d..535b654f0 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -419,6 +419,16 @@ export const en = { "models.allowlistLabel": "Only selected", "models.allowlistHint": "Only checked models ship to the catalog (empty = all). Useful for providers exposing thousands of models.", "models.selectedCount": "{n} selected", + "models.orderHint": "Picker order: Subagents picks (in the selected order) → remaining routed models alphabetically by provider, then model ID → native models. Visibility switches only filter models; they do not change this order.", + "models.smartRoutingTitle": "Smart routing", + "models.smartRoutingHint": "Create three automatic routes from your enabled models for intelligence, balanced use, and cost.", + "models.smartRoutingApply": "Apply smart routes", + "models.smartRoutingApplying": "Applying…", + "models.smartRoutingApplied": "Applied {mode} smart routing.", + "models.smartRoutingFailed": "Could not apply smart routing.", + "models.smartRouting_intelligence": "intelligence", + "models.smartRouting_balance": "balanced", + "models.smartRouting_cost": "cost", // subagents "sub.subtitle": "Codex's {cmd} advertises only the first 5 models (by priority) as overrides. Pick up to 5 here — native gpt or routed — and opencodex sets their catalog priority so exactly these lead. Any other model is still callable by its exact name; this only controls what's shown.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 5b2ca79d7..08a612241 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -384,6 +384,16 @@ export const ja: Record = { "models.allowlistLabel": "選択のみ", "models.allowlistHint": "チェックしたモデルのみカタログに送信します(空 = すべて)。数千のモデルを公開するプロバイダーで有用です。", "models.selectedCount": "{n} 件選択", + "models.orderHint": "ピッカーの順序: サブエージェントの選択(選択順) → 残りのルーティングモデルはプロバイダー別、次にモデル ID 別のアルファベット順 → ネイティブモデル。表示切り替えはモデルをフィルタするだけで、この順序は変更しません。", + "models.smartRoutingTitle": "スマートルーティング", + "models.smartRoutingHint": "有効なモデルから、知能・バランス・コスト向けの3つの自動ルートを作成します。", + "models.smartRoutingApply": "スマートルートを適用", + "models.smartRoutingApplying": "適用中…", + "models.smartRoutingApplied": "{mode} のスマートルーティングを適用しました。", + "models.smartRoutingFailed": "スマートルーティングを適用できませんでした。", + "models.smartRouting_intelligence": "知能", + "models.smartRouting_balance": "バランス", + "models.smartRouting_cost": "コスト", // subagents "sub.subtitle": "Codex の {cmd} は最初の 5 モデル(優先度順)のみをオーバーライドとして通知します。ここで最大 5 つを選んでください — ネイティブ gpt またはルーティング — opencodex がカタログ優先度を設定し、これらが先頭に来るようにします。他のモデルも正確な名前で呼び出し可能です; これは表示のみを制御します。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 092f791b7..9e66903da 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -413,6 +413,16 @@ export const ko: Record = { "models.allowlistLabel": "선택만 노출", "models.allowlistHint": "체크한 모델만 카탈로그에 노출돼요 (비우면 전체). 수천 개 모델을 노출하는 프로바이더에 유용해요.", "models.selectedCount": "{n}개 선택", + "models.orderHint": "피커 순서: Subagents에서 지정한 순서 → 나머지 라우팅 모델(프로바이더, 모델 ID 순 알파벳 정렬) → 네이티브 모델. 노출 토글은 모델을 필터링할 뿐 이 순서를 바꾸지 않습니다.", + "models.smartRoutingTitle": "스마트 라우팅", + "models.smartRoutingHint": "활성화된 모델에서 지능, 균형, 비용을 위한 세 개의 자동 경로를 만듭니다.", + "models.smartRoutingApply": "스마트 경로 적용", + "models.smartRoutingApplying": "적용 중…", + "models.smartRoutingApplied": "{mode} 스마트 라우팅을 적용했습니다.", + "models.smartRoutingFailed": "스마트 라우팅을 적용하지 못했습니다.", + "models.smartRouting_intelligence": "지능", + "models.smartRouting_balance": "균형", + "models.smartRouting_cost": "비용", // subagents "sub.subtitle": "Codex의 {cmd} 는 우선순위 상위 5개 모델만 오버라이드로 노출합니다. 여기서 최대 5개를 선택하면 — 네이티브 gpt 또는 라우팅된 모델 — opencodex가 카탈로그 우선순위를 설정해 정확히 이들이 앞에 옵니다. 다른 모델도 정확한 이름으로 호출할 수 있으며, 이 설정은 표시 항목만 제어합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index f8c02741d..0fc86b925 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -419,6 +419,16 @@ export const ru: Record = { "models.allowlistLabel": "Только выбранные", "models.allowlistHint": "В каталог попадают только отмеченные модели (пусто = все). Полезно для провайдеров, предоставляющих тысячи моделей.", "models.selectedCount": "Выбрано: {n}", + "models.orderHint": "Порядок в селекторе: модели, выбранные на странице «Подагенты» (в заданном порядке) → остальные маршрутизируемые модели по алфавиту — сначала по провайдеру, затем по ID модели → нативные модели. Переключатели видимости лишь фильтруют модели и не меняют этот порядок.", + "models.smartRoutingTitle": "Умная маршрутизация", + "models.smartRoutingHint": "Создаёт из включённых моделей три автоматических маршрута: для качества, баланса и стоимости.", + "models.smartRoutingApply": "Применить умные маршруты", + "models.smartRoutingApplying": "Применяется…", + "models.smartRoutingApplied": "Применена умная маршрутизация для режима «{mode}».", + "models.smartRoutingFailed": "Не удалось применить умную маршрутизацию.", + "models.smartRouting_intelligence": "качество", + "models.smartRouting_balance": "баланс", + "models.smartRouting_cost": "стоимость", // subagents "sub.subtitle": "{cmd} в Codex объявляет как переопределения только первые 5 моделей (по приоритету). Выберите здесь до 5 моделей — нативные gpt или маршрутизируемые — и opencodex задаст им приоритет в каталоге так, чтобы именно они шли первыми. Любую другую модель по-прежнему можно вызвать по её точному имени; эта настройка управляет только тем, что отображается.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ab68c424f..bfb1489fa 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -413,6 +413,16 @@ export const zh: Record = { "models.allowlistLabel": "仅所选", "models.allowlistHint": "仅勾选的模型进入目录(留空 = 全部)。适用于暴露成千上万模型的提供商。", "models.selectedCount": "已选 {n} 个", + "models.orderHint": "选择器顺序:Subagents 中的选择(按所选顺序)→ 其余已路由模型(依次按提供方、模型 ID 字母排序)→ 原生模型。可见性开关仅用于筛选,不会改变此顺序。", + "models.smartRoutingTitle": "智能路由", + "models.smartRoutingHint": "从已启用的模型创建三条自动路由,分别用于智能、均衡和成本场景。", + "models.smartRoutingApply": "应用智能路由", + "models.smartRoutingApplying": "正在应用…", + "models.smartRoutingApplied": "已应用 {mode} 智能路由。", + "models.smartRoutingFailed": "无法应用智能路由。", + "models.smartRouting_intelligence": "智能", + "models.smartRouting_balance": "均衡", + "models.smartRouting_cost": "成本", // subagents "sub.subtitle": "Codex 的 {cmd} 仅将优先级最高的前 5 个模型作为覆盖项公开。在此最多选择 5 个 — 原生 gpt 或已路由模型 — opencodex 会设置它们的目录优先级,使其正好排在前面。其他模型仍可按确切名称调用;此设置仅控制显示项。", From 9b05269eb705a0cfa534c7c80769227c37a766a0 Mon Sep 17 00:00:00 2001 From: HaydernCenterpoint Date: Sat, 25 Jul 2026 14:07:09 +0700 Subject: [PATCH 11/11] fix(gui): reconcile workspace translations Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- gui/src/i18n/de.ts | 1 - gui/src/i18n/en.ts | 1 - gui/src/i18n/ja.ts | 1 - gui/src/i18n/ko.ts | 1 - gui/src/i18n/ru.ts | 1 - gui/src/i18n/zh.ts | 1 - gui/src/pages/Models.tsx | 2 +- 7 files changed, 1 insertion(+), 7 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 6ff20d174..c84f71d0d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -403,7 +403,6 @@ export const de = { "models.allowlistLabel": "Nur ausgewählte", "models.allowlistHint": "Nur geprüfte Modelle gehen in den Katalog (leer = alle). Nützlich für Anbieter mit tausenden Modellen.", "models.selectedCount": "{n} ausgewählt", - "models.orderHint": "Reihenfolge in der Modellauswahl: Subagents-Auswahl (in der festgelegten Reihenfolge) → übrige geroutete Modelle alphabetisch nach Anbieter, dann Modell-ID → native Modelle. Sichtbarkeitsschalter filtern nur; sie ändern diese Reihenfolge nicht.", "models.smartRoutingTitle": "Intelligentes Routing", "models.smartRoutingHint": "Erstellt aus deinen aktivierten Modellen drei automatische Routen für Intelligenz, ausgewogene Nutzung und Kosten.", "models.smartRoutingApply": "Intelligente Routen anwenden", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 535b654f0..7f528e5fb 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -419,7 +419,6 @@ export const en = { "models.allowlistLabel": "Only selected", "models.allowlistHint": "Only checked models ship to the catalog (empty = all). Useful for providers exposing thousands of models.", "models.selectedCount": "{n} selected", - "models.orderHint": "Picker order: Subagents picks (in the selected order) → remaining routed models alphabetically by provider, then model ID → native models. Visibility switches only filter models; they do not change this order.", "models.smartRoutingTitle": "Smart routing", "models.smartRoutingHint": "Create three automatic routes from your enabled models for intelligence, balanced use, and cost.", "models.smartRoutingApply": "Apply smart routes", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 08a612241..91caafb93 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -384,7 +384,6 @@ export const ja: Record = { "models.allowlistLabel": "選択のみ", "models.allowlistHint": "チェックしたモデルのみカタログに送信します(空 = すべて)。数千のモデルを公開するプロバイダーで有用です。", "models.selectedCount": "{n} 件選択", - "models.orderHint": "ピッカーの順序: サブエージェントの選択(選択順) → 残りのルーティングモデルはプロバイダー別、次にモデル ID 別のアルファベット順 → ネイティブモデル。表示切り替えはモデルをフィルタするだけで、この順序は変更しません。", "models.smartRoutingTitle": "スマートルーティング", "models.smartRoutingHint": "有効なモデルから、知能・バランス・コスト向けの3つの自動ルートを作成します。", "models.smartRoutingApply": "スマートルートを適用", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 9e66903da..6f03ed717 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -413,7 +413,6 @@ export const ko: Record = { "models.allowlistLabel": "선택만 노출", "models.allowlistHint": "체크한 모델만 카탈로그에 노출돼요 (비우면 전체). 수천 개 모델을 노출하는 프로바이더에 유용해요.", "models.selectedCount": "{n}개 선택", - "models.orderHint": "피커 순서: Subagents에서 지정한 순서 → 나머지 라우팅 모델(프로바이더, 모델 ID 순 알파벳 정렬) → 네이티브 모델. 노출 토글은 모델을 필터링할 뿐 이 순서를 바꾸지 않습니다.", "models.smartRoutingTitle": "스마트 라우팅", "models.smartRoutingHint": "활성화된 모델에서 지능, 균형, 비용을 위한 세 개의 자동 경로를 만듭니다.", "models.smartRoutingApply": "스마트 경로 적용", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 0fc86b925..837821ae0 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -419,7 +419,6 @@ export const ru: Record = { "models.allowlistLabel": "Только выбранные", "models.allowlistHint": "В каталог попадают только отмеченные модели (пусто = все). Полезно для провайдеров, предоставляющих тысячи моделей.", "models.selectedCount": "Выбрано: {n}", - "models.orderHint": "Порядок в селекторе: модели, выбранные на странице «Подагенты» (в заданном порядке) → остальные маршрутизируемые модели по алфавиту — сначала по провайдеру, затем по ID модели → нативные модели. Переключатели видимости лишь фильтруют модели и не меняют этот порядок.", "models.smartRoutingTitle": "Умная маршрутизация", "models.smartRoutingHint": "Создаёт из включённых моделей три автоматических маршрута: для качества, баланса и стоимости.", "models.smartRoutingApply": "Применить умные маршруты", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index bfb1489fa..71b839891 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -413,7 +413,6 @@ export const zh: Record = { "models.allowlistLabel": "仅所选", "models.allowlistHint": "仅勾选的模型进入目录(留空 = 全部)。适用于暴露成千上万模型的提供商。", "models.selectedCount": "已选 {n} 个", - "models.orderHint": "选择器顺序:Subagents 中的选择(按所选顺序)→ 其余已路由模型(依次按提供方、模型 ID 字母排序)→ 原生模型。可见性开关仅用于筛选,不会改变此顺序。", "models.smartRoutingTitle": "智能路由", "models.smartRoutingHint": "从已启用的模型创建三条自动路由,分别用于智能、均衡和成本场景。", "models.smartRoutingApply": "应用智能路由", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 2e2cbf032..301bd401a 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -968,7 +968,7 @@ export default function Models({ apiBase, viewMode }: { apiBase: string; viewMod disabled={smartRoutingBusy} > {t(`models.smartRouting_${mode}` as TKey)} - {combo ? `${combo.model} · ${combo.targets.length}` : t(`models.smartRouting_${mode}Hint` as TKey)} + {combo ? `${combo.model} · ${combo.targets.length}` : t(`models.smartRouting_${mode}` as TKey)} ); })}