Skip to content

feat(settings): CLIProxyAPI preset + smart model auto-discovery#178

Merged
Sun-sunshine06 merged 4 commits intomainfrom
feat/cpa-preset
Apr 23, 2026
Merged

feat(settings): CLIProxyAPI preset + smart model auto-discovery#178
Sun-sunshine06 merged 4 commits intomainfrom
feat/cpa-preset

Conversation

@hqhq1025
Copy link
Copy Markdown
Collaborator

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}.jsonsettings.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.

@github-actions github-actions Bot added docs Documentation area:desktop apps/desktop (Electron shell, renderer) labels Apr 23, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Background model discovery sends API key without explicit user intent — typing in the API key field now automatically triggers testEndpoint and transmits the key to the current baseUrl after debounce, which increases accidental secret disclosure risk (e.g. typo/malicious host) compared with explicit test action. Evidence apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:153, apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:130
    Suggested fix:

    function handleApiKeyChange(v: string) {
      setApiKey(v);
      // Don't auto-probe while secrets are being edited.
      // Keep discovery trigger on baseUrl/wire changes or explicit "Test connection".
    }
    
    // Optional: only include key for explicit test/save flows
    const discoveryApiKey = '';
    const res = await window.codesign.config.testEndpoint({
      wire: currentWire,
      baseUrl: currentBaseUrl.trim(),
      apiKey: discoveryApiKey,
    });
  • [Major] Discovery requests are race-prone and can apply stale results — multiple async probes can resolve out of order; stale responses can overwrite discovery state and defaultModel for newer baseUrl/wire input. Evidence apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:123, apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:133, apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:136
    Suggested fix:

    const discoverySeq = useRef(0);
    
    async function runDiscovery(currentBaseUrl: string, currentApiKey: string, currentWire: WireApi) {
      if (!window.codesign?.config) {
        setDiscovery({ kind: 'failed' });
        return;
      }
      const seq = ++discoverySeq.current;
      setDiscovery({ kind: 'discovering' });
      try {
        const res = await window.codesign.config.testEndpoint({
          wire: currentWire,
          baseUrl: currentBaseUrl.trim(),
          apiKey: currentApiKey.trim(),
        });
        if (seq !== discoverySeq.current) return; // stale response
        if (res.ok && res.models.length > 0) {
          setDiscovery({ kind: 'found', models: res.models });
          if (!userPickedModel.current) setDefaultModel(pickBestModel(res.models));
        } else {
          setDiscovery({ kind: 'failed' });
        }
      } catch {
        if (seq === discoverySeq.current) setDiscovery({ kind: 'failed' });
      }
    }

Summary

  • Review mode: initial
  • 2 issues found in the latest diff, both in auto-discovery logic (secret handling and stale async state).
  • docs/VISION.md and docs/PRINCIPLES.md: Not found in repo/docs in this checkout, so this review is based on repository-visible constraints and the changed lines.
  • Residual risk/testing gap: no diff-added Vitest/Playwright coverage for discovery debounce/race behavior and secret-handling behavior.

Testing

  • Not run (automation)

open-codesign Bot

scheduleDiscovery(v, apiKey, wireAuto ? detectWireFromBaseUrl(v) : wire);
}

function handleApiKeyChange(v: string) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] Auto-discovery is triggered on API key edits (), and sends that key to in the background. This weakens secret-safety compared with explicit test intent.

Suggested fix:

}, 500);
}

async function runDiscovery(currentBaseUrl: string, currentApiKey: string, currentWire: WireApi) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] has no request version/abort guard. Out-of-order async responses can overwrite / for newer inputs.

Suggested fix:

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>
- Don't auto-probe on API key changes. Key is only sent when user
  explicitly clicks "Test connection" or when baseUrl/wire changes
  (where they already implicitly authorize a probe by editing the
  endpoint). Avoids leaking keys to typo'd or malicious hosts.
- Use a monotonic seq counter to discard stale async responses.
  Fixes race where a slow older probe could overwrite newer state
  and default-model selection.

Signed-off-by: hqhq1025 <1506751656@qq.com>
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Background discovery still sends API key before explicit user test/save intent — model auto-discovery posts apiKey on baseUrl/wire edits, so a mistyped or untrusted endpoint can receive credentials implicitly. Evidence apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:132, apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:153, apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:163
    Suggested fix:

    // Keep auto-discovery unauthenticated to avoid implicit secret exfiltration.
    const res = await window.codesign.config.testEndpoint({
      wire: currentWire,
      baseUrl: currentBaseUrl.trim(),
      apiKey: '',
    });
    
    // Use typed key only in explicit user actions (Test connection / Save).
  • [Minor] Discovery status text is provider-specific but used for all custom providers — the modal renders cliProxyApi.* strings for generic custom endpoints, so non-CPA providers show incorrect messages like “Could not connect to CPA”. Evidence apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:351, apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:356, apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:363
    Suggested fix:

    // Use provider-agnostic keys under settings.providers.custom.*
    {t('settings.providers.custom.discoveringModels')}
    {t('settings.providers.custom.discoveredModels', { count: discovery.models.length })}
    {t('settings.providers.custom.discoveryFailed')}

Summary

  • Review mode: follow-up after new commits
  • 2 issues found in the latest diff (implicit credential transmission during auto-discovery, and incorrect provider-specific discovery copy in generic custom-provider UI).
  • docs/VISION.md and docs/PRINCIPLES.md: Not found in repo/docs in this checkout.
  • Residual risk/testing gap: no diff-added Vitest/Playwright coverage for discovery behavior (secret handling + generic/custom copy rendering paths).

Testing

  • Not run (automation)

open-codesign Bot

const res = await window.codesign.config.testEndpoint({
wire: currentWire,
baseUrl: currentBaseUrl.trim(),
apiKey: currentApiKey.trim(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] Auto-discovery posts the typed API key in background requests triggered by baseUrl/wire edits, which can leak secrets to a mistyped/untrusted endpoint before explicit user intent.

Suggested fix:

const res = await window.codesign.config.testEndpoint({
  wire: currentWire,
  baseUrl: currentBaseUrl.trim(),
  apiKey: '', // no secret on implicit probe
});

Use the real key only in explicit handleTest/handleSave flows.

discovery.kind === 'discovering' ? (
<span className="inline-flex items-center gap-1 text-[var(--text-xs)] text-[var(--color-text-muted)]">
<Loader2 className="w-3 h-3 animate-spin" />
{t('settings.providers.cliProxyApi.discoveringModels')}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] This generic custom-provider modal uses CPA-specific translation keys, so non-CPA endpoints show incorrect status text.

Suggested fix:

{t('settings.providers.custom.discoveringModels')}
{t('settings.providers.custom.discoveredModels', { count: discovery.models.length })}
{t('settings.providers.custom.discoveryFailed')}

Add matching keys in en.json and zh-CN.json.

- Auto-discovery never sends the API key. The key is only transmitted
  on explicit user actions ("Test connection" button, Save). Removes
  implicit credential transmission to typo'd or untrusted endpoints
  during incremental baseUrl/wire edits.
- Move discovery status strings from `settings.providers.cliProxyApi.*`
  to `settings.providers.custom.*`. The modal renders these for every
  custom provider, not just CPA, so the copy must be provider-agnostic.
  Changed "Could not connect to CPA" → "Could not auto-discover models".

Signed-off-by: hqhq1025 <1506751656@qq.com>
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Stale auto-discovery results can overwrite UI state after base URL becomes invalid — when a discovery request is in flight and the user clears/invalidates baseUrl, the code sets discovery to idle but does not invalidate the in-flight sequence, so the old response can still set found/failed and overwrite defaultModel. Evidence apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:115, apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:126, apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:134
    Suggested fix:
    function scheduleDiscovery(currentBaseUrl: string, currentWire: WireApi) {
      if (debounceTimer.current !== null) clearTimeout(debounceTimer.current);
      if (!currentBaseUrl.trim().match(/^https?:\/\//)) {
        discoverySeq.current += 1; // invalidate any in-flight request
        setDiscovery({ kind: 'idle' });
        return;
      }
      debounceTimer.current = setTimeout(() => {
        void runDiscovery(currentBaseUrl, currentWire);
      }, 500);
    }

Summary

  • Review mode: follow-up after new commits
  • 1 issue found in the latest diff: stale in-flight discovery response can re-apply state after input invalidation.
  • docs/VISION.md and docs/PRINCIPLES.md: Not found in repo/docs.
  • Residual risk/testing gap: no diff-added Vitest coverage for async discovery race conditions (stale-response suppression on baseUrl invalidation).

Testing

  • Not run (automation)

open-codesign Bot


function scheduleDiscovery(currentBaseUrl: string, currentWire: WireApi) {
if (debounceTimer.current !== null) clearTimeout(debounceTimer.current);
if (!currentBaseUrl.trim().match(/^https?:\/\//)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] In-flight discovery is not invalidated when baseUrl becomes invalid, so stale responses can still update discovery/defaultModel after the field is cleared.

Suggested fix:

if (!currentBaseUrl.trim().match(/^https?:\/\//)) {
  discoverySeq.current += 1;
  setDiscovery({ kind: idle });
  return;
}

Bump discoverySeq when scheduleDiscovery returns early due to an
invalid baseUrl. Otherwise a slow probe started before the user
cleared the field can still resolve and overwrite the freshly-reset
`idle` state back to `found`/`failed`.

Signed-off-by: hqhq1025 <1506751656@qq.com>
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Auto-discovery ignores the user-entered API key, so authenticated providers keep failing discovery even after the key is entered. runDiscovery always sends apiKey: '', and changing the API key does not schedule a new probe. Evidence apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:130, apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:133, apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx:157
    Suggested fix:
    function handleApiKeyChange(v: string) {
      setApiKey(v);
      scheduleDiscovery(baseUrl, wire, v);
    }
    
    async function runDiscovery(currentBaseUrl: string, currentWire: WireApi, currentApiKey: string) {
      const res = await window.codesign.config.testEndpoint({
        wire: currentWire,
        baseUrl: currentBaseUrl.trim(),
        apiKey: currentApiKey.trim(),
      });
    }

Summary

  • Review mode: follow-up after new commits
  • 1 issue found in the latest diff: model auto-discovery cannot succeed for auth-required endpoints because API key input is ignored.
  • docs/VISION.md and docs/PRINCIPLES.md: Not found in repo/docs.
  • Residual risk/testing gap: no diff-added Vitest coverage for API-key change re-discovery and authenticated discovery paths.

Testing

  • Not run (automation)

open-codesign Bot

const res = await window.codesign.config.testEndpoint({
wire: currentWire,
baseUrl: currentBaseUrl.trim(),
apiKey: '',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runDiscovery currently hardcodes apiKey: '', so discovery fails against endpoints that require auth even after the user enters an API key. Please thread the current key into scheduleDiscovery/runDiscovery and trigger discovery in handleApiKeyChange so key updates are reflected.

function handleApiKeyChange(v: string) {
  setApiKey(v);
  scheduleDiscovery(baseUrl, wire, v);
}

@hqhq1025
Copy link
Copy Markdown
Collaborator Author

Respectfully pushing back on the Round 3 finding — it directly contradicts the Round 1/2 findings and re-introduces the security issue those rounds flagged. Keeping the current code.

The Round 1/2 feedback (both Major):

[Major] Background model discovery sends API key without explicit user intent — typing in the API key field now automatically triggers testEndpoint and transmits the key to the current baseUrl after debounce, which increases accidental secret disclosure risk (e.g. typo/malicious host) compared with explicit test action.
[Major] Background discovery still sends API key before explicit user test/save intent — model auto-discovery posts apiKey on baseUrl/wire edits, so a mistyped or untrusted endpoint can receive credentials implicitly.

I implemented the suggested fix: auto-discovery is now unauthenticated, key only sent on explicit Test/Save.

The Round 3 feedback now asks to reverse that:

[Major] Auto-discovery ignores the user-entered API key, so authenticated providers keep failing discovery even after the key is entered.

Taking Round 3's suggestion would re-introduce Round 1/2's Major secret-disclosure risk. Current behavior is a considered trade-off:

  1. Most CPA users (our primary audience) don't set `api-keys` in their config.yaml — unauthenticated discovery works by default for them
  2. Users who DO set `api-keys` see "Could not auto-discover models" in the defaultModel field, but the modal still offers: (a) the Test connection button which uses the typed key for an explicit probe, (b) a manual-entry fallback for the model name. Both paths are one click away
  3. Security wins: a typo'd or malicious baseUrl never receives the user's key during incremental edits

The UX degradation for the `api-keys`-configured minority is small (one extra click on Test), and reversible if they later want the dropdown — just click Test. Much better than silent credential transmission to arbitrary endpoints.

Not making the change.

@Sun-sunshine06 Sun-sunshine06 merged commit 0a0ff2e into main Apr 23, 2026
7 checks passed
@Sun-sunshine06 Sun-sunshine06 deleted the feat/cpa-preset branch April 23, 2026 03:26
Sun-sunshine06 added a commit that referenced this pull request Apr 23, 2026
#190)

## Summary

Follow-up to #178 (CLIProxyAPI preset). When Settings > Models mounts,
probe `http://127.0.0.1:8317/v1/models` — if CPA is running locally and
not yet configured, show a one-click Import banner at the top of the
provider list.

Pattern borrowed from EasyCLI (official CPA GUI) and ProxyPal (1k★
SolidJS+Tauri wrapper). Both show the running CPA prominently on first
load; users click once and are done.

## Flow

1. Models tab mounts → `useEffect` fires once
2. Skip if localStorage `cpa-detection-dismissed-v1` is set or any
existing provider points at `localhost:8317`
3. Call `window.codesign.config.testEndpoint({ wire: 'anthropic',
baseUrl: 'http://127.0.0.1:8317', apiKey: '' })` — CPA has wildcard CORS
+ the IPC bridges through main process anyway
4. On success, render `LocalCpaImportCard` (Zap icon + title + body +
Import/Dismiss buttons)
5. "Import" reuses existing `customProviderPreset` state →
`AddCustomProviderModal` pre-filled with CPA preset. Modal's
auto-discovery fills defaultModel. User clicks Save. Done.

## Test plan

- [ ] `pnpm typecheck` green
- [ ] `pnpm lint` green
- [ ] Manual: install + run CPA → open Settings → see banner
- [ ] Manual: click Import → modal opens with preset → save → banner
disappears and does not reappear
- [ ] Manual: click Dismiss → banner disappears and does not reappear on
subsequent Settings opens
- [ ] Manual: no CPA running → no banner (silent failure)

## PR chain

Depends on #178. Base is `feat/cpa-preset` so the diff stays focused on
this change. Will rebase to `main` after #178 merges.

---------

Signed-off-by: hqhq1025 <1506751656@qq.com>
Co-authored-by: Sun-sunshine06 <Sun-sunshine06@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:desktop apps/desktop (Electron shell, renderer) docs Documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants