Skip to content

Commit 616acaf

Browse files
committed
feat(settings): CLIProxyAPI preset + smart model auto-discovery
Adds support for CLIProxyAPI (CPA) — a popular Go local proxy (router-for-me/CLIProxyAPI, ~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 → "CLIProxyAPI" → baseUrl auto-fills http://127.0.0.1:8317 with anthropic wire → typing baseUrl/key triggers /v1/models auto-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. Zero backend code needed: - CPA serves wildcard CORS - pi-ai's anthropic-messages wire already speaks /v1/messages - claude-code-compat already injects claude-cli identity headers for any non-api.anthropic.com anthropic-wire baseUrl - IPC config:v1:test-endpoint already calls /v1/models Incidentally closes the long-standing model selector gap for imported and custom providers. Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent 803efab commit 616acaf

7 files changed

Lines changed: 238 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: 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
);

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1550,6 +1550,16 @@ function ModelsTab() {
15501550
setShowAddMenu(false);
15511551
setShowAddCustom(true);
15521552
}}
1553+
onAddCliProxyApi={() => {
1554+
setShowAddMenu(false);
1555+
setCustomProviderPreset({
1556+
name: 'CLIProxyAPI',
1557+
baseUrl: 'http://127.0.0.1:8317',
1558+
wire: 'anthropic',
1559+
defaultModel: '',
1560+
});
1561+
setShowAddCustom(true);
1562+
}}
15531563
/>
15541564
</div>
15551565

@@ -2159,6 +2169,7 @@ interface AddProviderMenuProps {
21592169
onImportClaudeCode: () => void;
21602170
onAddOllama: () => void;
21612171
onAddCustom: () => void;
2172+
onAddCliProxyApi: () => void;
21622173
}
21632174

21642175
function AddProviderMenu({
@@ -2170,6 +2181,7 @@ function AddProviderMenu({
21702181
onImportClaudeCode,
21712182
onAddOllama,
21722183
onAddCustom,
2184+
onAddCliProxyApi,
21732185
}: AddProviderMenuProps) {
21742186
const t = useT();
21752187
const rootRef = useRef<HTMLDivElement>(null);
@@ -2233,6 +2245,15 @@ function AddProviderMenu({
22332245
disabled: false,
22342246
onClick: onAddCustom,
22352247
},
2248+
{
2249+
key: 'cli-proxy-api',
2250+
label: t('settings.providers.cliProxyApi.presetName', { defaultValue: 'CLIProxyAPI' }),
2251+
desc: t('settings.providers.cliProxyApi.presetDescription', {
2252+
defaultValue: 'Local proxy that wraps Claude/Codex/Gemini OAuth subscriptions',
2253+
}),
2254+
disabled: false,
2255+
onClick: onAddCliProxyApi,
2256+
},
22362257
];
22372258

22382259
return (

packages/i18n/src/locales/en.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,8 @@
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",
@@ -301,6 +303,15 @@
301303
"connectionOk": "Connection OK",
302304
"connectionFailed": "Connection failed"
303305
},
306+
"cliProxyApi": {
307+
"presetName": "CLIProxyAPI",
308+
"presetDescription": "Local proxy that wraps Claude/Codex/Gemini OAuth subscriptions",
309+
"apiKeyOptional": "API key only required if you configured `api-keys` in CPA config.yaml",
310+
"thinkingHint": "Tip: append `(high)` / `(xhigh)` / `(8192)` to model name to control thinking budget",
311+
"discoveringModels": "Discovering models...",
312+
"discoveredModels": "Found {{count}} models",
313+
"discoveryFailed": "Could not connect to CPA"
314+
},
304315
"reasoning": {
305316
"label": "Reasoning depth",
306317
"default": "Default (auto)",

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,8 @@
215215
"apiKey": "API Key",
216216
"apiKeyEditPlaceholder": "留空则保留 {{mask}}",
217217
"defaultModel": "默认模型",
218+
"switchToManual": "手动输入",
219+
"switchToDropdown": "从列表选择",
218220
"test": "测试连接",
219221
"testOk": "正常 — 共 {{count}} 个模型",
220222
"save": "保存并继续",
@@ -301,6 +303,15 @@
301303
"connectionOk": "连接正常",
302304
"connectionFailed": "连接失败"
303305
},
306+
"cliProxyApi": {
307+
"presetName": "CLIProxyAPI",
308+
"presetDescription": "本地反代,将 Claude/Codex/Gemini 的订阅账号包装成统一 API",
309+
"apiKeyOptional": "仅当你在 CPA config.yaml 里配置了 api-keys 才需要填",
310+
"thinkingHint": "提示:在 model 名后加 `(high)` / `(xhigh)` / `(8192)` 可控制思考力度",
311+
"discoveringModels": "正在发现模型…",
312+
"discoveredModels": "发现 {{count}} 个模型",
313+
"discoveryFailed": "连接 CPA 失败"
314+
},
304315
"reasoning": {
305316
"label": "推理深度",
306317
"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)