Skip to content

Commit 259dd85

Browse files
authored
Merge pull request #68 from deepagent-ltd/dev
v4.0.1
2 parents 7294546 + 746e73e commit 259dd85

74 files changed

Lines changed: 10990 additions & 1018 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ tmp
1313
dist
1414
ts-dist
1515
.turbo
16+
# Web/edge build artifacts (nitro .output, cloudflare .wrangler) — e.g. packages/console, packages/stats
17+
.output
18+
.wrangler
19+
packages/console/app/public/sitemap.xml
1620
**/.serena
1721
.serena/
1822
**/.omo

README.md

Lines changed: 99 additions & 68 deletions
Large diffs are not rendered by default.

README.zh.md

Lines changed: 99 additions & 68 deletions
Large diffs are not rendered by default.

design/README.md

Lines changed: 228 additions & 82 deletions
Large diffs are not rendered by default.

packages/app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@deepagent-code/app",
3-
"version": "1.4.0",
3+
"version": "1.4.1",
44
"description": "",
55
"type": "module",
66
"exports": {

packages/app/src/components/dialog-custom-provider-form.ts

Lines changed: 136 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
1+
import { isOfficialProvider } from "@deepagent-code/core/provider-official"
2+
13
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
24
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
5+
const ANTHROPIC = "@ai-sdk/anthropic"
6+
7+
export type ProviderProtocol = "openai-compatible" | "anthropic"
8+
9+
// The config payload written under `provider.<id>`. `discovery` and `models` are mutually exclusive
10+
// in practice (discovery mode emits an empty models map), but both are typed optional so the emitted
11+
// object has one consistent shape instead of a union callers must narrow.
12+
export type CustomProviderConfig = {
13+
npm: string
14+
name: string
15+
env?: string[]
16+
options: {
17+
baseURL: string
18+
apiKey?: string
19+
headers?: Record<string, string>
20+
}
21+
discovery?: boolean
22+
models: Record<string, { name: string }>
23+
}
24+
25+
const npmForProtocol = (kind: ProviderProtocol | undefined) => (kind === "anthropic" ? ANTHROPIC : OPENAI_COMPATIBLE)
26+
27+
// Leading host labels that are generic service prefixes and make a poor provider id, so we skip past
28+
// them to reach the brand label (api.deepseek.com -> "deepseek", not "api"). Kept deliberately small:
29+
// only unambiguous service prefixes, never anything that could be a brand.
30+
const GENERIC_HOST_LABELS = new Set(["api", "www", "app", "gateway", "proxy", "open"])
331

432
type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
533

@@ -46,29 +74,108 @@ type ValidateArgs = {
4674
t: Translator
4775
disabledProviders: string[]
4876
existingProviderIDs: Set<string>
77+
// Protocol detected during model discovery; decides the SDK npm written to config. Defaults to
78+
// openai-compatible when omitted (backward compatible with the manual form).
79+
protocol?: ProviderProtocol
80+
// Runtime discovery mode: when true AND the user listed no manual models, persist `discovery: true`
81+
// with an empty model list so the backend refreshes models from the provider's /models endpoint on
82+
// every load instead of freezing them into config. Manual models always take precedence and turn
83+
// this off for that provider.
84+
discovery?: boolean
85+
}
86+
87+
// Turn a base URL into a stable, unique provider id + a human display name so the user only has to
88+
// enter URL + key. Rules:
89+
// - id is derived from the registrable host label (api.deepseek.com -> "deepseek",
90+
// open.bigmodel.cn -> "bigmodel"), slugified to satisfy PROVIDER_ID.
91+
// - reserved official ids (openai/deepseek/anthropic/zhipuai/xai/google/...) and any id already in
92+
// use are avoided by appending a numeric suffix, since a third-party id that collides with an
93+
// official one is rejected by the backend (THIRD_PARTY_PROVIDER_CONFLICT).
94+
// - `disabledProviders` do NOT count as taken: re-adding a previously disabled provider should be
95+
// able to reuse its id.
96+
export function deriveProviderIdentity(input: {
97+
baseURL: string
98+
existingProviderIDs: Set<string>
99+
disabledProviders?: string[]
100+
}): { providerID: string; name: string } {
101+
const disabled = new Set(input.disabledProviders ?? [])
102+
const taken = (id: string) =>
103+
(input.existingProviderIDs.has(id) && !disabled.has(id)) || (isOfficialProvider(id) && !disabled.has(id))
104+
105+
const base = baseSlug(input.baseURL)
106+
let providerID = base
107+
let n = 2
108+
while (taken(providerID)) {
109+
providerID = `${base}-${n}`
110+
n++
111+
}
112+
return { providerID, name: displayName(base) }
113+
}
114+
115+
function baseSlug(baseURL: string): string {
116+
let host = ""
117+
try {
118+
host = new URL(baseURL.trim()).hostname
119+
} catch {
120+
host = ""
121+
}
122+
const labels = host.split(".").filter(Boolean)
123+
// Drop leading generic service labels (api., www., ...) so we land on the brand label.
124+
while (labels.length > 1 && GENERIC_HOST_LABELS.has(labels[0].toLowerCase())) labels.shift()
125+
// Prefer the registrable label: for a.b.com pick "b"; for single-label/localhost keep as-is.
126+
const label = labels.length >= 2 ? labels[labels.length - 2] : (labels[0] ?? "")
127+
const slug = label
128+
.toLowerCase()
129+
.replace(/[^a-z0-9-_]+/g, "-")
130+
.replace(/^-+|-+$/g, "")
131+
// Must satisfy PROVIDER_ID (starts alphanumeric). Fall back to a safe default.
132+
return slug && PROVIDER_ID.test(slug) ? slug : "custom-provider"
133+
}
134+
135+
function displayName(slug: string): string {
136+
const cleaned = slug.replace(/[-_]+/g, " ").trim()
137+
if (!cleaned) return "Custom Provider"
138+
return cleaned
139+
.split(" ")
140+
.map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word))
141+
.join(" ")
49142
}
50143

51144
export function validateCustomProvider(input: ValidateArgs) {
52-
const providerID = input.form.providerID.trim()
53-
const name = input.form.name.trim()
145+
const typedID = input.form.providerID.trim()
146+
const typedName = input.form.name.trim()
54147
const baseURL = input.form.baseURL.trim()
55148
const apiKey = input.form.apiKey.trim()
56149

57150
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
58151
const key = apiKey && !env ? apiKey : undefined
59152

153+
const urlError = !baseURL
154+
? input.t("provider.custom.error.baseURL.required")
155+
: !/^https?:\/\//.test(baseURL)
156+
? input.t("provider.custom.error.baseURL.format")
157+
: undefined
158+
159+
// Zero-config path: when the user leaves id/name blank we derive them from the URL, so those
160+
// fields are no longer required. Derivation needs a usable URL — if the URL itself is invalid we
161+
// skip it and let urlError drive the failure instead of emitting a spurious id/name error.
162+
const derived = !urlError && (!typedID || !typedName) ? deriveProviderIdentity({
163+
baseURL,
164+
existingProviderIDs: input.existingProviderIDs,
165+
disabledProviders: input.disabledProviders,
166+
}) : undefined
167+
const providerID = typedID || derived?.providerID || ""
168+
const name = typedName || derived?.name || ""
169+
170+
// Only the user's explicitly-typed id is format-checked; a derived id is always valid by
171+
// construction. A blank id with no derivable URL still surfaces as "required".
60172
const idError = !providerID
61173
? input.t("provider.custom.error.providerID.required")
62-
: !PROVIDER_ID.test(providerID)
174+
: typedID && !PROVIDER_ID.test(typedID)
63175
? input.t("provider.custom.error.providerID.format")
64176
: undefined
65177

66178
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
67-
const urlError = !baseURL
68-
? input.t("provider.custom.error.baseURL.required")
69-
: !/^https?:\/\//.test(baseURL)
70-
? input.t("provider.custom.error.baseURL.format")
71-
: undefined
72179

73180
const disabled = input.disabledProviders.includes(providerID)
74181
const existsError = idError
@@ -77,6 +184,11 @@ export function validateCustomProvider(input: ValidateArgs) {
77184
? input.t("provider.custom.error.providerID.exists")
78185
: undefined
79186

187+
// Discovery mode is only active when the user listed no manual models: the model list then comes
188+
// from the backend at runtime, so the empty model rows must not fail validation.
189+
const hasManualModels = input.form.models.some((m) => m.id.trim().length > 0)
190+
const discoveryMode = !!input.discovery && !hasManualModels
191+
80192
const seenModels = new Set<string>()
81193
const models = input.form.models.map((m) => {
82194
const id = m.id.trim()
@@ -91,7 +203,7 @@ export function validateCustomProvider(input: ValidateArgs) {
91203
const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
92204
return { id: idError, name: nameError }
93205
})
94-
const modelsValid = models.every((m) => !m.id && !m.name)
206+
const modelsValid = discoveryMode || models.every((m) => !m.id && !m.name)
95207
const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
96208

97209
const seenHeaders = new Set<string>()
@@ -128,26 +240,25 @@ export function validateCustomProvider(input: ValidateArgs) {
128240
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
129241
if (!ok) return { err, models, headers }
130242

243+
const config: CustomProviderConfig = {
244+
npm: npmForProtocol(input.protocol),
245+
name,
246+
...(env ? { env: [env] } : {}),
247+
options: {
248+
baseURL,
249+
...(key ? { apiKey: key } : {}),
250+
...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
251+
},
252+
// Discovery mode: persist the opt-in flag and an empty model list (backend refreshes at runtime).
253+
// Manual mode: freeze the listed models and leave discovery off.
254+
...(discoveryMode ? { discovery: true, models: {} } : { models: modelConfig }),
255+
}
256+
131257
return {
132258
err,
133259
models,
134260
headers,
135-
result: {
136-
providerID,
137-
name,
138-
key,
139-
config: {
140-
npm: OPENAI_COMPATIBLE,
141-
name,
142-
...(env ? { env: [env] } : {}),
143-
options: {
144-
baseURL,
145-
...(key ? { apiKey: key } : {}),
146-
...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
147-
},
148-
models: modelConfig,
149-
},
150-
},
261+
result: { providerID, name, key, config },
151262
}
152263
}
153264

0 commit comments

Comments
 (0)