Skip to content

Commit 0a0ff2e

Browse files
authored
feat(settings): CLIProxyAPI preset + smart model auto-discovery (#178)
## Summary Add support for **CLIProxyAPI (CPA)** — `router-for-me/CLIProxyAPI`, a popular Go local proxy (~27K stars, MIT) that wraps Claude Code / Codex / Gemini OAuth subscriptions into a unified API. Heavily requested by the linux.do user base. User flow: Settings → Add provider → pick "CLIProxyAPI" → baseUrl pre-fills `http://127.0.0.1:8317` + `anthropic` wire → typing baseUrl/key auto-triggers `/v1/models` discovery (500ms debounce) → defaultModel becomes a dropdown auto-selecting best model (claude-sonnet-4-5 > opus > sonnet > gemini-2.5-pro > gpt-5 > first) → save & ready. Incidentally closes the long-standing model selector gap for custom/imported providers. ## Why this works with zero backend code - CPA serves wildcard CORS — Electron talks to it directly - pi-ai's anthropic-messages wire already speaks CPA's `/v1/messages` endpoint - `packages/providers/src/claude-code-compat.ts` already auto-injects `claude-cli` identity headers for any non-`api.anthropic.com` anthropic-wire baseUrl — CPA inherits this for free - IPC `config:v1:test-endpoint` already calls `/v1/models` and returns the discovered list ## Changes - `packages/shared/src/proxy-presets.ts` — new `cli-proxy-api` preset - `packages/i18n/src/locales/{en,zh-CN}.json` — `settings.providers.cliProxyApi.*` keys - `apps/desktop/src/renderer/src/components/Settings.tsx` — preset quick-pick menu item - `apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx` — debounced auto-discovery + hybrid dropdown/text defaultModel + smart auto-pick - `.changeset/cpa-preset.md` + `.changeset/cpa-modal-autodiscover.md` ## Test plan - [ ] `pnpm typecheck` — green - [ ] `pnpm lint` — green - [ ] Manual: install CPA locally, OAuth login Claude, pick CLIProxyAPI preset → verify auto-discovery + dropdown + claude-sonnet-4-5 auto-pick - [ ] Manual: type bad URL → verify graceful "Could not connect" + manual entry fallback ## Note on history These commits originally landed directly on `main` (per the v0.x fast-merge convention), then were reverted specifically to open this PR for Codex bot review. The PR merges back as a clean single commit that re-introduces the feature. --------- Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent b7ab2c8 commit 0a0ff2e

7 files changed

Lines changed: 241 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.

.changeset/cpa-preset.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@open-codesign/desktop': minor
3+
'@open-codesign/shared': patch
4+
'@open-codesign/i18n': patch
5+
---
6+
7+
feat(settings): add CLIProxyAPI preset quick-pick
8+
9+
Adds CLIProxyAPI (`router-for-me/CLIProxyAPI`) as a first-class preset in the Add Provider menu. CLIProxyAPI is a Go local proxy on port 8317 that wraps Claude/Codex/Gemini OAuth subscriptions into a unified Anthropic Messages API — heavily requested by the Chinese user base.
10+
11+
- `packages/shared`: new `cli-proxy-api` entry in `PROXY_PRESETS` (anthropic wire, `http://127.0.0.1:8317`)
12+
- `packages/i18n`: `settings.providers.cliProxyApi.*` keys in both `en.json` and `zh-CN.json` (preset name, description, api-key-optional hint, thinking-budget hint, model discovery strings)
13+
- `apps/desktop`: `AddProviderMenu` gains a CLIProxyAPI item that opens `AddCustomProviderModal` pre-filled with the CPA endpoint and anthropic wire; claude-cli identity headers are injected automatically by the existing `shouldForceClaudeCodeIdentity` path (no extra code needed)

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

Lines changed: 168 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,77 @@ 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+
const discoverySeq = useRef(0);
112+
113+
function scheduleDiscovery(currentBaseUrl: string, currentWire: WireApi) {
114+
if (debounceTimer.current !== null) clearTimeout(debounceTimer.current);
115+
if (!currentBaseUrl.trim().match(/^https?:\/\//)) {
116+
discoverySeq.current += 1;
117+
setDiscovery({ kind: 'idle' });
118+
return;
119+
}
120+
debounceTimer.current = setTimeout(() => {
121+
void runDiscovery(currentBaseUrl, currentWire);
122+
}, 500);
123+
}
124+
125+
async function runDiscovery(currentBaseUrl: string, currentWire: WireApi) {
126+
if (!window.codesign?.config) return;
127+
const seq = ++discoverySeq.current;
128+
setDiscovery({ kind: 'discovering' });
129+
try {
130+
const res = await window.codesign.config.testEndpoint({
131+
wire: currentWire,
132+
baseUrl: currentBaseUrl.trim(),
133+
apiKey: '',
134+
});
135+
if (seq !== discoverySeq.current) return;
136+
if (res.ok && res.models.length > 0) {
137+
setDiscovery({ kind: 'found', models: res.models });
138+
if (!userPickedModel.current) {
139+
const best = pickBestModel(res.models);
140+
setDefaultModel(best);
141+
}
142+
} else {
143+
setDiscovery({ kind: 'failed' });
144+
}
145+
} catch {
146+
if (seq === discoverySeq.current) setDiscovery({ kind: 'failed' });
147+
}
148+
}
149+
82150
function handleBaseUrlChange(v: string) {
83151
setBaseUrl(v);
84152
if (wireAuto) setWire(detectWireFromBaseUrl(v));
85153
setTest({ kind: 'idle' });
154+
scheduleDiscovery(v, wireAuto ? detectWireFromBaseUrl(v) : wire);
155+
}
156+
157+
function handleApiKeyChange(v: string) {
158+
setApiKey(v);
86159
}
87160

88161
function handleWireChange(v: WireApi) {
89162
setWire(v);
90163
setWireAuto(false);
164+
scheduleDiscovery(baseUrl, v);
165+
}
166+
167+
function handleModelSelect(v: string) {
168+
setDefaultModel(v);
169+
userPickedModel.current = true;
170+
}
171+
172+
function handleModelTextChange(v: string) {
173+
setDefaultModel(v);
174+
userPickedModel.current = v.length > 0;
91175
}
92176

93177
async function handleTest() {
@@ -168,6 +252,10 @@ export function AddCustomProviderModal({
168252
? t('settings.providers.custom.editTitle')
169253
: t('settings.providers.custom.title');
170254

255+
// Show the model dropdown when discovery found models AND user hasn't switched to manual entry.
256+
const showModelDropdown =
257+
!manualModel && discovery.kind === 'found' && discovery.models.length > 0;
258+
171259
return (
172260
<div
173261
role="dialog"
@@ -243,7 +331,7 @@ export function AddCustomProviderModal({
243331
<Field label={t('settings.providers.custom.apiKey')}>
244332
<TextInput
245333
value={apiKey}
246-
onChange={setApiKey}
334+
onChange={handleApiKeyChange}
247335
type="password"
248336
placeholder={
249337
isEdit && editTarget?.keyMask !== undefined && editTarget.keyMask.length > 0
@@ -255,8 +343,68 @@ export function AddCustomProviderModal({
255343
/>
256344
</Field>
257345

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

262410
<div className="flex items-center gap-2">
@@ -304,12 +452,23 @@ export function AddCustomProviderModal({
304452
);
305453
}
306454

307-
function Field({ label, children }: { label: string; children: React.ReactNode }) {
455+
function Field({
456+
label,
457+
inline,
458+
children,
459+
}: {
460+
label: string;
461+
inline?: React.ReactNode;
462+
children: React.ReactNode;
463+
}) {
308464
return (
309465
<div>
310-
<p className="block text-[var(--text-xs)] font-medium text-[var(--color-text-secondary)] mb-1.5">
311-
{label}
312-
</p>
466+
<div className="flex items-center justify-between mb-1.5">
467+
<p className="block text-[var(--text-xs)] font-medium text-[var(--color-text-secondary)]">
468+
{label}
469+
</p>
470+
{inline !== undefined && <span>{inline}</span>}
471+
</div>
313472
{children}
314473
</div>
315474
);

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1558,6 +1558,16 @@ function ModelsTab() {
15581558
setShowAddMenu(false);
15591559
setShowAddCustom(true);
15601560
}}
1561+
onAddCliProxyApi={() => {
1562+
setShowAddMenu(false);
1563+
setCustomProviderPreset({
1564+
name: 'CLIProxyAPI',
1565+
baseUrl: 'http://127.0.0.1:8317',
1566+
wire: 'anthropic',
1567+
defaultModel: '',
1568+
});
1569+
setShowAddCustom(true);
1570+
}}
15611571
/>
15621572
</div>
15631573

@@ -2215,6 +2225,7 @@ interface AddProviderMenuProps {
22152225
onImportClaudeCode: () => void;
22162226
onAddOllama: () => void;
22172227
onAddCustom: () => void;
2228+
onAddCliProxyApi: () => void;
22182229
}
22192230

22202231
function AddProviderMenu({
@@ -2226,6 +2237,7 @@ function AddProviderMenu({
22262237
onImportClaudeCode,
22272238
onAddOllama,
22282239
onAddCustom,
2240+
onAddCliProxyApi,
22292241
}: AddProviderMenuProps) {
22302242
const t = useT();
22312243
const rootRef = useRef<HTMLDivElement>(null);
@@ -2289,6 +2301,15 @@ function AddProviderMenu({
22892301
disabled: false,
22902302
onClick: onAddCustom,
22912303
},
2304+
{
2305+
key: 'cli-proxy-api',
2306+
label: t('settings.providers.cliProxyApi.presetName', { defaultValue: 'CLIProxyAPI' }),
2307+
desc: t('settings.providers.cliProxyApi.presetDescription', {
2308+
defaultValue: 'Local proxy that wraps Claude/Codex/Gemini OAuth subscriptions',
2309+
}),
2310+
disabled: false,
2311+
onClick: onAddCliProxyApi,
2312+
},
22922313
];
22932314

22942315
return (

packages/i18n/src/locales/en.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,11 @@
216216
"apiKey": "API Key",
217217
"apiKeyEditPlaceholder": "Leave empty to keep {{mask}}",
218218
"defaultModel": "Default model",
219+
"switchToManual": "Enter manually",
220+
"switchToDropdown": "Pick from list",
221+
"discoveringModels": "Discovering models...",
222+
"discoveredModels": "Found {{count}} models",
223+
"discoveryFailed": "Could not auto-discover models",
219224
"test": "Test connection",
220225
"testOk": "OK — {{count}} models available",
221226
"save": "Save & continue",
@@ -302,6 +307,12 @@
302307
"connectionOk": "Connection OK",
303308
"connectionFailed": "Connection failed"
304309
},
310+
"cliProxyApi": {
311+
"presetName": "CLIProxyAPI",
312+
"presetDescription": "Local proxy that wraps Claude/Codex/Gemini OAuth subscriptions",
313+
"apiKeyOptional": "API key only required if you configured `api-keys` in CPA config.yaml",
314+
"thinkingHint": "Tip: append `(high)` / `(xhigh)` / `(8192)` to model name to control thinking budget"
315+
},
305316
"reasoning": {
306317
"label": "Reasoning depth",
307318
"default": "Default (auto)",

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,11 @@
216216
"apiKey": "API Key",
217217
"apiKeyEditPlaceholder": "留空则保留 {{mask}}",
218218
"defaultModel": "默认模型",
219+
"switchToManual": "手动输入",
220+
"switchToDropdown": "从列表选择",
221+
"discoveringModels": "正在发现模型…",
222+
"discoveredModels": "发现 {{count}} 个模型",
223+
"discoveryFailed": "无法自动发现模型",
219224
"test": "测试连接",
220225
"testOk": "正常 — 共 {{count}} 个模型",
221226
"save": "保存并继续",
@@ -302,6 +307,12 @@
302307
"connectionOk": "连接正常",
303308
"connectionFailed": "连接失败"
304309
},
310+
"cliProxyApi": {
311+
"presetName": "CLIProxyAPI",
312+
"presetDescription": "本地反代,将 Claude/Codex/Gemini 的订阅账号包装成统一 API",
313+
"apiKeyOptional": "仅当你在 CPA config.yaml 里配置了 api-keys 才需要填",
314+
"thinkingHint": "提示:在 model 名后加 `(high)` / `(xhigh)` / `(8192)` 可控制思考力度"
315+
},
305316
"reasoning": {
306317
"label": "推理深度",
307318
"default": "默认(自动)",

packages/shared/src/proxy-presets.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ export const PROXY_PRESETS = [
5252
baseUrl: 'http://localhost:3000/v1',
5353
notes: 'Edit URL to your deployment',
5454
},
55+
{
56+
id: 'cli-proxy-api',
57+
label: 'CLIProxyAPI',
58+
provider: 'anthropic',
59+
baseUrl: 'http://127.0.0.1:8317',
60+
notes: '',
61+
},
5562
{
5663
id: 'custom',
5764
label: 'Custom...',

0 commit comments

Comments
 (0)