1919
2020import { useState , type FormEvent } from 'react' ;
2121import {
22+ type ModelInfo ,
2223 OPENCODE_FREE_DEFAULT_ENABLED_MODELS ,
2324 type ProviderType ,
2425} from '@maka/core/llm-connections' ;
2526import { PROVIDER_DEFAULTS , deriveConnectionSlug } from '@maka/core/llm-connections' ;
2627import {
2728 providerAuthRequiresSecret ,
2829 providerAuthSupportsApiKey ,
30+ providerSupportsModelDiscovery ,
2931} from '@maka/core/llm-connections' ;
30- import { Banner , HStack , VStack } from '@astryxdesign/core' ;
32+ import { Banner , HStack , Selector , VStack } from '@astryxdesign/core' ;
3133import { Collapsible } from '@astryxdesign/core/Collapsible' ;
3234import {
3335 Button ,
@@ -61,9 +63,16 @@ import {
6163
6264/* No `defaultModel`: the creation gate has no rule that can fail on the model
6365 id, so an error could never be reported against that field. The union is
64- kept aligned with `AddProviderIssue` plus the two form-local fields the
66+ kept aligned with `AddProviderIssue` plus the three form-local fields the
6567 gate does not own. */
66- type ProviderFormField = 'slug' | 'apiKey' | 'accountId' | 'baseUrl' | 'advancedRequest' | 'form' ;
68+ type ProviderFormField =
69+ | 'slug'
70+ | 'apiKey'
71+ | 'accountId'
72+ | 'baseUrl'
73+ | 'modelDiscovery'
74+ | 'advancedRequest'
75+ | 'form' ;
6776
6877type ProviderFormError = {
6978 field : ProviderFormField ;
@@ -90,18 +99,22 @@ export function AddProviderForm(props: {
9099 const [ cloudflareAccountId , setCloudflareAccountId ] = useState ( '' ) ;
91100 const [ apiKey , setApiKey ] = useState ( '' ) ;
92101 const [ defaultModel , setDefaultModel ] = useState ( recommendedDefaultModel ) ;
102+ const [ discoveredModels , setDiscoveredModels ] = useState < ModelInfo [ ] | null > ( null ) ;
93103 const [ requestHeaders , setRequestHeaders ] = useState < RequestHeaderDraft [ ] > ( [ ] ) ;
94104 const [ requestBodyText , setRequestBodyText ] = useState ( '' ) ;
95105 const [ advancedOpen , setAdvancedOpen ] = useState ( false ) ;
96106 const [ error , setError ] = useState < ProviderFormError | null > ( null ) ;
97107 const [ busy , setBusy ] = useState ( false ) ;
98- const submitGuard = useActionGuard < 'submit' > ( ) ;
108+ const [ fetchingModels , setFetchingModels ] = useState ( false ) ;
109+ const submitGuard = useActionGuard < 'submit' | 'fetch-models' > ( ) ;
99110 const addProviderMountedRef = useMountedRef ( ) ;
100111
101112 const isCloudflareWorkersAi = props . providerType === 'cloudflare-workers-ai' ;
102113 const requiresBaseUrl = ! defaults . baseUrl && ! isCloudflareWorkersAi ;
103114 const showsDefaultModel = recommendedDefaultModel . trim ( ) === '' ;
115+ const isCustomRelay = defaults . category === 'custom' ;
104116 const isExperimental = defaults . status === 'phase3-experimental' ;
117+ const supportsRemoteDiscovery = providerSupportsModelDiscovery ( props . providerType ) ;
105118 const supportsApiKey = providerAuthSupportsApiKey ( props . providerType ) ;
106119 const requiresApiKey = providerAuthRequiresSecret ( props . providerType ) && supportsApiKey ;
107120 const usesApiKeyDialog = usesQuickApiKeyDialog ( props . providerType ) ;
@@ -129,6 +142,58 @@ export function AddProviderForm(props: {
129142 return copy . accountLogin ;
130143 }
131144
145+ function invalidateDiscoveredModels ( ) {
146+ setDiscoveredModels ( null ) ;
147+ clearFieldError ( 'modelDiscovery' ) ;
148+ }
149+
150+ async function fetchModelOptions ( ) {
151+ if ( submitGuard . current !== null ) return ;
152+ setError ( null ) ;
153+ const normalizedApiKey = apiKey . trim ( ) ;
154+ if ( requiresApiKey && ! normalizedApiKey ) {
155+ return setError ( { field : 'apiKey' , message : copy . keyRequired ( display . name ) } ) ;
156+ }
157+ const normalizedBaseUrl = baseUrl . trim ( ) ;
158+ if ( requiresBaseUrl && ! normalizedBaseUrl ) {
159+ return setError ( { field : 'baseUrl' , message : copy . endpointRequired } ) ;
160+ }
161+ let normalizedRequestHeaders : Readonly < Record < string , string > > ;
162+ try {
163+ normalizedRequestHeaders = newRequestHeaders ( requestHeaders ) ;
164+ } catch {
165+ setAdvancedOpen ( true ) ;
166+ return setError ( { field : 'advancedRequest' , message : copy . requestCustomizationInvalid } ) ;
167+ }
168+ submitGuard . begin ( 'fetch-models' ) ;
169+ setFetchingModels ( true ) ;
170+ try {
171+ const models = await props . bridge . previewModels ( {
172+ providerType : props . providerType ,
173+ ...( normalizedBaseUrl ? { baseUrl : normalizedBaseUrl } : { } ) ,
174+ ...( normalizedApiKey ? { apiKey : normalizedApiKey } : { } ) ,
175+ ...( Object . keys ( normalizedRequestHeaders ) . length > 0
176+ ? { requestHeaders : normalizedRequestHeaders }
177+ : { } ) ,
178+ } ) ;
179+ if ( ! addProviderMountedRef . current ) return ;
180+ setDiscoveredModels ( models ) ;
181+ setDefaultModel ( ( current ) =>
182+ models . some ( ( model ) => model . id === current ) ? current : models [ 0 ] ! . id ,
183+ ) ;
184+ } catch ( fetchError ) {
185+ if ( ! addProviderMountedRef . current ) return ;
186+ setDiscoveredModels ( null ) ;
187+ setError ( {
188+ field : 'modelDiscovery' ,
189+ message : providerPanelActionErrorMessage ( fetchError , locale ) ,
190+ } ) ;
191+ } finally {
192+ submitGuard . finish ( ) ;
193+ if ( addProviderMountedRef . current ) setFetchingModels ( false ) ;
194+ }
195+ }
196+
132197 async function submit ( ) {
133198 if ( submitGuard . current !== null ) return ;
134199 setError ( null ) ;
@@ -210,6 +275,7 @@ export function AddProviderForm(props: {
210275 onHeadersChange = { ( headers ) => {
211276 setRequestHeaders ( headers ) ;
212277 clearFieldError ( 'advancedRequest' ) ;
278+ invalidateDiscoveredModels ( ) ;
213279 } }
214280 bodyText = { requestBodyText }
215281 onBodyTextChange = { ( value ) => {
@@ -244,6 +310,7 @@ export function AddProviderForm(props: {
244310 onChange = { ( next ) => {
245311 setApiKey ( next ) ;
246312 clearFieldError ( 'apiKey' ) ;
313+ invalidateDiscoveredModels ( ) ;
247314 } }
248315 placeholder = { copy . apiKeyPlaceholder }
249316 label = { copy . apiKeyLabel }
@@ -284,6 +351,7 @@ export function AddProviderForm(props: {
284351 onChange = { ( next ) => {
285352 setApiKey ( next ) ;
286353 clearFieldError ( 'apiKey' ) ;
354+ invalidateDiscoveredModels ( ) ;
287355 } }
288356 placeholder = { copy . apiKeyPlaceholder }
289357 label = { copy . apiKeyLabel }
@@ -342,6 +410,7 @@ export function AddProviderForm(props: {
342410 onChange = { ( value ) => {
343411 setBaseUrl ( value ) ;
344412 clearFieldError ( 'baseUrl' ) ;
413+ invalidateDiscoveredModels ( ) ;
345414 } }
346415 placeholder = { defaults . baseUrl || 'https://…' }
347416 isDisabled = { isExperimental || busy }
@@ -355,14 +424,46 @@ export function AddProviderForm(props: {
355424 />
356425 ) }
357426 { showsDefaultModel && (
358- < TextInput
359- value = { defaultModel }
360- onChange = { setDefaultModel }
361- placeholder = { copy . defaultModelPlaceholder }
362- isDisabled = { isExperimental || busy }
363- label = { copy . defaultModel }
364- description = { copy . defaultModelHelp }
365- />
427+ discoveredModels ? (
428+ < Selector
429+ label = { copy . defaultModel }
430+ value = { defaultModel }
431+ options = { discoveredModels . map ( ( model ) => ( {
432+ value : model . id ,
433+ label : model . displayName ?? model . id ,
434+ description : model . displayName ? model . id : undefined ,
435+ } ) ) }
436+ width = "100%"
437+ isDisabled = { isExperimental || busy || fetchingModels }
438+ onChange = { setDefaultModel }
439+ />
440+ ) : (
441+ < TextInput
442+ value = { defaultModel }
443+ onChange = { setDefaultModel }
444+ placeholder = { copy . defaultModelPlaceholder }
445+ isDisabled = { isExperimental || busy || fetchingModels }
446+ label = { copy . defaultModel }
447+ description = { copy . defaultModelHelp }
448+ />
449+ )
450+ ) }
451+ { isCustomRelay && supportsRemoteDiscovery && (
452+ < VStack gap = { 1.5 } >
453+ < Button
454+ variant = "secondary"
455+ isDisabled = { busy || fetchingModels || isExperimental }
456+ onClick = { fetchModelOptions }
457+ label = { fetchingModels ? copy . fetchingModels : copy . fetchModels }
458+ />
459+ { error ?. field === 'modelDiscovery' && (
460+ < Banner
461+ status = "warning"
462+ title = { copy . modelsFetchFailed }
463+ description = { `${ error . message } ${ copy . modelsFetchFallback } ` }
464+ />
465+ ) }
466+ </ VStack >
366467 ) }
367468 { advancedRequestEditor }
368469 </ FormLayout >
0 commit comments