Skip to content

Commit 36ed81e

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 77d85e0 commit 36ed81e

2 files changed

Lines changed: 326 additions & 166 deletions

File tree

src/popup/sections/ApiModes.jsx

Lines changed: 199 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,43 +8,98 @@ import {
88
} from '../../utils/index.mjs'
99
import { PencilIcon, TrashIcon } from '@primer/octicons-react'
1010
import { useLayoutEffect, 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+
function normalizeProviderId(value) {
43+
return String(value || '')
44+
.trim()
45+
.toLowerCase()
46+
.replace(/[^a-z0-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+
3383
export 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 = /\/chat\/completions$/i.test(providerBaseUrl)
149+
const hasV1BasePath = /\/v1$/i.test(providerBaseUrl)
150+
const providerChatCompletionsUrl = hasChatCompletionsEndpoint ? providerBaseUrl : ''
151+
const providerCompletionsUrl = hasChatCompletionsEndpoint
152+
? providerBaseUrl.replace(/\/chat\/completions$/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

Comments
 (0)