diff --git a/devlog/_plan/261009_claude_1m_default/000_plan.md b/devlog/_plan/261009_claude_1m_default/000_plan.md new file mode 100644 index 00000000000..898b0609d62 --- /dev/null +++ b/devlog/_plan/261009_claude_1m_default/000_plan.md @@ -0,0 +1,134 @@ +# 261009 Claude surfaces: 1M by default, 200k as an opt-in + +## Summary + +Long-window models routed to Claude surfaces are accounted at 200k even when their real window is +872,000. The Desktop Code-tab picker shows `GPT 6 Astra (native)`, `GPT 6 Luna (native)` and +`GPT 6.1 Sol (native)` without the `[1m]` selector, so Claude Code (2.1.288) falls back to its 200k +default for them, although the live picker snapshot already carries `contextWindow: 872000`. The +launch path (`ocx claude`) marks the same models because it also injects +`CLAUDE_CODE_AUTO_COMPACT_WINDOW`; surfaces whose runner does not inherit that variable mark only +windows of 1M or more. + +This unit makes every Claude surface that picks a default (Desktop picker, `cc` picker, launch env +slots, Desktop 3P, generated subagents) mark a long window (>= 829,800) as 1M by default, offers a +`· 1M` row for it in discovery, adds the +recovery that makes this safe where the compaction variable is absent, and adds an explicit 200k +opt-in. It is delivered as a manual PR chain on `dev`, one layer per work-phase. + +## Loop spec + +- Class: C3 (cross-module Claude surface behavior, public config/API/CLI field; no auth, no persistence migration). +- Loop archetype: satisfy-spec, multi-cycle (wp0 docs, wp1..wp4 implementation). +- Trigger: user request 2026-10-09 (Desktop picker shows Astra/K3 as 200k; make 1M the default, 200k opt-in in GUI/CLI; stacked PRs; Sol agents as verifiers only). +- Goal: session eb6a39f9-d450-4b3e-8f06-60c43022f3cf, goalplan `claude-surfaces-claude-code-launch-discovery-des`. +- Non-goals: changing the Codex native window defaults (272,000 / 872,000 opt-in stays a Codex catalog decision); the Anthropic beta allowlist; writing the user's `~/.claude/settings.json` env; windows below 829,800 (K3 bare 262,144, Grok 256,000, Devin 262,000 stay 200k-accounted because the compaction floor cannot sit under their real window). +- Verifier per layer: `bun run typecheck`, the focused test files named in each decade doc, `bun run structure:check`; full suite and cross-platform shards are left to exact-head GitHub CI (local full runs are excluded for this repository per the maintainer's standing instruction). +- Stop condition: wp4 PR opened with its CI observed, or a blocker reported. +- Memory artifact: this unit (000 roadmap, 010..040 phase docs, per-phase audit/done notes). +- Expected outcomes: DONE when the four layers are open with templates filled and typecheck green locally; BLOCKED when CI on a layer fails for a cause outside the layer. +- Escalation: Sol agents act as architect, auditors and reviewers only; main writes all code. A slice is reclaimed after two failed reviewer rounds on the same finding. +- Resource bounds: no token or wall-clock budget was set by the user; tool scope is local repo, `gh` on `lidge-jun/opencodex`, read-only inspection of the installed Claude Code binary. + +## Evidence + +| Fact | Source | +|---|---| +| Claude Code context window: `[1m]` -> 1e6; declared knowledge windows > 200k clamp to 200k unless native 1M; otherwise `CLAUDE_CODE_MAX_CONTEXT_TOKENS` (non-claude ids) else 200k | Claude Code 2.1.288 binary, functions `vv`, `I5r`, `Kd`, `yv` (strings at `~/.local/share/claude/versions/2.1.288`) | +| Claude Code classifies `prompt_too_long` when the error message includes `prompt is too long` or `input is too long for requested model`; parses `prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)` for the gap; runs reactive compaction | same binary, functions `b4n`, `fdt`, `mDt`; strings `Reactive compact: ...`, `tengu_reactive_compact_triggered` | +| Embedded gateway contract: relay a 400/413 Anthropic envelope like `prompt is too long: ...`; "the client's recovery (auto-compact etc.) keys on it" | same binary, embedded "Claude Code gateway protocol" text | +| opencodex emits `context_length_exceeded` with upstream text or `PROVIDER_INPUT_TOO_LARGE_MESSAGE` | `src/claude/outbound.ts:73`, `src/server/claude-messages.ts:1338-1386`, `src/server/responses/context-overflow.ts:5` | +| Desktop picker marks only >= 1M because Desktop runners do not inherit the compaction env | `src/claude/intercept/picker-models.ts:51-54` | +| Live picker snapshot: Astra/Luna/Sol 6.1 `contextWindow: 872000` unmarked; `K3 1M (kimi)` already `[1m]` | `~/.opencodex/claude-intercept/cli-picker-models.json` (local, not committed) | +| Discovery 1M variant requires >= 1M; `auto` passed but unused | `src/claude/model-info.ts:150-176`, `src/server/index/serve-options.ts:1053` | +| Desktop 3P `supports1m` threshold 1M in three owners | `src/claude/desktop-3p.ts:63,254`, `src/claude/desktop-profile.ts:362`, `src/server/management/shared.ts:417` | +| Brackets: Desktop 3P names are hashed `claude-opus-4-8-*` aliases; `k3[1m]` already round-trips | `src/claude/desktop-3p.ts:122-161`, `src/claude/desktop-3p-guard.ts:11-32` | + +## Architect consultation + +Architect: gpt-6.1-sol subagent, read-only, proposal D1-D7 (session transcript). Dispositions: + +| ID | Proposal | Disposition | +|---|---|---| +| D1 | Normalize at `anthropicErrorBody`; keep recognized text; numbers only when parsed | ACCEPT. Folded the missed lane: `messages-native.ts:901` drops `classified.code`, so 010 rewrites its message without changing the native envelope shape | +| D2 | Pure leaf `long-context.ts`; Desktop/profile/context-windows depend inward | ACCEPT | +| D3 | Discovery emits the unmarked base row first, so variants do not make 1M "the default" | REBUT for discovery: the `/v1/models` list is an explicit choice list and Claude Code shows both rows; the default selection on Claude surfaces is the picker row (one row per model, now marked) and the launch env slot. ACCEPT the ceiling point: variants keep reporting `min(1e6, max input)` | +| D4 | Sparse persistence, `"1m"` deletes, reject others; typed `[1m]` survives; 200k disables compact injection too; precedence over autoContext | ACCEPT, written into 030 | +| D5 | Full field chain incl. system-env reconcile list, picker inputs, GUI normalization, refresh | ACCEPT. Chain table in 030; GUI normalization and manual-env export in 040 | +| D6 | Exact-text tests and ratchet headroom | ACCEPT; tests in 010/020, headroom table below | +| D7 | structure/ ownership, docs-site guide, CLI capability registries, anthropic2 | ACCEPT. `isAnthropicInstanceId` replaces `provider === "anthropic"` in the new predicate and the discovery mode choice; capability registries + `skill:surface` in 030 | + +## Work-phase map (dependency order) + +| wp | Layer | Doc | Depends on | Branch | +|---|---|---|---|---| +| wp0 | Roadmap (this unit) | 000 | - | committed as the first commit of wp1's branch | +| wp1 | Overflow envelope `prompt is too long` (safety net) | 010 | wp0 | `claude/1m-01-prompt-too-long` -> `dev` | +| wp2 | Long-window eligibility on every Claude surface | 020 | wp1 | `claude/1m-02-long-context` -> wp1 | +| wp3 | `claudeCode.contextAccounting: "200k"` opt-in (config, launch, surfaces, API, CLI) | 030 | wp2 | `claude/1m-03-context-accounting` -> wp2 | +| wp4 | Dashboard control + locales | 040 | wp3 | `claude/1m-04-context-accounting-gui` -> wp3 | + +wp1 goes first because wp2 widens marking onto runners that may lack the compaction variable; the +recovery has to exist before the widening ships. wp3 consumes wp2's single marking mode. wp4 only +renders wp3's field. + +## Ratchet headroom + +Universal cap: a file outside `tests/fixtures/file-size-baseline.json` fails at 2000 lines. Files +this unit grows: `tests/claude-integration/claude-outbound.test.ts` 1936 (edit in place only, no +new cases there), `src/server/management/agent-settings-routes.ts` 1858, `src/server/claude-messages.ts` +1730, `src/types/config.ts` 1733, `src/claude/outbound.ts` 1103; none is in the baseline. New cases +go into new sibling test files. All eleven locale catalogs are exempt. + +## Source-of-truth sync + +`structure/` owner docs (checked by `bun run structure:check`) are updated in the layer that changes +the behavior they describe: `structure/runtime.md` and `structure/clients/claude-desktop.md`, the +owners of `src/claude/` per `structure/INDEX.md:126` (wp1, wp2), and the config / +management owners for the new `claudeCode` key and `/api/claude-code` field (wp3). Each layer's P +resolves the exact owner file from `structure/INDEX.md`. The user-facing docs-site Claude Code page is updated +in wp3 (new CLI flag and config key). + +## Architect reflection (round 1: MISALIGNED, folded) + +| Gap | Fold | +|---|---| +| D1 regex took any later count | 010: counts need `resulted in` / `you requested`; otherwise prefix only | +| D2 discovery followed a custom compact window | 020: discovery uses the fixed unpaired rule; `auto` only switches it off | +| D3 base/Fast rows still 200k; sub-1M variant advertised 1e6 | 000 summary + 020 scope note; variant advertises `min(1e6, max input ?? window)` | +| D5 roster/self subagent sites; GUI cache | 030 adds `agents-inject.ts:100,133` via `subagentMarkingMode`; 040 explains the normalizer covers cached reads and `EDITABLE_KEYS` | +| D6 headroom table missing | added above | +| D7 config/management SoT owners | added above | + +## Architect reflection (round 2: MISALIGNED, folded) + +| Gap | Fold | +|---|---| +| GUI cached state bypasses the GET normalizer | 040: normalize in `normalizeFirstPartyState` (`ClaudeCode.tsx:37`), shared by cache (`:115`) and GET (`:153`); test with an older cached state | +| `200k` would strip explicit `[1m]` subagent selectors | 030: explicit markers follow the unpaired safety rule regardless of mode; mode governs unmarked selectors only; precedence tests for roster/self/force | + +## Independent audit (round 1: FAIL, synthesized) + +| Blocker | Disposition | +|---|---| +| 1 native Messages lanes | Pre-stream HTTP folded (010). Classifier widening and native SSE frame rewrite REBUTTED as out of scope with reason in 010 (Codex-shared classifier; no native-lane route crosses the 1M line in this unit); residual recorded | +| 2 roster/self subagents ignore 200k | Already folded after architect reflection (030 `subagentMarkingMode`, explicit-marker rule) | +| 3 Desktop option chain | Folded: option threads through `generateDesktop3pConfig`; writer derives it from the re-read config; remote export endpoint added (030) | +| 4 Pool 2 guard | Folded: pickers and `buildClaudeContextWindows` use `isAnthropicInstanceId` (020) | +| 5 discovery floor / ceiling | Already folded (020 `variantMode`, ceiling from window) | +| 6 boundary tests | Folded: 999,999 cases rewritten to 829,799 / 829,800 (020) | +| notes | GUI test paths, i18n parity tests, structure owners named | + +## Independent audit (round 2: FAIL on B1 only, folded) + +B1 rebuttal withdrawn: a custom `anthropic`-adapter provider reaches the native lane with any +window, so wp2 can widen a native route. 010 now normalizes the native lane locally (HTTP with +upstream `code` and a local wording test, streaming via the existing SSE payload rewriter for +non-Anthropic-instance providers, collected-stream errors -> 400), leaving the Codex-shared +`classifyError` untouched. Regression with a custom provider and unfamiliar wording added. + +## Independent audit (round 3): PASS + +Notes folded into 010: gate the SSE rewrite on `nativeInstance` (`messages-native.ts:309-310`), not +on the provider name; place it after `echoRequestedModel` before the stream/collect split; add a +413 case and an SSE `rate_limit_error` negative; the superseded HTTP-only snippet was removed. diff --git a/devlog/_plan/261009_claude_1m_default/010_prompt_too_long_envelope.md b/devlog/_plan/261009_claude_1m_default/010_prompt_too_long_envelope.md new file mode 100644 index 00000000000..cc500a94fde --- /dev/null +++ b/devlog/_plan/261009_claude_1m_default/010_prompt_too_long_envelope.md @@ -0,0 +1,167 @@ +# 010 wp1: context overflow reaches Claude clients as `prompt is too long` + +## Why + +Claude Code recovers from an oversized prompt only when the API error says so in Anthropic's words: +it lowercases `error.message` and looks for `prompt is too long` or `input is too long for requested +model`, reads the token gap from `prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)`, and then +runs reactive compaction (2.1.288 binary, `b4n` / `fdt` / `mDt`). opencodex already classifies the +refusal (`code: "context_length_exceeded"`, status 400) but keeps the routed provider's wording, for +example `Your input exceeds the context window` or the proxy-owned +`PROVIDER_INPUT_TOO_LARGE_MESSAGE`. Claude Code then shows a fatal API error instead of compacting. + +This layer is the safety net wp2 relies on: wp2 marks 872k windows as 1M on runners that do not +inherit `CLAUDE_CODE_AUTO_COMPACT_WINDOW`, so such a session can outgrow the real window before +proactive compaction fires. With this layer the first refusal compacts the session instead of +ending it. + +## Scope + +IN: the Anthropic error envelope builder and every Claude-wire producer that already passes +`code: "context_length_exceeded"` through it. OUT: status mapping (already 400), Codex/Responses +wire messages, the native Anthropic passthrough body (Anthropic already writes `prompt is too long`). + +## Diff + +### MODIFY `src/claude/outbound.ts` + +Before (`:73-75`): + +```ts +export function anthropicErrorBody(status: number, message: string, type?: string, code?: string): Rec { + return { type: "error", error: { type: type ?? anthropicErrorType(status), message, ...(code ? { code } : {}) } }; +} +``` + +After: + +```ts +/** + * Claude Code compacts reactively only when an oversized-prompt refusal is worded the way the + * Anthropic API words it: it looks for `prompt is too long` (or `input is too long for requested + * model`) in the message and reads `prompt is too long: tokens > maximum` for the + * gap (2.1.288 binary). Routed providers word the same refusal differently, so a + * context_length_exceeded envelope is rewritten into that form, keeping the counts when the + * upstream text states them. Without this the client ends the turn on a fatal error instead of + * compacting (devlog/_plan/261009_claude_1m_default/010). + */ +const CLAUDE_PROMPT_TOO_LONG_RE = /prompt is too long|input is too long for requested model/i; +const STATED_CONTEXT_COUNTS_RE = /maximum context length is (\d+) tokens[\s\S]*?(?:resulted in|you requested) (\d+) tokens/i; + +export function claudePromptTooLongMessage(message: string): string { + if (CLAUDE_PROMPT_TOO_LONG_RE.test(message)) return message; + const counts = STATED_CONTEXT_COUNTS_RE.exec(message); + if (counts) return `prompt is too long: ${counts[2]} tokens > ${counts[1]} maximum`; + return `prompt is too long: ${message}`; +} + +export function anthropicErrorBody(status: number, message: string, type?: string, code?: string): Rec { + const text = code === "context_length_exceeded" ? claudePromptTooLongMessage(message) : message; + return { type: "error", error: { type: type ?? anthropicErrorType(status), message: text, ...(code ? { code } : {}) } }; +} +``` + +Every producer inherits the rewrite through the builder; none is edited: + +| Producer | Path | +|---|---| +| Translated SSE `response.failed` | `src/claude/outbound.ts:441-471` (`fail` -> `anthropicErrorBody`) | +| Translated JSON `status: failed` | `src/server/claude-messages.ts:1460-1461` (`anthropicErrorResponse`) | +| Translated non-OK HTTP | `src/server/claude-messages.ts:1338-1386` (`anthropicErrorBody(..., contextError ? "context_length_exceeded" : undefined)`) | +| Direct Messages encoder | `src/protocols/encoders/messages.ts:186-201`, `:429-438` | +| Native Messages (pre-stream `fail`) | `src/server/messages-native.ts:349-352` (inherits through the builder) | + +(The native lane, including the architect's D1 note on `messages-native.ts:901`, is covered in the next section.) + +### Native Messages lane (audit blocker 1, round 2: folded, not rebutted) + +A configured `anthropic`-adapter provider can reach the native lane with any window +(`src/server/messages-native-eligibility.ts:136-149`), so wp2 can widen a route served here. The +lane is normalized locally; `classifyError` (shared with the Codex wire) is not touched. + +NEW in `src/claude/outbound.ts` beside `claudePromptTooLongMessage`: + +```ts +/** Provider wordings of an oversized-input refusal, for lanes that relay a provider's own error text. */ +const CONTEXT_OVERFLOW_TEXT_RE = /prompt is too long|input is too long|context window|context length|maximum context|too many tokens|model token limit/i; + +export function isContextOverflowText(message: string): boolean { + return CONTEXT_OVERFLOW_TEXT_RE.test(message); +} + +/** + * SSE payload rewrite for the native Messages lane: an `error` event that refuses an oversized + * input gets the Anthropic wording; every other payload passes through byte-identical. + */ +export function claudeOverflowSsePayload(payload: string): string { + if (!payload.includes("\"error\"")) return payload; + let parsed: unknown; + try { parsed = JSON.parse(payload); } catch { return payload; } + if (!isRec(parsed) || parsed.type !== "error" || !isRec(parsed.error)) return payload; + const error = parsed.error; + const message = typeof error.message === "string" ? error.message : ""; + const sized = error.type === "invalid_request_error" || error.type === "request_too_large"; + if (!sized || !(error.code === "context_length_exceeded" || isContextOverflowText(message))) return payload; + const next = claudePromptTooLongMessage(message); + return next === message ? payload : JSON.stringify({ ...parsed, error: { ...error, message: next } }); +} +``` + +MODIFY `src/server/messages-native.ts`: + +- HTTP (`nativeMessagesErrorResponse`, `:865-906`): read `details.code` beside `details.type`; + `const overflow = classified.code === "context_length_exceeded" || upstreamCode === "context_length_exceeded" || ((response.status === 400 || response.status === 413) && isContextOverflowText(safeMessage));` + and pass `overflow ? claudePromptTooLongMessage(safeMessage) : safeMessage` to `anthropicErrorBody` + (status and envelope shape unchanged). +- Streaming (`:747-770`): when `nativeInstance` (`:309-310`, the configured-instance identity, so a + custom provider merely named `anthropic2` is not exempt) is absent, wrap `source` right after + `echoRequestedModel` and before the stream/collect split, so both consumers see the same payload, with + `relaySseWithPayloadRewrite(source, claudeOverflowSsePayload, translatorBudget)` (the helper the + OAuth tool-name restore already uses, `src/server/sse-payload-rewrite.ts:257`). Real Anthropic + pools skip the wrapper: they refuse oversized input pre-stream in their own wording, and the + passthrough hot path keeps its single relay. +- Collected stream (`:785-792`): when the folded error passes the same test, answer + `fail(400, claudePromptTooLongMessage(text), "invalid_request_error")` instead of 502. + +Tests (new 010 test file, native harness pattern from `tests/claude-integration/`): + +- HTTP 400 `{"type":"error","error":{"type":"invalid_request_error","message":"Your request exceeded model token limit: 262144"}}` from a custom `anthropic`-adapter provider -> client message starts `prompt is too long`. +- HTTP 400 with `"code":"context_length_exceeded"` and unfamiliar text -> rewritten. +- HTTP 413 with overflow wording -> rewritten, status 413 kept. +- HTTP 429 `rate limit ... tokens` -> untouched. +- SSE `rate_limit_error` whose message says `context window` -> untouched (type gate). +- Streaming: upstream `event: error` with `invalid_request_error` + `model token limit` -> relayed frame rewritten; a `content_block_delta` whose text contains `"error"` passes byte-identical. +- Collected (non-stream caller, streaming upstream): overflow error -> 400 with the rewritten message. + +### MODIFY tests whose exact text changes + +- `tests/claude-integration/claude-outbound.test.ts:939`: `message: "Cursor context limit exceeded"` -> `message: "prompt is too long: Cursor context limit exceeded"`. +- `tests/responses/protocol-direct-encoders-messages.test.ts:331` and `:337`: `message: "Synthetic input limit"` -> `message: "prompt is too long: Synthetic input limit"`. +- `tests/server/replay-refusal-parity.test.ts:308` uses `toContain` on the original text and still holds. + +### NEW `tests/claude-integration/claude-prompt-too-long.test.ts` + +Registered in `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. + +Cases (activation scenario -> observable effect): + +1. Plain upstream text: `anthropicErrorBody(400, "Your input exceeds the context window", undefined, "context_length_exceeded")` -> message `prompt is too long: Your input exceeds the context window`, code kept. +2. Stated counts: OpenAI wording `This model's maximum context length is 272000 tokens. However, your messages resulted in 301234 tokens.` -> `prompt is too long: 301234 tokens > 272000 maximum`; and the message matches Claude Code's own parser regex `/prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)/i` with groups 301234 / 272000. +3. Already Anthropic-worded: `prompt is too long: 1000 tokens > 900 maximum` and `Input is too long for requested model.` pass through unchanged. +4. Other codes untouched: `rate_limit` / `translation_buffer_limit` / no code keep the original message. +5. End to end on the translated SSE path: `responsesSseToAnthropicSse` with a `response.failed` frame carrying `context_length_exceeded` and `PROVIDER_INPUT_TOO_LARGE_MESSAGE` -> terminal `error` frame whose message lowercased includes `prompt is too long`. + +## Verification + +- `bun run typecheck` +- `bun test tests/claude-integration/claude-prompt-too-long.test.ts tests/claude-integration/claude-outbound.test.ts tests/responses/protocol-direct-encoders-messages.test.ts tests/server/replay-refusal-parity.test.ts` +- `bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts` (new file registration) +- Full suite: exact-head CI. + +## Risks + +- Counts are only carried over when both OpenAI phrases are present (`maximum context length is L + tokens` and `resulted in` / `you requested A tokens`); any other wording is prefixed verbatim, so + no number is guessed (architect reflection D1). Test case 2b: `maximum context length is 272000 + tokens; 3 tokens of overhead` (no request phrase) -> prefixed, no counts. +- Native Anthropic text is already compliant; the rewrite is a no-op there. diff --git a/devlog/_plan/261009_claude_1m_default/020_long_context_eligibility.md b/devlog/_plan/261009_claude_1m_default/020_long_context_eligibility.md new file mode 100644 index 00000000000..730acfc923f --- /dev/null +++ b/devlog/_plan/261009_claude_1m_default/020_long_context_eligibility.md @@ -0,0 +1,206 @@ +# 020 wp2: long windows are 1M on every Claude surface + +## Why + +Claude Code (2.1.288) accounts an unmarked gateway id at 200k: knowledge windows above 200k are +clamped unless the model is natively 1M, and the discovery `max_input_tokens` is not part of the +window computation. The only per-model lever a proxy has is the `[1m]` selector (Desktop 3P: +`supports1m` / `prefer1m`). Today it is granted by two different rules: + +| Surface | Rule today | Owner | +|---|---|---| +| `ocx claude` launch env, tier slots | window >= 1M, or > 200k and >= compact window (829,800) | `src/claude/context-windows.ts:133-136`, `:230-274` | +| Desktop Code-tab picker, CLI `cc` picker | window >= 1M only | `src/claude/intercept/picker-models.ts:51-54` | +| Anthropic discovery `· 1M` variant | window >= 1M only (`auto` passed, unused) | `src/claude/model-info.ts:150-176` | +| Desktop 3P `supports1m` / `prefer1m` | window >= 1M only | `src/claude/desktop-3p.ts:254`, `src/claude/desktop-profile.ts:362`, `src/server/management/shared.ts:417` | +| Generated subagent defs | window >= 1M only | `src/claude/subagent-model.ts:23-33`, `:79` | + +The narrow rule exists because those runners may not inherit `CLAUDE_CODE_AUTO_COMPACT_WINDOW`, so +a marked 872k model could outgrow its real window before proactive compaction (the #854 defect +was a 372k route marked while the compact window was 350k). wp1 removes the failure mode: an +overflow now arrives as `prompt is too long` and Claude Code compacts reactively. The compaction +floor still applies, so only windows >= 829,800 widen; 262k / 256k / 400k windows stay at 200k. + +## Decisions + +- One predicate for the unpaired surfaces, in a leaf module so `desktop-3p.ts` and + `desktop-profile.ts` can use it without importing `context-windows.ts` (which imports + `desktop-3p.ts`). +- Real Anthropic routes keep the >= 1M rule everywhere (they ride the native passthrough; the + existing "Anthropic passthrough guard" in `context-windows.ts:196-205` and `model-info.ts:250` stays). +- Discovery uses the fixed unpaired rule, not the configurable launch compact window: a custom + `autoCompactWindow: 350000` must not re-admit a 372k variant (#854). The server's `auto` only + switches the rule off: `autoContext: false` (and wp3's `200k`) fall back to >= 1M. +- Scope note (architect D3): discovery stays a choice list. Its base and `· Fast` rows remain + unmarked (200k-accounted) by design; the `· 1M` row is the long-context choice there. The + surfaces that pick a default (Desktop picker, `cc` picker, launch env slots, Desktop 3P + `prefer1m`, generated subagents) are the ones made 1M by default. + +## Diff + +### NEW `src/claude/long-context.ts` + +```ts +/** + * Long-context thresholds shared by every Claude surface (devlog/_plan/261009_claude_1m_default/020). + * A leaf module: desktop-3p.ts and desktop-profile.ts need the predicate, and context-windows.ts + * already imports desktop-3p.ts. + */ +import { isAnthropicInstanceId } from "../providers/anthropic-instance-id"; + +export const ONE_MILLION = 1_000_000; + +/** A window at or below this is Claude Code's own default accounting; widening it gains nothing. */ +export const AUTO_CONTEXT_FLOOR = 200_000; + +/** (comment moved verbatim from context-windows.ts:30-41) */ +export const AUTO_COMPACT_WINDOW_DEFAULT = 829_800; + +/** + * Whether a window earns the 1M selection on a surface whose Claude Code runner may not inherit + * CLAUDE_CODE_AUTO_COMPACT_WINDOW (Desktop pickers, discovery, Desktop 3P, generated subagents). + * A window >= 1M always does. A smaller one does when it can host the default compact window, so + * a runner that has the variable compacts in time, and one that lacks it overflows into the + * `prompt is too long` envelope (wp1) and compacts reactively. + */ +export function isLongContextWindow(window: number | undefined): boolean { + if (typeof window !== "number" || window <= 0) return false; + return window >= ONE_MILLION || (window > AUTO_CONTEXT_FLOOR && window >= AUTO_COMPACT_WINDOW_DEFAULT); +} + +/** Desktop 3P / dashboard eligibility. Real Anthropic routes (either pool) need a genuine 1M window. */ +export function claudeSurfaceSupportsOneMillion(provider: string, window: number | undefined): boolean { + if (isAnthropicInstanceId(provider)) return typeof window === "number" && window >= ONE_MILLION; + return isLongContextWindow(window); +} +``` + +### MODIFY `src/claude/context-windows.ts` + +- Replace `const ONE_MILLION = 1_000_000;` (`:19`) and the two constant definitions (`:42-43`) with + `import { AUTO_COMPACT_WINDOW_DEFAULT, AUTO_CONTEXT_FLOOR, ONE_MILLION } from "./long-context";` + plus `export { AUTO_COMPACT_WINDOW_DEFAULT, AUTO_CONTEXT_FLOOR } from "./long-context";` (public + names unchanged for every importer). +- Add after `AUTO_CONTEXT_OFF` (`:66`): + +```ts +/** + * Marking mode for surfaces whose runner may not inherit the compaction env (Desktop pickers, + * discovery, generated subagents). Equals isLongContextWindow; kept as a mode so the existing + * withOneMillionMarker / shouldMarkOneMillion helpers serve every surface. + */ +export const UNPAIRED_AUTO_CONTEXT: AutoContextMode = { enabled: true, compactWindow: AUTO_COMPACT_WINDOW_DEFAULT }; +``` + +### MODIFY `src/claude/intercept/picker-models.ts` + +- Import `UNPAIRED_AUTO_CONTEXT` instead of `AUTO_CONTEXT_OFF`. +- `:51-54` before: + +```ts +/** Desktop runners do not inherit the proxy's compaction env: mark only real >=1M windows. */ +function pickerSelector(alias: string, contextWindow: number | undefined): string { + return withOneMillionMarker(alias, contextWindow === undefined ? {} : { [alias]: contextWindow }, AUTO_CONTEXT_OFF)!; +} +``` + +after: + +```ts +/** + * Desktop runners do not inherit the proxy's compaction env, so a long window (>= the default + * compact window) is marked on the strength of the `prompt is too long` recovery instead (020). + */ +function pickerSelector(alias: string, contextWindow: number | undefined): string { + return withOneMillionMarker(alias, contextWindow === undefined ? {} : { [alias]: contextWindow }, UNPAIRED_AUTO_CONTEXT)!; +} +``` + +`buildPickerModels` skips `anthropic/claude-*` already (`:61`); `buildCliPickerModels` the same (`:79`). + +Both picker builders pass the row's provider: `pickerSelector(alias, contextWindow, provider)` uses +`isAnthropicInstanceId(provider) ? AUTO_CONTEXT_OFF : UNPAIRED_AUTO_CONTEXT`, so a sub-1M +`anthropic2` row (Pool 2 is not skipped by the `anthropic/claude-*` filter at `:60`, `:80`) is never +widened (audit blocker 4). + +### MODIFY `src/claude/context-windows.ts:187,199` (Pool 2 guard, audit blocker 4) + +`m.provider === "anthropic"` -> `isAnthropicInstanceId(m.provider)` in both the passthrough `capped` +set and the `registrable` filter, so the launch window map treats Pool 2 rows like Pool 1. + +### MODIFY `src/claude/model-info.ts` + +- Import `shouldMarkOneMillion` with `AUTO_CONTEXT_OFF`. +- `push1mVariant` gains a mode parameter and replaces the >= 1M gate: + +```ts + const push1mVariant = ( + base: AnthropicModelInfo, + contextWindow: number | undefined, + maxInputTokens?: number, + selectorId?: string, + mode: AutoContextMode = variantMode, + ) => { + // The [1m] marker makes Claude Code account 1e6 tokens for the row. A window >= 1M always + // earns it; a long window earns it under the server's auto-context mode, because the + // compaction floor sits under it and an overflow is answered as `prompt is too long`, + // which Claude Code compacts on (020). A 372k route under a 350k compact window was the + // #854 defect; the floor keeps such windows out. + if (!shouldMarkOneMillion(contextWindow, mode)) return; + if (base.id.includes("[1m]")) return; + ... + // A long window under 1M must not be advertised as 1e6 input (architect D3). + const ceiling = typeof maxInputTokens === "number" && maxInputTokens > 0 ? maxInputTokens : contextWindow; + const advertised = typeof ceiling === "number" && ceiling > 0 ? Math.min(ONE_MILLION, ceiling) : ONE_MILLION; +``` + + with, before the loops: `const variantMode = auto.enabled ? UNPAIRED_AUTO_CONTEXT : AUTO_CONTEXT_OFF;` + (`auto` disabled by `autoContext: false`, `maxContextTokens`, or wp3's `200k`). +- Routed call `:251` passes `isAnthropicInstanceId(m.provider) ? AUTO_CONTEXT_OFF : variantMode` as the mode (`anthropic2` is the same Anthropic implementation, `src/providers/anthropic-instance-id.ts`, a no-import leaf). +- Comment block `:150-155` replaced by the one above. + +### MODIFY Desktop 3P eligibility (three owners, one predicate) + +- `src/claude/desktop-3p.ts:254`: `const supports1m = claudeSurfaceSupportsOneMillion(provider, contextWindow) ? { supports1m: true as const } : {};` (import from `./long-context`). `DESKTOP_SUPPORTS_1M_THRESHOLD` stays exported (= `ONE_MILLION`) for the Anthropic rule and its existing test. +- `src/claude/desktop-profile.ts:362`: `supports1m: claudeSurfaceSupportsOneMillion(route.slice(0, route.indexOf("/")), model.contextWindow),`. +- `src/server/management/shared.ts:417`: `supports1m: claudeSurfaceSupportsOneMillion(route.slice(0, route.indexOf("/")), modelByRoute.get(route)?.contextWindow),`; comment keeps "SAME predicate". + +### MODIFY `src/claude/subagent-model.ts` + +- `:30`: `shouldMarkOneMillion(authoritativeWindow, UNPAIRED_AUTO_CONTEXT) ? (withOneMillionMarker(selector, windows, UNPAIRED_AUTO_CONTEXT) ?? selector) : bare`. +- `:79`: the requested-marker admission uses `isLongContextWindow(window)` instead of `window >= 1_000_000`. +- Doc comment `:16-22` updated: generated defs follow the unpaired long-context rule. + +### Tests + +Updated (intentional behavior change, comments rewritten): + +- `tests/claude-integration/claude-picker-models.test.ts:84-91`: 872k / 922k natives now end in `[1m]`; gpt-5.5 (272k) stays unmarked. Title becomes "picker marks long native windows on the prompt-too-long recovery". +- `tests/claude-integration/claude-model-info.test.ts:124-129`: with the default `auto` argument (`AUTO_CONTEXT_OFF`) no native variants still holds; add a sibling case in the new file for the server mode. +- `tests/claude-integration/claude-picker-models.test.ts:93-102`: the 999,999 row now ends in `[1m]`; add 829,799 (bare) and 829,800 (`[1m]`) rows for the floor. +- `tests/claude-integration/claude-subagent-model-force.test.ts:46`: the 999,999 boundary becomes 829,799 (bare) plus 829,800 (`[1m]`); 1,000,000 unchanged. +- `tests/clients/desktop-3p.test.ts:214-231`: `optedIn` (1M cap -> Sol 922,000) now carries `supports1m` / `prefer1m`; `uncapped` and `capped` (272,000) unchanged. + +NEW `tests/claude-integration/claude-long-context-eligibility.test.ts` (registered in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`): + +1. `isLongContextWindow`: 200,000 / 262,144 / 829,799 false; 829,800 / 872,000 / 1,000,000 true; undefined / 0 false. +2. `claudeSurfaceSupportsOneMillion("anthropic", 872_000)` false, `("native", 872_000)` true. +3. Discovery: a routed 400,000 row under `{ enabled: true, compactWindow: 350_000 }` gets no variant (fixed floor); a routed 900,000 row without `maxInputTokens` gets a variant advertising 900,000. `buildAnthropicModelInfos(["gpt-6-astra"], [], UNPAIRED_AUTO_CONTEXT, "readable", undefined, { modelWindows: { "gpt-6-astra": 872_000 } })` has a `[1m]` variant with `max_input_tokens` 872,000 and display `· 1M`; with `AUTO_CONTEXT_OFF` none; a routed `anthropic` row at 872,000 gets none under either mode; a routed 262,144 row gets none. +4. Desktop 3P: `generateDesktop3pModels(["gpt-6-astra"], [], undefined, { modelWindows: { "gpt-6-astra": 872_000 } })` emits `supports1m` + `prefer1m`; `[{ provider: "kimi", id: "k3", contextWindow: 262_144 }]` emits neither. +5. Pool 2: `buildPickerModels` with `{ provider: "anthropic2", id: "x", contextWindow: 872_000 }` stays bare; `buildClaudeContextWindows([], [{ provider: "anthropic2", id: "x", contextWindow: 872_000 }])` has no entry for it. +6. Subagent: `withSubagentContextMarker("ocx-claude-native--gpt-6-astra", { "ocx-claude-native--gpt-6-astra": 872_000 })` ends in `[1m]`; at 262,144 stays bare. + +## Verification + +- `bun run typecheck` +- `bun test tests/claude-integration/claude-long-context-eligibility.test.ts tests/claude-integration/claude-picker-models.test.ts tests/claude-integration/claude-model-info.test.ts tests/clients/desktop-3p.test.ts tests/clients/desktop-profile.test.ts tests/claude-integration/claude-desktop-1m.test.ts tests/claude-integration/claude-subagent-model-force.test.ts tests/claude-integration/claude-cli-picker.test.ts` +- `bun run structure:check` +- Full suite: exact-head CI. + +## Risks + +- A runner without the compaction env compacts reactively once per overflow instead of + proactively. That costs one refused request per long session, against today's state where the + model is unusable past 200k. +- `prefer1m` now defaults Desktop 3P long-window models to the 1M variant. wp3's `200k` policy + drops `prefer1m` for users who want the old default. diff --git a/devlog/_plan/261009_claude_1m_default/030_context_accounting_policy.md b/devlog/_plan/261009_claude_1m_default/030_context_accounting_policy.md new file mode 100644 index 00000000000..c0589d909a5 --- /dev/null +++ b/devlog/_plan/261009_claude_1m_default/030_context_accounting_policy.md @@ -0,0 +1,125 @@ +# 030 wp3: `claudeCode.contextAccounting: "200k"` opt-in + +## Why + +After wp2 every Claude surface marks long windows as 1M by default. The user asked for 200k to +remain reachable as an explicit opt-in from the CLI and the dashboard. The existing +`autoContext: false` is not that switch: it only stops sub-1M widening, while windows >= 1M stay +marked (`context-windows.ts:133-136`). + +## Semantics (decisions D4 of the architect consultation, accepted) + +- Stored sparse like `autoContext`: absent = `"1m"` (default); `"200k"` is the only stored value. + The API accepts `"1m"` (deletes the key) and `"200k"`; anything else is a 400. A hand-edited + unknown value reads as the default. +- `"200k"` wins over `autoContext` and the compact window: `resolveAutoContext` returns a mode with + `enabled: false` (so `CLAUDE_CODE_AUTO_COMPACT_WINDOW` is not injected) and `accounting200k: true` + (so `shouldMarkOneMillion` is false for every window). `maxContextTokens` keeps its own branch + (it already returns `AUTO_CONTEXT_OFF`); with `"200k"` set it still injects + `CLAUDE_CODE_MAX_CONTEXT_TOKENS` as before. +- A selector the user typed with `[1m]` keeps it (`withOneMillionMarker` returns already-marked + selectors unchanged, `context-windows.ts:232`); the policy changes defaults, not explicit picks. +- Explicit choices stay available: discovery keeps the `· 1M` variant rows for windows >= 1M, and + Desktop 3P keeps `supports1m` but drops `prefer1m`, so 1M is one click away. + +## Field chain (PLAN-FIELD-CHAIN-01) + +| Stage | Path | +|---|---| +| Type | `src/types/config.ts` `OcxClaudeCodeConfig.contextAccounting?: "200k"` next to `autoContext` (`:174`) | +| Creation: API | `src/server/management/agent-settings-routes.ts` PUT `/api/claude-code` body type (`:1460`) + validation block after `autoContext` (`:1724-1729`) | +| Creation: CLI | `src/cli/integrations.ts` `takeOption(args, "--context-accounting")` (`:76-91`), usage text (`:21-22`); `src/cli/capabilities-base.ts:574,578` and `src/cli/capabilities-integrations.ts:142,145` flag + usage; `bun run skill:surface` regenerates the skill map | +| Serialization | config JSON passthrough (`src/config/persist-unlocked.ts:81`, `src/config/schema/config-schema.ts:353` `.passthrough()`); no schema change | +| Read-back | GET `/api/claude-code` DTO (`agent-settings-routes.ts:1422`): `contextAccounting: config.claudeCode?.contextAccounting === "200k" ? "200k" : "1m"` | +| System env reconcile | `agent-settings-routes.ts:1843-1844` add `"contextAccounting"` to `systemEnvInputs` | +| Consumer: launch / system env / discovery | `resolveAutoContext` (`context-windows.ts:92`) is the single entry; `effectiveModelEnv`, `src/cli/claude.ts:407-418`, `src/server/system-env.ts:192,312-323`, `src/server/system-env-shell.ts:110-126`, `serve-options.ts:1053` all call it | +| Consumer: discovery variants | `model-info.ts` variant mode = `auto.accounting200k ? AUTO_CONTEXT_OFF : auto` (>= 1M rows stay selectable) | +| Consumer: pickers | `PickerRouteInput.auto?: AutoContextMode` (`picker-models.ts:15-20`), filled by `loadPickerRoutesFromCatalog` (`src/server/index/claude-intercept-lifecycle.ts:51-57`) with `resolveAutoContext(config.claudeCode)`; `pickerSelector` uses `input.auto?.accounting200k ? ACCOUNTING_200K : UNPAIRED_AUTO_CONTEXT` | +| Consumer: Desktop 3P | `generateDesktop3pModels(..., options?: { preferOneMillion?: boolean })` and `generateDesktop3pConfig(..., options?)` (`desktop-3p.ts:320`, `:377`, call `:401`); `prefer1m` emitted only when `preferOneMillion !== false`, in both the profile branch (`:221`) and the non-profile branch (`:266`, `:296`). The writer derives it from its freshly re-read config: `writeDesktop3pConfig` (`:656`) passes `{ preferOneMillion: latest.config.claudeCode?.contextAccounting !== "200k" }` at `:703`, so the apply (`native-integration-routes.ts:822-834`), CLI (`src/cli/claude-desktop.ts:537`) and sync (`config-routes.ts:804-822`) callers need no change. The remote export endpoint `serve-options.ts:1024-1027` passes the same option from its `config`. `writeRemoteDesktop3pConfig` writes the hub's entries verbatim (hub already applied its policy) | +| Consumer: subagents | `withSubagentContextMarker(selector, windows, mode = UNPAIRED_AUTO_CONTEXT)`; roster defs `src/claude/agents-inject.ts:100` and self def `:133` pass `subagentMarkingMode(config)`; `resolveSubagentForceModel` (`subagent-model.ts:81`) passes the same helper. `subagentMarkingMode(config) = resolveAutoContext(config.claudeCode).accounting200k ? ACCOUNTING_200K : UNPAIRED_AUTO_CONTEXT` lives in `subagent-model.ts` | +| Refresh | Picker snapshots rebuild on their staleness timer; Desktop 3P config is rewritten by the existing apply/sync paths (same as `autoContext` today). The PUT response adds no new side effect beyond the system-env reconcile; documented in the docs-site page | + +## Diff + +### MODIFY `src/claude/context-windows.ts` + +```ts +export interface AutoContextMode { + enabled: boolean; + compactWindow: number; + /** contextAccounting "200k": nothing is marked [1m] automatically, whatever its window. */ + accounting200k?: true; +} + +/** The "200k" opt-in mode: no widening, no compact-window injection, no automatic marker. */ +export const ACCOUNTING_200K: AutoContextMode = { enabled: false, compactWindow: AUTO_COMPACT_WINDOW_DEFAULT, accounting200k: true }; + +interface AutoContextConfigSlice { autoContext?: boolean; autoCompactWindow?: number; maxContextTokens?: number; contextAccounting?: string } + +export function resolveAutoContext(claudeCode, envOverride?) { + if (claudeCode?.contextAccounting === "200k") return ACCOUNTING_200K; + ...unchanged +} + +export function shouldMarkOneMillion(window, auto) { + if (typeof window !== "number" || window <= 0) return false; + if (auto.accounting200k) return false; + ...unchanged +} +``` + +`effectiveModelEnv`'s `claudeCode` parameter type gains `contextAccounting?: string`. + +### MODIFY `src/claude/subagent-model.ts` `withSubagentContextMarker` (architect reflection 2) + +Explicit markers keep their existing safety rule; the mode only controls automatic marking: + +```ts +export function withSubagentContextMarker(selector: string, windows: Record, mode: AutoContextMode = UNPAIRED_AUTO_CONTEXT): string { + const bare = stripOneMillionMarker(selector); + const wasMarked = selector !== bare; + const canonicalExact = wasMarked ? `${bare}[1m]` : selector; + const authoritativeWindow = windows[selector] ?? windows[canonicalExact] ?? windows[bare]; + if (typeof authoritativeWindow === "number" && authoritativeWindow > 0) { + // An explicit [1m] survives when the window can carry it, whatever the policy; an unsafe + // inherited one is stripped. Only an unmarked selector depends on the mode. + const marks = wasMarked + ? shouldMarkOneMillion(authoritativeWindow, UNPAIRED_AUTO_CONTEXT) + : shouldMarkOneMillion(authoritativeWindow, mode); + return marks ? (withOneMillionMarker(selector, windows, UNPAIRED_AUTO_CONTEXT) ?? selector) : bare; + } + return wasMarked ? selector : bare; +} +``` + +Tests (in the new 030 test file): under `ACCOUNTING_200K` an unmarked 872k roster entry stays bare; +a picker-pinned self model `...[1m]` with an 872k window keeps its marker; a `[1m]` selector whose +window is 262,144 is stripped under both modes; force resolution of `kimi/k3[1m]` is unchanged. + +### Other files + +As listed in the chain table. GUI is wp4. + +### docs-site + +`docs-site/src/content/docs/**/claude-code*.md(x)` (exact path resolved at wp3 P): document +`ocx claude config set --context-accounting <1m|200k>` and the default. English source; other +locales get the same flag line without contradicting text. + +## Tests + +NEW `tests/claude-integration/claude-context-accounting.test.ts` (registered in layout files): + +1. `resolveAutoContext({ contextAccounting: "200k" })` is `ACCOUNTING_200K`; `{ contextAccounting: "1m" }` and `{ contextAccounting: "bogus" }` resolve as default. +2. `effectiveModelEnv({ model: "native/gpt-6-astra", contextAccounting: "200k" }, { ...872k map })` has no `[1m]`; without the field it does; a typed `...[1m]` selector keeps its marker under 200k. +3. `buildPickerModels({ ..., auto: ACCOUNTING_200K })` leaves 872k and 1M rows unmarked. +4. `generateDesktop3pModels(["gpt-6-astra"], [], undefined, caps, { preferOneMillion: false })` has `supports1m` without `prefer1m`. +5. Discovery under `ACCOUNTING_200K`: 1M routed row keeps its `· 1M` variant; 872k native has none. +6. Management: PUT `{ contextAccounting: "200k" }` stores it, `"1m"` deletes it, `"x"` -> 400; GET reports `"200k"` / `"1m"` (reuse the existing claude-code route harness file named at P). +7. CLI: `ocx claude config set --context-accounting 200k` sends `{ contextAccounting: "200k" }` (existing integrations CLI test harness named at P). + +## Verification + +- `bun run typecheck` +- focused files above + `tests/ci-workflows/skill-ocx.test.ts` (capability map) + `bun run skill:surface:check` +- `bun run structure:check` diff --git a/devlog/_plan/261009_claude_1m_default/040_context_accounting_gui.md b/devlog/_plan/261009_claude_1m_default/040_context_accounting_gui.md new file mode 100644 index 00000000000..81873b43c39 --- /dev/null +++ b/devlog/_plan/261009_claude_1m_default/040_context_accounting_gui.md @@ -0,0 +1,89 @@ +# 040 wp4: dashboard control for context accounting + +## Why + +wp3 adds `contextAccounting` to config, API and CLI. The Claude Code settings page needs the same +switch so the 200k opt-in is reachable without a terminal. + +## Diff + +### MODIFY `gui/src/pages/claude-code-types.ts:57-59` + +Add `contextAccounting: "1m" | "200k";` after `autoCompactWindow`. + +### MODIFY `gui/src/pages/ClaudeCode.tsx:37` (`normalizeFirstPartyState`) + +Both entry points pass through this normalizer: the session cache (`:115`) and the GET read +(`:153`). Normalize there so an older cached state (no field) and an older server agree: + +```ts +function normalizeFirstPartyState(state: ClaudeCodeState): ClaudeCodeState { + const contextAccounting = state.contextAccounting === "200k" ? "200k" : "1m"; + ...existing body, returning { ...result, contextAccounting } +} +``` + +Test: an older cached state object without `contextAccounting` normalizes to `"1m"` before any +network read (the normalizer is exported for the test if it is not already). + +### MODIFY `gui/src/pages/claude-code-save.ts` + +- `EDITABLE_KEYS` (`:10-20`): add `"contextAccounting"`. +- Save body (`:45-46`): `contextAccounting: state.contextAccounting,`. + +### MODIFY `gui/src/pages/claude-code-sections.tsx` (before the auto-context row, `:117`) + +```tsx +
+
+ {t("claude.contextAccounting")} + {t("claude.contextAccountingDesc")} +
+
+