Skip to content

Commit fdc0497

Browse files
Add custom provider workflow to API modes
Extend API Modes editing so custom model entries can bind to a providerId and create a new OpenAI-compatible provider in the same flow with only provider name and base URL. Unify API key editing in General settings by resolving the currently selected OpenAI-compatible provider and writing secrets into the new providerSecrets map, while still syncing legacy key fields for backward compatibility. Preserve legacy custom URL behavior for legacy provider mode and clear apiMode.customUrl when users switch to a registered provider so provider registry URLs are applied correctly.
1 parent ce97baf commit fdc0497

File tree

2 files changed

+379
-167
lines changed

2 files changed

+379
-167
lines changed

src/popup/sections/ApiModes.jsx

Lines changed: 248 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -7,44 +7,109 @@ import {
77
modelNameToDesc,
88
} from '../../utils/index.mjs'
99
import { 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'
1112
import {
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

1817
ApiModes.propTypes = {
1918
config: PropTypes.object.isRequired,
2019
updateConfig: PropTypes.func.isRequired,
2120
}
2221

22+
const LEGACY_CUSTOM_PROVIDER_ID = 'legacy-custom-default'
23+
2324
const 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-z0-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+
3388
export 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 = /\/chat\/completions$/i.test(providerBaseUrl)
172+
const hasV1BasePath = /\/v1$/i.test(providerBaseUrl)
173+
const providerChatCompletionsUrl = hasChatCompletionsEndpoint ? providerBaseUrl : ''
174+
const providerCompletionsUrl = hasChatCompletionsEndpoint
175+
? providerBaseUrl.replace(/\/chat\/completions$/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

Comments
 (0)