Skip to content

Commit d6e4199

Browse files
committed
feat(settings): auto-discover models in custom provider modal
Debounced 500ms probe via config:v1:test-endpoint fires whenever baseUrl (http/https), apiKey, or wire changes. Shows inline spinner / success badge / failure hint next to the Default model label. On success, the text input becomes a <select> with smart auto-selection (claude-sonnet-4-5 > claude-opus > claude-sonnet > gemini-2.5-pro > gpt-5 > first in list). "Enter manually" / "Pick from list" toggles let users override the dropdown at any time. Empty or non-http(s) URLs are skipped so the existing manual flow is unaffected.
1 parent 9aa8c1e commit d6e4199

4 files changed

Lines changed: 189 additions & 9 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@open-codesign/desktop': patch
3+
'@open-codesign/i18n': patch
4+
---
5+
6+
feat(settings): auto-discover models in custom provider modal
7+
8+
When adding a custom provider (e.g. a CPA at http://127.0.0.1:8317), the modal now probes the endpoint automatically after the user types a valid http(s) baseUrl, debouncing 500ms. A spinner appears inline next to the "Default model" label while discovery runs, then either a green "Found N models" badge or a muted "Could not connect" hint.
9+
10+
On success the "Default model" input becomes a `<select>` pre-populated with discovered model IDs, with smart auto-selection prioritising claude-sonnet-4-5 → claude-opus → claude-sonnet → gemini-2.5-pro → gpt-5 → first in list. A "Enter manually" escape hatch lets users type any ID instead, and a "Pick from list" link restores the dropdown. The probe re-fires when the API key or wire protocol changes. Empty or non-http(s) baseUrls are skipped so the existing manual flow is completely unaffected.

apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx

Lines changed: 165 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { useT } from '@open-codesign/i18n';
22
import { type WireApi, canonicalBaseUrl, detectWireFromBaseUrl } from '@open-codesign/shared';
33
import { Button } from '@open-codesign/ui';
4-
import { AlertCircle, CheckCircle, Loader2, X } from 'lucide-react';
5-
import { useState } from 'react';
4+
import { AlertCircle, Check, CheckCircle, Loader2, X } from 'lucide-react';
5+
import { useRef, useState } from 'react';
66

77
interface Props {
88
onSave: () => void;
@@ -48,6 +48,28 @@ type TestState =
4848
| { kind: 'ok'; modelCount: number }
4949
| { kind: 'error'; message: string };
5050

51+
type DiscoveryState =
52+
| { kind: 'idle' }
53+
| { kind: 'discovering' }
54+
| { kind: 'found'; models: string[] }
55+
| { kind: 'failed' };
56+
57+
/** Priority-ordered model selection after a successful discovery. */
58+
function pickBestModel(models: string[]): string {
59+
const priorities: RegExp[] = [
60+
/^claude-sonnet-4-5/,
61+
/^claude-opus/,
62+
/^claude-sonnet/,
63+
/^gemini-2\.5-pro$|^gemini-3.*pro/,
64+
/^gpt-5/,
65+
];
66+
for (const pattern of priorities) {
67+
const match = models.find((m) => pattern.test(m));
68+
if (match !== undefined) return match;
69+
}
70+
return models[0] ?? '';
71+
}
72+
5173
/**
5274
* Minimal Custom Provider form — wire-agnostic endpoint onboarding.
5375
* Deliberately barebones (native form + FormData-ish accessors, no schema),
@@ -79,15 +101,74 @@ export function AddCustomProviderModal({
79101
const [saving, setSaving] = useState(false);
80102
const [error, setError] = useState<string | null>(null);
81103

104+
const [discovery, setDiscovery] = useState<DiscoveryState>({ kind: 'idle' });
105+
// When true, user explicitly chose to type a model name instead of picking from the dropdown.
106+
const [manualModel, setManualModel] = useState(false);
107+
// Track whether user has explicitly typed/picked a model so auto-pick doesn't override it.
108+
const userPickedModel = useRef(false);
109+
110+
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
111+
112+
function scheduleDiscovery(currentBaseUrl: string, currentApiKey: string, currentWire: WireApi) {
113+
if (debounceTimer.current !== null) clearTimeout(debounceTimer.current);
114+
if (!currentBaseUrl.trim().match(/^https?:\/\//)) {
115+
setDiscovery({ kind: 'idle' });
116+
return;
117+
}
118+
debounceTimer.current = setTimeout(() => {
119+
void runDiscovery(currentBaseUrl, currentApiKey, currentWire);
120+
}, 500);
121+
}
122+
123+
async function runDiscovery(currentBaseUrl: string, currentApiKey: string, currentWire: WireApi) {
124+
if (!window.codesign?.config) return;
125+
setDiscovery({ kind: 'discovering' });
126+
try {
127+
const res = await window.codesign.config.testEndpoint({
128+
wire: currentWire,
129+
baseUrl: currentBaseUrl.trim(),
130+
apiKey: currentApiKey.trim(),
131+
});
132+
if (res.ok && res.models.length > 0) {
133+
setDiscovery({ kind: 'found', models: res.models });
134+
if (!userPickedModel.current) {
135+
const best = pickBestModel(res.models);
136+
setDefaultModel(best);
137+
}
138+
} else {
139+
setDiscovery({ kind: 'failed' });
140+
}
141+
} catch {
142+
setDiscovery({ kind: 'failed' });
143+
}
144+
}
145+
82146
function handleBaseUrlChange(v: string) {
83147
setBaseUrl(v);
84148
if (wireAuto) setWire(detectWireFromBaseUrl(v));
85149
setTest({ kind: 'idle' });
150+
scheduleDiscovery(v, apiKey, wireAuto ? detectWireFromBaseUrl(v) : wire);
151+
}
152+
153+
function handleApiKeyChange(v: string) {
154+
setApiKey(v);
155+
scheduleDiscovery(baseUrl, v, wire);
86156
}
87157

88158
function handleWireChange(v: WireApi) {
89159
setWire(v);
90160
setWireAuto(false);
161+
scheduleDiscovery(baseUrl, apiKey, v);
162+
}
163+
164+
function handleModelSelect(v: string) {
165+
setDefaultModel(v);
166+
userPickedModel.current = true;
167+
}
168+
169+
function handleModelTextChange(v: string) {
170+
setDefaultModel(v);
171+
userPickedModel.current = v.length > 0;
91172
}
92173

93174
async function handleTest() {
@@ -168,6 +249,10 @@ export function AddCustomProviderModal({
168249
? t('settings.providers.custom.editTitle')
169250
: t('settings.providers.custom.title');
170251

252+
// Show the model dropdown when discovery found models AND user hasn't switched to manual entry.
253+
const showModelDropdown =
254+
!manualModel && discovery.kind === 'found' && discovery.models.length > 0;
255+
171256
return (
172257
<div
173258
role="dialog"
@@ -243,7 +328,7 @@ export function AddCustomProviderModal({
243328
<Field label={t('settings.providers.custom.apiKey')}>
244329
<TextInput
245330
value={apiKey}
246-
onChange={setApiKey}
331+
onChange={handleApiKeyChange}
247332
type="password"
248333
placeholder={
249334
isEdit && editTarget?.keyMask !== undefined && editTarget.keyMask.length > 0
@@ -255,8 +340,68 @@ export function AddCustomProviderModal({
255340
/>
256341
</Field>
257342

258-
<Field label={t('settings.providers.custom.defaultModel')}>
259-
<TextInput value={defaultModel} onChange={setDefaultModel} placeholder="model-name" />
343+
<Field
344+
label={t('settings.providers.custom.defaultModel')}
345+
inline={
346+
discovery.kind === 'discovering' ? (
347+
<span className="inline-flex items-center gap-1 text-[var(--text-xs)] text-[var(--color-text-muted)]">
348+
<Loader2 className="w-3 h-3 animate-spin" />
349+
{t('settings.providers.cliProxyApi.discoveringModels')}
350+
</span>
351+
) : discovery.kind === 'found' ? (
352+
<span className="inline-flex items-center gap-1 text-[var(--text-xs)] text-[var(--color-success)]">
353+
<Check className="w-3 h-3" />
354+
{t('settings.providers.cliProxyApi.discoveredModels', {
355+
count: discovery.models.length,
356+
})}
357+
</span>
358+
) : discovery.kind === 'failed' ? (
359+
<span className="inline-flex items-center gap-1 text-[var(--text-xs)] text-[var(--color-text-muted)]">
360+
<AlertCircle className="w-3 h-3" />
361+
{t('settings.providers.cliProxyApi.discoveryFailed')}
362+
</span>
363+
) : null
364+
}
365+
>
366+
{showModelDropdown ? (
367+
<div className="flex items-center gap-2">
368+
<select
369+
value={defaultModel}
370+
onChange={(e) => handleModelSelect(e.target.value)}
371+
className="flex-1 h-8 px-3 rounded-[var(--radius-md)] bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--text-sm)] text-[var(--color-text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]"
372+
>
373+
{discovery.models.map((m) => (
374+
<option key={m} value={m}>
375+
{m}
376+
</option>
377+
))}
378+
</select>
379+
<button
380+
type="button"
381+
onClick={() => setManualModel(true)}
382+
className="shrink-0 text-[var(--text-xs)] text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] underline"
383+
>
384+
{t('settings.providers.custom.switchToManual')}
385+
</button>
386+
</div>
387+
) : (
388+
<div className="flex items-center gap-2">
389+
<TextInput
390+
value={defaultModel}
391+
onChange={handleModelTextChange}
392+
placeholder="model-name"
393+
/>
394+
{manualModel && discovery.kind === 'found' && discovery.models.length > 0 && (
395+
<button
396+
type="button"
397+
onClick={() => setManualModel(false)}
398+
className="shrink-0 text-[var(--text-xs)] text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] underline"
399+
>
400+
{t('settings.providers.custom.switchToDropdown')}
401+
</button>
402+
)}
403+
</div>
404+
)}
260405
</Field>
261406

262407
<div className="flex items-center gap-2">
@@ -304,12 +449,23 @@ export function AddCustomProviderModal({
304449
);
305450
}
306451

307-
function Field({ label, children }: { label: string; children: React.ReactNode }) {
452+
function Field({
453+
label,
454+
inline,
455+
children,
456+
}: {
457+
label: string;
458+
inline?: React.ReactNode;
459+
children: React.ReactNode;
460+
}) {
308461
return (
309462
<div>
310-
<p className="block text-[var(--text-xs)] font-medium text-[var(--color-text-secondary)] mb-1.5">
311-
{label}
312-
</p>
463+
<div className="flex items-center justify-between mb-1.5">
464+
<p className="block text-[var(--text-xs)] font-medium text-[var(--color-text-secondary)]">
465+
{label}
466+
</p>
467+
{inline !== undefined && <span>{inline}</span>}
468+
</div>
313469
{children}
314470
</div>
315471
);

packages/i18n/src/locales/en.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,11 +215,18 @@
215215
"apiKey": "API Key",
216216
"apiKeyEditPlaceholder": "Leave empty to keep {{mask}}",
217217
"defaultModel": "Default model",
218+
"switchToManual": "Enter manually",
219+
"switchToDropdown": "Pick from list",
218220
"test": "Test connection",
219221
"testOk": "OK — {{count}} models available",
220222
"save": "Save & continue",
221223
"saveEdit": "Save changes"
222224
},
225+
"cliProxyApi": {
226+
"discoveringModels": "Discovering models…",
227+
"discoveredModels": "Found {{count}} models",
228+
"discoveryFailed": "Could not connect"
229+
},
223230
"import": {
224231
"action": "Import",
225232
"dismiss": "Dismiss",

packages/i18n/src/locales/zh-CN.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,11 +215,18 @@
215215
"apiKey": "API Key",
216216
"apiKeyEditPlaceholder": "留空则保留 {{mask}}",
217217
"defaultModel": "默认模型",
218+
"switchToManual": "手动输入",
219+
"switchToDropdown": "从列表选择",
218220
"test": "测试连接",
219221
"testOk": "正常 — 共 {{count}} 个模型",
220222
"save": "保存并继续",
221223
"saveEdit": "保存修改"
222224
},
225+
"cliProxyApi": {
226+
"discoveringModels": "正在探测模型…",
227+
"discoveredModels": "找到 {{count}} 个模型",
228+
"discoveryFailed": "无法连接"
229+
},
223230
"import": {
224231
"action": "导入",
225232
"dismiss": "忽略",

0 commit comments

Comments
 (0)