11import { isOfficialProvider } from "@deepagent-code/core/provider-official"
2+ import type { Provider as ResolvedProvider , ProviderConfig } from "@deepagent-code/sdk/v2"
23
34const PROVIDER_ID = / ^ [ a - z 0 - 9 ] [ a - z 0 - 9 - _ ] * $ /
45const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
56const ANTHROPIC = "@ai-sdk/anthropic"
67
78export type ProviderProtocol = "openai-compatible" | "anthropic"
89
10+ // Per-model spec override written under `provider.<id>.models.<id>`. Only the fields the user actually
11+ // set are emitted, so a blank field never clobbers the backend catalog-fill with a zero/false.
12+ export type CustomModelConfig = {
13+ name : string
14+ reasoning ?: boolean
15+ temperature ?: boolean
16+ limit ?: { context : number ; output ?: number }
17+ }
18+
919// The config payload written under `provider.<id>`. `discovery` and `models` are mutually exclusive
1020// in practice (discovery mode emits an empty models map), but both are typed optional so the emitted
1121// object has one consistent shape instead of a union callers must narrow.
@@ -19,7 +29,7 @@ export type CustomProviderConfig = {
1929 headers ?: Record < string , string >
2030 }
2131 discovery ?: boolean
22- models : Record < string , { name : string } >
32+ models : Record < string , CustomModelConfig >
2333}
2434
2535const npmForProtocol = ( kind : ProviderProtocol | undefined ) => ( kind === "anthropic" ? ANTHROPIC : OPENAI_COMPATIBLE )
@@ -34,6 +44,7 @@ type Translator = (key: string, vars?: Record<string, string | number | boolean>
3444export type ModelErr = {
3545 id ?: string
3646 name ?: string
47+ context ?: string
3748}
3849
3950export type HeaderErr = {
@@ -45,6 +56,11 @@ export type ModelRow = {
4556 row : string
4657 id : string
4758 name : string
59+ // Editable spec overrides. `context` is a text field (parsed to a positive int on save; blank means
60+ // "let the backend/catalog fill it"). reasoning/temperature are booleans.
61+ context : string
62+ reasoning : boolean
63+ temperature : boolean
4864 err : ModelErr
4965}
5066
@@ -82,6 +98,14 @@ type ValidateArgs = {
8298 // every load instead of freezing them into config. Manual models always take precedence and turn
8399 // this off for that provider.
84100 discovery ?: boolean
101+ // Edit mode: the provider being edited was persisted with `discovery: true`. We keep discovery on
102+ // (so the backend still refreshes the model list) AND emit the user's per-model spec overrides,
103+ // which the build loop merges over the discovered models (manual wins per-id). Without this, saving
104+ // spec edits would freeze the model snapshot and disable runtime refresh.
105+ editDiscovery ?: boolean
106+ // Providers whose ids are already taken but belong to the provider being edited (so an edit doesn't
107+ // trip the "already exists" check on its own id).
108+ editingProviderID ?: string
85109}
86110
87111// 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) {
178202 const nameError = ! name ? input . t ( "provider.custom.error.name.required" ) : undefined
179203
180204 const disabled = input . disabledProviders . includes ( providerID )
205+ // Editing a provider keeps its own id — don't flag that as a collision.
206+ const isSelf = input . editingProviderID === providerID
181207 const existsError = idError
182208 ? undefined
183- : input . existingProviderIDs . has ( providerID ) && ! disabled
209+ : input . existingProviderIDs . has ( providerID ) && ! disabled && ! isSelf
184210 ? input . t ( "provider.custom.error.providerID.exists" )
185211 : undefined
186212
@@ -201,10 +227,25 @@ export function validateCustomProvider(input: ValidateArgs) {
201227 return undefined
202228 } ) ( )
203229 const nameError = ! m . name . trim ( ) ? input . t ( "provider.custom.error.required" ) : undefined
204- return { id : idError , name : nameError }
230+ const ctx = m . context . trim ( )
231+ // Blank context is allowed (backend/catalog fills it); a non-empty value must be a positive int.
232+ const contextError = ctx && ! / ^ \d + $ / . test ( ctx ) ? input . t ( "provider.custom.error.context" ) : undefined
233+ return { id : idError , name : nameError , context : contextError }
205234 } )
206- const modelsValid = discoveryMode || models . every ( ( m ) => ! m . id && ! m . name )
207- const modelConfig = Object . fromEntries ( input . form . models . map ( ( m ) => [ m . id . trim ( ) , { name : m . name . trim ( ) } ] ) )
235+ const modelsValid =
236+ ( discoveryMode || models . every ( ( m ) => ! m . id && ! m . name ) ) && models . every ( ( m ) => ! m . context )
237+ const modelConfig = Object . fromEntries (
238+ input . form . models . map ( ( m ) => {
239+ const ctx = m . context . trim ( )
240+ const spec : CustomModelConfig = {
241+ name : m . name . trim ( ) ,
242+ ...( m . reasoning ? { reasoning : true } : { } ) ,
243+ ...( m . temperature ? { temperature : true } : { } ) ,
244+ ...( ctx ? { limit : { context : Number ( ctx ) } } : { } ) ,
245+ }
246+ return [ m . id . trim ( ) , spec ]
247+ } ) ,
248+ )
208249
209250 const seenHeaders = new Set < string > ( )
210251 const headers = input . form . headers . map ( ( h ) => {
@@ -249,9 +290,15 @@ export function validateCustomProvider(input: ValidateArgs) {
249290 ...( key ? { apiKey : key } : { } ) ,
250291 ...( Object . keys ( headerConfig ) . length ? { headers : headerConfig } : { } ) ,
251292 } ,
252- // Discovery mode: persist the opt-in flag and an empty model list (backend refreshes at runtime).
293+ // Edit-of-discovery: keep discovery on (runtime refresh) AND persist the spec overrides — the
294+ // build loop merges these over the discovered models (manual wins per-id).
295+ // New discovery mode: persist the opt-in flag and an empty model list (backend refreshes).
253296 // Manual mode: freeze the listed models and leave discovery off.
254- ...( discoveryMode ? { discovery : true , models : { } } : { models : modelConfig } ) ,
297+ ...( input . editDiscovery
298+ ? { discovery : true , models : modelConfig }
299+ : discoveryMode
300+ ? { discovery : true , models : { } }
301+ : { models : modelConfig } ) ,
255302 }
256303
257304 return {
@@ -262,9 +309,58 @@ export function validateCustomProvider(input: ValidateArgs) {
262309 }
263310}
264311
312+ // Build the dialog form state for editing an existing custom provider. Fields (URL/key/headers/name)
313+ // come from the raw config entry; model rows are seeded from the RESOLVED provider so the user sees the
314+ // actual context/reasoning/temperature values (a discovery provider has no models in config — its
315+ // specs only exist post-resolve). Each row is pre-filled so edits override just those fields.
316+ export function formStateFromProvider ( input : {
317+ config : ProviderConfig
318+ resolved : ResolvedProvider | undefined
319+ } ) : FormState {
320+ const { config, resolved } = input
321+ const headers = config . options ?. headers
322+ const headerRows =
323+ headers && typeof headers === "object" && Object . keys ( headers ) . length
324+ ? Object . entries ( headers as Record < string , string > ) . map ( ( [ key , value ] ) =>
325+ headerRow2 ( String ( key ) , String ( value ) ) ,
326+ )
327+ : [ headerRow ( ) ]
328+
329+ const resolvedModels = resolved ?. models ?? { }
330+ const modelRows = Object . entries ( resolvedModels ) . map ( ( [ id , m ] ) =>
331+ modelRow ( {
332+ id,
333+ name : m . name || id ,
334+ context : m . limit ?. context ? String ( m . limit . context ) : "" ,
335+ reasoning : ! ! m . capabilities ?. reasoning ,
336+ temperature : ! ! m . capabilities ?. temperature ,
337+ } ) ,
338+ )
339+
340+ return {
341+ providerID : resolved ?. id ?? config . id ?? "" ,
342+ name : config . name ?? resolved ?. name ?? "" ,
343+ baseURL : ( config . options ?. baseURL as string | undefined ) ?? "" ,
344+ apiKey : ( config . options ?. apiKey as string | undefined ) ?? "" ,
345+ models : modelRows . length ? modelRows : [ modelRow ( ) ] ,
346+ headers : headerRows ,
347+ err : { } ,
348+ }
349+ }
350+
265351let row = 0
266352
267353const nextRow = ( ) => `row-${ row ++ } `
268354
269- export const modelRow = ( ) : ModelRow => ( { row : nextRow ( ) , id : "" , name : "" , err : { } } )
355+ export const modelRow = ( init ?: Partial < ModelRow > ) : ModelRow => ( {
356+ row : nextRow ( ) ,
357+ id : "" ,
358+ name : "" ,
359+ context : "" ,
360+ reasoning : false ,
361+ temperature : false ,
362+ err : { } ,
363+ ...init ,
364+ } )
270365export const headerRow = ( ) : HeaderRow => ( { row : nextRow ( ) , key : "" , value : "" , err : { } } )
366+ const headerRow2 = ( key : string , value : string ) : HeaderRow => ( { row : nextRow ( ) , key, value, err : { } } )
0 commit comments