From 05dd47533516b9a250e96fdc1b41a5ab8c8e6033 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Thu, 16 Jul 2026 15:05:21 +0800 Subject: [PATCH] v4.0.2 (#69) ### Issue for this PR Closes # ### Type of change - [ ] Bug fix - [ ] New feature - [ ] Refactor / code improvement - [ ] Documentation ### What does this PR do? Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR. **If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** ### How did you verify your code works? ### Screenshots / recordings _If this is a UI change, please include a screenshot or recording._ ### Checklist - [ ] I have tested my changes locally - [ ] I have not included unrelated changes in this PR _If you do not follow this template your PR will be automatically rejected._ --------- Co-authored-by: Claude Opus 4.8 (1M context) --- README.md | 62 ++++++ README.zh.md | 57 ++++++ .../components/dialog-custom-provider-form.ts | 112 ++++++++++- .../components/dialog-custom-provider.test.ts | 153 +++++++++++++-- .../src/components/dialog-custom-provider.tsx | 183 ++++++++++++------ .../src/components/settings-v2/providers.tsx | 30 ++- .../app/src/context/global-sync/bootstrap.ts | 14 +- packages/app/src/i18n/en.ts | 8 + packages/app/src/i18n/zh.ts | 8 + packages/app/src/i18n/zht.ts | 8 + packages/core/src/v1/config/migrate.ts | 2 +- packages/core/src/v1/config/provider.ts | 5 +- .../src/provider/catalog-spec.ts | 129 ++++++++++++ .../src/provider/model-discovery.ts | 18 +- .../deepagent-code/src/provider/provider.ts | 97 +++++++--- .../instance/httpapi/groups/provider.ts | 13 ++ .../instance/httpapi/handlers/provider.ts | 13 +- .../test/provider/catalog-spec.test.ts | 126 ++++++++++++ .../test/provider/model-discovery.test.ts | 40 +++- .../test/provider/provider.test.ts | 47 +++++ packages/sdk/js/src/gen/types.gen.ts | 18 +- packages/sdk/js/src/v2/gen/types.gen.ts | 18 +- packages/ui/src/components/session-retry.tsx | 6 + packages/ui/src/i18n/ar.ts | 2 + packages/ui/src/i18n/br.ts | 2 + packages/ui/src/i18n/bs.ts | 2 + packages/ui/src/i18n/da.ts | 2 + packages/ui/src/i18n/de.ts | 2 + packages/ui/src/i18n/en.ts | 2 + packages/ui/src/i18n/es.ts | 2 + packages/ui/src/i18n/fr.ts | 2 + packages/ui/src/i18n/ja.ts | 2 + packages/ui/src/i18n/ko.ts | 2 + packages/ui/src/i18n/no.ts | 2 + packages/ui/src/i18n/pl.ts | 2 + packages/ui/src/i18n/ru.ts | 2 + packages/ui/src/i18n/th.ts | 2 + packages/ui/src/i18n/tr.ts | 2 + packages/ui/src/i18n/uk.ts | 2 + packages/ui/src/i18n/zh.ts | 1 + packages/ui/src/i18n/zht.ts | 1 + 41 files changed, 1088 insertions(+), 113 deletions(-) create mode 100644 packages/deepagent-code/src/provider/catalog-spec.ts create mode 100644 packages/deepagent-code/test/provider/catalog-spec.test.ts diff --git a/README.md b/README.md index e211230d..3e8d672f 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,67 @@ deepagent-code deepagent ``` +## Adding a Provider + +Before you can run a task, DeepAgent Code needs at least one model provider. It +supports 75+ providers through the [AI SDK](https://ai-sdk.dev/) and +[models.dev](https://models.dev), plus any OpenAI- or Anthropic-compatible +endpoint. Pick whichever path fits how you work. + +### Desktop app (recommended) + +Open **Settings → Providers**: + +- **Official providers** (OpenAI, Anthropic, DeepSeek, Google, xAI, ZhipuAI/GLM): + click **Connect**, paste your API key. +- **Any other provider or gateway**: click **Connect** on *Custom provider*, paste + the **Base URL** and **API key**. DeepAgent Code auto-detects the protocol + (OpenAI-compatible or Anthropic) and discovers the available models from the + endpoint's `/models` list — you don't have to fill anything else. + +Model specs (context window, reasoning) are auto-filled by matching each model +against the models.dev catalog. You can reopen a custom provider to override a +model's context/reasoning/temperature; those overrides are best-effort and not +guaranteed to keep the model working. + +### Terminal + +```bash +# Log in to a provider (official providers, or a plugin auth flow) +deepagent auth login + +# See what's connected +deepagent auth list +``` + +### Config file + +Providers also live in `~/.deepagent/code/config.jsonc`. A custom +OpenAI-compatible endpoint looks like this — set `discovery: true` to have models +refreshed from the endpoint at runtime, or list them explicitly under `models`: + +```jsonc +{ + "$schema": "https://deepagent-code.ai/config.json", + "provider": { + "myprovider": { + "name": "My Provider", + "npm": "@ai-sdk/openai-compatible", + "discovery": true, + "options": { + "baseURL": "https://api.myprovider.com/v1", + "apiKey": "sk-..." + } + } + } +} +``` + +Official-provider keys added via the app/CLI are stored separately in +`~/.deepagent/code/auth.json`, not in the config file. See the +[providers guide](https://deepagent-code.ai/docs/providers/) for the full +reference (base URL overrides, headers, per-model config, gateways). + ## Quick Example Start the agent and give it a task: @@ -216,6 +277,7 @@ bun run --cwd packages/deepagent-code dev import-history --from codex --dry-run ## Documentation +- [Providers & Models](https://deepagent-code.ai/docs/providers/) - [Architecture & Design](design/README.md) - [Security Policy](SECURITY.md) - [Privacy Policy](PRIVACY.md) diff --git a/README.zh.md b/README.zh.md index efe97275..15a1714d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -117,6 +117,62 @@ deepagent-code deepagent ``` +## 添加供应商 + +在运行任务之前,DeepAgent Code 至少需要一个模型供应商。它通过 +[AI SDK](https://ai-sdk.dev/) 和 [models.dev](https://models.dev) 支持 75+ 家供应商, +以及任意 OpenAI 或 Anthropic 兼容的接口。按你习惯的方式选一种即可。 + +### 桌面应用(推荐) + +打开 **设置 → 供应商(Settings → Providers)**: + +- **官方供应商**(OpenAI、Anthropic、DeepSeek、Google、xAI、智谱/GLM):点击 + **连接**,粘贴你的 API Key。 +- **其他供应商或网关**:在「自定义供应商」上点击 **连接**,填入 **Base URL** 和 + **API Key**。DeepAgent Code 会自动探测协议(OpenAI 兼容或 Anthropic),并从接口的 + `/models` 列表自动发现可用模型——其余字段无需填写。 + +模型规格(上下文窗口、推理能力)会通过与 models.dev 目录按模型 id 匹配来自动补全。 +你可以再次打开自定义供应商,覆盖某个模型的上下文/推理/温度;这些覆盖为尽力而为的默认值, +修改后不保证模型仍能正常使用。 + +### 终端 + +```bash +# 登录供应商(官方供应商,或插件鉴权流程) +deepagent auth login + +# 查看已连接的供应商 +deepagent auth list +``` + +### 配置文件 + +供应商也保存在 `~/.deepagent/code/config.jsonc` 中。一个自定义 OpenAI 兼容接口如下—— +设 `discovery: true` 让模型在运行时从接口刷新,或在 `models` 下显式列出: + +```jsonc +{ + "$schema": "https://deepagent-code.ai/config.json", + "provider": { + "myprovider": { + "name": "My Provider", + "npm": "@ai-sdk/openai-compatible", + "discovery": true, + "options": { + "baseURL": "https://api.myprovider.com/v1", + "apiKey": "sk-..." + } + } + } +} +``` + +通过应用/CLI 添加的官方供应商密钥单独存放在 `~/.deepagent/code/auth.json`,不在配置文件里。 +完整参考(Base URL 覆盖、请求头、逐模型配置、网关)见 +[供应商文档](https://deepagent-code.ai/docs/providers/)。 + ## 快速示例 启动智能体并交给它一个任务: @@ -216,6 +272,7 @@ bun run --cwd packages/deepagent-code dev import-history --from codex --dry-run ## 文档 +- [供应商与模型](https://deepagent-code.ai/docs/providers/) - [架构与设计](design/README.md) - [安全策略](SECURITY.md) - [隐私策略](PRIVACY.md) diff --git a/packages/app/src/components/dialog-custom-provider-form.ts b/packages/app/src/components/dialog-custom-provider-form.ts index c94ec521..cac792ea 100644 --- a/packages/app/src/components/dialog-custom-provider-form.ts +++ b/packages/app/src/components/dialog-custom-provider-form.ts @@ -1,4 +1,5 @@ import { isOfficialProvider } from "@deepagent-code/core/provider-official" +import type { Provider as ResolvedProvider, ProviderConfig } from "@deepagent-code/sdk/v2" const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" @@ -6,6 +7,15 @@ const ANTHROPIC = "@ai-sdk/anthropic" export type ProviderProtocol = "openai-compatible" | "anthropic" +// Per-model spec override written under `provider..models.`. Only the fields the user actually +// set are emitted, so a blank field never clobbers the backend catalog-fill with a zero/false. +export type CustomModelConfig = { + name: string + reasoning?: boolean + temperature?: boolean + limit?: { context: number; output?: number } +} + // The config payload written under `provider.`. `discovery` and `models` are mutually exclusive // in practice (discovery mode emits an empty models map), but both are typed optional so the emitted // object has one consistent shape instead of a union callers must narrow. @@ -19,7 +29,7 @@ export type CustomProviderConfig = { headers?: Record } discovery?: boolean - models: Record + models: Record } const npmForProtocol = (kind: ProviderProtocol | undefined) => (kind === "anthropic" ? ANTHROPIC : OPENAI_COMPATIBLE) @@ -34,6 +44,7 @@ type Translator = (key: string, vars?: Record export type ModelErr = { id?: string name?: string + context?: string } export type HeaderErr = { @@ -45,6 +56,11 @@ export type ModelRow = { row: string id: string name: string + // Editable spec overrides. `context` is a text field (parsed to a positive int on save; blank means + // "let the backend/catalog fill it"). reasoning/temperature are booleans. + context: string + reasoning: boolean + temperature: boolean err: ModelErr } @@ -82,6 +98,14 @@ type ValidateArgs = { // every load instead of freezing them into config. Manual models always take precedence and turn // this off for that provider. discovery?: boolean + // Edit mode: the provider being edited was persisted with `discovery: true`. We keep discovery on + // (so the backend still refreshes the model list) AND emit the user's per-model spec overrides, + // which the build loop merges over the discovered models (manual wins per-id). Without this, saving + // spec edits would freeze the model snapshot and disable runtime refresh. + editDiscovery?: boolean + // Providers whose ids are already taken but belong to the provider being edited (so an edit doesn't + // trip the "already exists" check on its own id). + editingProviderID?: string } // Turn a base URL into a stable, unique provider id + a human display name so the user only has to @@ -178,9 +202,11 @@ export function validateCustomProvider(input: ValidateArgs) { const nameError = !name ? input.t("provider.custom.error.name.required") : undefined const disabled = input.disabledProviders.includes(providerID) + // Editing a provider keeps its own id — don't flag that as a collision. + const isSelf = input.editingProviderID === providerID const existsError = idError ? undefined - : input.existingProviderIDs.has(providerID) && !disabled + : input.existingProviderIDs.has(providerID) && !disabled && !isSelf ? input.t("provider.custom.error.providerID.exists") : undefined @@ -201,10 +227,25 @@ export function validateCustomProvider(input: ValidateArgs) { return undefined })() const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined - return { id: idError, name: nameError } + const ctx = m.context.trim() + // Blank context is allowed (backend/catalog fills it); a non-empty value must be a positive int. + const contextError = ctx && !/^\d+$/.test(ctx) ? input.t("provider.custom.error.context") : undefined + return { id: idError, name: nameError, context: contextError } }) - const modelsValid = discoveryMode || models.every((m) => !m.id && !m.name) - const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }])) + const modelsValid = + (discoveryMode || models.every((m) => !m.id && !m.name)) && models.every((m) => !m.context) + const modelConfig = Object.fromEntries( + input.form.models.map((m) => { + const ctx = m.context.trim() + const spec: CustomModelConfig = { + name: m.name.trim(), + ...(m.reasoning ? { reasoning: true } : {}), + ...(m.temperature ? { temperature: true } : {}), + ...(ctx ? { limit: { context: Number(ctx) } } : {}), + } + return [m.id.trim(), spec] + }), + ) const seenHeaders = new Set() const headers = input.form.headers.map((h) => { @@ -249,9 +290,15 @@ export function validateCustomProvider(input: ValidateArgs) { ...(key ? { apiKey: key } : {}), ...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}), }, - // Discovery mode: persist the opt-in flag and an empty model list (backend refreshes at runtime). + // Edit-of-discovery: keep discovery on (runtime refresh) AND persist the spec overrides — the + // build loop merges these over the discovered models (manual wins per-id). + // New discovery mode: persist the opt-in flag and an empty model list (backend refreshes). // Manual mode: freeze the listed models and leave discovery off. - ...(discoveryMode ? { discovery: true, models: {} } : { models: modelConfig }), + ...(input.editDiscovery + ? { discovery: true, models: modelConfig } + : discoveryMode + ? { discovery: true, models: {} } + : { models: modelConfig }), } return { @@ -262,9 +309,58 @@ export function validateCustomProvider(input: ValidateArgs) { } } +// Build the dialog form state for editing an existing custom provider. Fields (URL/key/headers/name) +// come from the raw config entry; model rows are seeded from the RESOLVED provider so the user sees the +// actual context/reasoning/temperature values (a discovery provider has no models in config — its +// specs only exist post-resolve). Each row is pre-filled so edits override just those fields. +export function formStateFromProvider(input: { + config: ProviderConfig + resolved: ResolvedProvider | undefined +}): FormState { + const { config, resolved } = input + const headers = config.options?.headers + const headerRows = + headers && typeof headers === "object" && Object.keys(headers).length + ? Object.entries(headers as Record).map(([key, value]) => + headerRow2(String(key), String(value)), + ) + : [headerRow()] + + const resolvedModels = resolved?.models ?? {} + const modelRows = Object.entries(resolvedModels).map(([id, m]) => + modelRow({ + id, + name: m.name || id, + context: m.limit?.context ? String(m.limit.context) : "", + reasoning: !!m.capabilities?.reasoning, + temperature: !!m.capabilities?.temperature, + }), + ) + + return { + providerID: resolved?.id ?? config.id ?? "", + name: config.name ?? resolved?.name ?? "", + baseURL: (config.options?.baseURL as string | undefined) ?? "", + apiKey: (config.options?.apiKey as string | undefined) ?? "", + models: modelRows.length ? modelRows : [modelRow()], + headers: headerRows, + err: {}, + } +} + let row = 0 const nextRow = () => `row-${row++}` -export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} }) +export const modelRow = (init?: Partial): ModelRow => ({ + row: nextRow(), + id: "", + name: "", + context: "", + reasoning: false, + temperature: false, + err: {}, + ...init, +}) export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} }) +const headerRow2 = (key: string, value: string): HeaderRow => ({ row: nextRow(), key, value, err: {} }) diff --git a/packages/app/src/components/dialog-custom-provider.test.ts b/packages/app/src/components/dialog-custom-provider.test.ts index 15c8689f..4e0ff95e 100644 --- a/packages/app/src/components/dialog-custom-provider.test.ts +++ b/packages/app/src/components/dialog-custom-provider.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test" -import { deriveProviderIdentity, validateCustomProvider } from "./dialog-custom-provider-form" +import { + deriveProviderIdentity, + formStateFromProvider, + modelRow, + validateCustomProvider, +} from "./dialog-custom-provider-form" const t = (key: string) => key @@ -11,7 +16,7 @@ describe("validateCustomProvider", () => { name: " Custom Provider ", baseURL: "https://api.example.com ", apiKey: " {env: CUSTOM_PROVIDER_KEY} ", - models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }], + models: [modelRow({ id: " model-a ", name: " Model A " })], headers: [ { row: "h0", key: " X-Test ", value: " enabled ", err: {} }, { row: "h1", key: "", value: "", err: {} }, @@ -51,10 +56,7 @@ describe("validateCustomProvider", () => { name: "Provider", baseURL: "https://api.example.com", apiKey: "secret", - models: [ - { row: "m0", id: "model-a", name: "Model A", err: {} }, - { row: "m1", id: "model-a", name: "Model A 2", err: {} }, - ], + models: [modelRow({ id: "model-a", name: "Model A" }), modelRow({ id: "model-a", name: "Model A 2" })], headers: [ { row: "h0", key: "Authorization", value: "one", err: {} }, { row: "h1", key: "authorization", value: "two", err: {} }, @@ -71,6 +73,7 @@ describe("validateCustomProvider", () => { expect(result.models[1]).toEqual({ id: "provider.custom.error.duplicate", name: undefined, + context: undefined, }) expect(result.headers[1]).toEqual({ key: "provider.custom.error.duplicate", @@ -87,7 +90,7 @@ describe("validateCustomProvider", () => { apiKey: "secret", // No manual models: discovery would fill these; validation must not require them here since // the dialog auto-fills discovered models before calling validate. Simulate a filled row. - models: [{ row: "m0", id: "kimi", name: "Kimi", err: {} }], + models: [modelRow({ id: "kimi", name: "Kimi" })], headers: [{ row: "h0", key: "", value: "", err: {} }], err: {}, }, @@ -109,7 +112,7 @@ describe("validateCustomProvider", () => { name: "Claude Relay", baseURL: "https://relay.example.com", apiKey: "secret", - models: [{ row: "m0", id: "claude-x", name: "Claude X", err: {} }], + models: [modelRow({ id: "claude-x", name: "Claude X" })], headers: [{ row: "h0", key: "", value: "", err: {} }], err: {}, }, @@ -129,7 +132,7 @@ describe("validateCustomProvider", () => { name: "Relay", baseURL: "https://relay.example.com", apiKey: "secret", - models: [{ row: "m0", id: "m", name: "M", err: {} }], + models: [modelRow({ id: "m", name: "M" })], headers: [{ row: "h0", key: "", value: "", err: {} }], err: {}, }, @@ -149,7 +152,7 @@ describe("validateCustomProvider", () => { baseURL: "https://api.moonshot.cn/v1", apiKey: "secret", // No manual models: the backend owns the list at runtime. - models: [{ row: "m0", id: "", name: "", err: {} }], + models: [modelRow()], headers: [{ row: "h0", key: "", value: "", err: {} }], err: {}, }, @@ -174,7 +177,7 @@ describe("validateCustomProvider", () => { name: "Relay", baseURL: "https://relay.example.com", apiKey: "secret", - models: [{ row: "m0", id: "m", name: "M", err: {} }], + models: [modelRow({ id: "m", name: "M" })], headers: [{ row: "h0", key: "", value: "", err: {} }], err: {}, }, @@ -194,7 +197,7 @@ describe("validateCustomProvider", () => { name: "Relay", baseURL: "https://relay.example.com", apiKey: "secret", - models: [{ row: "m0", id: "custom-model", name: "Custom Model", err: {} }], + models: [modelRow({ id: "custom-model", name: "Custom Model" })], headers: [{ row: "h0", key: "", value: "", err: {} }], err: {}, }, @@ -208,6 +211,132 @@ describe("validateCustomProvider", () => { expect(result.result?.config.discovery).toBeUndefined() expect(result.result?.config.models).toEqual({ "custom-model": { name: "Custom Model" } }) }) + + test("emits spec overrides only for fields the user set", () => { + const result = validateCustomProvider({ + form: { + providerID: "relay", + name: "Relay", + baseURL: "https://relay.example.com", + apiKey: "secret", + models: [ + modelRow({ id: "with-specs", name: "With Specs", context: "128000", reasoning: true, temperature: true }), + modelRow({ id: "bare", name: "Bare" }), + ], + headers: [{ row: "h0", key: "", value: "", err: {} }], + err: {}, + }, + t, + disabledProviders: [], + existingProviderIDs: new Set(), + }) + + expect(result.result?.config.models).toEqual({ + "with-specs": { name: "With Specs", reasoning: true, temperature: true, limit: { context: 128000 } }, + bare: { name: "Bare" }, + }) + }) + + test("rejects a non-numeric context and produces no result", () => { + const result = validateCustomProvider({ + form: { + providerID: "relay", + name: "Relay", + baseURL: "https://relay.example.com", + apiKey: "secret", + models: [modelRow({ id: "m", name: "M", context: "12x" })], + headers: [{ row: "h0", key: "", value: "", err: {} }], + err: {}, + }, + t, + disabledProviders: [], + existingProviderIDs: new Set(), + }) + + expect(result.result).toBeUndefined() + expect(result.models[0].context).toBe("provider.custom.error.context") + }) + + test("edit mode keeps discovery on and layers spec overrides", () => { + const result = validateCustomProvider({ + form: { + providerID: "relay", + name: "Relay", + baseURL: "https://relay.example.com", + apiKey: "secret", + models: [modelRow({ id: "gpt-4o", name: "GPT-4o", context: "200000" })], + headers: [{ row: "h0", key: "", value: "", err: {} }], + err: {}, + }, + t, + disabledProviders: [], + // Its own id is "already in use" but must not be flagged when editing it. + existingProviderIDs: new Set(["relay"]), + editDiscovery: true, + editingProviderID: "relay", + }) + + expect(result.err.providerID).toBeUndefined() + expect(result.result?.config.discovery).toBe(true) + expect(result.result?.config.models).toEqual({ "gpt-4o": { name: "GPT-4o", limit: { context: 200000 } } }) + }) +}) + +describe("formStateFromProvider", () => { + test("prefills fields from config and specs from the resolved provider", () => { + const form = formStateFromProvider({ + config: { + name: "My Relay", + npm: "@ai-sdk/openai-compatible", + discovery: true, + options: { baseURL: "https://relay.example.com", apiKey: "secret", headers: { "X-Env": "prod" } }, + }, + resolved: { + id: "relay", + name: "My Relay", + source: "custom", + env: [], + options: {}, + models: { + "gpt-4o": { + id: "gpt-4o", + providerID: "relay", + api: { id: "gpt-4o", url: "", npm: "@ai-sdk/openai-compatible" }, + name: "GPT-4o", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 128000, output: 16384 }, + status: "active", + options: {}, + headers: {}, + release_date: "", + }, + }, + }, + }) + + expect(form.providerID).toBe("relay") + expect(form.name).toBe("My Relay") + expect(form.baseURL).toBe("https://relay.example.com") + expect(form.apiKey).toBe("secret") + expect(form.headers[0]).toMatchObject({ key: "X-Env", value: "prod" }) + expect(form.models).toHaveLength(1) + expect(form.models[0]).toMatchObject({ + id: "gpt-4o", + name: "GPT-4o", + context: "128000", + reasoning: false, + temperature: true, + }) + }) }) describe("deriveProviderIdentity", () => { diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 574fc9e3..1b4a0054 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -16,12 +16,14 @@ import { type FormState, type ProviderProtocol, deriveProviderIdentity, + formStateFromProvider, headerRow, modelRow, validateCustomProvider, } from "./dialog-custom-provider-form" import { DialogSelectProvider } from "./dialog-select-provider" import { Select } from "@deepagent-code/ui/select" +import { Switch } from "@deepagent-code/ui/switch" // "auto" keeps the zero-config behavior (probe openai-compatible then anthropic and persist whichever // answered). The explicit choices let the user pin the protocol when auto-detection is wrong or the @@ -32,6 +34,9 @@ const PROTOCOL_CHOICES: ProtocolChoice[] = ["auto", "openai-compatible", "anthro type Props = { back?: "providers" | "close" + // Edit mode: the id of an existing custom provider to load and edit. When set, the dialog prefills + // from config + resolved provider data and persists spec overrides (keeping discovery on if it was). + edit?: string } export function DialogCustomProvider(props: Props) { @@ -40,21 +45,37 @@ export function DialogCustomProvider(props: Props) { const serverSDK = useServerSDK() const language = useLanguage() - const [form, setForm] = createStore({ - providerID: "", - name: "", - baseURL: "", - apiKey: "", - models: [modelRow()], - headers: [headerRow()], - err: {}, - }) + const editConfig = () => (props.edit ? serverSync.data.config.provider?.[props.edit] : undefined) + const editResolved = () => (props.edit ? serverSync.data.provider.all.get(props.edit) : undefined) + // A provider persisted with `discovery: true`: on save we keep discovery on and layer the spec + // overrides, so runtime model refresh survives the edit. + const isEditDiscovery = () => props.edit != null && editConfig()?.discovery === true + + const initialForm = (): FormState => { + const cfg = editConfig() + if (props.edit && cfg) return formStateFromProvider({ config: cfg, resolved: editResolved() }) + return { + providerID: "", + name: "", + baseURL: "", + apiKey: "", + models: [modelRow()], + headers: [headerRow()], + err: {}, + } + } + + const [form, setForm] = createStore(initialForm()) // Advanced section (provider id / display name / headers / manual models) is collapsed by default: - // the zero-config path only needs Base URL + API key. Protocol detected during discovery decides - // which SDK npm gets persisted. - const [showAdvanced, setShowAdvanced] = createSignal(false) - const [detectedProtocol, setDetectedProtocol] = createSignal(undefined) + // the zero-config path only needs Base URL + API key. In edit mode we open it so the user sees the + // model rows they came to edit. Protocol detected during discovery decides which SDK npm persists. + const [showAdvanced, setShowAdvanced] = createSignal(!!props.edit) + // In edit mode, seed the detected protocol from the persisted SDK npm so a save that skips + // re-discovery still writes the correct npm. + const [detectedProtocol, setDetectedProtocol] = createSignal( + editConfig()?.npm === "@ai-sdk/anthropic" ? "anthropic" : props.edit ? "openai-compatible" : undefined, + ) // User's explicit protocol choice. "auto" (default) preserves auto-detection; an explicit choice // pins both the discovery probe kind and the persisted npm. const [protocolChoice, setProtocolChoice] = createSignal("auto") @@ -116,13 +137,18 @@ export function DialogCustomProvider(props: Props) { setForm("err", key, undefined) } - const setModel = (index: number, key: "id" | "name", value: string) => { + const setModel = (index: number, key: "id" | "name" | "context", value: string) => { batch(() => { setForm("models", index, key, value) - setForm("models", index, "err", key, undefined) + const errKey = key === "context" ? "context" : key + setForm("models", index, "err", errKey, undefined) }) } + const setModelBool = (index: number, key: "reasoning" | "temperature", value: boolean) => { + setForm("models", index, key, value) + } + const setHeader = (index: number, key: "key" | "value", value: string) => { batch(() => { setForm("headers", index, key, value) @@ -146,6 +172,8 @@ export function DialogCustomProvider(props: Props) { existingProviderIDs: new Set(serverSync.data.provider.all.keys()), protocol: resolvedProtocol(), discovery, + editDiscovery: isEditDiscovery(), + editingProviderID: props.edit, }) batch(() => { setForm("err", output.err) @@ -203,7 +231,9 @@ export function DialogCustomProvider(props: Props) { // endpoint answers discovery, persist `discovery: true` with an empty model list so the backend // refreshes models on every load instead of freezing this snapshot into config. let discoveryMode = false - if (baseURL && key && !env) { + // Edit mode never re-runs discovery: we're persisting spec overrides for an already-connected + // provider. editDiscovery in the validator keeps `discovery: true` when it was set originally. + if (!props.edit && baseURL && key && !env) { // Model discovery is a convenience, not a requirement: many OpenAI-compatible servers // (some vLLM setups, local runtimes) don't implement GET /v1/models and return 400/404. // Treat discovery as best-effort — probe to detect the protocol (→ SDK npm) and confirm the @@ -212,19 +242,24 @@ export function DialogCustomProvider(props: Props) { // Auto (kind omitted) lets the backend probe openai-compatible then anthropic and report which // protocol answered. An explicit choice pins the probe to that protocol. const choice = protocolChoice() - const res = await serverSDK.client.provider.models - .discover( - { - providerID: discoverID, - baseURL, - apiKey: key, - headers: headerConfig(), - ...(choice === "auto" ? {} : { kind: choice }), - }, - { throwOnError: true }, - ) - .then((res) => res.data) - .catch(() => undefined) + // Defensive client-side timeout: the backend already caps its /models fetch, but guard the + // whole round-trip too so a stalled request can never leave the submit button hung. On + // timeout (or any error) we fall through to manual/validation handling instead of blocking. + const res = await Promise.race([ + serverSDK.client.provider.models + .discover( + { + providerID: discoverID, + baseURL, + apiKey: key, + headers: headerConfig(), + ...(choice === "auto" ? {} : { kind: choice }), + }, + { throwOnError: true }, + ) + .then((res) => res.data), + new Promise((resolve) => setTimeout(() => resolve(undefined), 20_000)), + ]).catch(() => undefined) if (res?.kind) setDetectedProtocol(res.kind) const discovered = res?.models ?? [] if (discovered.length > 0 && !hasManualModels) { @@ -260,7 +295,9 @@ export function DialogCustomProvider(props: Props) {
-
{language.t("provider.custom.title")}
+
+ {language.t(props.edit ? "provider.custom.edit.title" : "provider.custom.title")} +
@@ -272,6 +309,10 @@ export function DialogCustomProvider(props: Props) { {language.t("provider.custom.description.suffix")}

+

+ {language.t("provider.custom.specWarning")} +

+
{language.t("provider.custom.models.autoHint")}

{(m, i) => ( -
-
- setModel(i(), "id", v)} - validationState={m.err.id ? "invalid" : undefined} - error={m.err.id} +
+
+
+ setModel(i(), "id", v)} + validationState={m.err.id ? "invalid" : undefined} + error={m.err.id} + /> +
+
+ setModel(i(), "name", v)} + validationState={m.err.name ? "invalid" : undefined} + error={m.err.name} + /> +
+ removeModel(i())} + disabled={form.models.length <= 1} + aria-label={language.t("provider.custom.models.remove")} />
-
- setModel(i(), "name", v)} - validationState={m.err.name ? "invalid" : undefined} - error={m.err.name} - /> +
+
+ setModel(i(), "context", v)} + validationState={m.err.context ? "invalid" : undefined} + error={m.err.context} + /> +
+ +
- removeModel(i())} - disabled={form.models.length <= 1} - aria-label={language.t("provider.custom.models.remove")} - />
)} diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index e77b5968..000f8a7d 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -91,7 +91,13 @@ export const SettingsProvidersV2: Component = () => { await serverSync .updateConfig({ disabled_providers: next }) - .then(() => { + .then(async () => { + // updateConfig only forks the backend instance rebuild (fire-and-forget), so the provider + // list the immediate refresh reads back can still include the just-disabled provider. Await an + // explicit dispose so the rebuild completes before refreshing — otherwise the row lingers in + // the connected list even though the toast reports success. + await serverSdk.client.global.dispose().catch(() => undefined) + serverSync.refreshProviders() showToast({ variant: "success", icon: "circle-check", @@ -107,7 +113,13 @@ export const SettingsProvidersV2: Component = () => { } const disconnect = async (providerID: string, name: string) => { - if (isConfigCustom(providerID)) { + // Any config-defined third-party provider — including discovery-mode ones whose config has no + // models (isConfigCustom misses those, so before this they fell through to the standard branch, + // which only clears a keystore credential the provider doesn't use and left it in the config → + // it reappeared on every restart). Route them through disableProvider, which writes + // `disabled_providers`; isProviderAllowed then filters the provider out and it stays gone across + // restarts. + if (isCustomProvider(providerID)) { await serverSdk.client.auth.remove({ providerID }).catch(() => undefined) await disableProvider(providerID, name) return @@ -132,7 +144,21 @@ export const SettingsProvidersV2: Component = () => { }) } + // A third-party provider defined via config (including discovery-mode providers whose config has no + // models). These open the custom-provider dialog in edit mode so the user can adjust auto-filled + // specs; official/api/env providers use the standard connect dialog. + const isCustomProvider = (providerID: string) => { + const cfg = serverSync.data.config.provider?.[providerID] + if (!cfg) return false + const resolvedSource = serverSync.data.provider.all.get(providerID)?.source + return resolvedSource === "custom" || isConfigCustom(providerID) + } + const openProviderConfig = (providerID: string) => { + if (isCustomProvider(providerID)) { + dialog.push(() => ) + return + } dialog.push(() => ) } diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 6d0f32c5..8792e665 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -60,6 +60,17 @@ function errors(list: PromiseSettledResult[]) { return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason) } +// A bootstrap query can be cancelled rather than genuinely failing: refreshProviders() both refetches +// and invalidates the same providers query key, so the superseded in-flight fetch rejects with a +// TanStack CancelledError; disposing/rebuilding the backend aborts in-flight requests the same way. +// These are benign races (a newer load replaces this one), not load failures — surfacing them as an +// error toast ("无法加载 …" / cancelled) is a false alarm, so skip the toast for them. +function isCancellation(error: unknown): boolean { + if (!error || typeof error !== "object") return false + const name = (error as { name?: unknown }).name + return name === "CancelledError" || name === "AbortError" +} + const providerRev = new Map() export function clearProviderRev(scope: ServerScope, directory: string) { @@ -314,6 +325,7 @@ export async function bootstrapDirectory(input: { input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))), () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => { + if (isCancellation(err)) return const project = getFilename(input.directory) showToast({ variant: "error", @@ -324,7 +336,7 @@ export async function bootstrapDirectory(input: { ].filter(Boolean) as (() => Promise)[] await waitForPaint() - const slowErrs = errors(await runAll(slow)) + const slowErrs = errors(await runAll(slow)).filter((err) => !isCancellation(err)) if (slowErrs.length > 0) { console.error("Failed to finish bootstrap instance", slowErrs[0]) const project = getFilename(input.directory) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c0847683..ebea8dba 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -203,10 +203,17 @@ export const dict = { "provider.custom.field.protocol.option.auto": "Auto-detect", "provider.custom.field.protocol.option.openai-compatible": "OpenAI-compatible", "provider.custom.field.protocol.option.anthropic": "Anthropic", + "provider.custom.edit.title": "Edit custom provider", + "provider.custom.specWarning": + "Model specs (context window, reasoning) are best-effort defaults auto-filled from a public catalog. If you edit them, we can't guarantee the model still works.", "provider.custom.models.label": "Models", "provider.custom.models.id.label": "ID", "provider.custom.models.id.placeholder": "model-id", "provider.custom.models.name.label": "Name", + "provider.custom.models.context.label": "Context tokens", + "provider.custom.models.context.placeholder": "e.g. 128000", + "provider.custom.models.reasoning.label": "Reasoning", + "provider.custom.models.temperature.label": "Temperature", "provider.custom.models.name.placeholder": "Display Name", "provider.custom.models.remove": "Remove model", "provider.custom.models.add": "Add model", @@ -225,6 +232,7 @@ export const dict = { "provider.custom.error.baseURL.format": "Must start with http:// or https://", "provider.custom.error.required": "Required", "provider.custom.error.duplicate": "Duplicate", + "provider.custom.error.context": "Enter a positive whole number, or leave blank", "provider.disconnect.toast.disconnected.title": "{{provider}} disconnected", "provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index be9a5ccc..9facb0d1 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -220,11 +220,18 @@ export const dict = { "provider.custom.field.apiKey.label": "API 密钥", "provider.custom.field.apiKey.placeholder": "API 密钥", "provider.custom.field.apiKey.description": "可选。如果你通过请求头管理认证,可留空。", + "provider.custom.edit.title": "编辑自定义提供方", + "provider.custom.specWarning": + "模型规格(上下文窗口、推理能力)是根据公共目录自动补全的默认值,仅供参考。如果你修改它们,我们无法保证模型仍能正常使用。", "provider.custom.models.label": "模型", "provider.custom.models.id.label": "ID", "provider.custom.models.id.placeholder": "model-id", "provider.custom.models.name.label": "名称", "provider.custom.models.name.placeholder": "显示名称", + "provider.custom.models.context.label": "上下文 tokens", + "provider.custom.models.context.placeholder": "例如 128000", + "provider.custom.models.reasoning.label": "推理", + "provider.custom.models.temperature.label": "温度", "provider.custom.models.remove": "移除模型", "provider.custom.models.add": "添加模型", "provider.custom.headers.label": "请求头(可选)", @@ -242,6 +249,7 @@ export const dict = { "provider.custom.error.baseURL.format": "必须以 http:// 或 https:// 开头", "provider.custom.error.required": "必填", "provider.custom.error.duplicate": "重复", + "provider.custom.error.context": "请输入正整数,或留空", "provider.disconnect.toast.disconnected.title": "{{provider}} 已断开连接", "provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index eb7fbdb1..608fadcb 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -171,11 +171,18 @@ export const dict = { "provider.custom.field.apiKey.label": "API 金鑰", "provider.custom.field.apiKey.placeholder": "API 金鑰", "provider.custom.field.apiKey.description": "選填。若您透過標頭管理驗證,可留空。", + "provider.custom.edit.title": "編輯自訂提供方", + "provider.custom.specWarning": + "模型規格(上下文視窗、推理能力)是根據公開目錄自動補全的預設值,僅供參考。若您修改它們,我們無法保證模型仍能正常使用。", "provider.custom.models.label": "模型", "provider.custom.models.id.label": "ID", "provider.custom.models.id.placeholder": "model-id", "provider.custom.models.name.label": "名稱", "provider.custom.models.name.placeholder": "顯示名稱", + "provider.custom.models.context.label": "上下文 tokens", + "provider.custom.models.context.placeholder": "例如 128000", + "provider.custom.models.reasoning.label": "推理", + "provider.custom.models.temperature.label": "溫度", "provider.custom.models.remove": "移除模型", "provider.custom.models.add": "新增模型", "provider.custom.headers.label": "標頭(選填)", @@ -193,6 +200,7 @@ export const dict = { "provider.custom.error.baseURL.format": "必須以 http:// 或 https:// 開頭", "provider.custom.error.required": "必填", "provider.custom.error.duplicate": "重複", + "provider.custom.error.context": "請輸入正整數,或留空", "provider.disconnect.toast.disconnected.title": "{{provider}} 已中斷連線", "provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。", diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 417e0217..2e292f73 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -241,7 +241,7 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st limit: info.limit && { context: int(info.limit.context), input: info.limit.input === undefined ? undefined : int(info.limit.input), - output: int(info.limit.output), + output: info.limit.output === undefined ? undefined : int(info.limit.output), }, } } diff --git a/packages/core/src/v1/config/provider.ts b/packages/core/src/v1/config/provider.ts index 396abe78..18920fda 100644 --- a/packages/core/src/v1/config/provider.ts +++ b/packages/core/src/v1/config/provider.ts @@ -42,7 +42,10 @@ export const Model = Schema.Struct({ Schema.Struct({ context: Schema.Finite, input: Schema.optional(Schema.Finite), - output: Schema.Finite, + // Optional: a custom/third-party model override may set only the context window and let the + // catalog/backend fill the output limit (the build loop defaults it). Existing configs that set + // output stay valid. + output: Schema.optional(Schema.Finite), }), ), modalities: Schema.optional( diff --git a/packages/deepagent-code/src/provider/catalog-spec.ts b/packages/deepagent-code/src/provider/catalog-spec.ts new file mode 100644 index 00000000..f10fff86 --- /dev/null +++ b/packages/deepagent-code/src/provider/catalog-spec.ts @@ -0,0 +1,129 @@ +import { OFFICIAL_PROVIDER_ID_SET } from "@deepagent-code/core/provider" +import type { ModelsDev } from "@deepagent-code/core/models-dev" + +// Cross-provider spec-fill for third-party gateways. +// +// A custom/third-party provider's /models endpoint (and hand-listed config models) only give us an id +// and a display name — never context window, reasoning capability, cost, or modalities. But gateways +// almost always forward WELL-KNOWN model ids (gpt-4o, claude-3-5-sonnet, deepseek-chat, glm-4.6, …) +// that already exist in the models.dev catalog — just under a DIFFERENT provider id. This module +// matches a bare model id against the whole catalog so the build loop can fill the missing *capability +// and limit* specs from the canonical catalog entry. +// +// Hard boundary: we only ever fill capability/limit/cost/modality/metadata fields. We never pull +// api.url or api.npm from a cross-provider match — the endpoint and protocol belong to the user's +// gateway, not to the catalog vendor. + +// Normalize a model id so gateway-prefixed / separator-variant spellings collapse to one key: +// "openai/gpt-4o" -> "gpt-4o", "anthropic.claude-3-5-sonnet" -> "claude-3-5-sonnet", +// "GPT-4O" -> "gpt-4o", "deepseek_chat" -> "deepseek-chat". +export function normalizeModelID(id: string): string { + let out = id.trim().toLowerCase() + // Drop a leading vendor prefix separated by "/" (e.g. "openai/gpt-4o", "some-router/claude-3"). + out = out.replace(/^[a-z0-9][a-z0-9-]*\//, "") + // Drop a leading known-vendor prefix separated by "." (e.g. bedrock-style "anthropic.claude-…"). + out = out.replace(/^(openai|anthropic|google|meta|deepseek|mistral|qwen|zhipuai|zai|xai|cohere)[.]/, "") + // Collapse "." and "_" separators to "-" so "glm-4.6" and "glm-4-6" match. + out = out.replace(/[._]/g, "-") + return out +} + +// Strip a trailing date/version stamp so "claude-3-5-sonnet-20241022" can fall back to +// "claude-3-5-sonnet". Only used as a secondary (loose) match after exact-normalized misses. +export function stripDateSuffix(normalized: string): string { + return normalized.replace(/-(?:\d{6,8}|v\d+(?:-\d+)*|latest|preview)$/g, "") +} + +export interface CatalogMatch { + providerID: string + model: ModelsDev.Model +} + +export interface CatalogIndex { + exact: Map + loose: Map +} + +// When the same normalized id exists under multiple catalog providers with different specs, prefer: +// (a) an official provider (the canonical source), +// (b) the largest context window (most permissive — least likely to truncate), +// (c) alphabetical providerID (stable tie-break). +function preferMatch(existing: CatalogMatch, candidate: CatalogMatch): CatalogMatch { + const existingOfficial = OFFICIAL_PROVIDER_ID_SET.has(existing.providerID) + const candidateOfficial = OFFICIAL_PROVIDER_ID_SET.has(candidate.providerID) + if (existingOfficial !== candidateOfficial) return existingOfficial ? existing : candidate + const existingCtx = existing.model.limit?.context ?? 0 + const candidateCtx = candidate.model.limit?.context ?? 0 + if (existingCtx !== candidateCtx) return existingCtx >= candidateCtx ? existing : candidate + return existing.providerID <= candidate.providerID ? existing : candidate +} + +function insert(map: Map, key: string, match: CatalogMatch) { + if (!key) return + const existing = map.get(key) + map.set(key, existing ? preferMatch(existing, match) : match) +} + +// Build the normalized cross-provider index ONCE per provider-state build (avoids O(providers·models) +// re-scans in the per-model loop). Tolerates an empty catalog (offline / fetch disabled) by returning +// empty maps. +export function buildCatalogIndex(catalog: Record): CatalogIndex { + const exact = new Map() + const loose = new Map() + for (const [providerID, provider] of Object.entries(catalog ?? {})) { + for (const model of Object.values(provider.models ?? {})) { + if (!model || typeof model.id !== "string") continue + const normalized = normalizeModelID(model.id) + const match: CatalogMatch = { providerID, model } + insert(exact, normalized, match) + insert(loose, stripDateSuffix(normalized), match) + } + } + return { exact, loose } +} + +// Look up catalog specs for a discovered/custom model. Tries the api id then the config id against the +// exact map, then the date-stripped loose map. Returns the matched catalog model or undefined. +export function catalogSpecFor(apiID: string, modelID: string, index: CatalogIndex): ModelsDev.Model | undefined { + const apiKey = normalizeModelID(apiID) + const idKey = normalizeModelID(modelID) + return ( + index.exact.get(apiKey)?.model ?? + index.exact.get(idKey)?.model ?? + index.loose.get(stripDateSuffix(apiKey))?.model ?? + index.loose.get(stripDateSuffix(idKey))?.model + ) +} + +// Small projection of the fields the discover dialog surfaces so the user can preview (and then +// override) the auto-filled specs. Undefined when there's no catalog match. +export interface ProjectedSpec { + context: number + output: number + reasoning: boolean + temperature: boolean + toolcall: boolean + matchedFrom: string +} + +export function projectSpec(match: CatalogMatch): ProjectedSpec { + return { + context: match.model.limit?.context ?? 0, + output: match.model.limit?.output ?? 0, + reasoning: match.model.reasoning ?? false, + temperature: match.model.temperature ?? false, + toolcall: match.model.tool_call ?? true, + matchedFrom: match.providerID, + } +} + +export function specMatchFor(apiID: string, modelID: string, index: CatalogIndex): CatalogMatch | undefined { + const apiKey = normalizeModelID(apiID) + const idKey = normalizeModelID(modelID) + return ( + index.exact.get(apiKey) ?? + index.exact.get(idKey) ?? + index.loose.get(stripDateSuffix(apiKey)) ?? + index.loose.get(stripDateSuffix(idKey)) + ) +} diff --git a/packages/deepagent-code/src/provider/model-discovery.ts b/packages/deepagent-code/src/provider/model-discovery.ts index d6e58211..a7a7151d 100644 --- a/packages/deepagent-code/src/provider/model-discovery.ts +++ b/packages/deepagent-code/src/provider/model-discovery.ts @@ -66,6 +66,12 @@ export async function discoverWithProtocol( throw lastError instanceof Error ? lastError : new Error("No provider models were returned") } +// Discovery must never hang forever. An unreachable or silent /models endpoint (wrong URL, a host +// that accepts the TCP connection but never responds) would otherwise leave the fetch pending +// indefinitely — the interactive "connect provider" submit awaits this call, so a hang shows up as a +// dead button. Cap the request so it always resolves (with an error the caller can fall back on). +const DISCOVERY_TIMEOUT_MS = 15_000 + export async function discoverProviderModels(input: { baseURL: string // Optional: some gateways authenticate discovery entirely via custom headers (no bearer/x-api-key). @@ -91,7 +97,17 @@ export async function discoverProviderModels(input: { headers["anthropic-version"] ??= "2023-06-01" } - const response = await fetch(listURL(input.baseURL), { headers }) + const response = await fetch(listURL(input.baseURL), { + headers, + signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS), + }).catch((error) => { + // A timeout surfaces as an AbortError/TimeoutError; give a discovery-specific message so the + // interactive flow reports "endpoint didn't respond" instead of a raw abort. + if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) { + throw new Error(`${input.providerID} model discovery timed out after ${DISCOVERY_TIMEOUT_MS}ms`) + } + throw error + }) if (!response.ok) throw new Error(`${input.providerID} model discovery failed: HTTP ${response.status}`) const body = (await response.json()) as { data?: unknown[] } diff --git a/packages/deepagent-code/src/provider/provider.ts b/packages/deepagent-code/src/provider/provider.ts index 336f1ed6..9177c248 100644 --- a/packages/deepagent-code/src/provider/provider.ts +++ b/packages/deepagent-code/src/provider/provider.ts @@ -26,6 +26,7 @@ import { FSUtil } from "@deepagent-code/core/fs-util" import { EffectFlock } from "@deepagent-code/core/util/effect-flock" import { discoverModelsCached } from "./discovery-cache" import type { DiscoveredModel, ProviderDiscoveryKind } from "./model-discovery" +import { buildCatalogIndex, catalogSpecFor } from "./catalog-spec" import { isRecord } from "@/util/record" import { optionalOmitUndefined } from "@deepagent-code/core/schema" import { ProviderTransform } from "./transform" @@ -1307,6 +1308,10 @@ export const layer = Layer.effect( // place the connect dialog's advanced section persists it (never the config file). const officialTransport = (yield* Effect.promise(() => SettingsStore.read())).providers ?? {} const modelsDev = yield* modelsDevSvc.get() + // Cross-provider spec index: lets a discovered/custom model whose id is NOT under its own + // provider (e.g. a gateway forwarding "deepseek-chat") inherit context/reasoning/cost/modality + // specs from the canonical catalog entry. Built once — the per-model loop below only looks up. + const catalogIndex = buildCatalogIndex(modelsDev) const catalog = mapValues(modelsDev, fromModelsDevProvider) const database = mapValues(catalog, toPublicInfo) // "Official" is a fixed, curated set (openai/deepseek/anthropic/zhipuai/xai/google) — NOT the @@ -1501,6 +1506,11 @@ export const layer = Layer.effect( for (const [modelID, model] of Object.entries(modelSource)) { const existingModel = parsed.models[model.id ?? modelID] const apiID = model.id ?? existingModel?.api.id ?? modelID + // Cross-provider catalog spec-fill: only when this id didn't already match its own catalog + // provider (no existingModel). Fills capability/limit/cost/modality/metadata specs from the + // canonical catalog entry so a gateway-forwarded well-known id isn't stuck at zero context / + // reasoning=false. Never used for api.url/api.npm — routing stays the user's gateway. + const catalogModel = existingModel ? undefined : catalogSpecFor(apiID, modelID, catalogIndex) const apiNpm = model.provider?.npm ?? provider.npm ?? @@ -1523,52 +1533,93 @@ export const layer = Layer.effect( name, providerID: ProviderV2.ID.make(providerID), capabilities: { - temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false, + temperature: model.temperature ?? existingModel?.capabilities.temperature ?? catalogModel?.temperature ?? false, reasoning: - model.reasoning ?? (existingModel?.capabilities.reasoning || inferredReasoning(providerID, apiID, modelID)), - attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false, - toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true, + model.reasoning ?? + (existingModel?.capabilities.reasoning || + catalogModel?.reasoning || + inferredReasoning(providerID, apiID, modelID)), + attachment: model.attachment ?? existingModel?.capabilities.attachment ?? catalogModel?.attachment ?? false, + toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? catalogModel?.tool_call ?? true, input: { - text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true, - audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false, - image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false, - video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false, - pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false, + text: + model.modalities?.input?.includes("text") ?? + existingModel?.capabilities.input.text ?? + catalogModel?.modalities?.input?.includes("text") ?? + true, + audio: + model.modalities?.input?.includes("audio") ?? + existingModel?.capabilities.input.audio ?? + catalogModel?.modalities?.input?.includes("audio") ?? + false, + image: + model.modalities?.input?.includes("image") ?? + existingModel?.capabilities.input.image ?? + catalogModel?.modalities?.input?.includes("image") ?? + false, + video: + model.modalities?.input?.includes("video") ?? + existingModel?.capabilities.input.video ?? + catalogModel?.modalities?.input?.includes("video") ?? + false, + pdf: + model.modalities?.input?.includes("pdf") ?? + existingModel?.capabilities.input.pdf ?? + catalogModel?.modalities?.input?.includes("pdf") ?? + false, }, output: { - text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true, + text: + model.modalities?.output?.includes("text") ?? + existingModel?.capabilities.output.text ?? + catalogModel?.modalities?.output?.includes("text") ?? + true, audio: - model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false, + model.modalities?.output?.includes("audio") ?? + existingModel?.capabilities.output.audio ?? + catalogModel?.modalities?.output?.includes("audio") ?? + false, image: - model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false, + model.modalities?.output?.includes("image") ?? + existingModel?.capabilities.output.image ?? + catalogModel?.modalities?.output?.includes("image") ?? + false, video: - model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false, - pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false, + model.modalities?.output?.includes("video") ?? + existingModel?.capabilities.output.video ?? + catalogModel?.modalities?.output?.includes("video") ?? + false, + pdf: + model.modalities?.output?.includes("pdf") ?? + existingModel?.capabilities.output.pdf ?? + catalogModel?.modalities?.output?.includes("pdf") ?? + false, }, interleaved: model.interleaved ?? existingModel?.capabilities.interleaved ?? + catalogModel?.interleaved ?? (!existingModel && apiNpm === "@ai-sdk/openai-compatible" && apiID.includes("deepseek") ? { field: "reasoning_content" } : false), }, cost: { - input: model?.cost?.input ?? existingModel?.cost?.input ?? 0, - output: model?.cost?.output ?? existingModel?.cost?.output ?? 0, + input: model?.cost?.input ?? existingModel?.cost?.input ?? catalogModel?.cost?.input ?? 0, + output: model?.cost?.output ?? existingModel?.cost?.output ?? catalogModel?.cost?.output ?? 0, cache: { - read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0, - write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0, + read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? catalogModel?.cost?.cache_read ?? 0, + write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? catalogModel?.cost?.cache_write ?? 0, }, }, options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}), limit: { - context: model.limit?.context ?? existingModel?.limit?.context ?? 0, - input: model.limit?.input ?? existingModel?.limit?.input, - output: model.limit?.output ?? existingModel?.limit?.output ?? 0, + context: model.limit?.context ?? existingModel?.limit?.context ?? catalogModel?.limit.context ?? 0, + input: model.limit?.input ?? existingModel?.limit?.input ?? catalogModel?.limit.input, + output: model.limit?.output ?? existingModel?.limit?.output ?? catalogModel?.limit.output ?? 0, }, headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}), - family: model.family ?? existingModel?.family ?? "", - release_date: model.release_date ?? existingModel?.release_date ?? "", + family: model.family ?? existingModel?.family ?? catalogModel?.family ?? "", + release_date: model.release_date ?? existingModel?.release_date ?? catalogModel?.release_date ?? "", variants: {}, } const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {}) diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/groups/provider.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/groups/provider.ts index e5275d14..d48dd365 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/groups/provider.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/groups/provider.ts @@ -50,9 +50,22 @@ export const ProviderModelDiscoverInput = Schema.Struct({ headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), }) +// Best-effort specs matched from the models.dev catalog by model id (cross-provider). Present only +// when the discovered id matches a known catalog model; the dialog uses it to pre-fill the editable +// spec fields. Purely additive/optional — clients that ignore it keep working. +export const ProviderDiscoveredModelSpec = Schema.Struct({ + context: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Boolean, + temperature: Schema.Boolean, + toolcall: Schema.Boolean, + matchedFrom: Schema.String, +}) + export const ProviderDiscoveredModel = Schema.Struct({ id: Schema.String, name: Schema.String, + spec: Schema.optional(ProviderDiscoveredModelSpec), }) export const ProviderModelDiscoverResult = Schema.Struct({ diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/provider.ts index c9ca8605..cb16b00e 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/provider.ts @@ -4,6 +4,7 @@ import { Config } from "@/config/config" import { ModelsDev } from "@deepagent-code/core/models-dev" import { Provider } from "@/provider/provider" import { discoverWithProtocol, isChatModel, normalizeBaseURL } from "@/provider/model-discovery" +import { buildCatalogIndex, projectSpec, specMatchFor } from "@/provider/catalog-spec" import { mapValues } from "remeda" import { Effect, Schema } from "effect" @@ -104,7 +105,17 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" }) const models = result.models.filter((model) => isChatModel(model.id)) - const selectable = models.length ? models : result.models + const chatModels = models.length ? models : result.models + + // Attach best-effort catalog specs (context/reasoning/…) matched by model id, cross-provider, so + // the dialog can preview and pre-fill the editable spec fields. Undefined when there's no match. + const catalog = yield* ModelsDev.Service.use((s) => s.get()) + const catalogIndex = buildCatalogIndex(catalog) + const selectable = chatModels.map((model) => { + const match = specMatchFor(model.id, model.id, catalogIndex) + return match ? { ...model, spec: projectSpec(match) } : model + }) + const requested = ctx.payload.modelID?.trim() const selected = requested ? (selectable.find((model) => model.id === requested) ?? { id: requested, name: requested }) diff --git a/packages/deepagent-code/test/provider/catalog-spec.test.ts b/packages/deepagent-code/test/provider/catalog-spec.test.ts new file mode 100644 index 00000000..2501e60d --- /dev/null +++ b/packages/deepagent-code/test/provider/catalog-spec.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test" +import type { ModelsDev } from "@deepagent-code/core/models-dev" +import { + buildCatalogIndex, + catalogSpecFor, + normalizeModelID, + projectSpec, + specMatchFor, + stripDateSuffix, +} from "@/provider/catalog-spec" + +const model = (id: string, over: Partial = {}): ModelsDev.Model => ({ + id, + name: id, + release_date: "", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + limit: { context: 0, output: 0 }, + ...over, +}) + +const catalog = (providers: Record): Record => + Object.fromEntries( + Object.entries(providers).map(([id, models]) => [ + id, + { id, name: id, env: [], models: Object.fromEntries(models.map((m) => [m.id, m])) }, + ]), + ) + +describe("normalizeModelID", () => { + test("lowercases, drops vendor prefixes, collapses separators", () => { + expect(normalizeModelID("openai/GPT-4o")).toBe("gpt-4o") + expect(normalizeModelID("gpt-4o")).toBe("gpt-4o") + expect(normalizeModelID("anthropic.claude-3-5-sonnet")).toBe("claude-3-5-sonnet") + expect(normalizeModelID("glm-4.6")).toBe("glm-4-6") + expect(normalizeModelID("deepseek_chat")).toBe("deepseek-chat") + }) + + test("some-router/ style prefixes are stripped", () => { + expect(normalizeModelID("some-router/claude-3")).toBe("claude-3") + }) +}) + +describe("stripDateSuffix", () => { + test("removes trailing date/version stamps", () => { + expect(stripDateSuffix("claude-3-5-sonnet-20241022")).toBe("claude-3-5-sonnet") + expect(stripDateSuffix("gpt-4o-latest")).toBe("gpt-4o") + expect(stripDateSuffix("model-preview")).toBe("model") + expect(stripDateSuffix("gpt-4o")).toBe("gpt-4o") + }) +}) + +describe("catalogSpecFor", () => { + test("matches a bare id against a different provider (exact-normalized)", () => { + const index = buildCatalogIndex( + catalog({ + openai: [model("gpt-4o", { limit: { context: 128000, output: 16384 }, temperature: true })], + }), + ) + // A gateway forwards "openai/gpt-4o" under its own provider id. + const spec = catalogSpecFor("openai/gpt-4o", "gpt-4o-alias", index) + expect(spec?.limit.context).toBe(128000) + expect(spec?.temperature).toBe(true) + }) + + test("falls back to the date-stripped loose map", () => { + const index = buildCatalogIndex( + catalog({ anthropic: [model("claude-3-5-sonnet", { limit: { context: 200000, output: 8192 }, reasoning: true })] }), + ) + const spec = catalogSpecFor("claude-3-5-sonnet-20241022", "claude-3-5-sonnet-20241022", index) + expect(spec?.limit.context).toBe(200000) + expect(spec?.reasoning).toBe(true) + }) + + test("returns undefined for an unknown id", () => { + const index = buildCatalogIndex(catalog({ openai: [model("gpt-4o")] })) + expect(catalogSpecFor("totally-unknown", "totally-unknown", index)).toBeUndefined() + }) + + test("tolerates an empty catalog", () => { + const index = buildCatalogIndex({}) + expect(catalogSpecFor("gpt-4o", "gpt-4o", index)).toBeUndefined() + }) +}) + +describe("collision disambiguation", () => { + test("prefers an official provider over a third-party one", () => { + const index = buildCatalogIndex( + catalog({ + // aggregator has a bigger context but openai is official → official wins. + aggregator: [model("gpt-4o", { limit: { context: 999999, output: 0 } })], + openai: [model("gpt-4o", { limit: { context: 128000, output: 16384 } })], + }), + ) + const match = specMatchFor("gpt-4o", "gpt-4o", index) + expect(match?.providerID).toBe("openai") + expect(match?.model.limit.context).toBe(128000) + }) + + test("among non-official ties, prefers the largest context", () => { + const index = buildCatalogIndex( + catalog({ + small: [model("llama-3", { limit: { context: 8000, output: 0 } })], + big: [model("llama-3", { limit: { context: 128000, output: 0 } })], + }), + ) + const match = specMatchFor("llama-3", "llama-3", index) + expect(match?.providerID).toBe("big") + }) +}) + +describe("projectSpec", () => { + test("projects the surfaced fields", () => { + const m = model("gpt-4o", { limit: { context: 128000, output: 16384 }, temperature: true, reasoning: true }) + expect(projectSpec({ providerID: "openai", model: m })).toEqual({ + context: 128000, + output: 16384, + reasoning: true, + temperature: true, + toolcall: true, + matchedFrom: "openai", + }) + }) +}) diff --git a/packages/deepagent-code/test/provider/model-discovery.test.ts b/packages/deepagent-code/test/provider/model-discovery.test.ts index 0e3668c5..423ff277 100644 --- a/packages/deepagent-code/test/provider/model-discovery.test.ts +++ b/packages/deepagent-code/test/provider/model-discovery.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, test } from "bun:test" -import { discoverWithProtocol, isChatModel, normalizeBaseURL } from "@/provider/model-discovery" +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { discoverProviderModels, discoverWithProtocol, isChatModel, normalizeBaseURL } from "@/provider/model-discovery" describe("normalizeBaseURL", () => { test("strips query, hash and trailing slashes", () => { @@ -67,3 +67,39 @@ describe("discoverWithProtocol", () => { ).rejects.toThrow("fail-anthropic") }) }) + +describe("discoverProviderModels (real fetch)", () => { + let server: ReturnType | undefined + let baseURL = "" + + beforeAll(() => { + server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url) + // A responsive /models endpoint that answers well under the discovery timeout. + if (url.pathname.endsWith("/models")) { + await Bun.sleep(50) + return Response.json({ data: [{ id: "model-a", display_name: "Model A" }] }) + } + return new Response("not found", { status: 404 }) + }, + }) + baseURL = `http://localhost:${server.port}/v1` + }) + + afterAll(() => server?.stop(true)) + + test("returns models from a responsive endpoint", async () => { + const models = await discoverProviderModels({ baseURL, apiKey: "k", providerID: "relay" }) + expect(models).toEqual([{ id: "model-a", name: "Model A" }]) + }) + + test("rejects (does not hang) when the host is unreachable", async () => { + // Port 1 is a reserved port nothing listens on → connection refused resolves fast, proving the + // call surfaces an error instead of blocking the caller forever. + await expect( + discoverProviderModels({ baseURL: "http://127.0.0.1:1/v1", apiKey: "k", providerID: "dead" }), + ).rejects.toBeDefined() + }) +}) diff --git a/packages/deepagent-code/test/provider/provider.test.ts b/packages/deepagent-code/test/provider/provider.test.ts index af74e78f..2c2c07c6 100644 --- a/packages/deepagent-code/test/provider/provider.test.ts +++ b/packages/deepagent-code/test/provider/provider.test.ts @@ -144,6 +144,18 @@ afterAll(() => { discoveryServer?.stop(true) }) +// Common model ids a third-party gateway typically forwards; the cross-provider spec-fill test lists +// these bare and asserts against whichever the live models.dev catalog knows. +const SPEC_FILL_CANDIDATES = [ + "gpt-4o", + "gpt-4o-mini", + "claude-3-5-sonnet", + "claude-3-5-haiku", + "deepseek-chat", + "deepseek-reasoner", + "gemini-1.5-pro", +] + const alphaProviderConfig = { provider: { "custom-provider": { @@ -257,6 +269,41 @@ it.instance( }, ) +it.instance( + "third-party model with a bare well-known id inherits specs from the models.dev catalog", + Effect.gen(function* () { + // The custom provider below lists a set of common well-known model ids bare (name only) under its + // own third-party id. Config gives them no limit/reasoning, and none of these ids belong to this + // gateway's own catalog entry (it has none). So a non-zero context on the loaded model can only + // come from the cross-provider models.dev spec-fill. Assert against whichever candidate the live + // catalog knows, so the test tracks catalog data instead of pinning a single id that could churn. + const providers = yield* list + const provider = providers[ProviderV2.ID.make("spec-fill-gw")] + expect(provider).toBeDefined() + + const filled = SPEC_FILL_CANDIDATES.map((id) => provider.models[id]).find((m) => m && m.limit.context > 0) + if (!filled) return // Offline / catalog lacks all candidates: nothing deterministic to assert. + + // A bare config model with no catalog match would be stuck at context 0; a positive context proves + // the cross-provider fill ran. + expect(filled.limit.context).toBeGreaterThan(0) + }), + { + config: () => ({ + provider: { + "spec-fill-gw": { + name: "Spec Fill Gateway", + npm: "@ai-sdk/openai-compatible", + api: "https://gw.example.com/v1", + options: { apiKey: "k", baseURL: "https://gw.example.com/v1" }, + // Bare ids, name only — no limit/reasoning. Fill must come from the catalog. + models: Object.fromEntries(SPEC_FILL_CANDIDATES.map((id) => [id, { name: id }])), + }, + }, + }), + }, +) + it.instance( "discovery provider that yields no models surfaces a config error instead of vanishing silently", Effect.gen(function* () { diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index c1ebf4c0..19bd6ce5 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1900,7 +1900,7 @@ export type ProviderConfig = { limit?: { context: number input?: number - output: number + output?: number } modalities?: { input?: Array<"text" | "audio" | "image" | "video" | "pdf"> @@ -11693,10 +11693,26 @@ export type ProviderModelsDiscoverResponses = { models: Array<{ id: string name: string + spec?: { + context: number + output: number + reasoning: boolean + temperature: boolean + toolcall: boolean + matchedFrom: string + } }> selected: { id: string name: string + spec?: { + context: number + output: number + reasoning: boolean + temperature: boolean + toolcall: boolean + matchedFrom: string + } } } } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c1ebf4c0..19bd6ce5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1900,7 +1900,7 @@ export type ProviderConfig = { limit?: { context: number input?: number - output: number + output?: number } modalities?: { input?: Array<"text" | "audio" | "image" | "video" | "pdf"> @@ -11693,10 +11693,26 @@ export type ProviderModelsDiscoverResponses = { models: Array<{ id: string name: string + spec?: { + context: number + output: number + reasoning: boolean + temperature: boolean + toolcall: boolean + matchedFrom: string + } }> selected: { id: string name: string + spec?: { + context: number + output: number + reasoning: boolean + temperature: boolean + toolcall: boolean + matchedFrom: string + } } } } diff --git a/packages/ui/src/components/session-retry.tsx b/packages/ui/src/components/session-retry.tsx index 203b5cb7..d7340637 100644 --- a/packages/ui/src/components/session-retry.tsx +++ b/packages/ui/src/components/session-retry.tsx @@ -31,6 +31,12 @@ export function SessionRetry(props: { status: SessionStatus; show?: boolean }) { if (current.message.includes("exceeded your current quota") && current.message.includes("gemini")) { return i18n.t("ui.sessionTurn.retry.geminiHot") } + // Third-party gateway (distributor) reports the model in its /models list but has no live route + // for it under the selected group. Retrying won't help — the user should pick another model or + // fix the gateway's group config. Surface a clear hint instead of the raw upstream error. + if (/no available channel|no channel available/i.test(current.message)) { + return i18n.t("ui.sessionTurn.retry.noChannel") + } if (current.message.length > 80) return current.message.slice(0, 80) + "..." return current.message }) diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index bc04d45a..f6ce9cbf 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -43,6 +43,8 @@ export const dict = { "ui.sessionTurn.retry.attempt": "المحاولة رقم {{attempt}}", "ui.sessionTurn.retry.attemptLine": "{{line}} - المحاولة رقم {{attempt}}", "ui.sessionTurn.retry.geminiHot": "gemini مزدحم حاليا", + "ui.sessionTurn.retry.noChannel": + "the gateway lists this model but has no available channel for it — pick another model or fix the gateway's group config", "ui.sessionTurn.error.freeUsageExceeded": "تم تجاوز حد الاستخدام المجاني", "ui.sessionTurn.error.addCredits": "إضافة رصيد", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index c84a1fcb..71983a39 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -43,6 +43,8 @@ export const dict = { "ui.sessionTurn.retry.attempt": "tentativa #{{attempt}}", "ui.sessionTurn.retry.attemptLine": "{{line}} - tentativa #{{attempt}}", "ui.sessionTurn.retry.geminiHot": "gemini está muito sobrecarregado agora", + "ui.sessionTurn.retry.noChannel": + "the gateway lists this model but has no available channel for it — pick another model or fix the gateway's group config", "ui.sessionTurn.error.freeUsageExceeded": "Limite de uso gratuito excedido", "ui.sessionTurn.error.addCredits": "Adicionar créditos", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 0a214b39..6adbd601 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -47,6 +47,8 @@ export const dict = { "ui.sessionTurn.retry.attempt": "pokušaj #{{attempt}}", "ui.sessionTurn.retry.attemptLine": "{{line}} - pokušaj #{{attempt}}", "ui.sessionTurn.retry.geminiHot": "gemini je trenutno preopterećen", + "ui.sessionTurn.retry.noChannel": + "the gateway lists this model but has no available channel for it — pick another model or fix the gateway's group config", "ui.sessionTurn.error.freeUsageExceeded": "Besplatna upotreba premašena", "ui.sessionTurn.error.addCredits": "Dodaj kredite", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index da1a305a..95d17d00 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -42,6 +42,8 @@ export const dict = { "ui.sessionTurn.retry.attempt": "forsøg #{{attempt}}", "ui.sessionTurn.retry.attemptLine": "{{line}} - forsøg #{{attempt}}", "ui.sessionTurn.retry.geminiHot": "gemini er meget overbelastet lige nu", + "ui.sessionTurn.retry.noChannel": + "the gateway lists this model but has no available channel for it — pick another model or fix the gateway's group config", "ui.sessionTurn.error.freeUsageExceeded": "Gratis forbrug overskredet", "ui.sessionTurn.error.addCredits": "Tilføj kreditter", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 15f6792a..9f844297 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -48,6 +48,8 @@ export const dict = { "ui.sessionTurn.retry.attempt": "Versuch #{{attempt}}", "ui.sessionTurn.retry.attemptLine": "{{line}} - Versuch #{{attempt}}", "ui.sessionTurn.retry.geminiHot": "gemini ist gerade sehr überlastet", + "ui.sessionTurn.retry.noChannel": + "the gateway lists this model but has no available channel for it — pick another model or fix the gateway's group config", "ui.sessionTurn.error.freeUsageExceeded": "Kostenloses Nutzungslimit überschritten", "ui.sessionTurn.error.addCredits": "Guthaben aufladen", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 1fe020eb..77574395 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -50,6 +50,8 @@ export const dict: Record = { "ui.sessionTurn.retry.attempt": "attempt #{{attempt}}", "ui.sessionTurn.retry.attemptLine": "{{line}} - attempt #{{attempt}}", "ui.sessionTurn.retry.geminiHot": "gemini is way too hot right now", + "ui.sessionTurn.retry.noChannel": + "the gateway lists this model but has no available channel for it — pick another model or fix the gateway's group config", "ui.sessionTurn.error.freeUsageExceeded": "Free usage exceeded", "ui.sessionTurn.error.addCredits": "Add credits", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index a19cc380..951f8eec 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -43,6 +43,8 @@ export const dict = { "ui.sessionTurn.retry.attempt": "intento #{{attempt}}", "ui.sessionTurn.retry.attemptLine": "{{line}} - intento #{{attempt}}", "ui.sessionTurn.retry.geminiHot": "gemini está demasiado saturado", + "ui.sessionTurn.retry.noChannel": + "the gateway lists this model but has no available channel for it — pick another model or fix the gateway's group config", "ui.sessionTurn.error.freeUsageExceeded": "Límite de uso gratuito excedido", "ui.sessionTurn.error.addCredits": "Añadir créditos", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index f57f63c7..48bd29b7 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -43,6 +43,8 @@ export const dict = { "ui.sessionTurn.retry.attempt": "tentative n°{{attempt}}", "ui.sessionTurn.retry.attemptLine": "{{line}} - tentative n°{{attempt}}", "ui.sessionTurn.retry.geminiHot": "gemini est en surchauffe", + "ui.sessionTurn.retry.noChannel": + "the gateway lists this model but has no available channel for it — pick another model or fix the gateway's group config", "ui.sessionTurn.error.freeUsageExceeded": "Limite d'utilisation gratuite dépassée", "ui.sessionTurn.error.addCredits": "Ajouter des crédits", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 5aba73be..5ed2653e 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -42,6 +42,8 @@ export const dict = { "ui.sessionTurn.retry.attempt": "{{attempt}}回目", "ui.sessionTurn.retry.attemptLine": "{{line}} - {{attempt}}回目", "ui.sessionTurn.retry.geminiHot": "gemini が混雑しています", + "ui.sessionTurn.retry.noChannel": + "the gateway lists this model but has no available channel for it — pick another model or fix the gateway's group config", "ui.sessionTurn.error.freeUsageExceeded": "無料使用制限に達しました", "ui.sessionTurn.error.addCredits": "クレジットを追加", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index 2a6f3a05..31d9fb8a 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -43,6 +43,8 @@ export const dict = { "ui.sessionTurn.retry.attempt": "{{attempt}}번째", "ui.sessionTurn.retry.attemptLine": "{{line}} - {{attempt}}번째", "ui.sessionTurn.retry.geminiHot": "gemini가 현재 과부하 상태입니다", + "ui.sessionTurn.retry.noChannel": + "the gateway lists this model but has no available channel for it — pick another model or fix the gateway's group config", "ui.sessionTurn.error.freeUsageExceeded": "무료 사용량 초과", "ui.sessionTurn.error.addCredits": "크레딧 추가", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index fd5c808d..e913fcd5 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -46,6 +46,8 @@ export const dict: Record = { "ui.sessionTurn.retry.attempt": "forsøk #{{attempt}}", "ui.sessionTurn.retry.attemptLine": "{{line}} - forsøk #{{attempt}}", "ui.sessionTurn.retry.geminiHot": "gemini er veldig overbelastet nå", + "ui.sessionTurn.retry.noChannel": + "the gateway lists this model but has no available channel for it — pick another model or fix the gateway's group config", "ui.sessionTurn.error.freeUsageExceeded": "Gratis bruk overskredet", "ui.sessionTurn.error.addCredits": "Legg til kreditt", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index bff87ed0..de122776 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -42,6 +42,8 @@ export const dict = { "ui.sessionTurn.retry.attempt": "próba #{{attempt}}", "ui.sessionTurn.retry.attemptLine": "{{line}} - próba #{{attempt}}", "ui.sessionTurn.retry.geminiHot": "gemini jest teraz mocno przeciążony", + "ui.sessionTurn.retry.noChannel": + "the gateway lists this model but has no available channel for it — pick another model or fix the gateway's group config", "ui.sessionTurn.error.freeUsageExceeded": "Przekroczono limit darmowego użytkowania", "ui.sessionTurn.error.addCredits": "Dodaj kredyty", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index f01e2e75..4ada2082 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -42,6 +42,8 @@ export const dict = { "ui.sessionTurn.retry.attempt": "попытка №{{attempt}}", "ui.sessionTurn.retry.attemptLine": "{{line}} - попытка №{{attempt}}", "ui.sessionTurn.retry.geminiHot": "gemini сейчас перегружен", + "ui.sessionTurn.retry.noChannel": + "the gateway lists this model but has no available channel for it — pick another model or fix the gateway's group config", "ui.sessionTurn.error.freeUsageExceeded": "Лимит бесплатного использования превышен", "ui.sessionTurn.error.addCredits": "Добавить кредиты", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 6ff719d6..b2d448b2 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -44,6 +44,8 @@ export const dict = { "ui.sessionTurn.retry.attempt": "ครั้งที่ {{attempt}}", "ui.sessionTurn.retry.attemptLine": "{{line}} - ครั้งที่ {{attempt}}", "ui.sessionTurn.retry.geminiHot": "gemini กำลังใช้งานหนาแน่นมาก", + "ui.sessionTurn.retry.noChannel": + "the gateway lists this model but has no available channel for it — pick another model or fix the gateway's group config", "ui.sessionTurn.error.freeUsageExceeded": "เกินขีดจำกัดการใช้งานฟรี", "ui.sessionTurn.error.addCredits": "เพิ่มเครดิต", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index dcade07f..41e63aa4 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -49,6 +49,8 @@ export const dict = { "ui.sessionTurn.retry.attempt": "deneme #{{attempt}}", "ui.sessionTurn.retry.attemptLine": "{{line}} - deneme #{{attempt}}", "ui.sessionTurn.retry.geminiHot": "gemini şu anda aşırı yoğun", + "ui.sessionTurn.retry.noChannel": + "the gateway lists this model but has no available channel for it — pick another model or fix the gateway's group config", "ui.sessionTurn.error.freeUsageExceeded": "Ücretsiz kullanım aşıldı", "ui.sessionTurn.error.addCredits": "Kredi ekle", diff --git a/packages/ui/src/i18n/uk.ts b/packages/ui/src/i18n/uk.ts index ff3e84e2..50684d61 100644 --- a/packages/ui/src/i18n/uk.ts +++ b/packages/ui/src/i18n/uk.ts @@ -50,6 +50,8 @@ export const dict: Record = { "ui.sessionTurn.retry.attempt": "спроба №{{attempt}}", "ui.sessionTurn.retry.attemptLine": "{{line}} — спроба №{{attempt}}", "ui.sessionTurn.retry.geminiHot": "gemini зараз перевантажений", + "ui.sessionTurn.retry.noChannel": + "the gateway lists this model but has no available channel for it — pick another model or fix the gateway's group config", "ui.sessionTurn.error.freeUsageExceeded": "Перевищено ліміт безкоштовного використання", "ui.sessionTurn.error.addCredits": "Додати кредити", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index b0ad85ad..e9ac6fd3 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -47,6 +47,7 @@ export const dict = { "ui.sessionTurn.retry.attempt": "第 {{attempt}} 次", "ui.sessionTurn.retry.attemptLine": "{{line}} - 第 {{attempt}} 次", "ui.sessionTurn.retry.geminiHot": "gemini 当前过载", + "ui.sessionTurn.retry.noChannel": "网关列出了该模型,但当前分组没有可用渠道——请换一个模型,或修正网关的分组配置", "ui.sessionTurn.error.freeUsageExceeded": "免费使用额度已用完", "ui.sessionTurn.error.addCredits": "添加积分", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 4c53fcb5..af9be2ad 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -47,6 +47,7 @@ export const dict = { "ui.sessionTurn.retry.attempt": "第 {{attempt}} 次", "ui.sessionTurn.retry.attemptLine": "{{line}} - 第 {{attempt}} 次", "ui.sessionTurn.retry.geminiHot": "gemini 目前過載", + "ui.sessionTurn.retry.noChannel": "閘道列出了該模型,但目前分組沒有可用通道——請換一個模型,或修正閘道的分組設定", "ui.sessionTurn.error.freeUsageExceeded": "免費使用額度已用完", "ui.sessionTurn.error.addCredits": "新增點數",