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": "新增點數",