1+ import { isOfficialProvider } from "@deepagent-code/core/provider-official"
2+
13const PROVIDER_ID = / ^ [ a - z 0 - 9 ] [ a - z 0 - 9 - _ ] * $ /
24const 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
432type 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 - z 0 - 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
51144export 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 ( / ^ \{ e n v : ( [ ^ } ] + ) \} $ / ) ?. [ 1 ] ?. trim ( )
58151 const key = apiKey && ! env ? apiKey : undefined
59152
153+ const urlError = ! baseURL
154+ ? input . t ( "provider.custom.error.baseURL.required" )
155+ : ! / ^ h t t p s ? : \/ \/ / . 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- : ! / ^ h t t p s ? : \/ \/ / . 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