@@ -11,22 +11,56 @@ import { useEffect, useMemo, useRef, useState } from "react";
1111import { baseUrlForChoice , matchChoiceId , resolvedBaseUrlForChoice } from "../../base-url-choice" ;
1212import { readJsonIfOk } from "../../fetch-json" ;
1313import { useT } from "../../i18n/shared" ;
14- import { IconLock } from "../../icons" ;
14+ import { IconLock , IconPlus , IconTrash } from "../../icons" ;
1515import { isCatalogProviderId } from "../../provider-icons" ;
1616import type { CatalogPreset } from "../provider-catalog/provider-presets" ;
1717import { authModeLabel } from "./ProviderRail" ;
18- import type { WorkspaceItem , ProviderUpdatePatch } from "./types" ;
18+ import type { ProviderFallbackTarget , WorkspaceItem , ProviderUpdatePatch } from "./types" ;
1919
2020const ADAPTERS = [ "openai-responses" , "openai-chat" , "anthropic" , "google" , "azure-openai" , "cursor" ] as const ;
2121const EMPTY_MODELS : string [ ] = [ ] ;
22+ const EMPTY_PEERS : ProviderPeerOption [ ] = [ ] ;
23+
24+ export type ProviderPeerOption = {
25+ name : string ;
26+ disabled ?: boolean ;
27+ models ?: string [ ] ;
28+ defaultModel ?: string ;
29+ } ;
2230
2331type ChoicesStatus = "idle" | "loading" | "ready" | "error" ;
2432
33+ function normalizeFallback ( raw : ProviderFallbackTarget [ ] | undefined ) : ProviderFallbackTarget [ ] {
34+ if ( ! Array . isArray ( raw ) ) return [ ] ;
35+ return raw
36+ . map ( row => ( {
37+ provider : typeof row . provider === "string" ? row . provider . trim ( ) : "" ,
38+ model : typeof row . model === "string" ? row . model . trim ( ) : "" ,
39+ } ) )
40+ . filter ( row => row . provider && row . model ) ;
41+ }
42+
43+ function fallbackFingerprint ( rows : ProviderFallbackTarget [ ] | undefined ) : string {
44+ return JSON . stringify ( normalizeFallback ( rows ) ) ;
45+ }
46+
47+ function modelsForPeer ( peer : ProviderPeerOption | undefined , currentModel : string ) : string [ ] {
48+ const set = new Set < string > ( ) ;
49+ for ( const id of peer ?. models ?? [ ] ) {
50+ if ( id . trim ( ) ) set . add ( id . trim ( ) ) ;
51+ }
52+ if ( peer ?. defaultModel ?. trim ( ) ) set . add ( peer . defaultModel . trim ( ) ) ;
53+ if ( currentModel . trim ( ) ) set . add ( currentModel . trim ( ) ) ;
54+ return [ ...set ] . sort ( ( a , b ) => a . localeCompare ( b ) ) ;
55+ }
56+
2557export default function ProviderSettings ( {
26- item, availableModels = EMPTY_MODELS , apiBase, onUpdateProvider, onDirtyChange, onRegisterSave,
58+ item, availableModels = EMPTY_MODELS , peerProviders = EMPTY_PEERS , apiBase, onUpdateProvider, onDirtyChange, onRegisterSave,
2759} : {
2860 item : WorkspaceItem ;
2961 availableModels ?: string [ ] ;
62+ /** Other configured providers (and their known models) for the fallback picker. */
63+ peerProviders ?: ProviderPeerOption [ ] ;
3064 /** When set, load endpoint choices for catalog providers that expose baseUrlChoices. */
3165 apiBase ?: string ;
3266 onUpdateProvider ?: ( name : string , patch : ProviderUpdatePatch ) => Promise < { ok : boolean ; error ?: string } > ;
@@ -44,6 +78,7 @@ export default function ProviderSettings({
4478 const [ note , setNote ] = useState ( item . note ?? "" ) ;
4579 const [ allowPrivateNetwork , setAllowPrivateNetwork ] = useState ( item . allowPrivateNetwork ?? false ) ;
4680 const [ liveModels , setLiveModels ] = useState ( item . liveModels !== false ) ;
81+ const [ fallback , setFallback ] = useState < ProviderFallbackTarget [ ] > ( ( ) => normalizeFallback ( item . fallback ) ) ;
4782 const [ saving , setSaving ] = useState ( false ) ;
4883 const [ msg , setMsg ] = useState < { ok : boolean ; text : string } | null > ( null ) ;
4984 const [ baseUrlChoices , setBaseUrlChoices ] = useState < CatalogPreset [ "baseUrlChoices" ] > ( ) ;
@@ -60,9 +95,10 @@ export default function ProviderSettings({
6095 setNote ( item . note ?? "" ) ;
6196 setAllowPrivateNetwork ( item . allowPrivateNetwork ?? false ) ;
6297 setLiveModels ( item . liveModels !== false ) ;
98+ setFallback ( normalizeFallback ( item . fallback ) ) ;
6399 setMsg ( null ) ;
64100 queueMicrotask ( ( ) => setEndpointChoice ( matchChoiceId ( baseUrlChoices , item . baseUrl ) ) ) ;
65- } , [ item . adapter , item . baseUrl , item . defaultModel , item . authMode , item . apiKeyTransport , item . keyOptional , item . note , item . allowPrivateNetwork , item . liveModels , baseUrlChoices ] ) ;
101+ } , [ item . adapter , item . baseUrl , item . defaultModel , item . authMode , item . apiKeyTransport , item . keyOptional , item . note , item . allowPrivateNetwork , item . liveModels , item . fallback , baseUrlChoices ] ) ;
66102 /* eslint-enable react-hooks/set-state-in-effect */
67103
68104 useEffect ( ( ) => {
@@ -102,7 +138,8 @@ export default function ProviderSettings({
102138 || ( adapter . trim ( ) === "anthropic" && authMode === "key" && apiKeyTransport !== ( item . apiKeyTransport ?? "x-api-key" ) )
103139 || note . trim ( ) !== ( item . note ?? "" )
104140 || allowPrivateNetwork !== ( item . allowPrivateNetwork ?? false )
105- || liveModels !== ( item . liveModels !== false ) ;
141+ || liveModels !== ( item . liveModels !== false )
142+ || fallbackFingerprint ( fallback ) !== fallbackFingerprint ( item . fallback ) ;
106143
107144 useEffect ( ( ) => { onDirtyChange ?.( dirty ) ; return ( ) => onDirtyChange ?.( false ) ; } , [ dirty , onDirtyChange ] ) ;
108145
@@ -119,6 +156,11 @@ export default function ProviderSettings({
119156 return list ;
120157 } , [ adapter ] ) ;
121158
159+ const fallbackPeers = useMemo (
160+ ( ) => peerProviders . filter ( p => p . name !== item . name ) ,
161+ [ peerProviders , item . name ] ,
162+ ) ;
163+
122164 const isPreset = isCatalogProviderId ( item . name ) ;
123165 const hasEndpointPicker = choicesStatus === "ready" && ! ! ( baseUrlChoices && baseUrlChoices . length > 0 ) ;
124166 const supportsApiKeyTransport = adapter . trim ( ) === "anthropic" && authMode === "key" ;
@@ -132,10 +174,24 @@ export default function ProviderSettings({
132174 ? resolvedBaseUrlForChoice ( baseUrlChoices , endpointChoice , baseUrl )
133175 : baseUrl . trim ( ) ;
134176 if ( ! adapter . trim ( ) || ! nextBaseUrl ) { setMsg ( { ok : false , text : t ( "pws.adapterBaseRequired" ) } ) ; return false ; }
177+ const nextFallback = normalizeFallback ( fallback ) ;
178+ if ( fallback . some ( row => ( row . provider . trim ( ) && ! row . model . trim ( ) ) || ( ! row . provider . trim ( ) && row . model . trim ( ) ) ) ) {
179+ setMsg ( { ok : false , text : t ( "pws.fallbackIncomplete" ) } ) ;
180+ return false ;
181+ }
135182 setSaving ( true ) ;
136183 setMsg ( null ) ;
137184 try {
138- const patch : ProviderUpdatePatch = { adapter : adapter . trim ( ) , baseUrl : nextBaseUrl , defaultModel : defaultModel . trim ( ) , authMode, note : note . trim ( ) , allowPrivateNetwork, liveModels } ;
185+ const patch : ProviderUpdatePatch = {
186+ adapter : adapter . trim ( ) ,
187+ baseUrl : nextBaseUrl ,
188+ defaultModel : defaultModel . trim ( ) ,
189+ authMode,
190+ note : note . trim ( ) ,
191+ allowPrivateNetwork,
192+ liveModels,
193+ fallback : nextFallback ,
194+ } ;
139195 if ( supportsApiKeyTransport ) patch . apiKeyTransport = apiKeyTransport ;
140196 else if ( item . apiKeyTransport !== undefined ) patch . apiKeyTransport = "" ;
141197 const res = await onUpdateProvider ( item . name , patch ) ;
@@ -160,19 +216,25 @@ export default function ProviderSettings({
160216 setAdapter ( item . adapter ) ; setBaseUrl ( item . baseUrl ) ;
161217 setDefaultModel ( item . defaultModel ?? "" ) ; setAuthMode ( initialAuth ) ;
162218 setApiKeyTransport ( item . apiKeyTransport ?? "x-api-key" ) ;
163- setNote ( item . note ?? "" ) ; setAllowPrivateNetwork ( item . allowPrivateNetwork ?? false ) ; setLiveModels ( item . liveModels !== false ) ; setMsg ( null ) ;
219+ setNote ( item . note ?? "" ) ; setAllowPrivateNetwork ( item . allowPrivateNetwork ?? false ) ; setLiveModels ( item . liveModels !== false ) ;
220+ setFallback ( normalizeFallback ( item . fallback ) ) ;
221+ setMsg ( null ) ;
164222 setEndpointChoice ( matchChoiceId ( baseUrlChoices , item . baseUrl ) ) ;
165223 } ;
166224
167- const endpointLabel = ( id : string , fallback : string ) => {
225+ const endpointLabel = ( id : string , fallbackLabel : string ) => {
168226 switch ( id ) {
169227 case "token-plan" : return t ( "modal.endpoint.tokenPlan" ) ;
170228 case "payg" : return t ( "modal.endpoint.payAsYouGo" ) ;
171229 case "custom" : return t ( "modal.endpoint.custom" ) ;
172- default : return fallback ;
230+ default : return fallbackLabel ;
173231 }
174232 } ;
175233
234+ const updateFallbackRow = ( index : number , patch : Partial < ProviderFallbackTarget > ) => {
235+ setFallback ( rows => rows . map ( ( row , i ) => i === index ? { ...row , ...patch } : row ) ) ;
236+ } ;
237+
176238 return (
177239 < div className = "pwi-settings-form" >
178240 < label className = "pwi-settings-field" >
@@ -264,6 +326,77 @@ export default function ProviderSettings({
264326 < span className = "muted text-label" style = { { display : "block" , marginTop : 2 } } > { t ( "pws.liveModelsDesc" ) } </ span >
265327 </ span >
266328 </ label >
329+
330+ < div className = "pwi-settings-field" >
331+ < span className = "pwi-settings-label" > { t ( "pws.fallback" ) } </ span >
332+ < span className = "pwi-settings-hint" > { t ( "pws.fallbackDesc" ) } </ span >
333+ < div className = "pwi-fallback-list" >
334+ { fallback . map ( ( row , index ) => {
335+ const peer = fallbackPeers . find ( p => p . name === row . provider ) ;
336+ const modelIds = modelsForPeer ( peer , row . model ) ;
337+ return (
338+ < div key = { index } className = "pwi-fallback-row" >
339+ < select
340+ className = "input"
341+ value = { row . provider }
342+ aria-label = { t ( "pws.fallback.provider" ) }
343+ onChange = { e => {
344+ const provider = e . target . value ;
345+ const first = modelsForPeer ( fallbackPeers . find ( p => p . name === provider ) , "" ) [ 0 ] ?? "" ;
346+ updateFallbackRow ( index , { provider, model : first } ) ;
347+ } }
348+ >
349+ < option value = "" > { t ( "pws.fallback.pickProvider" ) } </ option >
350+ { fallbackPeers . map ( p => (
351+ < option key = { p . name } value = { p . name } >
352+ { p . disabled ? t ( "pws.fallback.disabled" , { name : p . name } ) : p . name }
353+ </ option >
354+ ) ) }
355+ </ select >
356+ { modelIds . length > 0 ? (
357+ < select
358+ className = "input"
359+ value = { row . model }
360+ disabled = { ! row . provider }
361+ aria-label = { t ( "pws.fallback.model" ) }
362+ onChange = { e => updateFallbackRow ( index , { model : e . target . value } ) }
363+ >
364+ < option value = "" > { t ( "pws.fallback.pickModel" ) } </ option >
365+ { modelIds . map ( id => < option key = { id } value = { id } > { id } </ option > ) }
366+ </ select >
367+ ) : (
368+ < input
369+ className = "input"
370+ value = { row . model }
371+ disabled = { ! row . provider }
372+ placeholder = { t ( "pws.fallback.modelPlaceholder" ) }
373+ aria-label = { t ( "pws.fallback.model" ) }
374+ onChange = { e => updateFallbackRow ( index , { model : e . target . value } ) }
375+ />
376+ ) }
377+ < button
378+ type = "button"
379+ className = "btn btn-ghost btn-sm"
380+ aria-label = { t ( "common.remove" ) }
381+ onClick = { ( ) => setFallback ( rows => rows . filter ( ( _ , i ) => i !== index ) ) }
382+ >
383+ < IconTrash width = { 14 } height = { 14 } />
384+ </ button >
385+ </ div >
386+ ) ;
387+ } ) }
388+ < button
389+ type = "button"
390+ className = "btn btn-ghost btn-sm"
391+ style = { { alignSelf : "flex-start" } }
392+ disabled = { fallbackPeers . length === 0 }
393+ onClick = { ( ) => setFallback ( rows => [ ...rows , { provider : "" , model : "" } ] ) }
394+ >
395+ < IconPlus width = { 14 } height = { 14 } /> { t ( "pws.fallback.add" ) }
396+ </ button >
397+ </ div >
398+ </ div >
399+
267400 { dirty && (
268401 < div className = "pwi-settings-sticky-bar" >
269402 < span className = "muted" > { t ( "pws.settingsUnsavedBar" ) } </ span >
0 commit comments