@@ -7,44 +7,109 @@ import {
77 modelNameToDesc ,
88} from '../../utils/index.mjs'
99import { PencilIcon , TrashIcon } from '@primer/octicons-react'
10- import { useLayoutEffect , useState } from 'react'
10+ import { useLayoutEffect , useRef , useState } from 'react'
11+ import { AlwaysCustomGroups , ModelGroups } from '../../config/index.mjs'
1112import {
12- AlwaysCustomGroups ,
13- CustomApiKeyGroups ,
14- CustomUrlGroups ,
15- ModelGroups ,
16- } from '../../config/index.mjs'
13+ getCustomOpenAIProviders ,
14+ OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID ,
15+ } from '../../services/apis/provider-registry.mjs'
1716
1817ApiModes . propTypes = {
1918 config : PropTypes . object . isRequired ,
2019 updateConfig : PropTypes . func . isRequired ,
2120}
2221
22+ const LEGACY_CUSTOM_PROVIDER_ID = 'legacy-custom-default'
23+
2324const defaultApiMode = {
2425 groupName : 'chatgptWebModelKeys' ,
2526 itemName : 'chatgptFree35' ,
2627 isCustom : false ,
2728 customName : '' ,
2829 customUrl : 'http://localhost:8000/v1/chat/completions' ,
2930 apiKey : '' ,
31+ providerId : '' ,
3032 active : true ,
3133}
3234
35+ const defaultProviderDraft = {
36+ name : '' ,
37+ baseUrl : '' ,
38+ chatCompletionsPath : '/v1/chat/completions' ,
39+ completionsPath : '/v1/completions' ,
40+ }
41+
42+ const defaultProviderDraftValidation = {
43+ name : false ,
44+ baseUrl : false ,
45+ }
46+
47+ function normalizeProviderId ( value ) {
48+ return String ( value || '' )
49+ . trim ( )
50+ . toLowerCase ( )
51+ . replace ( / [ ^ a - z 0 - 9 ] + / g, '-' )
52+ . replace ( / ^ - + | - + $ / g, '' )
53+ }
54+
55+ function createProviderId ( providerName , existingProviders ) {
56+ const usedIds = new Set ( [
57+ ...Object . values ( OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID ) ,
58+ ...existingProviders . map ( ( provider ) => provider . id ) ,
59+ ] )
60+ const baseId =
61+ normalizeProviderId ( providerName ) || `custom-provider-${ existingProviders . length + 1 } `
62+ let nextId = baseId
63+ let suffix = 2
64+ while ( usedIds . has ( nextId ) ) {
65+ nextId = `${ baseId } -${ suffix } `
66+ suffix += 1
67+ }
68+ return nextId
69+ }
70+
71+ function normalizeBaseUrl ( value ) {
72+ return String ( value || '' )
73+ . trim ( )
74+ . replace ( / \/ + $ / , '' )
75+ }
76+
77+ function sanitizeApiModeForSave ( apiMode ) {
78+ const nextApiMode = { ...apiMode }
79+ if ( nextApiMode . groupName !== 'customApiModelKeys' ) {
80+ nextApiMode . providerId = ''
81+ nextApiMode . apiKey = ''
82+ return nextApiMode
83+ }
84+ if ( ! nextApiMode . providerId ) nextApiMode . providerId = LEGACY_CUSTOM_PROVIDER_ID
85+ return nextApiMode
86+ }
87+
3388export function ApiModes ( { config, updateConfig } ) {
3489 const { t } = useTranslation ( )
3590 const [ editing , setEditing ] = useState ( false )
3691 const [ editingApiMode , setEditingApiMode ] = useState ( defaultApiMode )
3792 const [ editingIndex , setEditingIndex ] = useState ( - 1 )
3893 const [ apiModes , setApiModes ] = useState ( [ ] )
3994 const [ apiModeStringArray , setApiModeStringArray ] = useState ( [ ] )
95+ const [ customProviders , setCustomProviders ] = useState ( [ ] )
96+ const [ providerSelector , setProviderSelector ] = useState ( LEGACY_CUSTOM_PROVIDER_ID )
97+ const [ providerDraft , setProviderDraft ] = useState ( defaultProviderDraft )
98+ const [ providerDraftValidation , setProviderDraftValidation ] = useState (
99+ defaultProviderDraftValidation ,
100+ )
101+ const providerNameInputRef = useRef ( null )
102+ const providerBaseUrlInputRef = useRef ( null )
40103
41104 useLayoutEffect ( ( ) => {
42- const apiModes = getApiModesFromConfig ( config )
43- setApiModes ( apiModes )
44- setApiModeStringArray ( apiModes . map ( apiModeToModelName ) )
105+ const nextApiModes = getApiModesFromConfig ( config )
106+ setApiModes ( nextApiModes )
107+ setApiModeStringArray ( nextApiModes . map ( apiModeToModelName ) )
108+ setCustomProviders ( getCustomOpenAIProviders ( config ) )
45109 } , [
46110 config . activeApiModes ,
47111 config . customApiModes ,
112+ config . customOpenAIProviders ,
48113 config . azureDeploymentName ,
49114 config . ollamaModelName ,
50115 ] )
@@ -61,6 +126,97 @@ export function ApiModes({ config, updateConfig }) {
61126 } )
62127 }
63128
129+ const shouldEditProvider = editingApiMode . groupName === 'customApiModelKeys'
130+
131+ const persistApiMode = ( nextApiMode , nextCustomProviders ) => {
132+ const payload = {
133+ activeApiModes : [ ] ,
134+ customApiModes :
135+ editingIndex === - 1
136+ ? [ ...apiModes , nextApiMode ]
137+ : apiModes . map ( ( apiMode , index ) => ( index === editingIndex ? nextApiMode : apiMode ) ) ,
138+ }
139+ if ( nextCustomProviders !== null ) payload . customOpenAIProviders = nextCustomProviders
140+ if ( editingIndex !== - 1 && isApiModeSelected ( apiModes [ editingIndex ] , config ) ) {
141+ payload . apiMode = nextApiMode
142+ }
143+ updateConfig ( payload )
144+ }
145+
146+ const onSaveEditing = ( event ) => {
147+ event . preventDefault ( )
148+ let nextApiMode = { ...editingApiMode }
149+ let nextCustomProviders = null
150+ const previousProviderId =
151+ editingIndex === - 1 ? '' : apiModes [ editingIndex ] ?. providerId || LEGACY_CUSTOM_PROVIDER_ID
152+
153+ if ( shouldEditProvider ) {
154+ if ( providerSelector === '__new__' ) {
155+ const providerName = providerDraft . name . trim ( )
156+ const providerBaseUrl = normalizeBaseUrl ( providerDraft . baseUrl )
157+ const nextProviderDraftValidation = {
158+ name : ! providerName ,
159+ baseUrl : ! providerBaseUrl ,
160+ }
161+ if ( nextProviderDraftValidation . name || nextProviderDraftValidation . baseUrl ) {
162+ setProviderDraftValidation ( nextProviderDraftValidation )
163+ if ( nextProviderDraftValidation . name ) {
164+ providerNameInputRef . current ?. focus ( )
165+ } else {
166+ providerBaseUrlInputRef . current ?. focus ( )
167+ }
168+ return
169+ }
170+ setProviderDraftValidation ( defaultProviderDraftValidation )
171+ const hasChatCompletionsEndpoint = / \/ c h a t \/ c o m p l e t i o n s $ / i. test ( providerBaseUrl )
172+ const hasV1BasePath = / \/ v 1 $ / i. test ( providerBaseUrl )
173+ const providerChatCompletionsUrl = hasChatCompletionsEndpoint ? providerBaseUrl : ''
174+ const providerCompletionsUrl = hasChatCompletionsEndpoint
175+ ? providerBaseUrl . replace ( / \/ c h a t \/ c o m p l e t i o n s $ / i, '/completions' )
176+ : ''
177+ const providerChatCompletionsPath = hasV1BasePath
178+ ? '/chat/completions'
179+ : providerDraft . chatCompletionsPath
180+ const providerCompletionsPath = hasV1BasePath
181+ ? '/completions'
182+ : providerDraft . completionsPath
183+
184+ const providerId = createProviderId ( providerName , customProviders )
185+ const createdProvider = {
186+ id : providerId ,
187+ name : providerName ,
188+ baseUrl : hasChatCompletionsEndpoint ? '' : providerBaseUrl ,
189+ chatCompletionsPath : providerChatCompletionsPath ,
190+ completionsPath : providerCompletionsPath ,
191+ chatCompletionsUrl : providerChatCompletionsUrl ,
192+ completionsUrl : providerCompletionsUrl ,
193+ enabled : true ,
194+ allowLegacyResponseField : true ,
195+ }
196+ nextCustomProviders = [ ...customProviders , createdProvider ]
197+ const shouldClearApiKey = editingIndex !== - 1 && providerId !== previousProviderId
198+ nextApiMode = {
199+ ...nextApiMode ,
200+ providerId,
201+ customUrl : '' ,
202+ apiKey : shouldClearApiKey ? '' : nextApiMode . apiKey ,
203+ }
204+ } else {
205+ const selectedProviderId = providerSelector || LEGACY_CUSTOM_PROVIDER_ID
206+ const shouldClearApiKey = editingIndex !== - 1 && selectedProviderId !== previousProviderId
207+ nextApiMode = {
208+ ...nextApiMode ,
209+ providerId : selectedProviderId ,
210+ customUrl : '' ,
211+ apiKey : shouldClearApiKey ? '' : nextApiMode . apiKey ,
212+ }
213+ }
214+ }
215+
216+ persistApiMode ( sanitizeApiModeForSave ( nextApiMode ) , nextCustomProviders )
217+ setEditing ( false )
218+ }
219+
64220 const editingComponent = (
65221 < div style = { { display : 'flex' , flexDirection : 'column' , '--spacing' : '4px' } } >
66222 < div style = { { display : 'flex' , gap : '12px' } } >
@@ -72,26 +228,7 @@ export function ApiModes({ config, updateConfig }) {
72228 >
73229 { t ( 'Cancel' ) }
74230 </ button >
75- < button
76- onClick = { ( e ) => {
77- e . preventDefault ( )
78- if ( editingIndex === - 1 ) {
79- updateConfig ( {
80- activeApiModes : [ ] ,
81- customApiModes : [ ...apiModes , editingApiMode ] ,
82- } )
83- } else {
84- const apiMode = apiModes [ editingIndex ]
85- if ( isApiModeSelected ( apiMode , config ) ) updateConfig ( { apiMode : editingApiMode } )
86- const customApiModes = [ ...apiModes ]
87- customApiModes [ editingIndex ] = editingApiMode
88- updateConfig ( { activeApiModes : [ ] , customApiModes } )
89- }
90- setEditing ( false )
91- } }
92- >
93- { t ( 'Save' ) }
94- </ button >
231+ < button onClick = { onSaveEditing } > { t ( 'Save' ) } </ button >
95232 </ div >
96233 < div style = { { display : 'flex' , gap : '4px' , alignItems : 'center' , whiteSpace : 'noWrap' } } >
97234 { t ( 'Type' ) }
@@ -103,7 +240,16 @@ export function ApiModes({ config, updateConfig }) {
103240 const isCustom =
104241 editingApiMode . itemName === 'custom' && ! AlwaysCustomGroups . includes ( groupName )
105242 if ( isCustom ) itemName = 'custom'
106- setEditingApiMode ( { ...editingApiMode , groupName, itemName, isCustom } )
243+ const providerId =
244+ groupName === 'customApiModelKeys'
245+ ? editingApiMode . providerId || LEGACY_CUSTOM_PROVIDER_ID
246+ : ''
247+ setEditingApiMode ( { ...editingApiMode , groupName, itemName, isCustom, providerId } )
248+ if ( groupName === 'customApiModelKeys' ) {
249+ setProviderSelector ( providerId )
250+ } else {
251+ setProviderSelector ( LEGACY_CUSTOM_PROVIDER_ID )
252+ }
107253 } }
108254 >
109255 { Object . entries ( ModelGroups ) . map ( ( [ groupName , { desc } ] ) => (
@@ -141,24 +287,68 @@ export function ApiModes({ config, updateConfig }) {
141287 />
142288 ) }
143289 </ div >
144- { CustomUrlGroups . includes ( editingApiMode . groupName ) &&
145- ( editingApiMode . isCustom || AlwaysCustomGroups . includes ( editingApiMode . groupName ) ) && (
290+ { shouldEditProvider && (
291+ < div style = { { display : 'flex' , gap : '4px' , alignItems : 'center' , whiteSpace : 'noWrap' } } >
292+ { t ( 'Provider' ) }
293+ < select
294+ value = { providerSelector }
295+ onChange = { ( e ) => {
296+ const value = e . target . value
297+ setProviderSelector ( value )
298+ if ( value !== '__new__' ) {
299+ setEditingApiMode ( { ...editingApiMode , providerId : value } )
300+ }
301+ setProviderDraftValidation ( defaultProviderDraftValidation )
302+ } }
303+ >
304+ < option value = { LEGACY_CUSTOM_PROVIDER_ID } > { t ( 'Custom' ) } </ option >
305+ { customProviders . map ( ( provider ) => (
306+ < option key = { provider . id } value = { provider . id } >
307+ { provider . name }
308+ </ option >
309+ ) ) }
310+ < option value = "__new__" > { t ( 'New' ) } </ option >
311+ </ select >
312+ </ div >
313+ ) }
314+ { shouldEditProvider && providerSelector === '__new__' && (
315+ < >
146316 < input
147317 type = "text"
148- value = { editingApiMode . customUrl }
149- placeholder = { t ( 'API Url' ) }
150- onChange = { ( e ) => setEditingApiMode ( { ...editingApiMode , customUrl : e . target . value } ) }
318+ ref = { providerNameInputRef }
319+ value = { providerDraft . name }
320+ placeholder = { t ( 'Provider' ) }
321+ onChange = { ( e ) => {
322+ setProviderDraft ( { ...providerDraft , name : e . target . value } )
323+ if ( providerDraftValidation . name ) {
324+ setProviderDraftValidation ( {
325+ ...providerDraftValidation ,
326+ name : false ,
327+ } )
328+ }
329+ } }
330+ aria-invalid = { providerDraftValidation . name }
331+ style = { providerDraftValidation . name ? { borderColor : 'red' } : undefined }
151332 />
152- ) }
153- { CustomApiKeyGroups . includes ( editingApiMode . groupName ) &&
154- ( editingApiMode . isCustom || AlwaysCustomGroups . includes ( editingApiMode . groupName ) ) && (
155333 < input
156- type = "password"
157- value = { editingApiMode . apiKey }
158- placeholder = { t ( 'API Key' ) }
159- onChange = { ( e ) => setEditingApiMode ( { ...editingApiMode , apiKey : e . target . value } ) }
334+ type = "text"
335+ ref = { providerBaseUrlInputRef }
336+ value = { providerDraft . baseUrl }
337+ placeholder = { t ( 'API Url' ) }
338+ onChange = { ( e ) => {
339+ setProviderDraft ( { ...providerDraft , baseUrl : e . target . value } )
340+ if ( providerDraftValidation . baseUrl ) {
341+ setProviderDraftValidation ( {
342+ ...providerDraftValidation ,
343+ baseUrl : false ,
344+ } )
345+ }
346+ } }
347+ aria-invalid = { providerDraftValidation . baseUrl }
348+ style = { providerDraftValidation . baseUrl ? { borderColor : 'red' } : undefined }
160349 />
161- ) }
350+ </ >
351+ ) }
162352 </ div >
163353 )
164354
@@ -190,7 +380,18 @@ export function ApiModes({ config, updateConfig }) {
190380 onClick = { ( e ) => {
191381 e . preventDefault ( )
192382 setEditing ( true )
193- setEditingApiMode ( apiMode )
383+ const isCustomApiMode = apiMode . groupName === 'customApiModelKeys'
384+ const providerId = isCustomApiMode
385+ ? apiMode . providerId || LEGACY_CUSTOM_PROVIDER_ID
386+ : ''
387+ setEditingApiMode ( {
388+ ...defaultApiMode ,
389+ ...apiMode ,
390+ providerId,
391+ } )
392+ setProviderSelector ( providerId || LEGACY_CUSTOM_PROVIDER_ID )
393+ setProviderDraft ( defaultProviderDraft )
394+ setProviderDraftValidation ( defaultProviderDraftValidation )
194395 setEditingIndex ( index )
195396 } }
196397 >
@@ -223,6 +424,9 @@ export function ApiModes({ config, updateConfig }) {
223424 e . preventDefault ( )
224425 setEditing ( true )
225426 setEditingApiMode ( defaultApiMode )
427+ setProviderSelector ( LEGACY_CUSTOM_PROVIDER_ID )
428+ setProviderDraft ( defaultProviderDraft )
429+ setProviderDraftValidation ( defaultProviderDraftValidation )
226430 setEditingIndex ( - 1 )
227431 } }
228432 >
0 commit comments