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 index c0589d909a5..db869e26247 100644 --- a/devlog/_plan/261009_claude_1m_default/030_context_accounting_policy.md +++ b/devlog/_plan/261009_claude_1m_default/030_context_accounting_policy.md @@ -123,3 +123,25 @@ NEW `tests/claude-integration/claude-context-accounting.test.ts` (registered in - `bun run typecheck` - focused files above + `tests/ci-workflows/skill-ocx.test.ts` (capability map) + `bun run skill:surface:check` - `bun run structure:check` + +## P amendment (wp3 entry, after wp2 review) + +wp2 changed two interfaces this doc relied on; the policy threads through them instead: + +- `withSubagentContextMarker(selector, windows, accounting200k = false)`: the marking mode is now + per selector (`subagentSelectorMode`: genuine 1M for Claude models, unpaired otherwise). Under + `200k` an unmarked selector stays bare; an explicit `[1m]` keeps following its selector mode + (kept when the window can carry it, stripped otherwise). `agents-inject.ts:100,133` and + `resolveSubagentForceModel` pass `resolveAutoContext(config.claudeCode).accounting200k === true`; + the `subagentMarkingMode` helper is not needed. +- Desktop eligibility is `claudeSurfaceSupportsOneMillion(provider, modelId, window)` / + `routeSupportsOneMillion(route, window)`; the policy only drops `prefer1m`, so those predicates + are unchanged. +- Discovery needs no change: `variantMode = auto.enabled ? UNPAIRED : OFF` already keeps the + >= 1M variants under `ACCOUNTING_200K` (enabled false). + +Audit of the amendment (PASS) notes: the earlier `withSubagentContextMarker(..., mode = UNPAIRED_AUTO_CONTEXT)` +snippet and the `subagentMarkingMode` chain row above are **superseded** by this amendment; the +picker keeps its Claude-model guard and takes `ACCOUNTING_200K` first; `shouldMarkOneMillion` +checks `accounting200k` before its >= 1M early return. wp3 B also carries the docs-site sync the +layer 1 and layer 2 branches still owe (cascaded onto those branches). diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 02b1641d58b..98a4f98e41d 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -490,6 +490,20 @@ reste prioritaire pour le marquage des variables de lancement ; le proxy s'appu Les lignes de découverte ignorent cet export et conservent le plancher fixe. Les valeurs de configuration invalides définies manuellement reviennent à 829,800. +### Comptabilisation du contexte (1M par défaut, 200k sur option) + +`claudeCode.contextAccounting` fixe ce qu'opencodex choisit par défaut. Absent (`1m`, valeur par défaut), les modèles à +longue fenêtre sont proposés à 1M dans les emplacements d'environnement du lancement, les sélecteurs Desktop, Desktop 3P +(`prefer1m`) et les sous-agents générés. Avec `200k`, rien n'est marqué `[1m]` automatiquement, `CLAUDE_CODE_AUTO_COMPACT_WINDOW` +n'est pas injecté et Desktop 3P retire `prefer1m` tout en gardant `supports1m`. Un sélecteur que vous marquez vous-même `[1m]` +reste disponible (les sous-agents générés ou forcés retirent toujours un marqueur que la fenêtre du modèle ne peut pas porter), et +la découverte liste toujours les lignes `· 1M` des modèles réellement à 1M. `200k` l'emporte sur le contexte automatique : une valeur +de compactage que vous exportez ne réactive pas le marquage automatique, mais opencodex laisse votre export en place. + +```bash +ocx claude config set --context-accounting 200k +``` + ### Environnement effectif des modèles `effectiveModelEnv` calcule six emplacements injectés par `ocx claude`, l'environnement système ou le fichier d'environnement du shell : diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 9922dde3fa5..1cc6b683441 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -678,6 +678,21 @@ for launch-slot marking (the proxy uses YOUR value to decide which models are sa Discovery rows ignore that export and keep the fixed floor. Invalid hand-edited config values fall back to 829,800. +### Context accounting (1M by default, 200k opt-in) + +`claudeCode.contextAccounting` decides what opencodex picks by default. Absent (`1m`, the +default), long-window models are offered at 1M in the launch env slots, the Desktop pickers, +Desktop 3P (`prefer1m`) and generated subagents. Set `200k` to opt out: nothing is marked `[1m]` +automatically, `CLAUDE_CODE_AUTO_COMPACT_WINDOW` is not injected, and Desktop 3P drops `prefer1m` +while keeping `supports1m`. A selector you mark `[1m]` yourself stays available (generated and +forced subagents still drop a marker the model's window cannot carry), and discovery still lists +the `· 1M` rows of genuine 1M models. `200k` wins over auto-context: a compact window you export no +longer turns automatic marking on, though opencodex leaves your own export in place. + +```bash +ocx claude config set --context-accounting 200k +``` + ### Effective model environment `effectiveModelEnv` computes six slots injected by `ocx claude` / system env / shell file: diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index c5690dc3a67..793367e24c5 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -340,6 +340,19 @@ Claude Code は未知モデルのコンテキストを 200k トークンとし 1M 未満のネイティブ Anthropic モデルには自動で標識を付けません。自分で export した圧縮値は起動スロットの標識に優先し、プロキシは**ユーザーが指定した**値を基準にどのモデルに安全に標識を付けるか決定します。ディスカバリー行はその export を無視し、固定の下限を保ちます。 直接編集した設定値が不正な場合は 829,800 に戻ります。 +### コンテキスト計算方式(既定は 1M、200k はオプトイン) + +`claudeCode.contextAccounting` は opencodex が既定で選ぶ値を決めます。未設定(`1m`、既定)では、 +長いコンテキストのモデルが起動環境スロット、Desktop ピッカー、Desktop 3P(`prefer1m`)、生成されたサブエージェントで 1M として扱われます。 +`200k` にすると `[1m]` 標識を自動では付けず、`CLAUDE_CODE_AUTO_COMPACT_WINDOW` も注入せず、Desktop 3P は `supports1m` を残して +`prefer1m` だけを外します。自分で `[1m]` を付けた選択は引き続き使えます(生成されたサブエージェントと強制サブエージェントは、モデルのウィンドウが支えられない標識を引き続き外します)。 +ディスカバリーには実際に 1M のモデルの `· 1M` 行が引き続き表示されます。`200k` は自動コンテキストより優先され、自分で export した圧縮値があっても自動標識は +再び有効になりません。その export 値自体は opencodex が変更しません。 + +```bash +ocx claude config set --context-accounting 200k +``` + ### 実モデル環境 `effectiveModelEnv` は `ocx claude` / システム環境 / シェルファイルが注入するスロット 6 つを計算します。 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 91de7d969bd..677bf26cbe1 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -374,6 +374,20 @@ Claude Code는 알 수 없는 모델의 컨텍스트를 200k 토큰으로 계산 디스커버리 행은 그 값을 무시하고 고정 하한을 유지해요. 직접 편집한 설정값이 잘못되면 829,800로 돌아가요. +### 컨텍스트 계산 방식 (기본 1M, 200k는 옵트인) + +`claudeCode.contextAccounting`은 opencodex가 기본으로 고르는 값을 정해요. 설정하지 않으면(`1m`, 기본값) +긴 컨텍스트 모델이 실행 환경 슬롯, Desktop 선택기, Desktop 3P(`prefer1m`), 생성된 서브에이전트에서 1M으로 +잡혀요. `200k`로 바꾸면 자동 `[1m]` 표식을 붙이지 않고 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`도 넣지 않으며, +Desktop 3P는 `supports1m`은 남기고 `prefer1m`만 빼요. 직접 `[1m]`을 붙인 선택은 계속 쓸 수 있어요(생성된 +서브에이전트와 강제 서브에이전트는 모델 창이 감당하지 못하는 표식을 여전히 떼요). 디스커버리 목록에는 실제 1M 모델의 +`· 1M` 행이 계속 나와요. `200k`는 자동 컨텍스트보다 우선해요. 직접 export한 압축 값이 있어도 자동 표식은 다시 켜지지 +않고, 그 export 값은 opencodex가 건드리지 않아요. + +```bash +ocx claude config set --context-accounting 200k +``` + ### 실제 모델 환경 `effectiveModelEnv`는 `ocx claude` / 시스템 환경 / 셸 파일이 주입할 슬롯 여섯 개를 계산해요. diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index dce282cfcc9..6eaee530207 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -363,6 +363,20 @@ Anthropic сохраняют канонические id в обоих инте можно безопасно пометить). Строки обнаружения игнорируют этот экспорт и сохраняют фиксированный пол. Некорректные значения, внесённые в конфигурацию вручную, откатываются к 829,800. +### Учёт контекста (1M по умолчанию, 200k по выбору) + +`claudeCode.contextAccounting` определяет, что opencodex выбирает по умолчанию. Без значения (`1m`, по умолчанию) модели +с длинным окном предлагаются как 1M в слотах окружения запуска, селекторах Desktop, Desktop 3P (`prefer1m`) и созданных +субагентах. Значение `200k` это отключает: маркер `[1m]` автоматически не ставится, `CLAUDE_CODE_AUTO_COMPACT_WINDOW` не +передаётся, а Desktop 3P убирает `prefer1m`, оставляя `supports1m`. Селектор, который вы сами пометили `[1m]`, остаётся +доступным (созданные и принудительные субагенты по-прежнему снимают маркер, который окно модели не выдерживает), а в списке +обнаружения остаются строки `· 1M` для моделей с настоящим окном 1M. `200k` важнее автоконтекста: экспортированное вами +значение сжатия не включает автоматическую разметку снова, но opencodex ваш экспорт не трогает. + +```bash +ocx claude config set --context-accounting 200k +``` + ### Эффективное окружение моделей `effectiveModelEnv` вычисляет шесть слотов, внедряемых `ocx claude`, системным окружением и diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 9f791d54db0..c5676ce26bb 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -320,6 +320,18 @@ v1 别名按字面解码(历史上 model ID 中包含的两字符序列 `~s` / 低于 1M 的原生 Anthropic 模型绝不会被自动标记。你自行导出的压缩值在启动槽位标记上始终优先(代理会使用**你的** 值来判断哪些模型可以安全标记)。发现列表忽略该导出值并保持固定下限。手动编辑配置时填入的无效值会回退到 829,800。 +### 上下文计算方式(默认 1M,200k 需选择启用) + +`claudeCode.contextAccounting` 决定 opencodex 默认选用的值。未设置(`1m`,默认)时,长上下文模型在启动环境槽位、 +Desktop 选择器、Desktop 3P(`prefer1m`)和生成的子代理中按 1M 提供。设为 `200k` 即可退出:不再自动添加 `[1m]` 标记, +不注入 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`,Desktop 3P 保留 `supports1m` 但去掉 `prefer1m`。你自己标记 `[1m]` 的选择仍可使用 +(生成的子代理和强制子代理仍会去掉模型窗口无法承载的标记),发现列表中真正 1M 模型的 `· 1M` 条目也仍然存在。`200k` 优先于 +自动上下文:你自行导出的压缩值不会重新启用自动标记,但 opencodex 不会改动你导出的值。 + +```bash +ocx claude config set --context-accounting 200k +``` + ### 有效模型环境变量 `effectiveModelEnv` 会计算由 `ocx claude` / 系统环境 / shell 文件注入的六个槽位: diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 4f033558616..c3f05c79778 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -396,6 +396,18 @@ v1 別名按字面解碼(歷史上 model ID 中包含的兩字元序列 `~s` / 低於 1M 的原生 Anthropic 模型絕不會被自動標記。你自行匯出的壓縮值在啟動槽位標記上始終優先(代理會使用**你的** 值來判斷哪些模型可以安全標記)。探索列表忽略該匯出值並保持固定下限。手動編輯設定時填入的無效值會回退到 829,800。 +### 上下文計算方式(預設 1M,200k 需選擇啟用) + +`claudeCode.contextAccounting` 決定 opencodex 預設選用的值。未設定(`1m`,預設)時,長上下文模型在啟動環境槽位、 +Desktop 選擇器、Desktop 3P(`prefer1m`)和產生的子代理中按 1M 提供。設為 `200k` 即可退出:不再自動加上 `[1m]` 標記, +不注入 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`,Desktop 3P 保留 `supports1m` 但移除 `prefer1m`。你自己標記 `[1m]` 的選擇仍可使用 +(產生的子代理和強制子代理仍會移除模型視窗無法承載的標記),探索清單中真正 1M 模型的 `· 1M` 條目也仍然存在。`200k` 優先於 +自動上下文:你自行匯出的壓縮值不會重新啟用自動標記,但 opencodex 不會改動你匯出的值。 + +```bash +ocx claude config set --context-accounting 200k +``` + ### 有效模型環境變數 `effectiveModelEnv` 會計算由 `ocx claude` / 系統環境 / shell 檔案注入的六個槽位: diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 84580dd3501..900e1ab1176 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -419,7 +419,7 @@ "messages-native-oauth.test.ts": "claude-integration", "messages-native-opaque-state.test.ts": "claude-integration", "messages-surface-matrix.test.ts": "claude-integration", "claude-long-context-eligibility.test.ts": "claude-integration", - "claude-model-info.test.ts": "claude-integration", + "claude-context-accounting.test.ts": "claude-integration", "claude-model-info.test.ts": "claude-integration", "claude-models-discovery.test.ts": "claude-integration", "claude-native-affinity.test.ts": "claude-integration", "claude-native-passthrough.test.ts": "claude-integration", diff --git a/skills/ocx/references/01_surface_integrations.md b/skills/ocx/references/01_surface_integrations.md index dce2495042c..bd8079e18b5 100644 --- a/skills/ocx/references/01_surface_integrations.md +++ b/skills/ocx/references/01_surface_integrations.md @@ -12,7 +12,7 @@ Declared capabilities: 41. ### `ocx claude config` -Usage: `ocx claude config status [--json]; ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]; or ocx claude config set --first-party [--json]` +Usage: `ocx claude config status [--json]; ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--context-accounting <1m|200k>] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]; or ocx claude config set --first-party [--json]` Read or update Claude Code settings, including independent CLI first-party routing. @@ -31,6 +31,7 @@ State-changing: yes. | `--fast-mode` | string | on or off. | | `--auto-context` | string | on or off. | | `--compact-window` | string | Positive token count or default to clear. | +| `--context-accounting` | string | 1m (default) or 200k. | | `--inject-agents` | string | on or off. | | `--small-fast-model` | string | Model ID; - clears. | | `--model-map` | string | Comma-separated from=to mappings; - clears. | @@ -566,7 +567,7 @@ JSON mode: `payload`. ### `ocx claude config set` -Usage: `ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]; or ocx claude config set --first-party [--json]` +Usage: `ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--context-accounting <1m|200k>] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]; or ocx claude config set --first-party [--json]` Change Claude Code settings through the runtime owner. @@ -584,6 +585,7 @@ State-changing: yes. | `--fast-mode` | string | on or off. | | `--auto-context` | string | on or off. | | `--compact-window` | string | Positive token count or default to clear. | +| `--context-accounting` | string | 1m (default) or 200k. | | `--inject-agents` | string | on or off. | | `--small-fast-model` | string | Model ID; - clears. | | `--model-map` | string | Comma-separated from=to mappings; - clears. | diff --git a/src/claude/agents-inject.ts b/src/claude/agents-inject.ts index 3ed355f2842..4ed16454f3e 100644 --- a/src/claude/agents-inject.ts +++ b/src/claude/agents-inject.ts @@ -16,7 +16,7 @@ import { join } from "node:path"; import type { OcxConfig } from "../types"; import { renameAtomicFile } from "../lib/windows-atomic-replace"; import { assertNotRealClaudeConfigUnderTest } from "../lib/test-home-guard"; -import { entryParts, SAFE_AGENT_MODEL_ID, withSubagentContextMarker } from "./subagent-model"; +import { accountsAt200k, entryParts, SAFE_AGENT_MODEL_ID, withSubagentContextMarker } from "./subagent-model"; import { stripOneMillionMarker } from "./context-windows"; import { claudeConfigDir } from "./gateway-cache"; import { DEFAULT_SUBAGENT_MODELS } from "../config"; @@ -98,7 +98,7 @@ export function buildClaudeAgentDefs( // Generated defs mark [1m] on the authoritative window only — never the // main-session auto-context predicate (a 372K route marked [1m] would be // accounted at 1M with no compaction safety net in the subagent). - const model = withSubagentContextMarker(alias, windows); + const model = withSubagentContextMarker(alias, windows, accountsAt200k(config)); const bare = alias.toLowerCase(); if (coveredModels.has(bare)) return; coveredModels.add(bare); @@ -131,7 +131,7 @@ export function buildClaudeAgentDefs( // the next launch sync — documented limit. No resolvable default -> no self def. const selfModel = pickerDefaultModel(configDir) ?? (config.claudeCode?.model?.trim() || null); if (selfModel) { - const marked = withSubagentContextMarker(selfModel, windows); + const marked = withSubagentContextMarker(selfModel, windows, accountsAt200k(config)); defs.push({ file: `${OWNED_PREFIX}self.md`, name: `${OWNED_PREFIX}self`, diff --git a/src/claude/context-windows.ts b/src/claude/context-windows.ts index 5c3caeda824..75428abc06e 100644 --- a/src/claude/context-windows.ts +++ b/src/claude/context-windows.ts @@ -45,6 +45,8 @@ export interface AutoContextMode { enabled: boolean; /** Effective CLAUDE_CODE_AUTO_COMPACT_WINDOW value (tokens). */ compactWindow: number; + /** claudeCode.contextAccounting "200k": nothing is marked [1m] automatically, whatever its window. */ + accounting200k?: true; } export const AUTO_CONTEXT_OFF: AutoContextMode = { enabled: false, compactWindow: AUTO_COMPACT_WINDOW_DEFAULT }; @@ -56,10 +58,14 @@ export const AUTO_CONTEXT_OFF: AutoContextMode = { enabled: false, compactWindow */ export const UNPAIRED_AUTO_CONTEXT: AutoContextMode = { enabled: true, compactWindow: AUTO_COMPACT_WINDOW_DEFAULT }; +/** The "200k" opt-in: 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; } function inAutoCompactRange(value: number): boolean { @@ -83,6 +89,8 @@ function inAutoCompactRange(value: number): boolean { * to AUTO_COMPACT_WINDOW_DEFAULT (the management API rejects them; this guards hand-edits). */ export function resolveAutoContext(claudeCode: AutoContextConfigSlice | undefined, envOverride?: string): AutoContextMode { + // The 200k opt-in wins over every other lever, a user-exported compact window included. + if (claudeCode?.contextAccounting === "200k") return ACCOUNTING_200K; if (claudeCode?.autoContext === false) return AUTO_CONTEXT_OFF; const maxCtx = claudeCode?.maxContextTokens; if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) return AUTO_CONTEXT_OFF; @@ -125,6 +133,7 @@ export function claudeToolSearchEnv(value: boolean | string | undefined): string */ export function shouldMarkOneMillion(window: number | undefined, auto: AutoContextMode): boolean { if (typeof window !== "number" || window <= 0) return false; + if (auto.accounting200k) return false; if (window >= ONE_MILLION) return true; return auto.enabled && window > AUTO_CONTEXT_FLOOR && window >= auto.compactWindow; } @@ -241,7 +250,7 @@ export interface ClaudeTierModels { * value injected into BOTH haiku variables. */ export function effectiveModelEnv( - claudeCode: { model?: string; smallFastModel?: string; tierModels?: ClaudeTierModels; autoContext?: boolean; autoCompactWindow?: number; maxContextTokens?: number } | undefined, + claudeCode: { model?: string; smallFastModel?: string; tierModels?: ClaudeTierModels; autoContext?: boolean; autoCompactWindow?: number; maxContextTokens?: number; contextAccounting?: string } | undefined, windows: Record, autoOverride?: AutoContextMode, ): Record { diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 26eb3fbfb2c..79c9fb312e6 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -319,16 +319,30 @@ export function buildDesktop3pRegistry( } /** Generate Claude Desktop 3P model entries from the proxy's available models. */ +export interface Desktop3pModelOptions { + /** + * false under claudeCode.contextAccounting "200k": a long-window model stays selectable at 1M + * (`supports1m`) but no longer defaults to it (`prefer1m` dropped). + */ + preferOneMillion?: boolean; +} + export function generateDesktop3pModels( nativeSlugs: string[], routedModels: Array, profile?: OcxClaudeDesktopProfile, nativeContextCap?: NativeContextLimitsInput, + options: Desktop3pModelOptions = {}, ): Desktop3pModelEntry[] { const { models, registry, realAnthropicIds } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); desktop3pRegistry = registry; desktop3pRealAnthropicIds = realAnthropicIds; - return models; + return options.preferOneMillion === false ? models.map(({ prefer1m: _prefer1m, ...model }) => model) : models; +} + +/** The Desktop model options a config asks for (one place, so writer and export agree). */ +export function desktop3pModelOptions(claudeCode: { contextAccounting?: string } | undefined): Desktop3pModelOptions { + return { preferOneMillion: claudeCode?.contextAccounting !== "200k" }; } /** Resolve an alias using the most recently generated Desktop model registry. */ @@ -384,6 +398,7 @@ export function generateDesktop3pConfig( mode: Desktop3pConfigMode = "static", profile?: OcxClaudeDesktopProfile, nativeContextCap?: NativeContextLimitsInput, + options: Desktop3pModelOptions = {}, ): object { const base = { inferenceProvider: "gateway", @@ -400,7 +415,7 @@ export function generateDesktop3pConfig( ...base, modelDiscoveryEnabled: mode === "hybrid", inferenceModels: (() => { - const models = generateDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); + const models = generateDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap, options); // Fail loud at the write boundary rather than ship a config Desktop rejects: // the output counterpart of the request-path guards. assertDesktop3pModelsValid(models); @@ -723,7 +738,8 @@ export function writeDesktop3pConfig( ); } return writeDesktop3pConfigWithGenerator(() => ( - generateDesktop3pConfig(destination.origin, nativeSlugs, routedModels, gatewayKey, mode, profile, nativeContextCap) + generateDesktop3pConfig(destination.origin, nativeSlugs, routedModels, gatewayKey, mode, profile, nativeContextCap, + desktop3pModelOptions(latest.config.claudeCode)) ), requiredId && refreshOnly ? { requiredId, expectedFingerprint: refreshOnly.appliedFingerprint, admit: refreshOnly.admit } : undefined); }), lifecycleLockDeps); } catch { return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_lifecycle_busy_or_unsafe" }; } diff --git a/src/claude/intercept/picker-models.ts b/src/claude/intercept/picker-models.ts index 65d3ca93e13..17ae9c3d9f9 100644 --- a/src/claude/intercept/picker-models.ts +++ b/src/claude/intercept/picker-models.ts @@ -7,7 +7,7 @@ import { dirname } from "node:path"; import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../../codex/catalog"; import type { OcxClaudeDesktopProfile } from "../../types"; import { aliasForRoute, claudeCodeNativeAlias } from "../alias"; -import { AUTO_CONTEXT_OFF, stripOneMillionMarker, UNPAIRED_AUTO_CONTEXT, withOneMillionMarker } from "../context-windows"; +import { AUTO_CONTEXT_OFF, stripOneMillionMarker, UNPAIRED_AUTO_CONTEXT, withOneMillionMarker, type AutoContextMode } from "../context-windows"; import { isAnthropicClaudeRoute } from "../long-context"; import { activeDesktop3pAlias, displayModelId, resolveDesktop3pAlias, type Desktop3pRoutedModel } from "../desktop-3p"; import { reconcileDesktopProfile, renderDesktopProfile, type DesktopProfileModel } from "../desktop-profile"; @@ -18,6 +18,8 @@ export interface PickerRouteInput { routedModels: Desktop3pRoutedModel[]; profile?: OcxClaudeDesktopProfile; nativeContextCap?: NativeContextLimitsInput; + /** resolveAutoContext(config.claudeCode); only its contextAccounting "200k" opt-in matters here. */ + auto?: AutoContextMode; } /** Candidate routes in the gateway profile's order and with its labels. */ @@ -52,8 +54,8 @@ function splitRoute(route: string): { provider: string; id: string } { * compact window) is marked on the strength of the `prompt is too long` recovery instead * (devlog/_plan/261009_claude_1m_default/020). Real Anthropic models need a genuine 1M. */ -function pickerSelector(alias: string, contextWindow: number | undefined, provider: string, id: string): string { - const mode = isAnthropicClaudeRoute(provider, id) ? AUTO_CONTEXT_OFF : UNPAIRED_AUTO_CONTEXT; +function pickerSelector(alias: string, contextWindow: number | undefined, provider: string, id: string, auto?: AutoContextMode): string { + const mode = auto?.accounting200k ? auto : isAnthropicClaudeRoute(provider, id) ? AUTO_CONTEXT_OFF : UNPAIRED_AUTO_CONTEXT; return withOneMillionMarker(alias, contextWindow === undefined ? {} : { [alias]: contextWindow }, mode)!; } @@ -67,7 +69,7 @@ export function buildPickerModels(input: PickerRouteInput): PickerModelEntry[] { const alias = provider === "native" ? claudeCodeNativeAlias(id) : aliasForRoute(provider, id); if (!alias || seen.has(alias)) continue; seen.add(alias); - out.push({ id: pickerSelector(alias, model.contextWindow, provider, id), name: model.label, + out.push({ id: pickerSelector(alias, model.contextWindow, provider, id, input.auto), name: model.label, ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }) }); } return out; @@ -87,7 +89,7 @@ export function buildCliPickerModels(input: PickerRouteInput): PickerModelEntry[ const alias = activeDesktop3pAlias(provider, id); if (seen.has(alias) || resolveDesktop3pAlias(alias) !== model.route) continue; seen.add(alias); - out.push({ id: pickerSelector(alias, model.contextWindow, provider, id), name: model.label, + out.push({ id: pickerSelector(alias, model.contextWindow, provider, id, input.auto), name: model.label, description: `opencodex · ${model.route}`, route: model.route, ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }) }); } diff --git a/src/claude/subagent-model.ts b/src/claude/subagent-model.ts index f5efe44f4d4..e7c04c8e5ed 100644 --- a/src/claude/subagent-model.ts +++ b/src/claude/subagent-model.ts @@ -38,12 +38,17 @@ function subagentSelectorMode(bare: string): AutoContextMode { return anthropic ? AUTO_CONTEXT_OFF : UNPAIRED_AUTO_CONTEXT; } -export function withSubagentContextMarker(selector: string, windows: Record): string { +/** + * `accounting200k` (claudeCode.contextAccounting "200k") stops automatic marking only: an unmarked + * selector stays bare, while an explicit [1m] still follows its selector's safety rule. + */ +export function withSubagentContextMarker(selector: string, windows: Record, accounting200k = false): 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) { + if (accounting200k && !wasMarked) return bare; const mode = subagentSelectorMode(bare); return shouldMarkOneMillion(authoritativeWindow, mode) ? (withOneMillionMarker(selector, windows, mode) ?? selector) @@ -75,6 +80,11 @@ function selectorIdentity(selector: string): string { return fullSelectorIdentity(stripOneMillionMarker(selector)); } +/** Whether the config opted into 200k accounting (shared by roster, self and force markers). */ +export function accountsAt200k(config: OcxConfig): boolean { + return config.claudeCode?.contextAccounting === "200k"; +} + /** Resolve only currently exposed entries; retained roster rows are not exposure proof. */ export function resolveSubagentForceModel(config: OcxConfig, windows: Record, available: SubagentForceExposure): string | null { const entry = config.claudeCode?.subagentModelForce; @@ -97,7 +107,7 @@ export function resolveSubagentForceModel(config: OcxConfig, windows: Record] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]; or ocx claude config set --first-party [--json]", + usage: "ocx claude config status [--json]; ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--context-accounting <1m|200k>] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]; or ocx claude config set --first-party [--json]", }, { command: ["claude","intercept","start"], diff --git a/src/cli/capabilities-integrations.ts b/src/cli/capabilities-integrations.ts index ba27f091265..2626f0c7301 100644 --- a/src/cli/capabilities-integrations.ts +++ b/src/cli/capabilities-integrations.ts @@ -139,10 +139,10 @@ export const INTEGRATION_CAPABILITIES: readonly Capability[] = [ }, { command: ["claude", "config", "set"], - usage: "ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]; or ocx claude config set --first-party [--json]", + usage: "ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--context-accounting <1m|200k>] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json]; or ocx claude config set --first-party [--json]", summary: "Change Claude Code settings through the runtime owner.", routes: [{"method": "PUT", "path": "/api/claude-code"}], - flags: [{"name": "--enabled", "value": "string", "summary": "on or off."}, {"name": "--auth-mode", "value": "string", "summary": "auto, proxy or subscription."}, {"name": "--system-env", "value": "string", "summary": "on or off."}, {"name": "--fast-mode", "value": "string", "summary": "on or off."}, {"name": "--auto-context", "value": "string", "summary": "on or off."}, {"name": "--compact-window", "value": "string", "summary": "Positive token count or default to clear."}, {"name": "--inject-agents", "value": "string", "summary": "on or off."}, {"name": "--small-fast-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--model-map", "value": "string", "summary": "Comma-separated from=to mappings; - clears."}, {"name": "--blocked-skills", "value": "string", "summary": "Comma-separated names; - clears."}, {"name": "--web-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--web-backend", "value": "string", "summary": "openai, anthropic, xai, gemini, exa or -."}, {"name": "--vision-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--vision-backend", "value": "string", "summary": "openai, anthropic or -."}, {"name": "--first-party", "value": "string", "summary": "on or off; must be the only setting."}, {"name": "--json", "value": "boolean", "summary": "Emit the result as JSON."}], + flags: [{"name": "--enabled", "value": "string", "summary": "on or off."}, {"name": "--auth-mode", "value": "string", "summary": "auto, proxy or subscription."}, {"name": "--system-env", "value": "string", "summary": "on or off."}, {"name": "--fast-mode", "value": "string", "summary": "on or off."}, {"name": "--auto-context", "value": "string", "summary": "on or off."}, {"name": "--compact-window", "value": "string", "summary": "Positive token count or default to clear."}, {"name": "--context-accounting", "value": "string", "summary": "1m (default) or 200k."}, {"name": "--inject-agents", "value": "string", "summary": "on or off."}, {"name": "--small-fast-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--model-map", "value": "string", "summary": "Comma-separated from=to mappings; - clears."}, {"name": "--blocked-skills", "value": "string", "summary": "Comma-separated names; - clears."}, {"name": "--web-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--web-backend", "value": "string", "summary": "openai, anthropic, xai, gemini, exa or -."}, {"name": "--vision-model", "value": "string", "summary": "Model ID; - clears."}, {"name": "--vision-backend", "value": "string", "summary": "openai, anthropic or -."}, {"name": "--first-party", "value": "string", "summary": "on or off; must be the only setting."}, {"name": "--json", "value": "boolean", "summary": "Emit the result as JSON."}], mutates: true, json: "payload", details: ["At least one setting is required. --first-party must be set alone and writes Claude settings immediately.", "Clear both helper model and backend with - to restore inheritance. Preserve shared_proxy_retained warnings."], diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index c553068e0f0..5c880bbb478 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -20,7 +20,7 @@ const CLAUDE_USAGE = `Usage: ocx claude config [status] [--json] ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] - [--compact-window ] [--inject-agents ] + [--compact-window ] [--context-accounting <1m|200k>] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json] @@ -76,6 +76,7 @@ export async function handleClaudeConfigCommand(argv: string[], deps: RuntimeApi const fastMode = takeBooleanOption(args, "--fast-mode"); const autoContext = takeBooleanOption(args, "--auto-context"); const compact = takeOption(args, "--compact-window"); + const accounting = takeOption(args, "--context-accounting"); const injectAgents = takeBooleanOption(args, "--inject-agents"); const smallFastModel = takeOption(args, "--small-fast-model"); const modelMap = takeOption(args, "--model-map"); @@ -98,6 +99,11 @@ export async function handleClaudeConfigCommand(argv: string[], deps: RuntimeApi body.autoCompactWindow = value; } } + if (accounting !== undefined) { + const value = accounting.toLowerCase(); + if (value !== "1m" && value !== "200k") throw new CliUsageError("--context-accounting must be 1m or 200k", CLAUDE_USAGE); + body.contextAccounting = value; + } if (injectAgents !== undefined) body.injectAgents = injectAgents; if (smallFastModel !== undefined) body.smallFastModel = smallFastModel === "-" ? "" : smallFastModel; if (modelMap !== undefined) body.modelMap = parseMap(modelMap); diff --git a/src/server/index/claude-intercept-lifecycle.ts b/src/server/index/claude-intercept-lifecycle.ts index fc9cea47ee5..b67ad17ed48 100644 --- a/src/server/index/claude-intercept-lifecycle.ts +++ b/src/server/index/claude-intercept-lifecycle.ts @@ -41,10 +41,11 @@ export function buildInterceptDesiredClients(config: OcxConfig, observed: Claude * imports keep the catalog and discovery off the synchronous startup path. */ export async function loadPickerRoutesFromCatalog(): Promise { - const [{ loadConfig }, { fetchAllModels }, catalog] = await Promise.all([ + const [{ loadConfig }, { fetchAllModels }, catalog, { resolveAutoContext }] = await Promise.all([ import("../../config"), import("../management-api"), import("../../codex/catalog"), + import("../../claude/context-windows"), ]); const config = loadConfig(); const models = await fetchAllModels(config); @@ -54,6 +55,7 @@ export async function loadPickerRoutesFromCatalog(): Promise { .map(model => ({ provider: model.provider, id: model.id, contextWindow: model.contextWindow })), ...(config.claudeCode?.desktopProfile ? { profile: config.claudeCode.desktopProfile } : {}), nativeContextCap: catalog.nativeContextLimits(config), + auto: resolveAutoContext(config.claudeCode), }; } diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 78705b5782a..748b27d5a62 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -119,6 +119,7 @@ import { handleChatCompletions } from "../chat-completions"; import { anthropicErrorResponse } from "../../claude/outbound"; import { buildDesktop3pRegistry, + desktop3pModelOptions, generateDesktop3pModels, } from "../../claude/desktop-3p"; import { buildDesktopDiscoveryInputs } from "../../claude/desktop-discovery-inputs"; @@ -1025,6 +1026,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { const models = config.claudeCode?.enabled === false ? [] : generateDesktop3pModels( desktopInputs.nativeSlugs, desktopInputs.routedModels, config.claudeCode?.desktopProfile, desktopInputs.nativeContextCap, + desktop3pModelOptions(config.claudeCode), ); const response = jsonResponse({ version: 1, models }, 200, req, policy); response.headers.set("Cache-Control", "no-store"); diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index d436565d8a2..2f23ba14a11 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1472,6 +1472,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise alwaysEnableEffort: config.claudeCode?.alwaysEnableEffort === true, autoContext: config.claudeCode?.autoContext !== false, autoCompactWindow: config.claudeCode?.autoCompactWindow ?? null, + contextAccounting: config.claudeCode?.contextAccounting === "200k" ? "200k" : "1m", blockedSkills: config.claudeCode?.blockedSkills ?? null, injectAgents: config.claudeCode?.injectAgents !== false, sidecarPools: { @@ -1508,7 +1509,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise return prototype === Object.prototype || prototype === null; }; if (!isPlainObject(parsedBody)) return jsonResponse({ error: "body must be an object" }, 400); - const body = parsedBody as { enabled?: unknown; cliFirstParty?: unknown; authMode?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; classifierModel?: unknown; classifierFallbacks?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; blockedSkills?: unknown; injectAgents?: unknown; webSearchSidecar?: unknown; visionSidecar?: unknown }; + const body = parsedBody as { enabled?: unknown; cliFirstParty?: unknown; authMode?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; classifierModel?: unknown; classifierFallbacks?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; contextAccounting?: unknown; blockedSkills?: unknown; injectAgents?: unknown; webSearchSidecar?: unknown; visionSidecar?: unknown }; if (body.cliFirstParty !== undefined && typeof body.cliFirstParty !== "boolean") return jsonResponse({ error: "cliFirstParty must be a boolean" }, 400); if (body.cliFirstParty !== undefined && Object.keys(body).length !== 1) @@ -1778,6 +1779,15 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (body.autoContext) delete next.autoContext; else next.autoContext = false; } + if (body.contextAccounting !== undefined) { + // Sparse like autoContext (devlog/_plan/261009_claude_1m_default/030): "1m" is the default + // and drops the key; "200k" is the only stored value. + if (body.contextAccounting !== "1m" && body.contextAccounting !== "200k") { + return jsonResponse({ error: "contextAccounting must be \"1m\" or \"200k\"" }, 400); + } + if (body.contextAccounting === "200k") next.contextAccounting = "200k"; + else delete next.contextAccounting; + } if (body.injectAgents !== undefined) { // Default-on boolean (devlog 260712 070): true = drop the key, false = store. if (typeof body.injectAgents !== "boolean") return jsonResponse({ error: "injectAgents must be a boolean" }, 400); @@ -1892,7 +1902,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // levers feed the same injection, so a changed or cleared slot must not linger in // launchd until the next restart. const systemEnvInputs = ["systemEnv", "authMode", "model", "smallFastModel", "tierModels", - "maxContextTokens", "alwaysEnableEffort", "autoContext", "autoCompactWindow"] as const; + "maxContextTokens", "alwaysEnableEffort", "autoContext", "autoCompactWindow", "contextAccounting"] as const; if (systemEnvInputs.some(field => body[field] !== undefined)) { try { await applySystemEnvToggle(config, config.port); diff --git a/src/types/config.ts b/src/types/config.ts index c5afa71c8b4..39be8ffeb9b 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -174,6 +174,15 @@ export interface OcxClaudeCodeConfig { autoContext?: boolean; /** Compact-window tokens for auto-context. Default 829_800 (AUTO_COMPACT_WINDOW_DEFAULT). */ autoCompactWindow?: number; + /** + * Context accounting on Claude surfaces (devlog/_plan/261009_claude_1m_default/030). Absent + * (the default) = "1m": long-window models are offered at 1M wherever opencodex picks the + * default (launch env slots, Desktop pickers, Desktop 3P prefer1m, generated subagents). + * "200k" is the opt-in: nothing is marked [1m] automatically and no compact window is injected; + * a selector the user marks [1m] keeps it, and discovery and Desktop 3P keep offering 1M as a + * choice. The only stored value; "1m" deletes the key. + */ + contextAccounting?: "200k"; /** * Local CONNECT proxy + TLS listener that intercepts Claude Code's own `api.anthropic.com` * traffic without any `ANTHROPIC_BASE_URL` rewrite (src/claude/intercept). Claude Code reaches diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 2953bf88032..75c69a9dcb3 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -288,7 +288,7 @@ intent and cannot repair the URL a running Desktop already pinned. The `claude.a certificate, streams every body and upgrade unchanged, and rewrites only the bootstrap response's local Code picker surfaces, `ccd` (what the Desktop Code tab reads) and its `code` fallback, never the remote `ccr` (`picker-bootstrap.ts`), failing open to the original bytes; the model list -comes from a persisted snapshot (`picker-models.ts`), so a bootstrap never waits on discovery. Picker aliases carry `[1m]` for long windows (`src/claude/long-context.ts`: at least 1M, or at least the 829,800 default compact window), and Claude models on either Anthropic pool only at a genuine 1M. The picker cannot guarantee the Desktop runner's compaction environment, so an 872k route that outgrows its window relies on the `prompt is too long` envelope, which Claude Code compacts on; Desktop 3P `supports1m`/`prefer1m`, discovery `· 1M` rows and generated subagent markers use the same rule. A +comes from a persisted snapshot (`picker-models.ts`), so a bootstrap never waits on discovery. Picker aliases carry `[1m]` for long windows (`src/claude/long-context.ts`: at least 1M, or at least the 829,800 default compact window), and Claude models on either Anthropic pool only at a genuine 1M. The picker cannot guarantee the Desktop runner's compaction environment, so an 872k route that outgrows its window relies on the `prompt is too long` envelope, which Claude Code compacts on; Desktop 3P `supports1m`/`prefer1m`, discovery `· 1M` rows and generated subagent markers use the same rule. `claudeCode.contextAccounting` (`src/types/config.ts`) is absent for the 1m default; the only stored value is `"200k"`, which stops automatic `[1m]` marking and `CLAUDE_CODE_AUTO_COMPACT_WINDOW` injection, drops Desktop 3P `prefer1m` while keeping `supports1m`, and still lists genuine 1M discovery rows. An explicit `[1m]` selector stays. `ocx claude config set --context-accounting` and `PUT /api/claude-code` write it (`"1m"` deletes the key). A CONNECT to claude.ai that arrives before the first refresh waits at most 3 s, then goes blind. A picker proxy bind failure only disables picker mode; a picker construction or start failure closes every socket the start had bound before rethrowing. Ordinary session cookies within the header diff --git a/structure/config.md b/structure/config.md index 378811247a3..86a27b4f835 100644 --- a/structure/config.md +++ b/structure/config.md @@ -120,7 +120,7 @@ Its client annotation compares only the bounded service-token fingerprint with t All config publication continues through the existing required ACL-hardened writers above. `claudeCode.desktopProfile` follows the same preserve-the-rest rule. JSON `null` (or any non-string) `appliedFingerprint` / `appliedAt` is treated as unset. A profile that is still invalid after that is dropped as a whole — `src/config/salvage.ts` already does this for independent `routingProfiles` / `combos` entries — so one bad Desktop marker cannot replace the operator's providers with `getDefaultConfig()`. A `claudeCode` value that is not an object still fails the document, because there is no safe subtree to keep. -`claudeCode.cliFirstParty` is an optional boolean in `src/types/config.ts`. The schema passes it through; the load normalizer (`src/config/load-degrade.ts`) drops a non-boolean hand edit, every reader treats only `true` as on, and `PUT /api/claude-code` accepts only a boolean. Absence means off. It is independent of `claudeCode.desktopMode`; changing CLI first-party pins an absent Desktop mode from the observation that will apply after the flag flips — an opt-out observes with `cliFirstParty` already cleared, so a shared env that predates the marker stays attributed to Desktop instead of being pinned `gateway` and removed from under it, and an owned env cannot be mistaken for CLI-only intent. The flag is written by a standalone `PUT /api/claude-code { cliFirstParty }`, including `ocx claude config set --first-party`; the mutation pins an absent `desktopMode` at the same time. The shared settings proxy status follows the ordered classifier in `src/claude/first-party-settings.ts`: unreadable settings are `unknown`; absent or unrecognized proxy URLs are `none`; a token-bearing opencodex URL beside a foreign CA is `foreign`, while a tokenless loopback URL beside that CA is `local` with unconfirmed ownership. An attributed proxy with no bound listener is `stopped`; a usable applied pair on a bound listener is `disabled` when Claude routing is ineligible and `live` when eligible; remaining mismatches are `broken` regardless of eligibility. Inspection never mints a token. A separate `ocx ensure` may write a config-derived port while this server remains bound elsewhere; status is then `broken` until the server restarts or ensure runs after restart. +`claudeCode.cliFirstParty` is an optional boolean in `src/types/config.ts`. The schema passes it through; the load normalizer (`src/config/load-degrade.ts`) drops a non-boolean hand edit, every reader treats only `true` as on, and `PUT /api/claude-code` accepts only a boolean. Absence means off. It is independent of `claudeCode.desktopMode`; changing CLI first-party pins an absent Desktop mode from the observation that will apply after the flag flips — an opt-out observes with `cliFirstParty` already cleared, so a shared env that predates the marker stays attributed to Desktop instead of being pinned `gateway` and removed from under it, and an owned env cannot be mistaken for CLI-only intent. The flag is written by a standalone `PUT /api/claude-code { cliFirstParty }`, including `ocx claude config set --first-party`; the mutation pins an absent `desktopMode` at the same time. The shared settings proxy status follows the ordered classifier in `src/claude/first-party-settings.ts`: unreadable settings are `unknown`; absent or unrecognized proxy URLs are `none`; a token-bearing opencodex URL beside a foreign CA is `foreign`, while a tokenless loopback URL beside that CA is `local` with unconfirmed ownership. An attributed proxy with no bound listener is `stopped`; a usable applied pair on a bound listener is `disabled` when Claude routing is ineligible and `live` when eligible; remaining mismatches are `broken` regardless of eligibility. Inspection never mints a token. A separate `ocx ensure` may write a config-derived port while this server remains bound elsewhere; status is then `broken` until the server restarts or ensure runs after restart. `claudeCode.contextAccounting` in `src/types/config.ts` stores only `"200k"`; absence is the 1m default and `"1m"` deletes the key. `ocx claude config set --context-accounting` writes it. The surface contract is [Claude Desktop picker mode](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). `showCodexCredits` is an optional, display-only boolean in `src/types/config.ts`, default off. The load schema degrades malformed values to false; `src/config/diagnostics.ts` rejects malformed write candidates. `GET /api/settings`, its successful PUT response, and the safe `/api/config` DTO always project a boolean. PUT accepts a partial boolean update, persists it, and restores both the previous value and key presence if saving fails. The switch gates account DTO exposure without changing probes or routing; [credits identity binding](providers/openai-accounts.md#display-only-codex-credits) owns the observation contract. The former `showCodexSparkQuota` key is inert passthrough data when loading an old config. It is absent from the typed settings contract and cannot re-enable Spark quota through the management API. Retirement does not migrate user-selected model ids or erase usage history. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 46690e75ad2..ab8e8032e3b 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -306,7 +306,7 @@ effective window when absent, and offers a read-only Reload after an unknown sav confirmed save whose list reload failed; a failed or retryable Codex catalog refresh is a warning after the save, and a non-retryable skip (no managed catalog) is a clean save. Close returns focus to the opening row control. -`GET /api/claude-code` reports `cliFirstParty`, `desktopFirstParty`, `cliFirstPartyApplied`, `interceptEligible`, `interceptRunning`, and the eight-value `sharedProxy: FirstPartyProxyStatus` from observed settings and the bound listener. `interceptEligible = claudeInterceptEnabled(config)` uses the same GET snapshot as `sharedProxy` and `interceptRunning`; the latter remains bound listener present AND eligible. The ordered classifier gives unreadable → `unknown`, absent or non-loopback URL → `none`, foreign CA with an opencodex token → `foreign`, foreign CA with a tokenless loopback URL → `local`, no bound listener → `stopped`, ineligible applied settings at the bound port → `disabled`, other ineligible or stale/mismatched settings → `broken`, and eligible applied settings at the bound port → `live`. `cliFirstPartyApplied` requires CLI intent and `live`. `PUT /api/claude-code` accepts standalone `cliFirstParty`; CLI-on repeats eligibility and port checks inside the locked persisted mutation, reconciles, and conditionally rolls back its own fields on failure. CLI-off deletes intent, pins an absent `desktopMode` from the post-clear observation, and retains an env Desktop still desires; when that retained env's ownership was ambiguous (an owned shared proxy suppressed by the flag), the success response warns `shared_proxy_retained` so the operator can pin `gateway` explicitly to release it. A successful nothing-desired reconcile returns `settings_residual` for every status except `none`, including `local`; unreadable cleanup returns the coded 500. Native Claude routing enable persists its setting and reports `interceptReason` when local start authority is absent; disable and no-op paths never attempt a start. `enabled:false` alone leaves the env untouched, and a mixed body returns 400 before save. GUI normalization maps only missing `sharedProxy:undefined` to `none`, invalid statuses including `null` to `unknown`; `interceptEligible:undefined` from an older cache maps to `true`, while present values use `=== true`. The source coverage map checks all eight statuses. Notice order is unknown, foreign, local, residual when undesired, disabled, routingOff for stopped/broken with ineligible routing, stopped, broken, notApplied, shared, null. Unknown copy states uncertainty, local copy names the unconfirmed 127.0.0.1 proxy and manual HTTPS_PROXY removal, disabled copy retains the first-party-off remedy, foreign copy directs manual CA/proxy repair, routingOff says to restore Claude routing or turn first-party off, stopped says to start opencodex, and eligible broken advises `ocx ensure` or restart. +`GET /api/claude-code` reports `cliFirstParty`, `desktopFirstParty`, `cliFirstPartyApplied`, `interceptEligible`, `interceptRunning`, and the eight-value `sharedProxy: FirstPartyProxyStatus` from observed settings and the bound listener. `interceptEligible = claudeInterceptEnabled(config)` uses the same GET snapshot as `sharedProxy` and `interceptRunning`; the latter remains bound listener present AND eligible. The ordered classifier gives unreadable → `unknown`, absent or non-loopback URL → `none`, foreign CA with an opencodex token → `foreign`, foreign CA with a tokenless loopback URL → `local`, no bound listener → `stopped`, ineligible applied settings at the bound port → `disabled`, other ineligible or stale/mismatched settings → `broken`, and eligible applied settings at the bound port → `live`. `cliFirstPartyApplied` requires CLI intent and `live`. `PUT /api/claude-code` accepts standalone `cliFirstParty`; CLI-on repeats eligibility and port checks inside the locked persisted mutation, reconciles, and conditionally rolls back its own fields on failure. CLI-off deletes intent, pins an absent `desktopMode` from the post-clear observation, and retains an env Desktop still desires; when that retained env's ownership was ambiguous (an owned shared proxy suppressed by the flag), the success response warns `shared_proxy_retained` so the operator can pin `gateway` explicitly to release it. A successful nothing-desired reconcile returns `settings_residual` for every status except `none`, including `local`; unreadable cleanup returns the coded 500. Native Claude routing enable persists its setting and reports `interceptReason` when local start authority is absent; disable and no-op paths never attempt a start. `enabled:false` alone leaves the env untouched, and a mixed body returns 400 before save. GUI normalization maps only missing `sharedProxy:undefined` to `none`, invalid statuses including `null` to `unknown`; `interceptEligible:undefined` from an older cache maps to `true`, while present values use `=== true`. The source coverage map checks all eight statuses. Notice order is unknown, foreign, local, residual when undesired, disabled, routingOff for stopped/broken with ineligible routing, stopped, broken, notApplied, shared, null. Unknown copy states uncertainty, local copy names the unconfirmed 127.0.0.1 proxy and manual HTTPS_PROXY removal, disabled copy retains the first-party-off remedy, foreign copy directs manual CA/proxy repair, routingOff says to restore Claude routing or turn first-party off, stopped says to start opencodex, and eligible broken advises `ocx ensure` or restart. `GET /api/claude-code` reports `contextAccounting` as `"200k"` or `"1m"`; `PUT` accepts only those two, stores `"200k"`, deletes the key for `"1m"`, and reconciles system env with the other levers. The marking contract is [Claude Desktop picker mode](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). | File-integration plans | `src/server/management/integration-routes.ts` and `aside-profile-routes.ts` — `POST /api/client-integrations/preview`, `POST /api/client-integrations/restore/preview`, and `POST /api/client-integrations/aside/profiles/{profileId}/preview`. Management-authenticated, declared non-mutating, and they write nothing: no snapshot, no lock, no maintenance, no recovery. They answer `409 integration_preview_unavailable` rather than gathering a model roster, because discovery refreshes credentials and writes the provider cache. Responses carry only declared managed schema paths, closed change kinds and an opaque fingerprint; no value, filesystem location or selected member identity appears. Mutation routes accept `operation` and `planFingerprint` together or not at all, reject a half-bound request and an operation that disagrees with the change, and answer `409 integration_preview_stale` with a freshly computed plan. Binding is an optimistic token, never authorization. [The integration contract](clients/integrations.md) owns the ordering. | | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). | diff --git a/structure/subagents.md b/structure/subagents.md index 906ce2149e4..71692ad7228 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -546,6 +546,6 @@ Automatic account exhaustion and recovery use the [spendable Codex credit eviden ### Forced Claude Code subagent model -`src/claude/subagent-model.ts` shares the roster alias and authoritative context-marker resolver with routed launch force. `claudeCode.subagentModelForce` is opt-in and default-off. Force availability excludes retained unavailable roster entries. Caller-added `[1m]` suffixes require a finite authoritative long window (`src/claude/long-context.ts`: 1M, or at least the 829,800 default compact window; a genuine 1M for Claude models on either Anthropic pool or a bare `claude-*` id) or an exact advertised marked identity; generated markers follow the same rule; exact upstream ids remain literal, while legacy roster marker handling is unchanged. The generated-agent legacy directive cannot replace a wire selector matching the configured forced alias. `src/claude/subagent-force-status.ts` performs bounded, read-only server-local version and settings-key inspection; unknown is not supported. +`src/claude/subagent-model.ts` shares the roster alias and authoritative context-marker resolver with routed launch force. `claudeCode.subagentModelForce` is opt-in and default-off. Force availability excludes retained unavailable roster entries. Caller-added `[1m]` suffixes require a finite authoritative long window (`src/claude/long-context.ts`: 1M, or at least the 829,800 default compact window; a genuine 1M for Claude models on either Anthropic pool or a bare `claude-*` id) or an exact advertised marked identity; generated markers follow the same rule; exact upstream ids remain literal, while legacy roster marker handling is unchanged. `claudeCode.contextAccounting` `"200k"` stops automatic marking on generated and forced selectors; an explicit `[1m]` still follows the window rule above ([Claude Desktop picker mode](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy)). The generated-agent legacy directive cannot replace a wire selector matching the configured forced alias. `src/claude/subagent-force-status.ts` performs bounded, read-only server-local version and settings-key inspection; unknown is not supported. Explicit gateway selectors outrank generated-agent `ocx-route` fallback independently of saved force state. `src/claude/inbound-model-options.ts` shares this precedence across Messages and count-tokens; bare Claude fallback and requests without directives retain their existing behavior. Native Claude force targets use reversible native aliases, restored before existing credential/model-map checks. Connected launch exposure comes from fresh authenticated gateway rows, not cached context-window keys; acquisition is independent of cache-write success. diff --git a/tests/claude-integration/claude-context-accounting.test.ts b/tests/claude-integration/claude-context-accounting.test.ts new file mode 100644 index 00000000000..1bec2b42bf7 --- /dev/null +++ b/tests/claude-integration/claude-context-accounting.test.ts @@ -0,0 +1,115 @@ +/** + * `claudeCode.contextAccounting: "200k"` opt-in (devlog/_plan/261009_claude_1m_default/030): no + * automatic [1m] anywhere, no compact-window injection, explicit 1M choices kept. + */ +import { describe, expect, test } from "bun:test"; +import { + ACCOUNTING_200K, + AUTO_COMPACT_WINDOW_DEFAULT, + effectiveModelEnv, + resolveAutoContext, +} from "../../src/claude/context-windows"; +import { buildPickerModels } from "../../src/claude/intercept/picker-models"; +import { buildAnthropicModelInfos } from "../../src/claude/model-info"; +import { desktop3pModelOptions, generateDesktop3pModels } from "../../src/claude/desktop-3p"; +import { withSubagentContextMarker } from "../../src/claude/subagent-model"; +import { buildClaudeEnv as buildClaudeEnvWithIo } from "../../src/cli/claude"; +import { handleClaudeConfigCommand } from "../../src/cli/integrations"; +import type { OcxConfig } from "../../src/types"; + +const ASTRA = "native/gpt-6-astra"; +const LONG_WINDOWS = { [ASTRA]: 872_000, "mock/huge": 1_000_000 }; + +describe("resolveAutoContext", () => { + test("200k wins over every other lever, a user-exported compact window included", () => { + expect(resolveAutoContext({ contextAccounting: "200k" })).toBe(ACCOUNTING_200K); + expect(resolveAutoContext({ contextAccounting: "200k", autoCompactWindow: 400_000 }, "500000")).toBe(ACCOUNTING_200K); + expect(ACCOUNTING_200K.enabled).toBe(false); + }); + + test("1m and unknown values read as the default", () => { + for (const contextAccounting of ["1m", "bogus", undefined]) { + expect(resolveAutoContext({ contextAccounting })).toEqual({ enabled: true, compactWindow: AUTO_COMPACT_WINDOW_DEFAULT }); + } + }); +}); + +describe("launch env", () => { + test("model slots are not marked under 200k, are by default, and a typed [1m] survives", () => { + expect(effectiveModelEnv({ model: ASTRA }, LONG_WINDOWS).ANTHROPIC_MODEL).toBe(`${ASTRA}[1m]`); + expect(effectiveModelEnv({ model: ASTRA, contextAccounting: "200k" }, LONG_WINDOWS).ANTHROPIC_MODEL).toBe(ASTRA); + expect(effectiveModelEnv({ model: "mock/huge", contextAccounting: "200k" }, LONG_WINDOWS).ANTHROPIC_MODEL).toBe("mock/huge"); + expect(effectiveModelEnv({ model: `${ASTRA}[1m]`, contextAccounting: "200k" }, LONG_WINDOWS).ANTHROPIC_MODEL).toBe(`${ASTRA}[1m]`); + }); + + test("no compact window is injected under 200k", () => { + const config = (claudeCode: OcxConfig["claudeCode"]) => ({ + port: 10100, defaultProvider: "mock", providers: { mock: { adapter: "openai-chat", baseUrl: "http://x/v1" } }, claudeCode, + } as OcxConfig); + const build = (claudeCode: OcxConfig["claudeCode"]) => buildClaudeEnvWithIo(config(claudeCode), 10100, {}, {}, { + authDetect: { readClaudeJson: () => undefined, credentialsFileExists: () => false, keychainProbe: () => "absent" as const }, + }); + expect(build({}).CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe("829800"); + expect(build({ contextAccounting: "200k" }).CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined(); + }); +}); + +describe("surfaces", () => { + test("pickers leave 872k and 1M rows unmarked under 200k", () => { + const rows = buildPickerModels({ + nativeSlugs: ["gpt-6-astra"], routedModels: [{ provider: "example", id: "huge", contextWindow: 1_000_000 }], + nativeContextCap: { modelWindows: { "gpt-6-astra": 872_000 } }, auto: ACCOUNTING_200K, + }); + expect(rows.some(row => row.id.endsWith("[1m]"))).toBe(false); + }); + + test("Desktop 3P keeps 1M selectable but drops the default under 200k", () => { + const options = desktop3pModelOptions({ contextAccounting: "200k" }); + const [astra] = generateDesktop3pModels(["gpt-6-astra"], [], undefined, { modelWindows: { "gpt-6-astra": 872_000 } }, options); + expect(astra!.supports1m).toBe(true); + expect(astra!.prefer1m).toBeUndefined(); + expect(desktop3pModelOptions({})).toEqual({ preferOneMillion: true }); + }); + + test("discovery keeps genuine 1M variants as a choice and drops widened ones under 200k", () => { + const infos = buildAnthropicModelInfos(["gpt-6-astra"], [{ provider: "example", id: "huge", contextWindow: 1_000_000 }], + ACCOUNTING_200K, "readable", undefined, { modelWindows: { "gpt-6-astra": 872_000 } }); + const variants = infos.filter(info => info.id.endsWith("[1m]")).map(info => info.display_name); + expect(variants).toEqual(["huge (example) · 1M"]); + }); + + test("subagents: unmarked stays bare under 200k, an explicit marker follows its safety rule", () => { + const alias = "ocx-claude-native--gpt-6-astra"; + const windows = { [alias]: 872_000 }; + expect(withSubagentContextMarker(alias, windows, true)).toBe(alias); + expect(withSubagentContextMarker(`${alias}[1m]`, windows, true)).toBe(`${alias}[1m]`); + expect(withSubagentContextMarker(`${alias}[1m]`, { [alias]: 262_144 }, true)).toBe(alias); + expect(withSubagentContextMarker(alias, windows)).toBe(`${alias}[1m]`); + }); +}); + +describe("CLI", () => { + for (const [value, expected] of [["200k", "200k"], ["1M", "1m"]] as const) { + test(`--context-accounting ${value} sends ${expected}`, async () => { + const requests: unknown[] = []; + const code = await handleClaudeConfigCommand(["set", "--context-accounting", value], { + baseUrl: "http://127.0.0.1:1", + fetchImpl: async (_url, init) => { + requests.push(JSON.parse(String(init?.body))); + return Response.json({ ok: true }); + }, + }); + expect(code).toBe(0); + expect(requests).toEqual([{ contextAccounting: expected }]); + }); + } + + test("an unknown value is a usage error and sends nothing", async () => { + let sent = 0; + const code = await handleClaudeConfigCommand(["set", "--context-accounting", "500k"], { + baseUrl: "http://127.0.0.1:1", fetchImpl: async () => { sent++; return Response.json({ ok: true }); }, + }); + expect(code).toBe(2); + expect(sent).toBe(0); + }); +}); diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index 498759f3589..23c0794dbc3 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -763,6 +763,7 @@ test.each([ ["alwaysEnableEffort", true], ["autoContext", false], ["autoCompactWindow", 400_000], + ["contextAccounting", "200k"], ] as const)("%s-only PUT triggers system-env reconciliation", async (field, value) => { const applySpy = spyOn(systemEnv, "applySystemEnvToggle").mockResolvedValue({ reverted: false, reason: "test" }); const server = startServer(0); @@ -949,6 +950,28 @@ test("PUT/GET round-trips the context/effort levers (devlog 136 B6)", async () = } }); +test("PUT/GET round-trips contextAccounting; 1m drops the key, anything else is a 400", async () => { + const applySpy = spyOn(systemEnv, "applySystemEnvToggle").mockResolvedValue({ reverted: false, reason: "test" }); + const server = startServer(0); + const put = (contextAccounting: unknown) => fetch(new URL("/api/claude-code", server.url), { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contextAccounting }), + }); + const get = async () => (await fetch(new URL("/api/claude-code", server.url)).then(r => r.json()) as Record).contextAccounting; + try { + expect(await get()).toBe("1m"); + expect((await put("200k")).status).toBe(200); + expect(loadConfig().claudeCode?.contextAccounting).toBe("200k"); + expect(await get()).toBe("200k"); + expect((await put("1m")).status).toBe(200); + expect(loadConfig().claudeCode?.contextAccounting).toBeUndefined(); + expect(await get()).toBe("1m"); + expect((await put("500k")).status).toBe(400); + } finally { + applySpy.mockRestore(); + await server.stop(true); + } +}); + test("PUT/GET round-trips auto-context (devlog 260712 020)", async () => { const server = startServer(0); try { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c8db6127857..08ed9a0ba56 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -303,6 +303,7 @@ "claude-model-info.test.ts": "claude-integration", "claude-models-discovery.test.ts": "claude-integration", "claude-native-affinity.test.ts": "claude-integration", "claude-native-passthrough.test.ts": "claude-integration", "claude-native-rate-limit-headers.test.ts": "claude-integration", "claude-outbound.test.ts": "claude-integration", + "claude-context-accounting.test.ts": "claude-integration", "claude-long-context-eligibility.test.ts": "claude-integration", "claude-prompt-too-long.test.ts": "claude-integration", "claude-shell-hook.test.ts": "claude-integration", "claude-sidecar-override.test.ts": "claude-integration",