@@ -8,43 +8,98 @@ import {
88} from '../../utils/index.mjs'
99import { PencilIcon , TrashIcon } from '@primer/octicons-react'
1010import { useLayoutEffect , 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+ function normalizeProviderId ( value ) {
43+ return String ( value || '' )
44+ . trim ( )
45+ . toLowerCase ( )
46+ . replace ( / [ ^ a - z 0 - 9 ] + / g, '-' )
47+ . replace ( / ^ - + | - + $ / g, '' )
48+ }
49+
50+ function createProviderId ( providerName , existingProviders ) {
51+ const usedIds = new Set ( [
52+ ...Object . values ( OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID ) ,
53+ ...existingProviders . map ( ( provider ) => provider . id ) ,
54+ ] )
55+ const baseId =
56+ normalizeProviderId ( providerName ) || `custom-provider-${ existingProviders . length + 1 } `
57+ let nextId = baseId
58+ let suffix = 2
59+ while ( usedIds . has ( nextId ) ) {
60+ nextId = `${ baseId } -${ suffix } `
61+ suffix += 1
62+ }
63+ return nextId
64+ }
65+
66+ function normalizeBaseUrl ( value ) {
67+ return String ( value || '' )
68+ . trim ( )
69+ . replace ( / \/ + $ / , '' )
70+ }
71+
72+ function sanitizeApiModeForSave ( apiMode ) {
73+ const nextApiMode = { ...apiMode }
74+ if ( nextApiMode . groupName !== 'customApiModelKeys' ) {
75+ nextApiMode . providerId = ''
76+ nextApiMode . apiKey = ''
77+ return nextApiMode
78+ }
79+ if ( ! nextApiMode . providerId ) nextApiMode . providerId = LEGACY_CUSTOM_PROVIDER_ID
80+ return nextApiMode
81+ }
82+
3383export function ApiModes ( { config, updateConfig } ) {
3484 const { t } = useTranslation ( )
3585 const [ editing , setEditing ] = useState ( false )
3686 const [ editingApiMode , setEditingApiMode ] = useState ( defaultApiMode )
3787 const [ editingIndex , setEditingIndex ] = useState ( - 1 )
3888 const [ apiModes , setApiModes ] = useState ( [ ] )
3989 const [ apiModeStringArray , setApiModeStringArray ] = useState ( [ ] )
90+ const [ customProviders , setCustomProviders ] = useState ( [ ] )
91+ const [ providerSelector , setProviderSelector ] = useState ( LEGACY_CUSTOM_PROVIDER_ID )
92+ const [ providerDraft , setProviderDraft ] = useState ( defaultProviderDraft )
4093
4194 useLayoutEffect ( ( ) => {
42- const apiModes = getApiModesFromConfig ( config )
43- setApiModes ( apiModes )
44- setApiModeStringArray ( apiModes . map ( apiModeToModelName ) )
95+ const nextApiModes = getApiModesFromConfig ( config )
96+ setApiModes ( nextApiModes )
97+ setApiModeStringArray ( nextApiModes . map ( apiModeToModelName ) )
98+ setCustomProviders ( getCustomOpenAIProviders ( config ) )
4599 } , [
46100 config . activeApiModes ,
47101 config . customApiModes ,
102+ config . customOpenAIProviders ,
48103 config . azureDeploymentName ,
49104 config . ollamaModelName ,
50105 ] )
@@ -61,6 +116,84 @@ export function ApiModes({ config, updateConfig }) {
61116 } )
62117 }
63118
119+ const shouldEditProvider = editingApiMode . groupName === 'customApiModelKeys'
120+
121+ const persistApiMode = ( nextApiMode , nextCustomProviders ) => {
122+ const payload = {
123+ activeApiModes : [ ] ,
124+ customApiModes :
125+ editingIndex === - 1
126+ ? [ ...apiModes , nextApiMode ]
127+ : apiModes . map ( ( apiMode , index ) => ( index === editingIndex ? nextApiMode : apiMode ) ) ,
128+ }
129+ if ( nextCustomProviders !== null ) payload . customOpenAIProviders = nextCustomProviders
130+ if ( editingIndex !== - 1 && isApiModeSelected ( apiModes [ editingIndex ] , config ) ) {
131+ payload . apiMode = nextApiMode
132+ }
133+ updateConfig ( payload )
134+ }
135+
136+ const onSaveEditing = ( event ) => {
137+ event . preventDefault ( )
138+ let nextApiMode = { ...editingApiMode }
139+ let nextCustomProviders = null
140+ const previousProviderId =
141+ editingIndex === - 1 ? '' : apiModes [ editingIndex ] ?. providerId || LEGACY_CUSTOM_PROVIDER_ID
142+
143+ if ( shouldEditProvider ) {
144+ if ( providerSelector === '__new__' ) {
145+ const providerName = providerDraft . name . trim ( )
146+ const providerBaseUrl = normalizeBaseUrl ( providerDraft . baseUrl )
147+ if ( ! providerName || ! providerBaseUrl ) return
148+ const hasChatCompletionsEndpoint = / \/ c h a t \/ c o m p l e t i o n s $ / i. test ( providerBaseUrl )
149+ const hasV1BasePath = / \/ v 1 $ / i. test ( providerBaseUrl )
150+ const providerChatCompletionsUrl = hasChatCompletionsEndpoint ? providerBaseUrl : ''
151+ const providerCompletionsUrl = hasChatCompletionsEndpoint
152+ ? providerBaseUrl . replace ( / \/ c h a t \/ c o m p l e t i o n s $ / i, '/completions' )
153+ : ''
154+ const providerChatCompletionsPath = hasV1BasePath
155+ ? '/chat/completions'
156+ : providerDraft . chatCompletionsPath
157+ const providerCompletionsPath = hasV1BasePath
158+ ? '/completions'
159+ : providerDraft . completionsPath
160+
161+ const providerId = createProviderId ( providerName , customProviders )
162+ const createdProvider = {
163+ id : providerId ,
164+ name : providerName ,
165+ baseUrl : hasChatCompletionsEndpoint ? '' : providerBaseUrl ,
166+ chatCompletionsPath : providerChatCompletionsPath ,
167+ completionsPath : providerCompletionsPath ,
168+ chatCompletionsUrl : providerChatCompletionsUrl ,
169+ completionsUrl : providerCompletionsUrl ,
170+ enabled : true ,
171+ allowLegacyResponseField : true ,
172+ }
173+ nextCustomProviders = [ ...customProviders , createdProvider ]
174+ const shouldClearApiKey = editingIndex !== - 1 && providerId !== previousProviderId
175+ nextApiMode = {
176+ ...nextApiMode ,
177+ providerId,
178+ customUrl : '' ,
179+ apiKey : shouldClearApiKey ? '' : nextApiMode . apiKey ,
180+ }
181+ } else {
182+ const selectedProviderId = providerSelector || LEGACY_CUSTOM_PROVIDER_ID
183+ const shouldClearApiKey = editingIndex !== - 1 && selectedProviderId !== previousProviderId
184+ nextApiMode = {
185+ ...nextApiMode ,
186+ providerId : selectedProviderId ,
187+ customUrl : '' ,
188+ apiKey : shouldClearApiKey ? '' : nextApiMode . apiKey ,
189+ }
190+ }
191+ }
192+
193+ persistApiMode ( sanitizeApiModeForSave ( nextApiMode ) , nextCustomProviders )
194+ setEditing ( false )
195+ }
196+
64197 const editingComponent = (
65198 < div style = { { display : 'flex' , flexDirection : 'column' , '--spacing' : '4px' } } >
66199 < div style = { { display : 'flex' , gap : '12px' } } >
@@ -72,26 +205,7 @@ export function ApiModes({ config, updateConfig }) {
72205 >
73206 { t ( 'Cancel' ) }
74207 </ 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 >
208+ < button onClick = { onSaveEditing } > { t ( 'Save' ) } </ button >
95209 </ div >
96210 < div style = { { display : 'flex' , gap : '4px' , alignItems : 'center' , whiteSpace : 'noWrap' } } >
97211 { t ( 'Type' ) }
@@ -103,7 +217,16 @@ export function ApiModes({ config, updateConfig }) {
103217 const isCustom =
104218 editingApiMode . itemName === 'custom' && ! AlwaysCustomGroups . includes ( groupName )
105219 if ( isCustom ) itemName = 'custom'
106- setEditingApiMode ( { ...editingApiMode , groupName, itemName, isCustom } )
220+ const providerId =
221+ groupName === 'customApiModelKeys'
222+ ? editingApiMode . providerId || LEGACY_CUSTOM_PROVIDER_ID
223+ : ''
224+ setEditingApiMode ( { ...editingApiMode , groupName, itemName, isCustom, providerId } )
225+ if ( groupName === 'customApiModelKeys' ) {
226+ setProviderSelector ( providerId )
227+ } else {
228+ setProviderSelector ( LEGACY_CUSTOM_PROVIDER_ID )
229+ }
107230 } }
108231 >
109232 { Object . entries ( ModelGroups ) . map ( ( [ groupName , { desc } ] ) => (
@@ -141,24 +264,45 @@ export function ApiModes({ config, updateConfig }) {
141264 />
142265 ) }
143266 </ div >
144- { CustomUrlGroups . includes ( editingApiMode . groupName ) &&
145- ( editingApiMode . isCustom || AlwaysCustomGroups . includes ( editingApiMode . groupName ) ) && (
267+ { shouldEditProvider && (
268+ < div style = { { display : 'flex' , gap : '4px' , alignItems : 'center' , whiteSpace : 'noWrap' } } >
269+ { t ( 'Provider' ) }
270+ < select
271+ value = { providerSelector }
272+ onChange = { ( e ) => {
273+ const value = e . target . value
274+ setProviderSelector ( value )
275+ if ( value !== '__new__' ) {
276+ setEditingApiMode ( { ...editingApiMode , providerId : value } )
277+ }
278+ } }
279+ >
280+ < option value = { LEGACY_CUSTOM_PROVIDER_ID } > { t ( 'Custom' ) } </ option >
281+ { customProviders . map ( ( provider ) => (
282+ < option key = { provider . id } value = { provider . id } >
283+ { provider . name }
284+ </ option >
285+ ) ) }
286+ < option value = "__new__" > { t ( 'New' ) } </ option >
287+ </ select >
288+ </ div >
289+ ) }
290+ { shouldEditProvider && providerSelector === '__new__' && (
291+ < >
146292 < input
147293 type = "text"
148- value = { editingApiMode . customUrl }
149- placeholder = { t ( 'API Url ' ) }
150- onChange = { ( e ) => setEditingApiMode ( { ...editingApiMode , customUrl : e . target . value } ) }
294+ value = { providerDraft . name }
295+ placeholder = { t ( 'Provider ' ) }
296+ onChange = { ( e ) => setProviderDraft ( { ...providerDraft , name : e . target . value } ) }
151297 />
152- ) }
153- { CustomApiKeyGroups . includes ( editingApiMode . groupName ) &&
154- ( editingApiMode . isCustom || AlwaysCustomGroups . includes ( editingApiMode . groupName ) ) && (
155298 < input
156- type = "password "
157- value = { editingApiMode . apiKey }
158- placeholder = { t ( 'API Key ' ) }
159- onChange = { ( e ) => setEditingApiMode ( { ...editingApiMode , apiKey : e . target . value } ) }
299+ type = "text "
300+ value = { providerDraft . baseUrl }
301+ placeholder = { t ( 'API Url ' ) }
302+ onChange = { ( e ) => setProviderDraft ( { ...providerDraft , baseUrl : e . target . value } ) }
160303 />
161- ) }
304+ </ >
305+ ) }
162306 </ div >
163307 )
164308
@@ -190,7 +334,17 @@ export function ApiModes({ config, updateConfig }) {
190334 onClick = { ( e ) => {
191335 e . preventDefault ( )
192336 setEditing ( true )
193- setEditingApiMode ( apiMode )
337+ const isCustomApiMode = apiMode . groupName === 'customApiModelKeys'
338+ const providerId = isCustomApiMode
339+ ? apiMode . providerId || LEGACY_CUSTOM_PROVIDER_ID
340+ : ''
341+ setEditingApiMode ( {
342+ ...defaultApiMode ,
343+ ...apiMode ,
344+ providerId,
345+ } )
346+ setProviderSelector ( providerId || LEGACY_CUSTOM_PROVIDER_ID )
347+ setProviderDraft ( defaultProviderDraft )
194348 setEditingIndex ( index )
195349 } }
196350 >
@@ -223,6 +377,8 @@ export function ApiModes({ config, updateConfig }) {
223377 e . preventDefault ( )
224378 setEditing ( true )
225379 setEditingApiMode ( defaultApiMode )
380+ setProviderSelector ( LEGACY_CUSTOM_PROVIDER_ID )
381+ setProviderDraft ( defaultProviderDraft )
226382 setEditingIndex ( - 1 )
227383 } }
228384 >
0 commit comments