Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 38 additions & 23 deletions packages/app/src/components/dialog-custom-provider-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ export type CustomProviderConfig = {

const npmForProtocol = (kind: ProviderProtocol | undefined) => (kind === "anthropic" ? ANTHROPIC : OPENAI_COMPATIBLE)

export function isAmbiguousProtocolError(error: unknown) {
const message =
error instanceof Error
? error.message
: error && typeof error === "object" && "message" in error && typeof error.message === "string"
? error.message
: ""
return message.includes("Provider protocol is ambiguous")
}

// Leading host labels that are generic service prefixes and make a poor provider id, so we skip past
// them to reach the brand label (api.deepseek.com -> "deepseek", not "api"). Kept deliberately small:
// only unambiguous service prefixes, never anything that could be a brand.
Expand Down Expand Up @@ -183,11 +193,14 @@ export function validateCustomProvider(input: ValidateArgs) {
// Zero-config path: when the user leaves id/name blank we derive them from the URL, so those
// fields are no longer required. Derivation needs a usable URL — if the URL itself is invalid we
// skip it and let urlError drive the failure instead of emitting a spurious id/name error.
const derived = !urlError && (!typedID || !typedName) ? deriveProviderIdentity({
baseURL,
existingProviderIDs: input.existingProviderIDs,
disabledProviders: input.disabledProviders,
}) : undefined
const derived =
!urlError && (!typedID || !typedName)
? deriveProviderIdentity({
baseURL,
existingProviderIDs: input.existingProviderIDs,
disabledProviders: input.disabledProviders,
})
: undefined
const providerID = typedID || derived?.providerID || ""
const name = typedName || derived?.name || ""

Expand Down Expand Up @@ -232,8 +245,7 @@ export function validateCustomProvider(input: ValidateArgs) {
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)) && models.every((m) => !m.context)
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()
Expand Down Expand Up @@ -309,10 +321,9 @@ 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.
// Discovery results are runtime state, not durable config. In discovery mode only configured model
// overrides become editable rows; otherwise merely opening and saving the dialog would snapshot every
// cached model into config and keep revoked/removed models alive indefinitely.
export function formStateFromProvider(input: {
config: ProviderConfig
resolved: ResolvedProvider | undefined
Expand All @@ -321,20 +332,24 @@ export function formStateFromProvider(input: {
const headers = config.options?.headers
const headerRows =
headers && typeof headers === "object" && Object.keys(headers).length
? Object.entries(headers as Record<string, string>).map(([key, value]) =>
headerRow2(String(key), String(value)),
)
? Object.entries(headers as Record<string, string>).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,
}),
const modelRows = Object.entries(config.discovery ? (config.models ?? {}) : (resolved?.models ?? {})).map(
([id, configured]) => {
const model = resolved?.models[id]
return modelRow({
id,
name: configured.name || model?.name || id,
context: configured.limit?.context
? String(configured.limit.context)
: model?.limit?.context
? String(model.limit.context)
: "",
reasoning: configured.reasoning ?? !!model?.capabilities?.reasoning,
temperature: configured.temperature ?? !!model?.capabilities?.temperature,
})
},
)

return {
Expand Down
53 changes: 53 additions & 0 deletions packages/app/src/components/dialog-custom-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,20 @@ import { describe, expect, test } from "bun:test"
import {
deriveProviderIdentity,
formStateFromProvider,
isAmbiguousProtocolError,
modelRow,
validateCustomProvider,
} from "./dialog-custom-provider-form"

const t = (key: string) => key

test("recognizes protocol ambiguity from SDK and Error response shapes", () => {
const message = "Provider protocol is ambiguous; select OpenAI-compatible or Anthropic explicitly"
expect(isAmbiguousProtocolError(new Error(message))).toBe(true)
expect(isAmbiguousProtocolError({ message })).toBe(true)
expect(isAmbiguousProtocolError(new Error("HTTP 404"))).toBe(false)
})

describe("validateCustomProvider", () => {
test("builds trimmed config payload", () => {
const result = validateCustomProvider({
Expand Down Expand Up @@ -290,6 +298,7 @@ describe("formStateFromProvider", () => {
npm: "@ai-sdk/openai-compatible",
discovery: true,
options: { baseURL: "https://relay.example.com", apiKey: "secret", headers: { "X-Env": "prod" } },
models: { "gpt-4o": { name: "GPT-4o" } },
},
resolved: {
id: "relay",
Expand Down Expand Up @@ -337,6 +346,50 @@ describe("formStateFromProvider", () => {
temperature: true,
})
})

test("does not turn discovered runtime models into durable overrides", () => {
const form = formStateFromProvider({
config: {
name: "My Relay",
npm: "@ai-sdk/openai-compatible",
discovery: true,
options: { baseURL: "https://relay.example.com", apiKey: "secret" },
},
resolved: {
id: "relay",
name: "My Relay",
source: "custom",
env: [],
options: {},
models: {
stale: {
id: "stale",
providerID: "relay",
api: { id: "stale", url: "", npm: "@ai-sdk/openai-compatible" },
name: "Stale",
capabilities: {
temperature: false,
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: 0, output: 0 },
status: "active",
options: {},
headers: {},
release_date: "",
},
},
},
})

expect(form.models).toHaveLength(1)
expect(form.models[0]).toMatchObject({ id: "", name: "" })
})
})

describe("deriveProviderIdentity", () => {
Expand Down
20 changes: 17 additions & 3 deletions packages/app/src/components/dialog-custom-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
deriveProviderIdentity,
formStateFromProvider,
headerRow,
isAmbiguousProtocolError,
modelRow,
validateCustomProvider,
} from "./dialog-custom-provider-form"
Expand Down Expand Up @@ -244,8 +245,10 @@ export function DialogCustomProvider(props: Props) {
const choice = protocolChoice()
// 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([
// A timeout or ordinary discovery failure falls through to manual validation. Protocol
// ambiguity is different: continuing would either guess the wrong SDK or fail with no useful
// feedback, so require an explicit protocol choice.
const discovery = await Promise.race([
serverSDK.client.provider.models
.discover(
{
Expand All @@ -259,7 +262,18 @@ export function DialogCustomProvider(props: Props) {
)
.then((res) => res.data),
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 20_000)),
]).catch(() => undefined)
]).then(
(data) => ({ data, error: undefined }),
(error: unknown) => ({ data: undefined, error }),
)
if (choice === "auto" && isAmbiguousProtocolError(discovery.error)) {
showToast({
title: language.t("common.requestFailed"),
description: language.t("provider.custom.error.protocol.ambiguous"),
})
return
}
const res = discovery.data
if (res?.kind) setDetectedProtocol(res.kind)
const discovered = res?.models ?? []
if (discovered.length > 0 && !hasManualModels) {
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/components/provider-model-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ describe("provider model refresh", () => {
test("allows official, discovery, and legacy imported providers", () => {
expect(canRefreshProviderModels("openai", undefined)).toBe(true)
expect(canRefreshProviderModels("custom", { discovery: true })).toBe(true)
expect(
canRefreshProviderModels("grouped", {
groups: { anthropic: { npm: "@ai-sdk/anthropic", discovery: true } },
}),
).toBe(true)
expect(
canRefreshProviderModels("mistral", {
options: { baseURL: "https://api.mistral.ai/v1" },
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/provider-model-refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ProviderConfig } from "@deepagent-code/sdk/v2"
export function canRefreshProviderModels(providerID: string, config: ProviderConfig | undefined) {
if (isOfficialProvider(providerID)) return true
if (config?.discovery === true) return true
if (Object.values(config?.groups ?? {}).some((group) => group.discovery === true)) return true
return (
config?.npm === undefined &&
typeof config?.options?.baseURL === "string" &&
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,8 @@ export const dict = {
"provider.custom.error.required": "Required",
"provider.custom.error.duplicate": "Duplicate",
"provider.custom.error.context": "Enter a positive whole number, or leave blank",
"provider.custom.error.protocol.ambiguous":
"This endpoint supports multiple authentication styles. Select OpenAI-compatible or Anthropic explicitly.",

"provider.disconnect.toast.disconnected.title": "{{provider}} disconnected",
"provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.",
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,8 @@ export const dict = {
"provider.custom.error.required": "必填",
"provider.custom.error.duplicate": "重复",
"provider.custom.error.context": "请输入正整数,或留空",
"provider.custom.error.protocol.ambiguous":
"该端点同时支持多种认证方式,无法安全地自动判断协议。请明确选择 OpenAI-compatible 或 Anthropic。",

"provider.disconnect.toast.disconnected.title": "{{provider}} 已断开连接",
"provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。",
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/zht.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ export const dict = {
"provider.custom.error.required": "必填",
"provider.custom.error.duplicate": "重複",
"provider.custom.error.context": "請輸入正整數,或留空",
"provider.custom.error.protocol.ambiguous":
"此端點同時支援多種驗證方式,無法安全地自動判斷協定。請明確選擇 OpenAI-compatible 或 Anthropic。",

"provider.disconnect.toast.disconnected.title": "{{provider}} 已中斷連線",
"provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/deepagent/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ export const buildOrchestrationSection = (mode: AgentMode): string | null => {
"抑制信号(命中则本体做,禁止过度编排):单文件;机制已明确;纯机械改动(改名/typo/格式);用户要求快速/直接。",
"",
"关键判定(reviewer 的 verdict、研究结果的合并)走结构化结果:调 `task` 时传 `output_schema`(reviewer→ReviewResult,researcher→ResearchResult),不要依赖散文解析。",
`扇出规模自控(宽松上限,非硬性):单次编排子 agent 总数控制在 ${DEFAULT_MAX_FANOUT} 个以内,单轮并行不超过 ${DEFAULT_MAX_CONCURRENCY} 个;确有必要可分多轮,但不要一次性发起远超此规模的 task。本轮的具体扇出建议数见对话末尾 <deepagent-round-context>。`,
`扇出规模自控(宽松上限,非硬性):单次编排子 agent 总数控制在 ${DEFAULT_MAX_FANOUT} 个以内,单轮并行不超过 ${DEFAULT_MAX_CONCURRENCY} 个;确有必要可分多轮,但不要一次性发起远超此规模的 task。本轮的具体扇出建议数由系统通过 <deepagent-round-context> 提供。`,
]
if (mode === "ultra") {
lines.push("当前为 ultra:默认倾向编排并可多轮迭代。")
Expand Down
Loading
Loading