Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions devlog/_plan/261002_claude_cli_picker/010_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# 261002 Claude Code CLI first-party picker — plan (010)

## Problem

With `claudeCode.cliFirstParty` on, the standalone `claude` CLI talks to OpenCodex's intercept
proxy, but its `/model` picker only lists Anthropic models. Claude Desktop's picker mode already
injects opencodex routes into Desktop's claude.ai bootstrap catalog; the CLI has no equivalent.

## Evidence (live probe, Claude Code 2.1.287, first-party OAuth, throwaway capture proxy in .tmp/)

- The CLI builds `/model` from `GET https://api.anthropic.com/api/organizations/<org>/model_selector/cc`
(`/api/model_selector/cc` for token/api-key auth). Body: `{model_selector_state, model_selector_config:
[{id:"cc", models:[…Desktop-shaped rows…]}]}`. User-Agent `claude-cli/<v> (external, cli)`.
- Debug log: `[servedCatalog] primary: … the served list replaces the compiled picker`.
- Rows appended to the cc surface appear in `/model` and are selectable when the id is Claude-shaped:
`claude-opus-4-8-20260919`, `claude-opus-4-8-p1ab`, `claude-opus-4-8-k3x`, `…[1m]` all worked.
`gpt-6-luna`, `ocx-claude-native--gpt-6-sol`, `ocx-claude-xai--grok-4.7` render
"Update Claude Code to use this model" (unselectable). So the Desktop-picker `ocx-claude-*` ids cannot
be reused; the Desktop 3P registry ids (`activeDesktop3pAlias`) can.
- The CLI caches the served catalog ~1 h in `<claudeConfigDir>/cache/model-catalog/<org>-<hash>-cc.json`
and does not refetch while fresh, so a toggle needs that cache invalidated.
- When the served catalog flag is off, the CLI falls back to the compiled picker plus
`additional_model_options` from `GET /api/claude_cli/bootstrap` (UA `claude-code/<v>`), shape
`{model, name, description, disabled_reason?}`.

## Diff-level plan

1. `src/claude/intercept/picker-bootstrap.ts`: `PickerModelEntry.description?`; `injectPickerModels(body, models,
explain?, options?: {surfaces?, strip?})` — defaults unchanged for Desktop (`ccd`,`code`); when an entry carries
a description it is written after stripping.
2. New `src/claude/intercept/cli-catalog.ts` (no heavy imports): `cliCatalogKind(method, path)`,
`rewriteCliCatalogBody(kind, text, models)` (cc surface via injectPickerModels with surfaces `["cc"]` and extra
strip `notice`, `selection_notice`; bootstrap appends `additional_model_options` rows not already present),
`rewriteCliCatalogResponse(response, kind, models)` (2xx JSON only, size cap, drops content-length/etag,
untouched bytes on any failure), `cliCatalogEligible(kind, userAgent, desired)` (model_selector: client's own
intent via interceptRouteFor; bootstrap: `desired.cli` and not a Desktop entrypoint),
`invalidateClaudeCodeServedCatalog(claudeDir)` (unlink `*-cc.json` only).
3. `src/claude/intercept/picker-models.ts`: extract shared candidate/profile rendering; add
`buildCliPickerModels(input)`: skip real Anthropic routes, alias = `activeDesktop3pAlias`, keep only when
`resolveDesktop3pAlias(alias) === route` (never advertise an id the router cannot decode), 1M marker as in
Desktop, name = label, description `opencodex · <route>`. `createPickerModelSnapshot(load, path, build?)` and the
persisted-snapshot parser accept the optional description.
4. `src/claude/intercept/listener.ts`: optional `cliCatalog(req, kind)` hook; for a matching GET, relay upstream
(same upstream choice as today) and rewrite when models are returned. Messages dispatch unchanged.
5. `src/claude/intercept/runtime.ts`: when picker routes and desired clients are wired, build a CLI snapshot
(`claude-intercept/cli-picker-models.json`), lazily refreshed (stale 5 min; first request waits ≤2.5 s), and pass
the hook. No timers, no discovery unless an eligible catalog request arrives.
6. `src/server/management/agent-settings-routes.ts`: after a successful `cliFirstParty` change, invalidate the CLI
served-catalog cache (best effort) so the next `claude` launch refetches through the proxy.
7. Tests: new `tests/claude-integration/claude-cli-picker.test.ts` (+ layout.json and test-layout-expected.json).
8. Docs: `docs-site/.../guides/claude-code.md` paragraph; `structure/runtime.md` sentence on the catalog rewrite.

## Verification

Focused tests, `bun run typecheck`, `bun run test:changed`, `structure:check`, `privacy:scan`; live: dev server
from this branch (isolated OPENCODEX_HOME with cliFirstParty + a provider that forwards to the running ocx),
`~/.claude/settings.json` env applied through the real apply path, plain `claude` in tmux → `/model` lists routes
→ pick one → prompt answered; settings and catalog cache restored afterwards.

## Risks

- Claude Code changes the catalog contract: rewrite is fail-open (unchanged bytes).
- Aliases depend on the Desktop 3P registry built at startup; unregistered routes are omitted, not guessed.
- UA classification is a hint, not a boundary (same as the Messages split).

18 changes: 18 additions & 0 deletions devlog/_plan/261002_claude_cli_picker/020_audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# 020 Audit fold

Reviewer verdict: NEAR-PASS (independent subagent, read-only). Folded:

1. Registry readiness — new `src/claude/desktop-3p-startup.ts`: `initDesktop3pRegistry(config)` (deduped, startup inputs incl.
entitlements) replaces the inline block in `src/cli/index.ts`; the CLI loader awaits it when the registry is empty.
Served rows are re-filtered against the live registry at response time.
2. Bootstrap — the listener consults the catalog hook before the relay-native shortcut; bootstrap eligibility is
`desired.cli` only (its `claude-code/<v>` UA carries no entrypoint); upstream stays the route-selected one
(`CLAUDE_INTERCEPT_UPSTREAM` for unknown clients).
3. cc rewrite only for clients classified `cli` with `desired.cli`; Desktop entrypoints untouched (test).
4. Cache invalidation lives in `settings.ts` apply/remove (on change) and in `reconcileClaudeFirstPartySettings` (every ok
reconcile), covering the toggle route, ensure, disable and rollback paths. agent-settings-routes.ts is not touched.
5. Logs carry kind/status/counts only. 8. CLI hook wired from `loadPickerRoutes`, independent of the Desktop picker.
10. Desktop bootstrap output regression test. 13. Risk: `additionalModelOptionsCache` in `~/.claude.json` keeps
bootstrap rows until the CLI's next bootstrap fetch (every launch).
Not folded: 7 (error text) — documented instead.

26 changes: 22 additions & 4 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ working. OpenCodex only writes two variables into the `env` block of `~/.claude/
}
```

Claude Desktop first-party routes its Code tab and subagents through OpenCodex. The standalone Claude Code CLI has a separate first-party switch. Both clients read the same `~/.claude/settings.json` proxy and CA settings: if only one switch is on, the other client still transits the local proxy, where TLS terminates, but its Messages requests relay to Anthropic unchanged. Other Anthropic paths relay unchanged and unrelated hosts remain blind tunnels.
Claude Desktop first-party routes its Code tab and subagents through OpenCodex. The standalone Claude Code CLI has a separate first-party switch. Both clients read the same `~/.claude/settings.json` proxy and CA settings: if only one switch is on, the other client still transits the local proxy, where TLS terminates, but its Messages requests relay to Anthropic unchanged. Other Anthropic paths relay unchanged and unrelated hosts remain blind tunnels. With the CLI switch on, the standalone `claude` also lists routed models in `/model`; see [Model picker in the CLI](#model-picker-in-the-cli).

:::note[Windows system proxy (Clash, v2rayN, corporate proxies)]
When a Windows system proxy is on, Claude Desktop hands it to the Code tab as `HTTPS_PROXY`, and
Expand Down Expand Up @@ -300,9 +300,10 @@ Turn on the CLI switch in Claude → Code, or run `ocx claude config set --first
Disabling Claude routing leaves the owned settings env untouched. While the bound listener still runs, every Messages request relays unchanged; after it stops, plain `claude` cannot connect until OpenCodex runs or Desktop/CLI first-party is turned off. Native `ocx claude` sets `NO_PROXY=*` only for an owned env without a foreign inherited `HTTPS_PROXY`/`https_proxy`. With a foreign proxy it preserves that value and warns that the settings-owned intercept still applies; turn Desktop/CLI first-party off or unset the setting.
The UI distinguishes uncertainty about whether settings still point at its proxy (unknown), a token-bearing opencodex proxy with a foreign CA (foreign: fix HTTPS_PROXY / NODE_EXTRA_CA_CERTS manually), and a tokenless loopback proxy beside a foreign CA (local: ownership is unconfirmed; remove HTTPS_PROXY if unused). With matching applied settings and a bound listener but Claude routing off, disabled means requests relay unchanged until restart; turn first-party off to remove settings. An owned URL with no listener is stopped; a bound listener with an owned CA but mismatched port or token is broken even when routing is off. With an intent on, stopped or broken plus ineligible interception displays routingOff: Claude routing or the intercept is off, or this machine is a client of another opencodex hub; enable interception on this machine or turn first-party off to remove the settings. Only when interception is eligible does stopped advise starting opencodex and broken advise `ocx ensure` or restart. CLI intent with no proxy is not applied; one intent with a live proxy gets the shared-relay notice; any remaining proxy with neither intent is residual, unless unknown, foreign, or local takes precedence.

- Model discovery (`/model` → "From gateway") is not available; Claude Code only queries
`GET /v1/models` on a configured gateway. Bind a built-in Anthropic model id to a route
(`ocx claude desktop bind`, above), use `modelMap`, or type an alias directly.
- Gateway discovery (`/model` → "From gateway") is not used, because Claude Code only queries
`GET /v1/models` on a configured gateway. Routed models reach the picker another way; see
[Model picker in the CLI](#model-picker-in-the-cli). You can also bind a built-in Anthropic
model id to a route (`ocx claude desktop bind`, above) or use `modelMap`.
- `ANTHROPIC_SMALL_FAST_MODEL` and `CLAUDE_CODE_SUBAGENT_MODEL` are chosen by the CLI before the
request is sent; set them in `settings.json` yourself if a sidecar or subagent should use a
mapped id.
Expand All @@ -312,6 +313,23 @@ The UI distinguishes uncertainty about whether settings still point at its proxy
- Claude Code honours `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` as documented for corporate proxies;
a CLI release that stops doing so would stop routing, not break login.

#### Model picker in the CLI

With the CLI first-party switch on and Claude routing enabled, start a new `claude` and open
`/model`. Your opencodex models appear next to the Claude models from your account, labelled
like "Grok 4.7 (xai)" and described with their route (`opencodex · xai/grok-4.7`). Picking one
routes that session to the model, just like a binding.

- Only new `claude` sessions pick up the list. Restart any session that was already running when
you turned the switch on or off.
- Claude Code saves the list for about an hour. Turning the switch on or off, and `ocx ensure`,
clear that saved copy, so the next `claude` launch fetches a fresh one. If the routed models
are missing (the first launch after OpenCodex starts can miss them while models are still being
discovered), run `ocx ensure` and restart `claude`.
- The rows use Claude-style ids (for example `claude-opus-4-8-…`), because the CLI only offers
ids shaped like Claude models. Claude Desktop's Code tab is unaffected; it keeps using
[first-party bindings](#use-opencodex-models-from-the-desktop-code-tab-first-party-bindings).

## Claude Desktop profile (gateway mode)

The profile below is written only in gateway mode. Claude Desktop uses a separate profile from Claude Code. Open **Claude → Desktop** in the
Expand Down
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@
"claude-desktop-picker-routes.test.ts": "claude-integration",
"claude-desktop-native-context.test.ts": "claude-integration",
"claude-desktop-policy.test.ts": "claude-integration",
"claude-cli-picker.test.ts": "claude-integration",
"claude-cli-picker-surface.test.ts": "claude-integration",
"claude-picker-bootstrap.test.ts": "claude-integration",
"claude-picker-ca.test.ts": "claude-integration",
"claude-picker-listener.test.ts": "claude-integration",
Expand Down
62 changes: 62 additions & 0 deletions src/claude/desktop-3p-startup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* One build of the Desktop 3P alias registry from the startup discovery inputs.
*
* Startup builds the registry asynchronously so a slow provider cannot hold the proxy closed. A
* consumer that needs decodable aliases before that build finishes — the Claude Code CLI picker,
* whose catalog the CLI then caches for an hour — awaits the same in-flight build instead of
* starting a second one, or starts it when none ran. Inputs match `/v1/models` (entitlement-admitted
* native slugs plus catalog-visible routes), so both paths install the same registry.
*/
import type { OcxConfig } from "../types";
import { buildDesktop3pRegistry, desktop3pRegistrySize } from "./desktop-3p";

let pending: Promise<boolean> | null = null;
let lastUnproductiveAt = -Infinity;
/** After a build fails or installs nothing, on-demand callers wait this long before retrying. */
export const DESKTOP_3P_REGISTRY_RETRY_MS = 30_000;

/** Build and install the registry; concurrent callers share one build. Resolves false on failure. */
export function initDesktop3pRegistry(config: OcxConfig): Promise<boolean> {
pending ??= (async () => {
try {
const { fetchAllModels } = await import("../server/management-api");
const { desktopVisibleNativeSlugs } = await import("../codex/catalog");
const { resolveAdmittedCodexModelEntitlements } = await import("../codex/model-entitlement-admission");
const { buildDesktopDiscoveryInputs } = await import("./desktop-discovery-inputs");
const [models, modelEntitlements] = await Promise.all([
fetchAllModels(config),
resolveAdmittedCodexModelEntitlements(config, { clientVersion: null }),
]);
const inputs = buildDesktopDiscoveryInputs({
config, models, modelEntitlements,
desktopNativeCandidates: desktopVisibleNativeSlugs(config),
});
buildDesktop3pRegistry(inputs.nativeSlugs, inputs.routedModels, config.claudeCode?.desktopProfile, inputs.nativeContextCap);
if (desktop3pRegistrySize() === 0) lastUnproductiveAt = Date.now();
return true;
} catch {
lastUnproductiveAt = Date.now();
// Best-effort; model discovery can rebuild it. Never reflect credential or provider errors.
console.warn("[opencodex] Claude Desktop model registry could not be initialized.");
return false;
} finally {
pending = null;
}
})();
return pending;
}

/**
* Resolve once a build has installed the registry, starting one only when none ran or is running.
* A failed or empty build is not retried on demand until the cooldown passes, so a broken provider
* cannot turn every CLI catalog request into a fresh discovery and entitlement round.
*/
export async function ensureDesktop3pRegistry(readConfig: () => OcxConfig, now: () => number = Date.now): Promise<void> {
if (pending) {
await pending;
return;
}
if (desktop3pRegistrySize() > 0) return;
if (now() - lastUnproductiveAt < DESKTOP_3P_REGISTRY_RETRY_MS) return;
await initDesktop3pRegistry(readConfig());
}
5 changes: 5 additions & 0 deletions src/claude/desktop-3p.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,11 @@ export function resolveDesktop3pAlias(alias: string): string | null {
return desktop3pRegistry.get(alias) ?? null;
}

/** Number of decodable aliases in the installed registry (0 before the first build). */
export function desktop3pRegistrySize(): number {
return desktop3pRegistry.size;
}

/** Exact registered and identity-preserving catalog IDs precede synthetic syntax. */
export function isKnownDesktop3pModelId(id: string): boolean {
return desktop3pRegistry.has(id) || desktop3pRealAnthropicIds.has(id);
Expand Down
14 changes: 14 additions & 0 deletions src/claude/first-party-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type DesktopFirstPartyOptions,
} from "./desktop-first-party";
import { claudeInterceptCaCertPath } from "./intercept/local-ca";
import { invalidateClaudeCodeServedCatalog } from "./intercept/cli-catalog";
import { claudeInterceptEnabled } from "./intercept/runtime";
import { isClaudeInterceptProxyUrl, removeClaudeInterceptSettings, type ClaudeInterceptSettingsState } from "./intercept/settings";

Expand Down Expand Up @@ -44,6 +45,19 @@ export function reconcileClaudeFirstPartySettings(
config: Pick<OcxConfig, "claudeCode" | "port" | "runtimeRole" | "clientIntegrations">,
desired: ClaudeFirstPartyDesired,
options: DesktopFirstPartyOptions = {},
): ClaudeFirstPartyReconcileResult {
const result = reconcileSettingsEnv(config, desired, options);
// CLI intent can change without the shared env changing (Desktop already holds it), and the
// proxy only adds opencodex rows to the CLI's /model catalog while that intent is on. Drop the
// CLI's cached catalog on every successful reconcile so its next launch refetches the list.
if (result.ok) invalidateClaudeCodeServedCatalog(options.claudeConfigDir ?? claudeConfigDir());
return result;
}

function reconcileSettingsEnv(
config: Pick<OcxConfig, "claudeCode" | "port" | "runtimeRole" | "clientIntegrations">,
desired: ClaudeFirstPartyDesired,
options: DesktopFirstPartyOptions,
): ClaudeFirstPartyReconcileResult {
if (!desired.desktop && !desired.cli) {
const ownedCa = claudeInterceptCaCertPath(options.opencodexConfigDir ?? getConfigDir());
Expand Down
Loading
Loading