diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 376bc472166..9683f4c390b 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -93,6 +93,25 @@ Operational contract when enabled: See [Configuration](/reference/configuration/providers/#anthropicaccountpool-experimental). +### Native Messages with account pooling + +Enabling the Anthropic account pool also prefers native Claude Messages for eligible direct +Anthropic routes. In Providers → Anthropic → Account pooling, open **How account selection works** to +change **Preserve native Claude requests**. It is on by default; turn it off to use the legacy +translation path for pooled requests. The setting is stored as +`anthropicAccountPool.nativeMessages` and applies when pooling is enabled. With pooling off, +the explicit native Messages rollout settings retain their previous behavior. + +Native requests preserve history and cache breakpoints, with sticky account routing and bounded +pre-output recovery. Routes needing proxy-only processing still use translation. An account +switch may cold-start the replacement account's cache; native routing does not share a cache +across accounts or guarantee cache hits for changing prompts. Recognized native CLI and Desktop +Code requests retain their billing/identity preamble and supported feature beta headers. Inline +tool declarations, references and removals use consistent OAuth names. + +For first-party Desktop Code, keep the desired picker model bound to its `anthropic/...` route. +The pooling setting does not change the Desktop login, Chat-tab connection mode or account history. + ## Quickstart ```bash diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 729d00601ff..8944c032261 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -885,12 +885,21 @@ for the subscription and client conditions the pool is meant for. | Key | Type | Default | Description | | --- | --- | --- | --- | | `anthropicAccountPool.enabled?` | `boolean` | `false` | Enable sticky session affinity and quota-ranked new-session selection. **429 failover is not gated here**: it activates whenever two or more usable accounts are stored, exactly like every other multi-credential provider, and cannot be switched off. | +| `anthropicAccountPool.nativeMessages?` | `boolean` | `true` | Preserve native Claude requests for eligible direct routes when pooling is enabled; `false` uses the legacy bridge. Pool-off retains explicit native rollout settings. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | For new sessions, when the active account reaches this threshold, choose the lowest known cached usage in the configured window; the account chosen does not itself have to be at or above the threshold. `0` disables **proactive** usage-based switching only — new-session selection and routing recovery after an eligible 429 still consult `quotaWindow`. | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session strategy; `quota` ranks accounts by the window set by `quotaWindow`, and `fill-first` evaluates its drain threshold in that same window. | | `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | The cached provider-reported utilization bar used for usage-aware account selection. `five-hour` keeps the original behavior. `weekly` scores the weekly bar and skips accounts whose 5-hour bar is exhausted while another eligible account remains, but falls back to exhausted candidates when none do. `max-utilization` scores the highest known bar, so it can use 5-hour usage before weekly usage is available; if neither is known, the account follows unknown-usage ordering. Known usage ranks before unknown usage under the opt-in `weekly` and `max-utilization` windows only; an omitted or explicit `five-hour` preserves the legacy ordering. If every eligible account is unknown, selection still returns one in eligible order. After the documented lower-5-hour tie-break, exact ties preserve eligible order. A healthy affinity-bound session is not proactively rebalanced. For new-session assignment and routing recovery after an eligible 429 replacement, `quota` ranks eligible candidates directly with this window; `fill-first` advances in stable order using this window's threshold and exhaustion rules; `round-robin` ignores it. Cooldown, failover limits, and reauthentication eligibility remain separate local state. Per-account weekly bars come from usage probes or observed response headers. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Successful new-session binds retained on one round-robin selection. Range 1–100. | | `anthropicAccountPool.routes?` | `{name, match, accounts, fallback?}[]` | — | Ordered model routes for the enabled Anthropic OAuth pool. `match` is a full, case-sensitive model ID glob (`*` and `?`); first match wins. `accounts` contains stored account IDs, not aliases. Eligible accounts are limited to the route for initial selection and 429 retry. Without fallback, an empty route returns a local 401, or 429 with route-scoped `Retry-After` if all its declared accounts are cooling. With `fallback: true`, an all-cooling ordinary pool returns 429 with its earliest usable cooldown, even if a saved route account was removed; the client response never names the route; the proxy log uses `route:#` for the rule’s 1-based position. `fallback: true` widens only when no routed account is eligible; fill-first then uses ordinary pool order. No matching rule retains normal selection; disabling the pool makes saved routes inactive. Invalid rules fail validated writes and prevent routed dispatch until corrected. `null` clears routes through the settings API. | +#### Native request preservation for the Anthropic pool + +`anthropicAccountPool.nativeMessages?: boolean` defaults to `true` when the pool is enabled. +It selects both managed native Messages paths together for eligible direct Anthropic OAuth +requests. Set `false` to retain the legacy bridge for pooled requests. With pooling disabled, +this preference is retained but does not override explicit protocol rollout settings. +The dashboard exposes it under **How account selection works** in the Anthropic account pool. + Anthropic OAuth vision and web-search helpers match these routes using each helper's own model, independently of the main request model. Each helper authenticates with its routed account, which can differ from the globally active account. If a strict helper route has no eligible account, the diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index e5ba4e2dc33..8dd0d611cb4 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -596,7 +596,7 @@ modes, the preview, and the per-request trace. | `protocols.unrepresentable?` | `"legacy" \| "reject"` | `"legacy"` | `legacy` sends a request whose path drops a feature and records the loss in the trace. `reject` refuses it with HTTP 400 before any send, naming only the feature keys. | | `protocols.rollout.nativeChatCombos?` | `boolean` | `false` | Send an eligible Chat candidate inside a combo natively from its own copy of the client body. | | `protocols.rollout.managedMessagesNative?` | `boolean` | `false` | Send Messages natively to a direct, key-authenticated Anthropic provider instead of through the internal Responses bridge. | -| `protocols.rollout.managedMessagesNativeOAuth?` | `boolean` | `false` | Native Messages for the unpooled `anthropic` OAuth provider on `api.anthropic.com`. Read as off unless `managedMessagesNative` is on; a pooled account set stays on the bridge. Recognized JSON-string `metadata.user_id` account UUIDs are aligned with the serving OAuth credential; device and session fields are preserved. | +| `protocols.rollout.managedMessagesNativeOAuth?` | `boolean` | `false` | Native Messages for the `anthropic` OAuth provider on `api.anthropic.com`. Read as off unless `managedMessagesNative` is on when pooling is disabled. An enabled Anthropic pool instead follows `anthropicAccountPool.nativeMessages` (default true), enabling both native switches together or selecting the legacy bridge. Recognized JSON-string `metadata.user_id` account UUIDs are aligned with the serving OAuth credential; device and session fields are preserved. | | `protocols.rollout.directEncoders?` | `boolean` | `false` | Encode Chat and Messages answers from a non-Responses upstream directly from adapter events. | | `protocols.rollout.shadowPlan?` | `boolean` | `false` | Compare each Chat or Messages request's path with the plan a preview predicts and mark a disagreement as `planMismatch` on its log row. Sends nothing extra. | diff --git a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx index 4d9c1ad1422..07842af36a5 100644 --- a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx +++ b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx @@ -37,6 +37,7 @@ type PoolState = { strategy: AccountPoolStrategy; stickyLimit: number; quotaWindow: AccountPoolQuotaWindow; + nativeMessages: boolean; }; /** @@ -128,6 +129,7 @@ export default function AnthropicAccountPoolSettings({ strategy: normalizeAccountPoolStrategy(json.strategy), stickyLimit: nextSticky, quotaWindow: normalizeAccountPoolQuotaWindow(json.quotaWindow), + nativeMessages: json.nativeMessages, }); setDraft(String(nextThreshold)); onThresholdChangeRef.current?.(nextThreshold); @@ -150,6 +152,7 @@ export default function AnthropicAccountPoolSettings({ strategy: AccountPoolStrategy; stickyLimit: number; quotaWindow: AccountPoolQuotaWindow; + nativeMessages: boolean; }) => { const requestApiBase = apiBase; saveAbortRef.current?.abort(); @@ -164,6 +167,7 @@ export default function AnthropicAccountPoolSettings({ strategy: next.strategy, stickyLimit: next.stickyLimit, quotaWindow: next.quotaWindow, + nativeMessages: next.nativeMessages, }); setSaving(true); setError(null); @@ -176,6 +180,7 @@ export default function AnthropicAccountPoolSettings({ strategy: next.strategy, stickyLimit: next.stickyLimit, quotaWindow: next.quotaWindow, + nativeMessages: next.nativeMessages, }, (input, init) => fetch(input, init), { signal: controller.signal }); if (!currentRequest()) return; if (!json) throw new Error("save"); @@ -189,6 +194,7 @@ export default function AnthropicAccountPoolSettings({ strategy: savedStrategy, stickyLimit: savedSticky, quotaWindow: savedWindow, + nativeMessages: json.nativeMessages, }); setDraft(String(savedThreshold)); onThresholdChangeRef.current?.(savedThreshold); @@ -213,6 +219,7 @@ export default function AnthropicAccountPoolSettings({ const strategy = state?.strategy ?? DEFAULT_ACCOUNT_POOL_STRATEGY; const stickyLimit = state?.stickyLimit ?? DEFAULT_ACCOUNT_POOL_STICKY_LIMIT; const quotaWindow = state?.quotaWindow ?? DEFAULT_ACCOUNT_POOL_QUOTA_WINDOW; + const nativeMessages = state?.nativeMessages ?? true; // The window is inert ONLY under round-robin, which never scores a usage bar at any stage. // // A 0 threshold is not inertness: it disables PROACTIVE usage-based switching, but @@ -253,6 +260,7 @@ export default function AnthropicAccountPoolSettings({ strategy, stickyLimit, quotaWindow, + nativeMessages, }); }} > @@ -273,6 +281,21 @@ export default function AnthropicAccountPoolSettings({

{t("anthropicPool.detailsEnabling")}

{t("anthropicPool.detailsFailover")}

{t("anthropicPool.detailsActivity")}

+

{t("anthropicPool.detailsGuide")}

@@ -306,6 +329,7 @@ export default function AnthropicAccountPoolSettings({ strategy, stickyLimit, quotaWindow, + nativeMessages, }); } }} @@ -327,6 +351,7 @@ export default function AnthropicAccountPoolSettings({ strategy: next, stickyLimit, quotaWindow, + nativeMessages, }); }} onStickyDraftChange={setStickyDraft} @@ -347,6 +372,7 @@ export default function AnthropicAccountPoolSettings({ strategy, stickyLimit: parsed, quotaWindow, + nativeMessages, }); }} /> @@ -371,6 +397,7 @@ export default function AnthropicAccountPoolSettings({ strategy, stickyLimit, quotaWindow: parsed, + nativeMessages, }); }} /> diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index fc9eee0c806..f5abcc7696b 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1937,6 +1937,8 @@ export const de: Record = { "anthropicPool.detailsEnabling": "Mit aktivem Pool bleibt eine Sitzung bei ihrem Konto, solange es funktioniert; die gewählte Strategie bestimmt das Konto für neue Sitzungen und nach einer Ablehnung.", "anthropicPool.detailsFailover": "Ausschalten beendet das Failover nicht: Nach einem 429 oder einer als Kontoablehnung eingestuften Antwort kann eine Anfrage weiterhin zu einem anderen angemeldeten Konto wechseln. Pausiere ein Konto, um es davon auszunehmen.", "anthropicPool.detailsActivity": "OpenCodex sendet keine Keep-warm-Anfragen. Standardmäßig erneuert es Claude-Tokens nicht im Hintergrund und fragt dort auch keine Nutzung ab; die Nutzung wird nur gelesen, wenn das Dashboard, die Menüleisten-App oder ein ocx-Befehl sie anfordert.", + "anthropicPool.nativeMessagesLabel": `Native Claude-Anfragen beibehalten`, + "anthropicPool.nativeMessagesHelp": `Bei aktiviertem Pool bleiben Claude-Code-Verlauf und Cache-Markierungen auf dem nativen Messages-Pfad, sofern die gewählte Route ihn unterstützt. Andere Routen können Anfragen weiterhin übersetzen. Ein Kontowechsel kann den Cache neu aufbauen; Cache-Treffer sind nicht garantiert und Konten teilen keinen Cache.`, "anthropicPool.detailsGuide": "Anleitung zum Kontenpool", "anthropicPool.needTwoAccounts": "Füge mindestens zwei Claude-OAuth-Konten hinzu, bevor du den Pool aktivierst.", "anthropicPool.threshold": "Nutzungsschwelle für neue Sitzungen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 045601993e1..8319e599cc5 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2548,6 +2548,8 @@ export const en = { "anthropicPool.detailsEnabling": "Turning the pool on keeps each session on its account while that account is healthy, and lets the selected strategy choose accounts for new sessions and after a refusal.", "anthropicPool.detailsFailover": "Turning it off does not turn off failover: after a 429 or a classified account refusal, a request can still move to another signed-in account. Pause an account to keep it out of failover.", "anthropicPool.detailsActivity": "OpenCodex sends no keep-warm requests. By default it does not refresh Claude tokens or check usage in the background; usage is read only when the dashboard, the menu bar app, or an ocx command asks for it.", + "anthropicPool.nativeMessagesLabel": `Preserve native Claude requests`, + "anthropicPool.nativeMessagesHelp": `With pooling enabled, keeps Claude Code history and cache markers on the native Messages path when the selected route supports it. Other routes may still translate requests. An account change may start a cold cache; cache hits are not guaranteed, and caches are not shared between accounts.`, "anthropicPool.detailsGuide": "Account pool guide", "anthropicPool.needTwoAccounts": "Add at least two Claude OAuth accounts before enabling the pool.", "anthropicPool.threshold": "New-session usage threshold", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index eb629193027..e45c25ab29b 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2474,6 +2474,8 @@ export const fr: Record = { "anthropicPool.detailsEnabling": "Une fois le groupe activé, chaque session reste sur son compte tant qu’il fonctionne, et la stratégie choisie désigne le compte des nouvelles sessions et celui qui prend le relais après un refus.", "anthropicPool.detailsFailover": "Le désactiver n’arrête pas la bascule : après un 429 ou un refus de compte identifié, une requête peut encore passer à un autre compte connecté. Mettez un compte en pause pour l’exclure.", "anthropicPool.detailsActivity": "OpenCodex n’envoie aucune requête de maintien à chaud (keep-warm). Par défaut, il ne rafraîchit pas les jetons Claude et ne consulte pas l’usage en arrière-plan ; l’usage n’est lu que lorsque le tableau de bord, l’app de la barre des menus ou une commande ocx le demande.", + "anthropicPool.nativeMessagesLabel": `Conserver les requêtes Claude natives`, + "anthropicPool.nativeMessagesHelp": `Lorsque le pool est activé, l’historique Claude Code et les marqueurs de cache restent sur le chemin Messages natif si la route choisie le permet. D’autres routes peuvent toujours traduire les requêtes. Un changement de compte peut repartir avec un cache froid ; les résultats du cache ne sont pas garantis et les comptes ne partagent pas leur cache.`, "anthropicPool.detailsGuide": "Guide du groupe de comptes", "anthropicPool.needTwoAccounts": "Ajoutez au moins deux comptes OAuth Claude avant d’activer le groupe.", "anthropicPool.threshold": "Seuil d’utilisation des nouvelles sessions", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index a298a02b15e..e33928a0567 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2370,6 +2370,8 @@ export const ja: Record = { "anthropicPool.detailsEnabling": "プールをオンにすると、各セッションはアカウントが正常な間そのアカウントに留まり、新規セッションと拒否後のアカウントは選択した戦略に従って選ばれます。", "anthropicPool.detailsFailover": "オフにしてもフェイルオーバーは止まりません。429 やアカウント拒否と判定された応答の後、リクエストはサインイン済みの別アカウントへ移ることがあります。対象から外すにはそのアカウントを一時停止してください。", "anthropicPool.detailsActivity": "OpenCodex はキープウォーム(keep-warm)用のリクエストを送信しません。既定ではバックグラウンドで Claude トークンを更新したり使用量を確認したりせず、使用量はダッシュボード、メニューバーアプリ、または ocx コマンドが要求したときにのみ読み取ります。", + "anthropicPool.nativeMessagesLabel": `Claude のネイティブリクエストを維持`, + "anthropicPool.nativeMessagesHelp": `プールが有効な場合、選択したルートが対応していれば、Claude Code の履歴とキャッシュマーカーをネイティブ Messages 経路で維持します。他のルートでは引き続きリクエストが変換されることがあります。アカウントを切り替えるとキャッシュが新規状態になる場合があり、キャッシュヒットは保証されず、アカウント間でキャッシュは共有されません。`, "anthropicPool.detailsGuide": "アカウントプールのガイド", "anthropicPool.needTwoAccounts": "プールを有効にする前に、Claude OAuth アカウントを 2 つ以上追加してください。", "anthropicPool.threshold": "新規セッションの使用率しきい値", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d32503633ed..6364753e0b5 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1981,6 +1981,8 @@ export const ko: Record = { "anthropicPool.detailsEnabling": "풀을 켜면 세션은 계정이 정상인 동안 그 계정에 머물고, 새 세션과 거부 응답 뒤에 쓸 계정은 선택한 전략에 따라 고릅니다.", "anthropicPool.detailsFailover": "풀을 꺼도 장애 조치는 꺼지지 않습니다. 429나 계정 거부로 판정된 응답이 오면 요청이 로그인된 다른 계정으로 넘어갈 수 있습니다. 특정 계정을 빼려면 그 계정을 일시 중지하세요.", "anthropicPool.detailsActivity": "OpenCodex는 계정을 깨워 두는 요청(keep-warm)을 보내지 않습니다. 기본 설정에서는 백그라운드에서 Claude 토큰을 갱신하거나 사용량을 조회하지 않고, 대시보드나 메뉴 막대 앱, ocx 명령이 요청할 때만 사용량을 읽습니다.", + "anthropicPool.nativeMessagesLabel": `Claude 네이티브 요청 유지`, + "anthropicPool.nativeMessagesHelp": `풀이 켜져 있고 선택한 경로가 지원하면 Claude Code 기록과 캐시 표시를 네이티브 Messages 경로로 유지합니다. 다른 경로에서는 요청이 계속 변환될 수 있습니다. 계정을 바꾸면 캐시가 새로 시작될 수 있으며 캐시 적중은 보장되지 않고 계정 간 캐시는 공유되지 않습니다.`, "anthropicPool.detailsGuide": "계정 풀 가이드", "anthropicPool.needTwoAccounts": "풀을 켜기 전에 Claude OAuth 계정을 두 개 이상 추가하세요.", "anthropicPool.threshold": "새 세션 사용량 임계값", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 8916a78ba48..26c96ff9c15 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2521,6 +2521,8 @@ export const pt: Record = { "anthropicPool.detailsEnabling": "Ativar o pool mantém cada sessão em sua conta enquanto ela estiver saudável e permite que a estratégia selecionada escolha as contas das novas sessões e após uma recusa.", "anthropicPool.detailsFailover": "Desativá-lo não desativa o failover: após um 429 ou uma recusa de conta classificada, uma requisição ainda pode passar para outra conta conectada. Pause uma conta para mantê-la fora do failover.", "anthropicPool.detailsActivity": "O OpenCodex não envia requisições keep-warm. Por padrão, não renova tokens do Claude nem consulta o uso em segundo plano; o uso só é lido quando o painel, o app da barra de menus ou um comando ocx o solicita.", + "anthropicPool.nativeMessagesLabel": `Preservar solicitações nativas do Claude`, + "anthropicPool.nativeMessagesHelp": `Com o pool ativado, mantém o histórico do Claude Code e os marcadores de cache no caminho nativo de Messages quando a rota selecionada oferece suporte. Outras rotas ainda podem traduzir solicitações. A troca de conta pode iniciar um cache frio; acertos de cache não são garantidos e as contas não compartilham cache.`, "anthropicPool.detailsGuide": "Guia do pool de contas", "anthropicPool.needTwoAccounts": "Adicione pelo menos duas contas Claude OAuth antes de ativar o pool.", "anthropicPool.threshold": "Limite de uso para novas sessões", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2dd8ef7f73f..fda49c662ce 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2461,6 +2461,8 @@ export const ru: Record = { "anthropicPool.detailsEnabling": "При включённом пуле сеанс остаётся на своём аккаунте, пока тот исправен, а аккаунт для новых сеансов и после отказа определяет заданная стратегия.", "anthropicPool.detailsFailover": "Выключение не отключает переключение при сбоях: после 429 или распознанного отказа аккаунта запрос всё равно может перейти на другой аккаунт, в который выполнен вход. Чтобы исключить аккаунт, приостановите его.", "anthropicPool.detailsActivity": "OpenCodex не отправляет запросы для поддержания аккаунтов «в тепле» (keep-warm). По умолчанию он не обновляет токены Claude и не проверяет использование в фоне; использование читается только по запросу панели управления, приложения в строке меню или команды ocx.", + "anthropicPool.nativeMessagesLabel": `Сохранять нативные запросы Claude`, + "anthropicPool.nativeMessagesHelp": `При включённом пуле история Claude Code и маркеры кэша остаются в нативном канале Messages, если выбранный маршрут его поддерживает. На других маршрутах запросы могут по-прежнему преобразовываться. При смене аккаунта кэш может начать заполняться заново; попадания в кэш не гарантируются, а аккаунты не используют общий кэш.`, "anthropicPool.detailsGuide": "Руководство по пулу аккаунтов", "anthropicPool.needTwoAccounts": "Перед включением пула добавьте минимум два OAuth-аккаунта Claude.", "anthropicPool.threshold": "Порог использования для новых сессий", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 050daecfe11..581afe40dbc 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2488,6 +2488,8 @@ export const tr: Record = { "anthropicPool.detailsEnabling": "Havuz açıkken her oturum, hesabı sorunsuz çalıştığı sürece o hesapta kalır; yeni oturumların ve ret sonrasının hesabını seçili strateji belirler.", "anthropicPool.detailsFailover": "Kapatmak yük devretmeyi kapatmaz: 429 veya hesap reddi olarak sınıflandırılan bir yanıttan sonra istek yine giriş yapılmış başka bir hesaba geçebilir. Bir hesabı bunun dışında tutmak için duraklatın.", "anthropicPool.detailsActivity": "OpenCodex hesapları sıcak tutmak için (keep-warm) istek göndermez. Varsayılan olarak Claude jetonlarını arka planda yenilemez ve kullanımı arka planda sorgulamaz; kullanım yalnızca pano, menü çubuğu uygulaması veya bir ocx komutu istediğinde okunur.", + "anthropicPool.nativeMessagesLabel": `Claude yerel isteklerini koru`, + "anthropicPool.nativeMessagesHelp": `Havuz açıkken, seçilen rota destekliyorsa Claude Code geçmişini ve önbellek işaretlerini yerel Messages yolunda tutar. Diğer rotalar istekleri çevirmeye devam edebilir. Hesap değişikliği önbelleği yeniden başlatabilir; önbellek isabeti garanti edilmez ve hesaplar arasında önbellek paylaşılmaz.`, "anthropicPool.detailsGuide": "Hesap havuzu kılavuzu", "anthropicPool.needTwoAccounts": "Havuzu etkinleştirmeden önce en az iki Claude OAuth hesabı ekleyin.", "anthropicPool.threshold": "Yeni oturum kullanım eşiği", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 3074945f070..6a1eac948af 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2501,6 +2501,8 @@ export const vi: Record = { "anthropicPool.detailsEnabling": "Khi bật nhóm, mỗi phiên ở lại tài khoản của nó khi tài khoản còn hoạt động bình thường; chiến lược đã chọn sẽ quyết định tài khoản cho phiên mới và sau khi bị từ chối.", "anthropicPool.detailsFailover": "Tắt nhóm không tắt chuyển đổi dự phòng: sau lỗi 429 hoặc một lần tài khoản bị từ chối đã được phân loại, yêu cầu vẫn có thể chuyển sang tài khoản khác đang đăng nhập. Hãy tạm dừng một tài khoản để loại nó ra.", "anthropicPool.detailsActivity": "OpenCodex không gửi yêu cầu giữ ấm (keep-warm). Theo mặc định, nó không làm mới token Claude hay kiểm tra mức sử dụng ở chế độ nền; mức sử dụng chỉ được đọc khi bảng điều khiển, ứng dụng thanh menu hoặc một lệnh ocx yêu cầu.", + "anthropicPool.nativeMessagesLabel": `Giữ nguyên yêu cầu Claude gốc`, + "anthropicPool.nativeMessagesHelp": `Khi bật pool, lịch sử Claude Code cùng dấu mốc bộ nhớ đệm sẽ được giữ trên luồng Messages gốc nếu tuyến đã chọn hỗ trợ. Các tuyến khác vẫn có thể chuyển đổi yêu cầu. Đổi tài khoản có thể khiến bộ nhớ đệm phải khởi tạo lại; không đảm bảo có cache hit và các tài khoản không dùng chung bộ nhớ đệm.`, "anthropicPool.detailsGuide": "Hướng dẫn nhóm tài khoản", "anthropicPool.needTwoAccounts": "Thêm ít nhất hai tài khoản Claude OAuth trước khi kích hoạt pool.", "anthropicPool.threshold": "Ngưỡng sử dụng cho session mới", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 8159f43a3bb..392a4c7cd36 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1832,6 +1832,8 @@ export const zhTW: Record = { "anthropicPool.detailsEnabling": "開啟帳號池後,工作階段在其帳號正常時會持續使用該帳號;新工作階段與拒絕後使用哪個帳號由所選策略決定。", "anthropicPool.detailsFailover": "關閉帳號池不會關閉容錯移轉:收到 429 或被判定為帳號拒絕的回應後,請求仍可能轉到另一個已登入帳號。若要排除某個帳號,請將其暫停。", "anthropicPool.detailsActivity": "OpenCodex 不會傳送保溫(keep-warm)請求。預設情況下,它不會在背景更新 Claude 權杖或查詢用量;只有儀表板、選單列應用程式或 ocx 指令要求時才會讀取用量。", + "anthropicPool.nativeMessagesLabel": `保留 Claude 原生要求`, + "anthropicPool.nativeMessagesHelp": `啟用帳號池後,如果所選路由支援,Claude Code 歷史記錄和快取標記會保留在原生 Messages 路徑上。其他路由仍可能轉換要求。切換帳號可能需要重新建立快取;不保證命中快取,帳號之間也不共用快取。`, "anthropicPool.detailsGuide": "帳號池指南", "anthropicPool.needTwoAccounts": "啟用帳號池前請至少新增兩個 Claude OAuth 帳號。", "anthropicPool.threshold": "新會話用量閾值", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 31764564d3c..7833f1ed287 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1954,6 +1954,8 @@ export const zh: Record = { "anthropicPool.detailsEnabling": "开启账户池后,会话在其账户正常时会一直使用该账户;新会话和拒绝后使用哪个账户由所选策略决定。", "anthropicPool.detailsFailover": "关闭账户池不会关闭故障转移:收到 429 或被识别为账户拒绝的响应后,请求仍可能转到另一个已登录账户。若要排除某个账户,请将其暂停。", "anthropicPool.detailsActivity": "OpenCodex 不发送保活(keep-warm)请求。默认情况下,它不会在后台刷新 Claude 令牌或查询用量;只有仪表盘、菜单栏应用或 ocx 命令请求时才会读取用量。", + "anthropicPool.nativeMessagesLabel": `保留 Claude 原生请求`, + "anthropicPool.nativeMessagesHelp": `启用账号池后,如果所选路由支持,Claude Code 历史记录和缓存标记会保留在原生 Messages 路径上。其他路由仍可能转换请求。切换账号可能需要重新建立缓存;不保证命中缓存,账号之间也不共享缓存。`, "anthropicPool.detailsGuide": "账户池指南", "anthropicPool.needTwoAccounts": "启用账户池前请至少添加两个 Claude OAuth 账户。", "anthropicPool.threshold": "新会话用量阈值", diff --git a/gui/src/pool-settings.ts b/gui/src/pool-settings.ts index dff50736a8f..bead3e77cc1 100644 --- a/gui/src/pool-settings.ts +++ b/gui/src/pool-settings.ts @@ -29,6 +29,8 @@ export interface PoolSettings { stickyLimit: number; autoSwitchThreshold: number | null; quotaWindow: AccountPoolQuotaWindow | null; + /** Keep native Claude Code Messages on first-party Anthropic when their route permits. */ + nativeMessages: boolean; } /** Fields a caller may write. Named in GUI terms; mapped to the wire below. */ @@ -39,6 +41,7 @@ export interface PoolSettingsWrite { /** GUI callers say "threshold"; the contract says autoSwitchThreshold. */ threshold?: number; quotaWindow?: AccountPoolQuotaWindow; + nativeMessages?: boolean; } function toDto(json: unknown, provider: string, fallback?: PoolSettingsWrite): PoolSettings { @@ -59,6 +62,11 @@ function toDto(json: unknown, provider: string, fallback?: PoolSettingsWrite): P quotaWindow: (raw.quotaWindow ?? fallback?.quotaWindow) === undefined || raw.quotaWindow === null ? null : normalizeAccountPoolQuotaWindow(raw.quotaWindow ?? fallback?.quotaWindow), + nativeMessages: typeof raw.nativeMessages === "boolean" + ? raw.nativeMessages + : typeof fallback?.nativeMessages === "boolean" + ? fallback.nativeMessages + : (raw.kind === "anthropic" || provider === "anthropic"), }; } @@ -79,6 +87,7 @@ export function poolSettingsRequestBody(provider: string, fields: PoolSettingsWr ...(fields.stickyLimit !== undefined ? { stickyLimit: fields.stickyLimit } : {}), ...(fields.threshold !== undefined ? { autoSwitchThreshold: fields.threshold } : {}), ...(fields.quotaWindow !== undefined ? { quotaWindow: fields.quotaWindow } : {}), + ...(provider === "anthropic" && fields.nativeMessages !== undefined ? { nativeMessages: fields.nativeMessages } : {}), }; } diff --git a/gui/src/styles.css b/gui/src/styles.css index 3932c985d91..41971fc8931 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1927,6 +1927,15 @@ dialog.modal-overlay::backdrop { .anthropic-pool-card__details { margin: 8px 16px 12px; font-size: var(--text-label); line-height: var(--leading-body); color: var(--muted); } .anthropic-pool-card__details > summary { cursor: pointer; width: fit-content; color: var(--text); } .anthropic-pool-card__details > p { margin: 6px 0 0; max-width: var(--prose-measure); text-wrap: pretty; } +.anthropic-pool-card__native-messages { + display: flex; + align-items: flex-start; + gap: var(--space-2); + margin-top: var(--space-3); + cursor: pointer; +} +.anthropic-pool-card__native-messages > input { margin: 2px 0 0; } +.anthropic-pool-card__native-messages-copy { display: grid; gap: var(--space-1); max-width: var(--prose-measure); } .anthropic-pool-card__field { display: block; margin-top: 12px; diff --git a/gui/tests/account-pool-strategy.test.tsx b/gui/tests/account-pool-strategy.test.tsx index f524a9f447a..6ea3fcdabfb 100644 --- a/gui/tests/account-pool-strategy.test.tsx +++ b/gui/tests/account-pool-strategy.test.tsx @@ -1,4 +1,4 @@ -import { getPoolSettings, putPoolSettings, putCodexPoolStrategy } from "../src/pool-settings"; +import { getPoolSettings, putPoolSettings, putCodexPoolStrategy, poolSettingsRequestBody } from "../src/pool-settings"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { act } from "react"; @@ -142,6 +142,20 @@ describe("account pool strategy helpers", () => { expect(normalizeAccountPoolStrategy(undefined)).toBe("quota"); }); + test("native Messages preference defaults on for Anthropic and preserves a false 204 write", async () => { + const read = await getPoolSettings("", "anthropic", async () => Response.json({ kind: "anthropic" })); + expect(read?.nativeMessages).toBe(true); + let request: Record | undefined; + const written = await putPoolSettings("", "anthropic", { nativeMessages: false }, async (_url, init) => { + request = JSON.parse(String(init?.body)) as Record; + return new Response(null, { status: 204 }); + }); + expect(request).toEqual({ provider: "anthropic", nativeMessages: false }); + expect(written?.nativeMessages).toBe(false); + const codex = poolSettingsRequestBody("openai", { nativeMessages: false }); + expect(codex).toEqual({ provider: "openai" }); + }); + test("normalizes sticky limits to 1–100 integers", () => { expect(normalizeAccountPoolStickyLimit(3)).toBe(3); expect(normalizeAccountPoolStickyLimit(1)).toBe(1); diff --git a/gui/tests/anthropic-pool-conditions.test.tsx b/gui/tests/anthropic-pool-conditions.test.tsx index 6d178695c12..88fa058ae9d 100644 --- a/gui/tests/anthropic-pool-conditions.test.tsx +++ b/gui/tests/anthropic-pool-conditions.test.tsx @@ -53,7 +53,7 @@ afterEach(async () => { await testWindow.happyDOM?.close?.(); }); -type Settings = { enabled: boolean; autoSwitchThreshold?: number; strategy?: string; quotaWindow?: string }; +type Settings = { enabled: boolean; autoSwitchThreshold?: number; strategy?: string; quotaWindow?: string; nativeMessages?: boolean }; /** GET answers with settings (or fails); PUT answers per the supplied status. */ function stubPool(get: Settings | "fail", putStatus = 200): { puts: number } { @@ -159,6 +159,40 @@ describe("Claude account pool conditions", () => { expect(toggleOf(host).disabled).toBe(true); expect(host.querySelector(".anthropic-pool-card__notice")?.getAttribute("role")).toBeNull(); }); + + test("native Messages preservation defaults on and saves the server-confirmed value", async () => { + const calls: Record[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as Record; + calls.push(body); + return Response.json({ ...body, nativeMessages: false }); + } + return Response.json({ enabled: true, kind: "anthropic" }); + }) as typeof fetch; + const host = await mount(2); + const toggle = host.querySelector('input[aria-describedby="anthropic-pool-native-messages-help"]')!; + expect(toggle.checked).toBe(true); + expect(host.textContent).toContain("cache hits are not guaranteed"); + await act(async () => { toggle.click(); await flush(); }); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ provider: "anthropic", nativeMessages: false }); + expect(host.querySelector('input[aria-describedby="anthropic-pool-native-messages-help"]')!.checked).toBe(false); + }); + + test("native Messages toggle rolls back after a failed save and stays disabled after a failed load", async () => { + stubPool({ enabled: true, nativeMessages: true }, 500); + const host = await mount(2); + const toggle = host.querySelector('input[aria-describedby="anthropic-pool-native-messages-help"]')!; + expect(toggle.checked).toBe(true); + await act(async () => { toggle.click(); await flush(); }); + expect(host.querySelector('input[aria-describedby="anthropic-pool-native-messages-help"]')!.checked).toBe(true); + expect(host.querySelector('[role="alert"]')?.textContent).toBe(DICTS.en["anthropicPool.saveFailed"]); + + stubPool("fail"); + const failedHost = await mount(2); + expect(failedHost.querySelector('input[aria-describedby="anthropic-pool-native-messages-help"]')!.disabled).toBe(true); + }); }); describe("the enabled status line follows the selected strategy", () => { diff --git a/gui/tests/anthropic-pool-quota-window.test.tsx b/gui/tests/anthropic-pool-quota-window.test.tsx index a8ccd338470..5f4c2f701d3 100644 --- a/gui/tests/anthropic-pool-quota-window.test.tsx +++ b/gui/tests/anthropic-pool-quota-window.test.tsx @@ -282,6 +282,7 @@ describe("Anthropic account pool quota window", () => { strategy: "quota", stickyLimit: 1, quotaWindow: "weekly", + nativeMessages: true, }); }); }); diff --git a/src/adapters/anthropic/beta-allowlist.ts b/src/adapters/anthropic/beta-allowlist.ts index 249976b4a35..a9c04839082 100644 --- a/src/adapters/anthropic/beta-allowlist.ts +++ b/src/adapters/anthropic/beta-allowlist.ts @@ -36,6 +36,20 @@ const FIRST_PARTY_BETAS: readonly string[] = [ */ const COMPATIBLE_BETAS: readonly string[] = []; +/** An observed Claude client already requested these native body schemas. Advisor may + * perform its explicitly requested model sub-inference; no feature or body is injected. */ +const NATIVE_CLIENT_BETAS: readonly string[] = [ + ...FIRST_PARTY_BETAS, + "advisor-tool-2026-03-01", + "per-turn-control-2026-07-01", + "redact-thinking-2026-02-12", + "inline-tools-2026-09-15", + "thinking-display-updates-2026-08-18", + "prompt-caching-scope-2026-01-05", + "mid-conversation-system-2026-04-07", +]; +const NATIVE_CLIENT_ALLOWLIST = new Map(NATIVE_CLIENT_BETAS.map(beta => [beta.toLowerCase(), beta])); + const ALLOWLISTS: Readonly>> = { "first-party": new Map(FIRST_PARTY_BETAS.map(beta => [beta.toLowerCase(), beta])), compatible: new Map(COMPATIBLE_BETAS.map(beta => [beta.toLowerCase(), beta])), @@ -58,10 +72,11 @@ export interface AllowlistedAnthropicBetas { export function allowlistAnthropicBetas( callerHeader: string | null | undefined, providerClass: AnthropicProviderClass, + observedNativeClient = false, ): AllowlistedAnthropicBetas { if (typeof callerHeader !== "string" || callerHeader.trim() === "") return { betas: [], dropped: false }; if (callerHeader.length > MAX_CALLER_BETA_HEADER_CHARS) return { betas: [], dropped: true }; - const allowed = ALLOWLISTS[providerClass]; + const allowed = providerClass === "first-party" && observedNativeClient ? NATIVE_CLIENT_ALLOWLIST : ALLOWLISTS[providerClass]; const betas: string[] = []; let dropped = false; for (const part of callerHeader.split(",")) { diff --git a/src/adapters/anthropic/client-identity.ts b/src/adapters/anthropic/client-identity.ts index b224bdd4b88..5a927089b4c 100644 --- a/src/adapters/anthropic/client-identity.ts +++ b/src/adapters/anthropic/client-identity.ts @@ -1,5 +1,5 @@ /** Request-local compatibility data. Client headers never grant authorization or credential authority. */ -import { classifyInterceptClient } from "../../claude/intercept/client-class"; +import { DESKTOP_ENTRYPOINTS, interceptEntrypoint } from "../../claude/intercept/client-class"; const MAX_IDENTITY_BYTES = 4096; const MAX_VALUE_BYTES = 512; @@ -19,7 +19,11 @@ const bundles = new WeakMap MAX_VALUE_BYTES || /[^\x20-\x7e]/.test(value)) return false; - if (name === "User-Agent") return classifyInterceptClient(value) === "cli"; + if (name === "User-Agent") { + const entrypoint = interceptEntrypoint(value); + return entrypoint === "cli" || entrypoint === "sdk-cli" || entrypoint === "sdk" + || (DESKTOP_ENTRYPOINTS as readonly string[]).includes(entrypoint ?? ""); + } if (name === "X-App") return value === "cli"; if (name === "X-Claude-Code-Session-Id" || name === "x-client-request-id") return UUID.test(value); if (name === "X-Stainless-Lang") return value === "js"; @@ -54,6 +58,11 @@ export function captureAnthropicClientIdentity(headers: Headers): AnthropicClien return handle; } +/** A locally captured coherent compatibility bundle; neither provenance nor authorization. */ +export function hasObservedAnthropicClientIdentity(identity: AnthropicClientIdentity | undefined): boolean { + return identity !== undefined && bundles.has(identity); +} + /** Called only after the builder verifies a first-party destination. Unknown/forged handles do nothing. */ export function applyAnthropicClientIdentity(headers: Record, identity: AnthropicClientIdentity | undefined, operatorHeaders?: Readonly>): void { const values = identity && bundles.get(identity); diff --git a/src/adapters/anthropic/native-client-preamble.ts b/src/adapters/anthropic/native-client-preamble.ts new file mode 100644 index 00000000000..0630faed07e --- /dev/null +++ b/src/adapters/anthropic/native-client-preamble.ts @@ -0,0 +1,31 @@ +/** Preserve an observed native client's existing prefix; never create or sign billing text. */ +import { hasObservedAnthropicClientIdentity, type AnthropicClientIdentity } from "./client-identity"; + +const NATIVE_IDENTITIES = new Set([ + "You are Claude Code, Anthropic's official CLI for Claude.", + "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.", + "You are a Claude agent, built on Anthropic's Claude Agent SDK.", +]); +const BILLING_PREAMBLE = /^x-anthropic-billing-header: cc_version=[A-Za-z0-9._-]{1,128}; cc_entrypoint=[A-Za-z0-9._-]{1,128};/; + +function textBlock(value: unknown): string | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined; + const block = value as Record; + return block.type === "text" && typeof block.text === "string" ? block.text : undefined; +} + +/** + * Only the native first-party builder asks this, after selecting its destination. The coherent + * handle and the two exact prefix blocks select compatibility behavior, not credential authority. + * Returning true leaves the original system array and every cache marker untouched. + */ +export function shouldPreserveNativeClientPreamble( + system: unknown, + clientIdentity: AnthropicClientIdentity | undefined, +): boolean { + if (!hasObservedAnthropicClientIdentity(clientIdentity) || !Array.isArray(system) || system.length < 2) return false; + const billing = textBlock(system[0]); + const identity = textBlock(system[1]); + return billing !== undefined && billing.length <= 4096 && !/[^\x20-\x7e]/.test(billing) + && BILLING_PREAMBLE.test(billing) && identity !== undefined && NATIVE_IDENTITIES.has(identity); +} diff --git a/src/adapters/anthropic/passthrough.ts b/src/adapters/anthropic/passthrough.ts index 00af4870fcc..3be4926572b 100644 --- a/src/adapters/anthropic/passthrough.ts +++ b/src/adapters/anthropic/passthrough.ts @@ -27,8 +27,9 @@ import { applyAnthropicOAuthAuth, resolveAnthropicMessagesUrl, } from "../anthropic"; -import { applyAnthropicClientIdentity, type AnthropicClientIdentity } from "./client-identity"; +import { applyAnthropicClientIdentity, hasObservedAnthropicClientIdentity, type AnthropicClientIdentity } from "./client-identity"; import { bindAnthropicAccountMetadata } from "./account-metadata"; +import { shouldPreserveNativeClientPreamble } from "./native-client-preamble"; import { allowlistAnthropicBetas } from "./beta-allowlist"; /** @@ -113,7 +114,7 @@ function isClientTool(tool: unknown): tool is Rec & { name: string } { * the input is not mutated. Two caller names that meet under the prefix are refused rather than * guessed at. */ -export function anthropicOAuthWireBody(body: Rec): { body: Rec; toolNames: Map } { +export function anthropicOAuthWireBody(body: Rec, clientIdentity?: AnthropicClientIdentity): { body: Rec; toolNames: Map } { const out: Rec = { ...body }; const toolNames = new Map(); const owners = new Map(); @@ -126,7 +127,9 @@ export function anthropicOAuthWireBody(body: Rec): { body: Rec; toolNames: Map 0) { + if (shouldPreserveNativeClientPreamble(body.system, clientIdentity)) { + out.system = body.system; + } else if (typeof body.system === "string" && body.system.length > 0) { out.system = [identity, { type: "text", text: body.system }]; } else if (Array.isArray(body.system)) { const first = body.system[0]; @@ -146,18 +149,79 @@ export function anthropicOAuthWireBody(body: Rec): { body: Rec; toolNames: Map(); + const collectInlineDeclarations = (blocks: unknown[]): void => { + for (const block of blocks) { + if (!isRec(block) || block.type !== "tool_addition" || !isRec(block.tool)) continue; + if (block.tool.type === "tool_definition" && isClientTool(block.tool.definition)) { + declared.add(block.tool.definition.name); + } else if (block.tool.type === "tool_definition" && isRec(block.tool.definition) && typeof block.tool.definition.name === "string") { + inlineTypedNames.add(block.tool.definition.name); + } + } + }; + if (Array.isArray(body.messages)) { + for (const message of body.messages) { + if (isRec(message) && Array.isArray(message.content)) collectInlineDeclarations(message.content); + } + } + if ([...inlineTypedNames].some(name => declared.has(name))) throw new Error("inline typed and client tool names collide"); const renames = (name: unknown): name is string => typeof name === "string" && declared.has(name); if (isRec(body.tool_choice) && body.tool_choice.type === "tool" && renames(body.tool_choice.name)) { out.tool_choice = { ...body.tool_choice, name: wireName(body.tool_choice.name) }; } - const isRenamedUse = (block: unknown): block is Rec & { name: string } => isRec(block) && block.type === "tool_use" && renames(block.name); + const mapContentBlocks = (blocks: unknown[]): { blocks: unknown[]; changed: boolean } => { + let changed = false; + const mapped = blocks.map(block => { + if (!isRec(block)) return block; + if (block.type === "tool_use" && renames(block.name)) { + changed = true; + return { ...block, name: wireName(block.name) }; + } + if (block.type === "tool_reference" && renames(block.tool_name)) { + changed = true; + return { ...block, tool_name: wireName(block.tool_name) }; + } + if ((block.type === "tool_addition" || block.type === "tool_removal") && isRec(block.tool)) { + const tool = block.tool; + if (tool.type === "tool_reference" && renames(tool.name)) { + changed = true; + return { ...block, tool: { ...tool, name: wireName(tool.name) } }; + } + if (block.type === "tool_addition" && tool.type === "tool_definition" && + isClientTool(tool.definition) && renames(tool.definition.name)) { + changed = true; + return { + ...block, + tool: { + ...tool, + definition: { ...tool.definition, name: wireName(tool.definition.name) }, + }, + }; + } + } + // Follow only typed inline tool containers and tool_result content; tool inputs and + // schemas are opaque caller data. + if (block.type === "tool_result" && Array.isArray(block.content)) { + const nested = mapContentBlocks(block.content); + if (nested.changed) { + changed = true; + return { ...block, content: nested.blocks }; + } + } + return block; + }); + return { blocks: mapped, changed }; + }; if (Array.isArray(body.messages)) { out.messages = body.messages.map(message => { - if (!isRec(message) || !Array.isArray(message.content) || !message.content.some(isRenamedUse)) return message; - return { - ...message, - content: message.content.map(block => isRenamedUse(block) ? { ...block, name: wireName(block.name) } : block), - }; + if (!isRec(message) || !Array.isArray(message.content)) return message; + const content = mapContentBlocks(message.content); + return content.changed ? { ...message, content: content.blocks } : message; }); } return { body: out, toolNames }; @@ -172,11 +236,12 @@ export function anthropicMessagesNativeWireBody( provider: Pick, modelId: string, body: Readonly>, + options: Pick = {}, ): { wireBody: Rec; strippedOpaqueState: boolean; oauthToolNames?: Map } { const allowlisted = anthropicMessagesPassthroughBody(body, modelId); const opaque = opaqueStateForDestination(allowlisted, credentialDomainFor(provider)); if (provider.authMode !== "oauth") return { wireBody: opaque.body, strippedOpaqueState: opaque.stripped }; - const oauth = anthropicOAuthWireBody(opaque.body); + const oauth = anthropicOAuthWireBody(opaque.body, credentialDomainFor(provider)?.firstPartyAnthropic ? options.clientIdentity : undefined); return { wireBody: oauth.body, strippedOpaqueState: opaque.stripped, oauthToolNames: oauth.toolNames }; } @@ -207,7 +272,7 @@ export function buildAnthropicMessagesPassthroughRequest( : "anthropic provider requires a non-empty apiKey (authMode: key)"); } const url = resolveAnthropicMessagesUrl(provider); - const native = anthropicMessagesNativeWireBody(provider, modelId, body); + const native = anthropicMessagesNativeWireBody(provider, modelId, body, options); const { strippedOpaqueState, oauthToolNames } = native; const wireBody = oauth ? bindAnthropicAccountMetadata(native.wireBody, options.providerAccountUuid) : native.wireBody; const headers = anthropicBaseRequestHeaders(wireBody.stream === true); @@ -226,7 +291,8 @@ export function buildAnthropicMessagesPassthroughRequest( } } if (domain?.firstPartyAnthropic) applyAnthropicClientIdentity(headers, options.clientIdentity, provider.headers); - const betas = allowlistAnthropicBetas(options.callerAnthropicBeta, domain?.firstPartyAnthropic ? "first-party" : "compatible"); + const betas = allowlistAnthropicBetas(options.callerAnthropicBeta, domain?.firstPartyAnthropic ? "first-party" : "compatible", + hasObservedAnthropicClientIdentity(options.clientIdentity)); mergeAnthropicBetaHeader(headers, betas.betas); return { url, diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index 8037724c950..71e157ad6f8 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -647,7 +647,15 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx if (memoryModels !== undefined && !memoryModelsSchema.safeParse(memoryModels).success) { return { ok: false, error: "schema_invalid: memoryModels: requires a nonblank model and an optional declared reasoningEffort per configured phase, and no other fields" }; } - const routeValue = (rawConfigRecord(value)?.anthropicAccountPool as Record | undefined)?.routes; + const rawAnthropicPool = rawConfigRecord(value)?.anthropicAccountPool; + const anthropicPool = rawConfigRecord(rawAnthropicPool); + if (rawAnthropicPool !== undefined && !anthropicPool) { + return { ok: false, error: "schema_invalid: anthropicAccountPool: must be an object" }; + } + if (anthropicPool?.nativeMessages !== undefined && typeof anthropicPool.nativeMessages !== "boolean") { + return { ok: false, error: "schema_invalid: anthropicAccountPool.nativeMessages: must be a boolean" }; + } + const routeValue = anthropicPool?.routes; if (routeValue !== undefined) { const parsed = parseAnthropicModelRoutes(routeValue); if (!parsed.ok) return { ok: false, error: `schema_invalid: anthropicAccountPool.routes: ${parsed.error}` }; diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 0ac3a0c3ed8..bd049a9e646 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -308,8 +308,9 @@ export const configSchema = z.object({ // path below and wipe providers/pool accounts. Warning emitted in loadConfig. streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined), blockedModelRedirects: blockedModelRedirectsSchema.optional().catch(undefined), - // Preserve malformed hand edits for a local routing error; candidate writes use the shared parser. - anthropicAccountPool: z.unknown().optional(), + // Degrade malformed hand edits locally; candidate writes reject them before parsing. + // An invalid native preference retains the legacy route instead of enabling native by default. + anthropicAccountPool: z.object({ nativeMessages: z.boolean().optional().catch(false) }).passthrough().optional().catch(undefined), // Same degrade-don't-reject rationale as the fields above: a hand-edited // non-string must not trip the backup-and-defaults repair path. Unset then // takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot). diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index 0401bee2f98..9b1fa496ffb 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -1172,12 +1172,10 @@ export function anthropicSessionKeyFromParts(input: { /** When true, prompt_cache_key is a shared Desktop cohort — ignore it for affinity. */ promptCacheKeyIsSharedCohort?: boolean; }): string | null { - const preferred = ( - input.clientThreadId - ?? input.sessionIdHeader - ?? input.threadIdHeader - ?? "" - ).trim(); + const preferred = input.clientThreadId?.trim() + || input.sessionIdHeader?.trim() + || input.threadIdHeader?.trim() + || ""; if (preferred) { return preferred.length <= 128 ? preferred : createHash("sha256").update(preferred).digest("hex"); } diff --git a/src/oauth/pool-settings-capability.ts b/src/oauth/pool-settings-capability.ts index fedb39b26d9..e5aef95d8d7 100644 --- a/src/oauth/pool-settings-capability.ts +++ b/src/oauth/pool-settings-capability.ts @@ -50,7 +50,7 @@ export function parseGenericStickyLimit(value: unknown): number | null { /** Fields the unified pool-settings contract can carry, per kind. */ export const POOL_SETTINGS_FIELDS = [ - "enabled", "strategy", "stickyLimit", "autoSwitchThreshold", "quotaWindow", "maxConcurrentPerAccount", "routes", + "enabled", "strategy", "stickyLimit", "autoSwitchThreshold", "quotaWindow", "maxConcurrentPerAccount", "routes", "nativeMessages", ] as const; export type PoolSettingsField = typeof POOL_SETTINGS_FIELDS[number]; @@ -84,6 +84,8 @@ export interface PoolSettingsDto { quotaWindow: string | null; maxConcurrentPerAccount: number | null; routes: AnthropicModelRoute[] | null; + /** Anthropic preference, default true; null for unsupported pool kinds. */ + nativeMessages: boolean | null; /** Present only when stored Anthropic routes fail validation on read. */ routesError?: string; } @@ -134,7 +136,7 @@ export function genericPoolSettingsDto( /** Which fields each kind actually honours. Declared, never silently omitted. */ const SUPPORTED_BY_KIND: Record = { codex: ["strategy", "stickyLimit", "autoSwitchThreshold"], - anthropic: ["enabled", "strategy", "stickyLimit", "autoSwitchThreshold", "quotaWindow", "routes"], + anthropic: ["enabled", "strategy", "stickyLimit", "autoSwitchThreshold", "quotaWindow", "routes", "nativeMessages"], generic: ["enabled", "strategy", "stickyLimit", "autoSwitchThreshold"], }; @@ -165,6 +167,7 @@ export function unifiedPoolSettingsDto( quotaWindow: null, maxConcurrentPerAccount: null, routes: null, + nativeMessages: null, }; } if (kind === "anthropic") { @@ -174,6 +177,7 @@ export function unifiedPoolSettingsDto( ...base, enabled, enabledEffective: enabled === true, + nativeMessages: pool.nativeMessages !== false, strategy: parseGenericPoolStrategy(pool.strategy) ?? "quota", stickyLimit: parseGenericStickyLimit(pool.stickyLimit) ?? 1, autoSwitchThreshold: parseGenericAutoSwitchThreshold(pool.autoSwitchThreshold) ?? 80, @@ -198,5 +202,6 @@ export function unifiedPoolSettingsDto( quotaWindow: null, maxConcurrentPerAccount: provider === "kiro" ? parseKiroAccountCap(failover.maxConcurrentPerAccount) : null, routes: null, + nativeMessages: null, }; } diff --git a/src/protocols/plan-snapshot.ts b/src/protocols/plan-snapshot.ts index 37294b9bb38..83ce4f88eec 100644 --- a/src/protocols/plan-snapshot.ts +++ b/src/protocols/plan-snapshot.ts @@ -138,7 +138,7 @@ function candidateFor( const reason = rows.effortRow ? "effort-row" : nativeChatDeclineReason(settled, chatBodyForFeatures(features), config); nativeEligible = reason === undefined; if (reason) declineReasons = [reason]; - } else if (inbound === "messages" && resolveProtocolSettings(config).rollout.managedMessagesNative) { + } else if (inbound === "messages" && resolveProtocolSettings(config, settled.providerName).rollout.managedMessagesNative) { // The runtime rule itself. With the switch off nothing is judged, so the default preview // is exactly what it was before the managed native lane existed. // Pinned effort is judged from config and the route; blocked-skill elision and the web-search diff --git a/src/protocols/settings.ts b/src/protocols/settings.ts index 84d9ffc5be7..ac8755e0861 100644 --- a/src/protocols/settings.ts +++ b/src/protocols/settings.ts @@ -71,19 +71,21 @@ export interface ProtocolSettings { } /** Resolve protocol policy with conservative defaults for every absent or malformed field. */ -export function resolveProtocolSettings(config: Pick): Readonly { +export function resolveProtocolSettings(config: Pick, providerName?: string): Readonly { const raw: unknown = config.protocols; const protocols = isRec(raw) ? raw : {}; const rollout = isRec(protocols.rollout) ? protocols.rollout : {}; const on = (key: keyof ProtocolRolloutSettings): boolean => rollout[key] === true; - const managedMessagesNative = on("managedMessagesNative"); + const poolNative = (providerName === undefined || providerName === "anthropic") && config.anthropicAccountPool?.enabled === true + ? config.anthropicAccountPool.nativeMessages !== false : undefined; + const managedMessagesNative = poolNative ?? on("managedMessagesNative"); return Object.freeze({ unrepresentable: protocols.unrepresentable === "reject" ? "reject" : "legacy", rollout: Object.freeze({ nativeChatCombos: on("nativeChatCombos"), managedMessagesNative, // OAuth extension is meaningless without the key-auth path it extends. - managedMessagesNativeOAuth: managedMessagesNative && on("managedMessagesNativeOAuth"), + managedMessagesNativeOAuth: poolNative ?? (managedMessagesNative && on("managedMessagesNativeOAuth")), directEncoders: on("directEncoders"), shadowPlan: on("shadowPlan"), }), @@ -95,9 +97,10 @@ export function resolveProtocolSettings(config: Pick): R * dashboard can tell a preview computed under an older policy from a current one. Not a * security boundary; FNV-1a over a canonical JSON projection. */ -export function protocolPolicyRevision(config: Pick): string { +export function protocolPolicyRevision(config: Pick): string { const surfaces = resolveApiSurfaceSettings(config); const settings = resolveProtocolSettings(config); + const explicitSettings = resolveProtocolSettings(config, ""); const canonical = JSON.stringify({ messages: [surfaces.messages.enabled, surfaces.messages.source], unrepresentable: settings.unrepresentable, @@ -105,6 +108,8 @@ export function protocolPolicyRevision(config: Pick { + const pool = { ...(target.anthropicAccountPool ?? {}) }; + if (fields.nativeMessages !== undefined) pool.nativeMessages = fields.nativeMessages as boolean; + if (fields.enabled !== undefined) pool.enabled = fields.enabled as boolean; + if (strategy !== undefined) pool.strategy = strategy as never; + if (stickyLimit !== undefined) pool.stickyLimit = stickyLimit; + if (autoSwitchThreshold !== undefined) pool.autoSwitchThreshold = autoSwitchThreshold; + if (quotaWindow !== undefined) pool.quotaWindow = quotaWindow as never; + if (Object.hasOwn(fields, "routes")) { + if (fields.routes === null) delete pool.routes; + else if (parsedRoutes?.ok) pool.routes = parsedRoutes.routes; + } + target.anthropicAccountPool = pool; + return { changed: true, value: pool }; + }; + try { + if (deps.saveConfigPreservingClaudeCode) { + const tentative = { ...config }; + patchPool(tentative); + deps.saveConfigPreservingClaudeCode(tentative); + config.anthropicAccountPool = tentative.anthropicAccountPool; + } else { + const saved = mutatePersistedConfig(patchPool); + if (saved.status === "unavailable") return jsonResponse({ error: "pool settings could not be saved", reason: saved.reason }, 409); + config.anthropicAccountPool = saved.value; + } + } catch { return jsonResponse({ error: "pool settings could not be saved" }, 500); } } else { const prov = config.providers[provider]!; const next = { ...(prov.oauthAccountFailover ?? {}) }; @@ -648,7 +669,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< if (Object.keys(next).length > 0) prov.oauthAccountFailover = next; else delete prov.oauthAccountFailover; } - saveConfigPreservingClaudeCode(config); + if (kind !== "anthropic") saveConfigPreservingClaudeCode(config); reconcileLiveStateStores(); } return jsonResponse(unifiedPoolSettingsDto(config, provider, kind)); @@ -679,6 +700,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< stickyLimit: normalizeAccountPoolStickyLimit(pool.stickyLimit), quotaWindow: normalizeAccountPoolQuotaWindow(pool.quotaWindow), ...readAnthropicModelRoutes(pool.routes), + ...(pool.nativeMessages !== undefined ? { nativeMessages: pool.nativeMessages } : {}), experimental: true, }); } @@ -696,8 +718,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< quotaWindow?: unknown; maxConcurrentPerAccount?: unknown; routes?: unknown; + nativeMessages?: unknown; }; const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : ""; + if (Object.hasOwn(body, "nativeMessages")) { + if (provider !== "anthropic") return jsonResponse({ error: "nativeMessages is only part of the anthropic pool contract" }, 400); + if (typeof body.nativeMessages !== "boolean") return jsonResponse({ error: "nativeMessages must be a boolean" }, 400); + } if (provider !== "anthropic") { const { poolSettingsCapability, genericPoolSettingsDto, parseGenericPoolStrategy, parseGenericAutoSwitchThreshold, @@ -796,28 +823,46 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const parsedLegacyRoutes = Object.hasOwn(body, "routes") && body.routes !== null ? parseAnthropicModelRoutes(body.routes) : null; if (parsedLegacyRoutes && !parsedLegacyRoutes.ok) return jsonResponse({ error: parsedLegacyRoutes.error }, 400); - const routes = Object.hasOwn(body, "routes") - ? (parsedLegacyRoutes?.ok ? parsedLegacyRoutes.routes : undefined) - : config.anthropicAccountPool?.routes; - config.anthropicAccountPool = { - enabled, - autoSwitchThreshold: threshold, - ...(strategy !== undefined ? { strategy } : {}), - ...(stickyLimit !== undefined ? { stickyLimit } : {}), - ...(quotaWindow !== undefined ? { quotaWindow } : {}), - ...(routes !== undefined ? { routes } : {}), + const legacyPatch = (target: OcxConfig) => { + const pool = { ...(target.anthropicAccountPool ?? {}) }; + if (body.enabled !== undefined) pool.enabled = enabled; + else if (pool.enabled === undefined) pool.enabled = false; + if (body.nativeMessages !== undefined) pool.nativeMessages = body.nativeMessages as boolean; + if (body.autoSwitchThreshold !== undefined) pool.autoSwitchThreshold = threshold; + else if (pool.autoSwitchThreshold === undefined) pool.autoSwitchThreshold = 80; + if (body.strategy !== undefined) pool.strategy = strategy; + if (body.stickyLimit !== undefined) pool.stickyLimit = stickyLimit; + if (body.quotaWindow !== undefined) pool.quotaWindow = quotaWindow; + if (Object.hasOwn(body, "routes")) { + if (body.routes === null) delete pool.routes; + else if (parsedLegacyRoutes?.ok) pool.routes = parsedLegacyRoutes.routes; + } + target.anthropicAccountPool = pool; + return { changed: true, value: pool }; }; - saveConfigPreservingClaudeCode(config); + try { + if (deps.saveConfigPreservingClaudeCode) { + const tentative = { ...config }; + legacyPatch(tentative); + deps.saveConfigPreservingClaudeCode(tentative); + config.anthropicAccountPool = tentative.anthropicAccountPool; + } else { + const saved = mutatePersistedConfig(legacyPatch); + if (saved.status === "unavailable") return jsonResponse({ error: "pool settings could not be saved", reason: saved.reason }, 409); + config.anthropicAccountPool = saved.value; + } + } catch { return jsonResponse({ error: "pool settings could not be saved" }, 500); } reconcileLiveStateStores(); return jsonResponse({ ok: true, provider, - enabled, - autoSwitchThreshold: threshold, - strategy: normalizeAccountPoolStrategy(strategy), - stickyLimit: normalizeAccountPoolStickyLimit(stickyLimit), - quotaWindow: normalizeAccountPoolQuotaWindow(quotaWindow), - routes: routes ?? null, + enabled: config.anthropicAccountPool?.enabled === true, + autoSwitchThreshold: config.anthropicAccountPool?.autoSwitchThreshold ?? 80, + strategy: normalizeAccountPoolStrategy(config.anthropicAccountPool?.strategy), + stickyLimit: normalizeAccountPoolStickyLimit(config.anthropicAccountPool?.stickyLimit), + quotaWindow: normalizeAccountPoolQuotaWindow(config.anthropicAccountPool?.quotaWindow), + routes: config.anthropicAccountPool?.routes ?? null, + ...(config.anthropicAccountPool?.nativeMessages !== undefined ? { nativeMessages: config.anthropicAccountPool.nativeMessages } : {}), experimental: true, }); } diff --git a/src/server/messages-native-eligibility.ts b/src/server/messages-native-eligibility.ts index 7fead1d9ccf..752a6fcae3a 100644 --- a/src/server/messages-native-eligibility.ts +++ b/src/server/messages-native-eligibility.ts @@ -7,7 +7,6 @@ */ import { anthropicBodyElidesBlockedSkill } from "../claude/inbound"; import { isClaudeWebSearchToolName } from "../claude/outbound"; -import { isAnthropicAccountPoolEnabled } from "../oauth/anthropic-routing"; import type { ProtocolReasonCode } from "../protocols/contract"; import { featuresFromMessagesBody } from "../protocols/features"; import { credentialDomainFor } from "../protocols/opaque-state"; @@ -96,8 +95,7 @@ function bridgeOnlyPolicyApplies( * The credential rule. A proxy-managed key is native since PF-08. An Anthropic OAuth account * is native only with `managedMessagesNativeOAuth` on (itself effective only with * `managedMessagesNative`), only for the `anthropic` provider the OAuth store serves, and only - * to `api.anthropic.com`. A pooled account set declines: rotation, session affinity and quota - * ranking live in the Responses pipeline's transport and are not replicated here. `forward` + * to `api.anthropic.com`. Pool selection and recovery are resolved at native dispatch. `forward` * belongs to the caller. */ function credentialDeclineReason( @@ -111,7 +109,6 @@ function credentialDeclineReason( if (!resolveProtocolSettings(config).rollout.managedMessagesNativeOAuth) return "auth-mode-not-native"; if (route.providerName !== "anthropic") return "auth-mode-not-native"; if (!credentialDomainFor(provider)?.firstPartyAnthropic) return "auth-mode-not-native"; - if (isAnthropicAccountPoolEnabled(config) || selector.oauthFailoverQuorum === true) return "oauth-account-pool"; return undefined; } @@ -135,7 +132,7 @@ export function nativeMessagesDeclineReason( config: OcxConfig, selector: NativeMessagesSelector = {}, ): NativeMessagesDeclineReason | undefined { - if (!resolveProtocolSettings(config).rollout.managedMessagesNative) return "rollout-disabled"; + if (!resolveProtocolSettings(config, route.providerName).rollout.managedMessagesNative) return "rollout-disabled"; const provider = route.provider; if (provider.adapter !== "anthropic") return "cross-wire-ir"; const credentialDecline = credentialDeclineReason(route, config, selector); diff --git a/src/server/messages-native-oauth.ts b/src/server/messages-native-oauth.ts index 47b67f28b36..dfe41451dbe 100644 --- a/src/server/messages-native-oauth.ts +++ b/src/server/messages-native-oauth.ts @@ -2,19 +2,15 @@ * Anthropic OAuth on the managed native Messages lane (PF-10, behind * `protocols.rollout.managedMessagesNativeOAuth`). * - * Credential. The account is the one the existing OAuth selection chooses for this request, by - * the same steps the Responses pipeline's transport takes for an unpooled Anthropic OAuth route - * (`prepareResponsesTransport`): capture the committed selection, resolve an eligible account's - * access snapshot (including pause/cooldown recovery), and commit that - * proposal against the captured selection so a concurrent manual switch wins. Nothing here picks - * an account of its own, and nothing runs at planning time: the planner and eligibility read - * config only, and this module is reached from the lane's dispatch alone. + * Credential. Shared Anthropic routing chooses the account for the session and model; + * the same generation-fenced selection commit as the Responses transport admits it. A lost + * commit re-evaluates routing so a concurrent manual switch or policy edit wins. Selection, + * token resolution and affinity binding run at dispatch only, never during planning. * - * Pools. A pooled account set (the opt-in Anthropic account pool, or two or more usable accounts, - * which turns on reactive 429 rotation) is declined before this lane is chosen - * (`oauth-account-pool`). Should one appear between that decision and dispatch, resolution fails - * closed rather than serve a pooled account without the pool's rotation and affinity. This - * lane-change 409 is distinct from the shared local authentication/pause/cooldown refusals. + * Pools. Native Messages uses the OAuth owner's strategy, model allowlist, session affinity, + * pause and cooldown admission. Pre-output refusal recovery proposes a replacement account; + * this module admits and binds its exact generation before a rebuilt request may send. + * A lane-change 409 remains distinct from typed local authentication/pause/cooldown refusals. * * Tool names. An OAuth request carries client tool names under the Claude OAuth prefix, as the * adapter sends them; the answer's `tool_use` names are mapped back here, for exactly the names @@ -28,9 +24,11 @@ import { getAnthropicPoolAccessSnapshot, getAnthropicAccountHealthSnapshot, resolveAnthropicDispatchAccountId, - hasAnthropicFailoverQuorum, + resolveAnthropicAccountForSession, + getEligibleAnthropicAccounts, isAnthropicAccountPoolEnabled, } from "../oauth/anthropic-routing"; +import { resolveAnthropicModelRoute, routeCandidates, type AnthropicRouteDecision } from "../oauth/anthropic-model-routes"; import { captureOAuthAccountSelection, commitOAuthAccountSelection, @@ -57,9 +55,13 @@ export interface NativeOAuthBinding { readonly snapshot: OAuthAccessSnapshot; /** Anthropic account UUID from the same stored generation, distinct from snapshot.accountId. */ readonly providerAccountUuid?: string; + readonly routeDecision: AnthropicRouteDecision | null; + readonly sessionKey: string | null; + readonly model?: string; + readonly config: OcxConfig; } -/** The selection moved or became pooled while it was being resolved. Maps to a 409 retry. */ +/** The selection moved or became ineligible while resolving. Maps to a 409 retry. */ export class NativeOAuthSelectionChangedError extends Error { constructor() { super("OAuth account selection changed; retry the request"); @@ -67,23 +69,40 @@ export class NativeOAuthSelectionChangedError extends Error { } } -function pooled(config: OcxConfig): boolean { - return isAnthropicAccountPoolEnabled(config) || hasAnthropicFailoverQuorum(); +export interface NativeOAuthBindingOptions { + sessionKey?: string | null; + model?: string; + /** An account proposed by the shared pre-output refusal recovery policy. */ + candidateAccountId?: string; + /** Selection captured before asynchronous refusal classification or throttle wait. */ + expectedRecoverySelection?: Selection | null; } -/** - * Resolve and commit the account for one native request. Throws the OAuth owner's own errors - * (login required, refresh failure) unchanged, and `NativeOAuthSelectionChangedError` when the - * selection could not be committed. - */ -export async function resolveNativeOAuthBinding(config: OcxConfig): Promise { +/** Resolve the shared pool selector and commit its exact credential generation for dispatch. */ +export async function resolveNativeOAuthBinding( + config: OcxConfig, + options: NativeOAuthBindingOptions = {}, +): Promise { + const sessionKey = options.sessionKey ?? null; + const model = options.model; + const resolvedRoute = model ? resolveAnthropicModelRoute(config, model) : { decision: null }; + if (resolvedRoute.error) throw new Error(`Invalid Anthropic model routes: ${resolvedRoute.error}`); + const routeDecision = resolvedRoute.decision; for (let attempt = 0; attempt < MAX_SELECTION_ATTEMPTS; attempt++) { const selection = captureOAuthAccountSelection(PROVIDER); - // Resolve eligibility before the lane guard: a local 401/403/429 must not turn - // into a generic lane-change 409 merely because it appeared during pacing. - const accountId = await resolveAnthropicDispatchAccountId(config); - if (pooled(config)) throw new NativeOAuthSelectionChangedError(); + // Always ask admission first so pause/login/cooldown retains its typed local refusal. + const admittedId = await resolveAnthropicDispatchAccountId(config, sessionKey, routeDecision, model); + const proposed = resolveAnthropicAccountForSession(sessionKey, config, Date.now(), routeDecision, model); + // A lost selection commit discards a recovery proposal: a newer manual/policy choice wins. + const recoverySelectionMatches = !!options.expectedRecoverySelection + && selection?.accountId === options.expectedRecoverySelection.accountId + && selection?.revision === options.expectedRecoverySelection.revision; + const accountId = attempt === 0 && options.candidateAccountId && recoverySelectionMatches + ? options.candidateAccountId : admittedId; if (!selection) continue; + if (!routeCandidates(getEligibleAnthropicAccounts(Date.now(), model), routeDecision).includes(accountId)) { + throw new NativeOAuthSelectionChangedError(); + } const candidate = await getAnthropicPoolAccessSnapshot(accountId); const committed = await commitOAuthAccountSelection(PROVIDER, candidate.accountId, { expectedSelection: selection, @@ -92,32 +111,37 @@ export async function resolveNativeOAuthBinding(config: OcxConfig): Promise Date.now() && !getAnthropicAccountHealthSnapshot(binding.snapshot.accountId) + && routeCandidates(getEligibleAnthropicAccounts(Date.now(), binding.model), binding.routeDecision).includes(binding.snapshot.accountId) && credentialGeneration(row.credential) === binding.snapshot.generation && row.credential.accountId === binding.providerAccountUuid; } diff --git a/src/server/messages-native.ts b/src/server/messages-native.ts index 89ad5a431e5..5d876ede9eb 100644 --- a/src/server/messages-native.ts +++ b/src/server/messages-native.ts @@ -17,6 +17,12 @@ * * Loaded lazily by `claude-messages.ts` only when the switch is on and a route is eligible. */ +import { bindAnthropicRefusalCredential, rotateAnthropicAccountOnResponse } from "../oauth/anthropic-account-refusal"; +import { claimAnthropicFamilyRevalidation } from "../oauth/anthropic-model-quota"; +import { captureConfigGeneration } from "../lib/state-store-sweeper"; +import { recordAnthropicAccountQuotaFromHeaders } from "../providers/quota"; +import { credentialGeneration, getAccountCredentialWithStatus } from "../oauth/store"; +import { ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST } from "../oauth/anthropic-routing"; import { enforceAnthropicImageLimits } from "../adapters/anthropic-image-guard"; import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; import { formatAnthropicErrorBody } from "../adapters/anthropic"; @@ -150,6 +156,7 @@ export interface HandleNativeMessagesOptions { */ callerAnthropicBeta?: string | null; clientIdentity?: AnthropicClientIdentity; + sessionKey?: string | null; } type FinishLog = (status: number, message?: string, meta?: FinalRequestLogMeta) => void; @@ -373,7 +380,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) const oauthProvider = (binding: NativeOAuthBinding): OcxProviderConfig => ({ ...route.provider, apiKey: binding.snapshot.accessToken }); if (route.provider.authMode === "oauth") { try { - oauthBinding = await resolveNativeOAuthBinding(config); + oauthBinding = await resolveNativeOAuthBinding(config, { sessionKey: options.sessionKey, model: route.modelId }); } catch (error) { cleanupAbort(); upstream.abort(); @@ -455,7 +462,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) const transientSendAvailable = (): boolean => remainingTransientSends() > 0; const selector: NativeMessagesSelector = options.selector ?? {}; - const send = async (recovery?: "rate-limit-429" | "key-429" | "key-401"): Promise => { + const send = async (recovery?: "rate-limit-429" | "oauth-account-403" | "key-429" | "key-401"): Promise => { const remaining = remainingTransientSends(); if (requestTransientPolicy && remaining <= 0) { throw new Error("native Messages transient send budget exhausted before recovery dispatch"); @@ -479,7 +486,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) for (let attempt = 0; !nativeOAuthBindingIsCurrent(oauthBinding); attempt++) { if (attempt >= 3) throw new NativeOAuthSelectionChangedError(); try { - oauthBinding = await resolveNativeOAuthBinding(config); + oauthBinding = await resolveNativeOAuthBinding(config, { sessionKey: options.sessionKey, model: route.modelId }); } catch (error) { if (error instanceof OAuthAccountPausedError || error instanceof OAuthLoginRequiredError || error instanceof AnthropicAccountCooldownError) throw error; @@ -503,12 +510,29 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) if (init.signal?.aborted) throw init.signal.reason; if (!spendTracker.charge()) throw new NativeMessagesSpendRefusal(); noteProviderAttemptSend(logCtx, route.providerName, activeProvider, logCtx.usageLogInputTokens, transportRecovery ?? recovery); - const dispatched = await sendWithConnectionPolicy( - (activeProvider as OcxProviderTransport).fetch ?? execute, - wire.url, - applyUpstreamRecoveryInit({ ...init, method: "POST", headers, body: wire.body }, transportRecovery), - { providerName: route.providerName, provider: activeProvider }, - ); + const snapshot = oauthBinding?.snapshot; + const writerGeneration = snapshot ? captureConfigGeneration() : 0; + const ownsBearer = snapshot && headers.get("authorization") === `Bearer ${snapshot.accessToken}` && !headers.has("x-api-key"); + const releaseFamily = snapshot ? claimAnthropicFamilyRevalidation(snapshot.accountId, route.modelId) : () => {}; + if (!releaseFamily) throw new AnthropicAccountCooldownError(1); + let dispatched: Response; + try { + dispatched = await sendWithConnectionPolicy( + (activeProvider as OcxProviderTransport).fetch ?? execute, + wire.url, + applyUpstreamRecoveryInit({ ...init, method: "POST", headers, body: wire.body }, transportRecovery), + { providerName: route.providerName, provider: activeProvider }, + ); + } finally { releaseFamily(); } + if (ownsBearer && snapshot) { + try { + const current = getAccountCredentialWithStatus("anthropic", snapshot.accountId); + if (current && !current.needsReauth && credentialGeneration(current.credential) === snapshot.generation) { + bindAnthropicRefusalCredential(dispatched, snapshot); + recordAnthropicAccountQuotaFromHeaders(snapshot.accountId, dispatched.headers, writerGeneration, dispatched.status, route.modelId); + } + } catch { /* Passive observation must not fail the response. */ } + } if (!dispatched.ok) await recordKeyAttemptFailure(logCtx, dispatched, init.signal ?? upstream.signal); return dispatched; }, @@ -544,7 +568,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) rebuildFor(rotated); response = await send("key-401"); } - const retryPolicy = rateLimitRetryPolicyFor(activeProvider); + const retryPolicy = oauthBinding ? undefined : rateLimitRetryPolicyFor(activeProvider); let retries = 0; // A refusal this proxy synthesized for a reset replay is not a provider rate limit. while ( @@ -577,6 +601,28 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) rebuildFor(rotated); response = await send("key-429"); } + const oauthRetryKey = {}; + let oauthFailovers = 0; + while (oauthBinding && (response.status === 429 || response.status === 403)) { + const expectedRecoverySelection = oauthBinding.selection; + const nextAccountId = await rotateAnthropicAccountOnResponse(response, { + config, accountId: oauthBinding.snapshot.accountId, model: route.modelId, + sessionKey: oauthBinding.sessionKey, decision: oauthBinding.routeDecision, + signal: upstream.signal, requestKey: oauthRetryKey, + canRetry: transientSendAvailable() && oauthFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST, + }); + if (!nextAccountId) break; + const recovery = response.status === 403 ? "oauth-account-403" : "rate-limit-429"; + discard(response); + oauthBinding = await resolveNativeOAuthBinding(config, { + sessionKey: options.sessionKey, model: route.modelId, candidateAccountId: nextAccountId, + expectedRecoverySelection, + }); + oauthFailovers++; + rebuildFor(oauthProvider(oauthBinding)); + logCtx.provider = formatAnthropicProviderForLog(route.providerName, oauthBinding.snapshot.accountId, config); + response = await send(recovery); + } } catch (error) { releaseRetainedRequest(); cleanupAbort(); @@ -791,6 +837,7 @@ export function nativeMessagesCountBody( cc: OcxConfig["claudeCode"], body: Rec, rows: Pick, + clientIdentity?: AnthropicClientIdentity, ): Rec | undefined { if (typeof body.model !== "string") return undefined; try { @@ -812,7 +859,7 @@ export function nativeMessagesCountBody( }; if (nativeMessagesDeclineReason(route, body, config, selector) !== undefined) return undefined; // The body without a credential: counting never resolves or refreshes an OAuth account. - return anthropicMessagesNativeWireBody(route.provider, route.modelId, body).wireBody; + return anthropicMessagesNativeWireBody(route.provider, route.modelId, body, { clientIdentity }).wireBody; } catch { return undefined; } diff --git a/src/types/config.ts b/src/types/config.ts index c771c6299ef..ce3a31353ac 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1218,6 +1218,8 @@ export interface OcxConfig { */ anthropicAccountPool?: { enabled?: boolean; + /** Preserve native Claude Messages while the pool is enabled. Default true; false selects legacy translation. */ + nativeMessages?: boolean; /** Usage % threshold for new-session auto-pick. Default 80. 0 = disabled (affinity/active only). */ autoSwitchThreshold?: number; /** New-session rotation strategy. Default quota (today's behaviour). */ diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 3206e5acfe8..5ac02026c57 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -259,10 +259,10 @@ transport side (send budget, failover, logging) is in Behind `protocols.rollout.managedMessagesNative` (default off). A Messages request whose settled route is a direct, key-auth `anthropic` provider is sent as Messages instead of replaying through -Responses; with `managedMessagesNativeOAuth` also on, so is an unpooled Anthropic OAuth account +Responses; with `managedMessagesNativeOAuth` also on, so is an Anthropic OAuth account pool (below). `nativeMessagesDeclineReason` names the first rule that keeps a route off the lane: `rollout-disabled`, `cross-wire-ir` (another adapter), `auth-mode-not-native` (`forward`, or an -OAuth route the OAuth rule does not admit), `oauth-account-pool`, `combo-or-policy-route`, `effort-row` / `fast-row` (synthetic rows need the +OAuth route the OAuth rule does not admit), `combo-or-policy-route`, `effort-row` / `fast-row` (synthetic rows need the adapter's wire rewrite), `vision-preprocessing` (an image for a model declared unable to read it), and `bridge-only-policy` when operator policy that only the translated path applies would engage: a pinned reasoning effort for the route (`resolvePinnedEffort`, read with the translated @@ -328,7 +328,7 @@ identity, generated Responses, caller-forward and compatible destinations keep t The accepted native first-party behavior preserves one genuine Claude Code session id and its metadata device/session components across token refresh and an eligible unpooled account switch, matching a genuine client on a manual account switch. Consequently, accounts serving that session -are linkable upstream. Pooled accounts stay on the Responses bridge described below. Traffic without +are linkable upstream. Pooled accounts use this native builder and shared Anthropic routing policy. Traffic without a genuine client identity retains per-credential synthesized session ids. `tests/adapters/anthropic/anthropic-client-identity.test.ts` and `tests/claude-integration/messages-native-oauth.test.ts` cover header continuity through refresh @@ -336,16 +336,14 @@ and account switch, destination isolation, bounded parsing and credential exclus OAuth. Behind `managedMessagesNativeOAuth`, which `resolveProtocolSettings` treats as off unless `managedMessagesNative` is on. Only the `anthropic` provider the OAuth store serves, only to -`api.anthropic.com` (the builder refuses any other host for an OAuth token), and only an unpooled -account set: `anthropicAccountPool.enabled` (config) or a stored quorum of two usable accounts -(`hasAnthropicFailoverQuorum`, supplied by the ingress and `count_tokens`, never by the planner) -declines with `oauth-account-pool`, because rotation, session affinity and quota ranking live in -`prepareResponsesTransport`. `src/server/messages-native-oauth.ts` resolves the account at -dispatch by the same steps that transport takes for an unpooled route (capture the selection, -resolve the active snapshot, commit against the capture) and re-checks the binding before every -physical send, re-resolving through the same owner if it moved. Planning and `count_tokens` read -config and the read-only account set only; nothing selects, refreshes or writes. The body gets the -Claude Code identity block and declared client tool names under the OAuth prefix. +`api.anthropic.com` (the builder refuses any other host for an OAuth token). The native lane +uses shared Anthropic strategy, model-route restrictions and session affinity. A shared Desktop +system cache cohort never supplies session affinity. `src/server/messages-native-oauth.ts` +resolves and commits the exact credential generation at dispatch and rechecks it immediately +before each physical send; a lost commit re-evaluates current selection. Planning and +`count_tokens` do not select, refresh or write accounts. The body gets the +Claude Code identity block and declared client tool names under the OAuth prefix, including nested typed `tool_reference` +blocks in tool results; tool arguments and schemas are never traversed. `src/adapters/anthropic/account-metadata.ts` copy-on-write aligns a valid JSON-string `metadata.user_id.account_uuid` with the provider UUID captured alongside the native binding. The local pool id is never used; malformed, absent and unknown metadata stays unchanged. @@ -353,8 +351,20 @@ Every rebuild starts from the source body; the binding also checks UUID equality Conflicting provider credential headers fail before dispatch on every OAuth build, including builds without a provider UUID. Key-auth and caller-forward requests retain their metadata. The answer's -`tool_use` names are mapped back for exactly those names. A 401 or 429 is answered as the bridge -answers an unpooled account: no refresh replay, no same-token replay, no rotation. +`tool_use` names are mapped back for exactly those names. A 401 remains terminal. Before output, classified 429 and entitlement 403 responses use the +shared bearer-fenced recovery helper within the existing account-failover and physical-send +bounds. Each physical response records quota headers against the credential that sent it. +There is no account rotation after streamed assistant output starts. + +Observed native CLI and Desktop Code compatibility bundles retain the native billing/identity +system preamble when its exact known shape is present. `src/adapters/anthropic/native-client-preamble.ts` +recognizes the source ordering without generating or signing billing text; generated requests +retain SDK identity insertion. Native counting uses the same observed bundle as dispatch. +The observed first-party client beta allowance preserves known inline-tool, per-message effort, +thinking display, advisor and scoped-cache request schemas. Advisor may perform the sub-inference +the native client already requested; the proxy injects neither advisor nor any beta-gated body. +Generic managed callers retain the original narrower allowance; compatible destinations never +receive the first-party native allowance. These compatibility handles grant no credential authority. `handleNativeMessages` mirrors native Chat on the shared pieces: `beginInferenceAttempt`, `createFinalRequestLog`, the request spend tracker charged per physical send, proactive key @@ -435,3 +445,9 @@ implicitly. The three are declared as `api` capabilities in `src/cli/capabilitie routes carry no exemption in `src/server/management/route-registry.ts`; the capability mutation check reads the registry's `mutates`, so the read-only plan POST is not a write. `tests/cli/cli-api-protocols.test.ts` pins the requests, the usage errors and that every protocol route is verbed. + +Native OAuth Messages rename typed `tool_reference.tool_name` blocks consistently with declared +client tools, including nested tool-result content and Claude Code inline `tool_addition` and +`tool_removal` blocks. Inline tool definitions are collected before references are mapped, so +references may precede their definition. Cache markers and lifetimes are retained, and arbitrary +tool arguments and input schemas are not traversed. diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index e7cc6b947ee..11b7f1e58c8 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -145,3 +145,21 @@ limits array retires absent families. Shared rejection and family rejection keep resets, so Fable must wait for both relevant windows while Sonnet need only wait for shared quota. Regression coverage: `tests/adapters/anthropic/anthropic-model-weekly-admission.test.ts`. + +## Native Messages dispatch + +`src/server/messages-native-oauth.ts` binds native Claude Messages requests through the same +Anthropic session, model-route and generation-fenced selection authority as Responses. +`src/server/messages-native.ts` preserves the source message/cache shape while substituting +the committed serving credential, observes that credential's quota headers and applies bounded +pre-output refusal recovery. Account changes may require a new cache on the replacement account; +the proxy does not claim cross-account cache sharing. + +## Native pooling preference + +`anthropicAccountPool.nativeMessages` is an Anthropic-only boolean pool preference. When +pooling is enabled, `src/protocols/settings.ts` resolves both native Messages switches together +from this value, defaulting to true. Pool-off leaves explicit protocol rollout behavior intact. +The canonical pool DTO and both pool writer surfaces read and persist the same preference; +other provider kinds do not support it. The dashboard control under **How account selection works** owns the setting while +the native route eligibility rules still decide when a request requires the bridge. diff --git a/tests/adapters/anthropic/anthropic-account-pool.test.ts b/tests/adapters/anthropic/anthropic-account-pool.test.ts index bd7dd9a0114..5300689e05a 100644 --- a/tests/adapters/anthropic/anthropic-account-pool.test.ts +++ b/tests/adapters/anthropic/anthropic-account-pool.test.ts @@ -330,6 +330,14 @@ describe("anthropic account pool", () => { })).toBe("sess-a"); }); + test("session key skips blank ids while preserving usable id priority", () => { + expect(anthropicSessionKeyFromParts({ clientThreadId: " ", sessionIdHeader: " session ", threadIdHeader: "thread" })).toBe("session"); + expect(anthropicSessionKeyFromParts({ clientThreadId: "", sessionIdHeader: "\t", threadIdHeader: " thread ", promptCacheKeyIsSharedCohort: true })).toBe("thread"); + expect(anthropicSessionKeyFromParts({ clientThreadId: " client ", sessionIdHeader: "session", threadIdHeader: "thread" })).toBe("client"); + expect(anthropicSessionKeyFromParts({ clientThreadId: " ", sessionIdHeader: " ", threadIdHeader: " ", promptCacheKey: " cache " })).toBe("cache"); + expect(anthropicSessionKeyFromParts({ clientThreadId: " ", sessionIdHeader: " ", threadIdHeader: " ", promptCacheKey: "cohort", promptCacheKeyIsSharedCohort: true })).toBeNull(); + }); + test("shared Desktop cache cohort alone does not create affinity key", () => { expect(anthropicSessionKeyFromParts({ promptCacheKey: "shared-cohort-hash", diff --git a/tests/adapters/anthropic/anthropic-beta-allowlist.test.ts b/tests/adapters/anthropic/anthropic-beta-allowlist.test.ts index fb32d988a30..fd2703df637 100644 --- a/tests/adapters/anthropic/anthropic-beta-allowlist.test.ts +++ b/tests/adapters/anthropic/anthropic-beta-allowlist.test.ts @@ -101,3 +101,15 @@ describe("the builder applies the allowlist", () => { expect(built.headers).not.toHaveProperty("anthropic-beta"); }); }); + +describe('observed native client feature betas', () => { + const nativeBetas = ['advisor-tool-2026-03-01', 'per-turn-control-2026-07-01', 'redact-thinking-2026-02-12', 'inline-tools-2026-09-15', 'thinking-display-updates-2026-08-18', 'prompt-caching-scope-2026-01-05', 'mid-conversation-system-2026-04-07']; + test('keeps required schemas only for an observed first-party native client', () => { + expect(allowlistAnthropicBetas(nativeBetas.join(','), 'first-party', true)).toEqual({betas:nativeBetas, dropped:false}); + expect(allowlistAnthropicBetas(nativeBetas.join(','), 'first-party')).toEqual({betas:[], dropped:true}); + expect(allowlistAnthropicBetas(nativeBetas.join(','), 'compatible', true)).toEqual({betas:[], dropped:true}); + }); + test('unknown betas remain excluded for an observed native client', () => { + expect(allowlistAnthropicBetas('inline-tools-2026-09-15,unknown-fixture-2099-01-01','first-party',true)).toEqual({betas:['inline-tools-2026-09-15'],dropped:true}); + }); +}); diff --git a/tests/adapters/anthropic/anthropic-client-identity.test.ts b/tests/adapters/anthropic/anthropic-client-identity.test.ts index f5d5483eb34..63277e53eb5 100644 --- a/tests/adapters/anthropic/anthropic-client-identity.test.ts +++ b/tests/adapters/anthropic/anthropic-client-identity.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { captureAnthropicClientIdentity, type AnthropicClientIdentity } from "../../../src/adapters/anthropic/client-identity"; +import { shouldPreserveNativeClientPreamble } from "../../../src/adapters/anthropic/native-client-preamble"; import { buildAnthropicMessagesPassthroughRequest } from "../../../src/adapters/anthropic/passthrough"; import { createAnthropicAdapter } from "../../../src/adapters/anthropic"; import { claudeCodeSessionId } from "../../../src/adapters/client-fingerprint"; @@ -51,9 +52,9 @@ describe("observed Claude Code identity", () => { expect(built.get("x-stainless-os")).not.toBe("fixture-os"); expect(built.get("anthropic-beta")).not.toContain("fixture-unlisted-beta"); }); - test("UA alone, non-CLI, malformed, oversized and duplicate required values fall back", () => { + test("UA alone, unknown clients, malformed, oversized and duplicate required values fall back", () => { expect(captureAnthropicClientIdentity(new Headers({ "User-Agent": UA }))).toBeUndefined(); - for (const [name, value] of [["User-Agent", "third-party/1.0"], ["User-Agent", "claude-cli/2.1.282 (external, claude-desktop)"], + for (const [name, value] of [["User-Agent", "third-party/1.0"], ["User-Agent", "claude-cli/2.1.288 (external, arbitrary-token)"], ["User-Agent", "claude-cli/2.1.282 (external, claude desktop)"], ["User-Agent", "x".repeat(513)], ["X-App", "web"], ["X-Claude-Code-Session-Id", "fixture-not-uuid"], ["X-Stainless-Runtime", "python"]]) { expect(captureAnthropicClientIdentity(new Headers({ ...observed, [name!]: value! }))).toBeUndefined(); } @@ -102,3 +103,74 @@ describe("observed Claude Code identity", () => { expect(built.headers["X-Claude-Code-Session-Id"]).toBe(claudeCodeSessionId(provider.apiKey)); }); }); + + +describe("native Claude system preamble", () => { + const nativeIdentities = [ + "You are Claude Code, Anthropic's official CLI for Claude.", + "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.", + "You are a Claude agent, built on Anthropic's Claude Agent SDK.", + ]; + const preamble = (identity = nativeIdentities[0]!) => [ + { type: "text", text: "x-anthropic-billing-header: cc_version=2.1.288.fixture; cc_entrypoint=sdk-cli; cch=fixture-observed-value;" }, + { type: "text", text: identity }, + { type: "text", text: "fixture stable prefix", cache_control: { type: "ephemeral" } }, + ]; + test("observed native Claude preambles preserve system block order and cache markers", () => { + for (const identityText of nativeIdentities) { + const system = preamble(identityText); + const before = JSON.stringify(system); + const clientIdentity = captureAnthropicClientIdentity(new Headers(observed)); + const built = buildAnthropicMessagesPassthroughRequest(provider, "m", { ...body, system }, undefined, { clientIdentity }); + expect(JSON.parse(built.body).system).toEqual(system); + expect(JSON.stringify(system)).toBe(before); + } + }); + test("the pure helper recognizes all native variants without changing caller blocks", () => { + const handle = captureAnthropicClientIdentity(new Headers(observed)); + for (const identityText of nativeIdentities) { + const system = preamble(identityText); + const before = JSON.stringify(system); + expect(shouldPreserveNativeClientPreamble(system, handle)).toBe(true); + expect(JSON.stringify(system)).toBe(before); + } + }); + test("missing, foreign and forged identities cannot select preamble preservation", () => { + const system = preamble(); + for (const handle of [undefined, {} as AnthropicClientIdentity, + captureAnthropicClientIdentity(new Headers({ ...observed, "User-Agent": "third-party/1.0" })), + captureAnthropicClientIdentity(new Headers({ "User-Agent": UA }))]) { + expect(shouldPreserveNativeClientPreamble(system, handle)).toBe(false); + } + }); + test("unrecognized, displaced and malformed prefix blocks keep generated SDK behavior", () => { + const handle = captureAnthropicClientIdentity(new Headers(observed)); + const system = preamble(); + for (const candidate of [system.slice(1), [system[1], system[0]], preamble("fixture foreign identity"), + [{ type: "text", text: "x-anthropic-billing-header: fixture" }, system[1]], + [{ type: "text", text: system[0]!.text + "\nfixture" }, system[1]], + [{ type: "text", text: system[0]!.text + "x".repeat(4096) }, system[1]], + [{ type: "image", text: system[0]!.text }, system[1]], "fixture system"]) { + expect(shouldPreserveNativeClientPreamble(candidate, handle)).toBe(false); + } + }); + +}); + + test("native Desktop Code entrypoints preserve their observed body and required feature betas", () => { + for (const entrypoint of ["claude-desktop", "claude-desktop-3p", "local-agent"]) { + const headers = new Headers({ ...observed, "User-Agent": `claude-cli/2.1.288 (external, ${entrypoint})` }); + const clientIdentity = captureAnthropicClientIdentity(headers); + expect(clientIdentity).toBeDefined(); + const system = [ + { type:"text", text:"x-anthropic-billing-header: cc_version=2.1.288.fixture; cc_entrypoint=claude-desktop; cch=fixture;" }, + { type:"text", text:"You are Claude Code, Anthropic's official CLI for Claude." }, + { type:"text", text:"fixture static", cache_control:{type:"ephemeral", ttl:"1h"} }, + ]; + const built = buildAnthropicMessagesPassthroughRequest(provider,"m",{...body,system},undefined,{clientIdentity,callerAnthropicBeta:"inline-tools-2026-09-15,thinking-display-updates-2026-08-18"}); + expect(built.wireBody.system).toEqual(system); + expect(built.headers["anthropic-beta"]).toContain("inline-tools-2026-09-15"); + expect(built.headers["anthropic-beta"]).toContain("thinking-display-updates-2026-08-18"); + } + expect(captureAnthropicClientIdentity(new Headers({ ...observed, "User-Agent":"Mozilla/5.0" }))).toBeUndefined(); +}); diff --git a/tests/adapters/anthropic/anthropic-messages-passthrough-oauth.test.ts b/tests/adapters/anthropic/anthropic-messages-passthrough-oauth.test.ts index 663fd325295..5a56ba09a76 100644 --- a/tests/adapters/anthropic/anthropic-messages-passthrough-oauth.test.ts +++ b/tests/adapters/anthropic/anthropic-messages-passthrough-oauth.test.ts @@ -40,11 +40,14 @@ const SOURCE = { messages: [ { role: "user", content: "fixture question" }, { role: "assistant", content: [ - { type: "tool_use", id: "toolu_1", name: "lookup", input: {} }, + { type: "tool_use", id: "toolu_1", name: "lookup", input: { opaque: { type: "tool_reference", tool_name: "lookup" } } }, { type: "tool_use", id: "toolu_2", name: "bash", input: { command: "true" } }, ] }, { role: "user", content: [ - { type: "tool_result", tool_use_id: "toolu_1", content: "fixture result" }, + { type: "tool_result", tool_use_id: "toolu_1", content: [ + { type: "text", text: "fixture result", cache_control: { type: "ephemeral", ttl: "1h", scope: "turn" } }, + { type: "tool_reference", tool_name: "lookup" }, + ] }, { type: "tool_result", tool_use_id: "toolu_2", content: "" }, ] }, ], @@ -100,10 +103,15 @@ describe("buildAnthropicMessagesPassthroughRequest with OAuth", () => { expect(wire.tool_choice).toEqual({ type: "tool", name: "custom_lookup" }); const history = wire.messages[1]!.content as { name: string }[]; expect(history.map(block => block.name)).toEqual(["custom_lookup", "bash"]); + expect((history[0] as { input: unknown }).input).toEqual({ opaque: { type: "tool_reference", tool_name: "lookup" } }); + const resultContent = (wire.messages[2]!.content as { content: { type: string; tool_name?: string; cache_control?: unknown }[] }[])[0]!.content; + expect(resultContent[1]).toEqual({ type: "tool_reference", tool_name: "custom_lookup" }); + expect(resultContent[0]!.cache_control).toEqual({ type: "ephemeral", ttl: "1h", scope: "turn" }); expect([...built.oauthToolNames!]).toEqual([["custom_lookup", "lookup"]]); // The source is untouched. expect(SOURCE.tools[0]!.name).toBe("lookup"); expect(SOURCE.system).toBe("fixture system"); + expect((SOURCE.messages[2]!.content[0] as { content: { tool_name?: string }[] }).content[1]!.tool_name).toBe("lookup"); }); test("an identity block already present is not repeated", () => { @@ -119,6 +127,50 @@ describe("buildAnthropicMessagesPassthroughRequest with OAuth", () => { expect(() => anthropicOAuthWireBody(body)).toThrow("collide"); }); + test("renames inline tool additions before references and removals without mutating source or cache metadata", () => { + const body = { + tools: [{ name: "lookup", input_schema: { type: "object", properties: {} } }], + messages: [{ role: "assistant", content: [ + { type: "tool_addition", tool: { type: "tool_reference", name: "ReadNotifications" }, cache_control: { type: "ephemeral", ttl: "1h" } }, + { type: "tool_addition", tool: { type: "tool_definition", definition: { + name: "ReadNotifications", input_schema: { type: "object", properties: { q: { type: "string" } } }, + } } }, + { type: "tool_removal", tool: { type: "tool_reference", name: "ReadNotifications" } }, + { type: "tool_addition", tool: { type: "tool_reference", name: "lookup" } }, + { type: "tool_removal", tool: { type: "tool_reference", name: "lookup" } }, + { type: "text", text: "keep cache", cache_control: { type: "ephemeral", ttl: "1h", scope: "turn" } }, + ] }], + }; + const original = structuredClone(body); + const shaped = anthropicOAuthWireBody(body); + const blocks = (shaped.body.messages as { content: Record[] }[])[0]!.content; + expect((blocks[0]!.tool as Record).name).toBe("custom_ReadNotifications"); + expect(blocks[0]!.cache_control).toEqual({ type: "ephemeral", ttl: "1h" }); + expect((((blocks[1]!.tool as Record).definition as Record).name)).toBe("custom_ReadNotifications"); + expect((blocks[2]!.tool as Record).name).toBe("custom_ReadNotifications"); + expect((blocks[3]!.tool as Record).name).toBe("custom_lookup"); + expect((blocks[4]!.tool as Record).name).toBe("custom_lookup"); + expect(blocks[5]!.cache_control).toEqual({ type: "ephemeral", ttl: "1h", scope: "turn" }); + expect([...shaped.toolNames]).toEqual([ + ["custom_lookup", "lookup"], + ["custom_ReadNotifications", "ReadNotifications"], + ]); + expect(body).toEqual(original); + }); + + test("typed inline builtin definitions and their references keep their fixed names", () => { + const definition = { type: "bash_20250124", name: "bash", input_schema: { properties: { scope: { type: "string" } } } }; + const content = [{ type: "tool_addition", tool: { type: "tool_definition", definition } }, + { type: "tool_removal", tool: { type: "tool_reference", name: "bash" } }]; + const source = { ...SOURCE, tools: [], messages: [{ role: "system", content }] }; + const built = buildAnthropicMessagesPassthroughRequest(oauthProvider(), "m", source); + expect(built.wireBody.messages).toEqual(source.messages); + expect(built.oauthToolNames?.size).toBe(0); + const collision = { ...source, tools: [{ name: "bash", input_schema: {} }] }; + expect(() => buildAnthropicMessagesPassthroughRequest(oauthProvider(), "m", collision)).toThrow("inline typed and client tool names collide"); + expect(definition.name).toBe("bash"); + }); + test("a key-auth provider gets no OAuth shaping", () => { const shaped = anthropicMessagesNativeWireBody({ baseUrl: "https://api.anthropic.com", authMode: "key" }, "m", SOURCE); expect(shaped.oauthToolNames).toBeUndefined(); diff --git a/tests/claude-integration/messages-native-oauth.test.ts b/tests/claude-integration/messages-native-oauth.test.ts index 64d591f3d11..642a2c5fe36 100644 --- a/tests/claude-integration/messages-native-oauth.test.ts +++ b/tests/claude-integration/messages-native-oauth.test.ts @@ -1,10 +1,10 @@ import { rotateAnthropicAccountOn429 } from "../helpers/anthropic-shared-quota"; /** * Anthropic OAuth on the managed native Messages lane (PF-10) against an in-process transport. - * With `managedMessagesNative` and `managedMessagesNativeOAuth` on, an unpooled Anthropic OAuth + * With `managedMessagesNative` and `managedMessagesNativeOAuth` on, an Anthropic OAuth * route sends the caller's Messages body with the access token of the account the existing OAuth * selection commits at dispatch — never the caller's credential — and maps the OAuth tool-name - * prefix back in the answer. A pooled account set stays on the bridge. Every credential here is + * prefix back in the answer. Pooled accounts keep the native wire with shared recovery. Every credential here is * synthetic, and any real network call fails the case. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; @@ -13,10 +13,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION } from "../../src/oauth/anthropic"; -import { clearAnthropicAccountPoolState, forgetAnthropicFailoverQuorum, formatAnthropicProviderForLog, getAnthropicAccountHealthSnapshot,} from "../../src/oauth/anthropic-routing"; -import { getAccountSet, markAccountNeedsReauth, replaceProviderAccountSet, saveAccountCredential, saveCredential, setAccountPaused, setActiveAccount } from "../../src/oauth/store"; +import { clearAnthropicAccountPoolState, resetAnthropicRoutingForManualSelection, forgetAnthropicFailoverQuorum, formatAnthropicProviderForLog, getAnthropicAccountHealthSnapshot,} from "../../src/oauth/anthropic-routing"; +import { captureOAuthAccountSelection, getAccountSet, markAccountNeedsReauth, replaceProviderAccountSet, saveAccountCredential, saveCredential, setAccountPaused, setActiveAccount } from "../../src/oauth/store"; import { clearUpstreamHostHealth, getUpstreamHostHealth, upstreamHostHealthKey } from "../../src/codex/upstream-host-health"; import { handleClaudeMessages } from "../../src/server/claude-messages"; +import { nativeOAuthBindingIsCurrent, resolveNativeOAuthBinding } from "../../src/server/messages-native-oauth"; import { getRequestLogEntries } from "../../src/server/request-log"; import { providerRequestPacingStatus, resetProviderRequestPacingForTest, waitForProviderRequestSlot } from "../../src/providers/request-pacing"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; @@ -376,11 +377,10 @@ describe("managed native Messages over Anthropic OAuth", () => { expect(sent.map(entry => entry.headers.get("authorization"))).toEqual([`Bearer ${credential(1).access}`]); }); - test("two usable accounts keep the bridge, which owns rotation", async () => { + test("two usable accounts retain the native wire lane", async () => { await seed(2); const { row } = await send(fixtureConfig(), { ...BODY, stream: false }); - expect(row.protocolTrace).toMatchObject({ inbound: "messages", mode: "legacy-bridge" }); - expect(row.protocolTrace?.reasonCodes).toContain("oauth-account-pool"); + expect(row.protocolTrace).toMatchObject({ inbound: "messages", mode: "native" }); }); test("with the OAuth switch off the route stays on the bridge", async () => { @@ -397,3 +397,258 @@ describe("managed native Messages over Anthropic OAuth", () => { expect(sent).toHaveLength(0); }); }); + + +describe("native OAuth pooled binding", () => { + test("round-robin binds each session and restores its own account after another session", async () => { + await seed(2); + const config = fixtureConfig(); + config.anthropicAccountPool = { enabled: true, strategy: "round-robin" }; + const first = await resolveNativeOAuthBinding(config, { sessionKey: "fixture-session-a", model: "claude-sonnet-4-5" }); + const second = await resolveNativeOAuthBinding(config, { sessionKey: "fixture-session-b", model: "claude-sonnet-4-5" }); + expect(second.snapshot.accountId).not.toBe(first.snapshot.accountId); + expect(nativeOAuthBindingIsCurrent(first)).toBe(true); + const resumed = await resolveNativeOAuthBinding(config, { sessionKey: "fixture-session-a", model: "claude-sonnet-4-5" }); + expect(resumed.snapshot.accountId).toBe(first.snapshot.accountId); + expect(nativeOAuthBindingIsCurrent(resumed)).toBe(true); + }); + + test("a recovery candidate commits its credential and rebinds the session", async () => { + const ids = await seed(2); + const config = fixtureConfig(); + config.anthropicAccountPool = { enabled: true }; + const first = await resolveNativeOAuthBinding(config, { sessionKey: "fixture-session", model: "claude-sonnet-4-5" }); + const alternate = ids.find(id => id !== first.snapshot.accountId)!; + const recovered = await resolveNativeOAuthBinding(config, { sessionKey: "fixture-session", model: "claude-sonnet-4-5", candidateAccountId: alternate, expectedRecoverySelection: captureOAuthAccountSelection("anthropic") }); + expect(recovered.snapshot.accountId).toBe(alternate); + expect((await resolveNativeOAuthBinding(config, { sessionKey: "fixture-session", model: "claude-sonnet-4-5" })).snapshot.accountId).toBe(alternate); + }); +}); + + +describe("native pooled Messages dispatch", () => { + test("shared quota 429 rebuilds from the source body with the replacement bearer and account UUID", async () => { + const ids = await seed(2); + const uuids = ["11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222"]; + for (const [index, id] of ids.entries()) await saveAccountCredential("anthropic", id, { ...credential(index), accountId: uuids[index] }); + const config = fixtureConfig(); + config.anthropicAccountPool = { enabled: true }; + config.providers.anthropic!.fetch = (async (input, init) => { + sent.push({ url: String(input), headers: new Headers(init?.headers), body: JSON.parse(String(init?.body)) }); + if (sent.length === 1) return Response.json({ type: "error", error: { type: "rate_limit_error", message: "fixture quota" } }, { + status: 429, headers: { "anthropic-ratelimit-unified-5h-status": "rejected", "retry-after": "30" }, + }); + return Response.json(MESSAGE); + }) as typeof fetch; + const metadata = { user_id: JSON.stringify({ account_uuid: uuids[0], session_id: "fixture-recovery-session" }) }; + const body = { ...BODY, stream: false, metadata, system: [{ type: "text", text: "fixture cache prefix", cache_control: { type: "ephemeral" } }] }; + const { response, row } = await send(config, body); + expect(response.status).toBe(200); + expect(row.protocolTrace?.mode).toBe("native"); + expect(sent).toHaveLength(2); + expect(sent.map(entry => entry.headers.get("authorization"))).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); + for (const [index, entry] of sent.entries()) { + expect(entry.headers.has("x-api-key")).toBe(false); + expect(JSON.parse((entry.body.metadata as typeof metadata).user_id).account_uuid).toBe(uuids[index]); + expect(entry.body.messages).toEqual(body.messages.map(message => message.role !== "assistant" ? message : { ...message, content: (message.content as Record[]).map(block => block.type === "tool_use" ? { ...block, name: "custom_lookup" } : block) })); + expect((entry.body.system as Record[])[1]).toEqual(body.system[0]); + } + expect(getAnthropicAccountHealthSnapshot(ids[0]!)).not.toBeNull(); + expect(JSON.parse(metadata.user_id).account_uuid).toBe(uuids[0]); + expect(row.attempts?.[1]?.recoveryKinds).toEqual(["rate-limit-429"]); + }); + + test("account-refusal 403 rebuild is attributed as OAuth account recovery", async () => { + await seed(2); + const config = fixtureConfig(); + config.anthropicAccountPool = { enabled: true }; + config.providers.anthropic!.fetch = (async (input, init) => { + sent.push({ url: String(input), headers: new Headers(init?.headers), body: JSON.parse(String(init?.body)) }); + return sent.length === 1 + ? Response.json({ type: "error", error: { type: "permission_error", message: "Your account does not have access to Claude Code" } }, { status: 403 }) + : Response.json(MESSAGE); + }) as typeof fetch; + + const { response, row } = await send(config, { ...BODY, stream: false }); + + expect(response.status).toBe(200); + expect(sent).toHaveLength(2); + expect(row.attempts?.[1]?.recoveryKinds).toEqual(["oauth-account-403"]); + }); + + test("two conversations retain sticky account routing after global selection moves", async () => { + await seed(2); + const config = fixtureConfig(); + config.anthropicAccountPool = { enabled: true, strategy: "round-robin" }; + for (const session of ["fixture-a", "fixture-b", "fixture-a"]) { + expect((await send(config, { ...BODY, stream: false }, { session_id: session })).response.status).toBe(200); + } + const bearers = sent.map(entry => entry.headers.get("authorization")); + expect(bearers[0]).not.toBe(bearers[1]); + expect(bearers[2]).toBe(bearers[0]); + }); + + test("blank session_id retains affinity through the Claude Code session header", async () => { + await seed(2); + const config = fixtureConfig(); + config.anthropicAccountPool = { enabled: true, strategy: "round-robin" }; + for (const session of ["fallback-a", "fallback-b", "fallback-a"]) { + expect((await send(config, { ...BODY, stream: false }, { session_id: " ", "x-claude-code-session-id": session })).response.status).toBe(200); + } + const bearers = sent.map(entry => entry.headers.get("authorization")); + expect(bearers[0]).not.toBe(bearers[1]); + expect(bearers[2]).toBe(bearers[0]); + }); + + test("strict model routes admit only their declared account", async () => { + const ids = await seed(2); + const config = fixtureConfig(); + config.anthropicAccountPool = { enabled: true, routes: [{ name: "fixture-route", match: "claude-sonnet-*", accounts: [ids[1]!] }] }; + const { response, row } = await send(config, { ...BODY, stream: false }); + expect(response.status).toBe(200); + expect(row.protocolTrace?.mode).toBe("native"); + expect(sent.map(entry => entry.headers.get("authorization"))).toEqual([`Bearer ${credential(1).access}`]); + await expect(resolveNativeOAuthBinding(config, { model: "claude-sonnet-4-5", candidateAccountId: ids[0], expectedRecoverySelection: captureOAuthAccountSelection("anthropic") })).rejects.toThrow("OAuth account selection changed"); + }); + + test("an error inside a stream after assistant output never rotates or replays", async () => { + await seed(2); + const config = fixtureConfig(); + config.anthropicAccountPool = { enabled: true }; + config.providers.anthropic!.fetch = (async (input, init) => { + sent.push({ url: String(input), headers: new Headers(init?.headers), body: JSON.parse(String(init?.body)) }); + const prefix = `event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { ...MESSAGE, content: [] } })}\n\nevent: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "fixture output" } })}\n\n`; + return new Response(prefix + 'event: error\ndata: {"type":"error","error":{"type":"rate_limit_error","message":"fixture quota"}}\n\n', { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const { response, text } = await send(config, { ...BODY, stream: true }); + expect(response.status).toBe(200); + expect(text).toContain("fixture output"); + expect(text).toContain("rate_limit_error"); + expect(sent).toHaveLength(1); + }); + + test("a concurrent manual selection wins over a stale recovery candidate", async () => { + const ids = await seed(3); + const config = fixtureConfig(); + config.anthropicAccountPool = { enabled: true }; + const pending = resolveNativeOAuthBinding(config, { sessionKey: "fixture-manual-race", candidateAccountId: ids[1], expectedRecoverySelection: captureOAuthAccountSelection("anthropic") }); + await setActiveAccount("anthropic", ids[2]!); + resetAnthropicRoutingForManualSelection(ids[2]!); + const binding = await pending; + expect(binding.snapshot.accountId).toBe(ids[2]!); + expect(nativeOAuthBindingIsCurrent(binding)).toBe(true); + }); +}); + + +describe("overlapping native pool dispatch", () => { + test("queued conversations send their own affine bearer and body without global selection churn", async () => { + await seed(2); + const config = fixtureConfig(); + config.anthropicAccountPool = { enabled: true, strategy: "round-robin" }; + config.providers.anthropic!.requestPacing = { enabled: true, maxConcurrentRequests: 2 }; + const first = await resolveNativeOAuthBinding(config, { sessionKey: "fixture-overlap-a", model: "claude-sonnet-4-5" }); + const second = await resolveNativeOAuthBinding(config, { sessionKey: "fixture-overlap-b", model: "claude-sonnet-4-5" }); + const slots = await Promise.all([0, 1].map(() => waitForProviderRequestSlot("anthropic", config.providers.anthropic!, "claude-sonnet-4-5"))); + const bodyA = { ...BODY, stream: false, messages: [{ role: "user", content: "fixture conversation a" }] }; + const bodyB = { ...BODY, stream: false, messages: [{ role: "user", content: "fixture conversation b" }] }; + const pending = [send(config, bodyA, { session_id: "fixture-overlap-a" }), send(config, bodyB, { session_id: "fixture-overlap-b" })]; + let revision: string | undefined; + try { + for (let i = 0; i < 100 && providerRequestPacingStatus("anthropic", config.providers.anthropic!).queued < 2; i++) await new Promise(resolve => setTimeout(resolve, 5)); + expect(providerRequestPacingStatus("anthropic", config.providers.anthropic!).queued).toBe(2); + revision = captureOAuthAccountSelection("anthropic")?.revision; + } finally { slots.forEach(slot => slot.release()); } + const results = await Promise.all(pending); + expect(results.map(result => result.response.status)).toEqual([200, 200]); + expect(sent).toHaveLength(2); + for (const [body, binding] of [[bodyA, first], [bodyB, second]] as const) { + const entry = sent.find(entry => JSON.stringify(entry.body.messages) === JSON.stringify(body.messages)); + expect(entry).toBeDefined(); + expect(entry!.headers.get("authorization")).toBe(`Bearer ${binding.snapshot.accessToken}`); + } + expect(captureOAuthAccountSelection("anthropic")?.revision).toBe(revision); + }); +}); + + +test("manual selection before recovery resolution defeats a pre-wait candidate", async () => { + const ids = await seed(2); + const config = fixtureConfig(); + config.anthropicAccountPool = { enabled: true }; + const expectedRecoverySelection = captureOAuthAccountSelection("anthropic"); + await setActiveAccount("anthropic", ids[1]!); + resetAnthropicRoutingForManualSelection(ids[1]!); + const binding = await resolveNativeOAuthBinding(config, { + sessionKey: "fixture-before-resolve", candidateAccountId: ids[0], expectedRecoverySelection, + }); + expect(binding.snapshot.accountId).toBe(ids[1]!); + expect(nativeOAuthBindingIsCurrent(binding)).toBe(true); +}); + + +test("manual override revokes pooled affinity while unbound bindings retain the global fence", async () => { + const ids = await seed(2); + const config = fixtureConfig(); + config.anthropicAccountPool = { enabled: true }; + const bound = await resolveNativeOAuthBinding(config, { sessionKey: "fixture-manual-invalidation" }); + const unbound = await resolveNativeOAuthBinding(config); + const alternate = ids.find(id => id !== bound.snapshot.accountId)!; + await setActiveAccount("anthropic", alternate); + resetAnthropicRoutingForManualSelection(alternate); + expect(nativeOAuthBindingIsCurrent(bound)).toBe(false); + expect(nativeOAuthBindingIsCurrent(unbound)).toBe(false); + expect((await resolveNativeOAuthBinding(config, { sessionKey: "fixture-manual-invalidation" })).snapshot.accountId).toBe(alternate); +}); + +test("manual switch while native throttle waits must win", async () => { + const ids = await seed(2); + const config = fixtureConfig(); + config.anthropicAccountPool = { enabled: true }; + config.providers.anthropic!.fetch = (async (input, init) => { + sent.push({ url: String(input), headers: new Headers(init?.headers), body: JSON.parse(String(init?.body)) }); + if (sent.length === 1) { + setTimeout(async () => { + await setActiveAccount("anthropic", ids[1]!); + resetAnthropicRoutingForManualSelection(ids[1]!); + }, 20); + return Response.json({ type: "error", error: { type: "rate_limit_error", message: "fixture throttle" } }, { status: 429, headers: { "retry-after": "0.3" } }); + } + return Response.json(MESSAGE); + }) as typeof fetch; + const { response } = await send(config, { ...BODY, stream: false }, { session_id: "review-manual-race" }); + expect(response.status).toBe(200); + expect(sent).toHaveLength(2); + expect(sent[1]!.headers.get("authorization")).toBe(`Bearer ${credential(1).access}`); +}); + + +test("two bound conversations can send together", async () => { + await seed(2); + const config = fixtureConfig(); + config.anthropicAccountPool = { enabled: true, strategy: "round-robin" }; + await send(config, { ...BODY, stream: false }, { session_id: "review-concurrent-a" }); + await send(config, { ...BODY, stream: false }, { session_id: "review-concurrent-b" }); + const statuses = await Promise.all(Array.from({ length: 10 }, (_, i) => send(config, { ...BODY, stream: false }, { session_id: i % 2 ? "review-concurrent-b" : "review-concurrent-a" }))); + expect(statuses.map(s => s.response.status)).toEqual(Array(10).fill(200)); +}); + + +test("manual switch before native response headers must win", async () => { + const ids = await seed(2); + const config = fixtureConfig(); + config.anthropicAccountPool = { enabled: true }; + config.providers.anthropic!.fetch = (async (input, init) => { + sent.push({ url: String(input), headers: new Headers(init?.headers), body: JSON.parse(String(init?.body)) }); + if (sent.length === 1) { + await setActiveAccount("anthropic", ids[1]!); + resetAnthropicRoutingForManualSelection(ids[1]!); + return Response.json({ type: "error", error: { type: "rate_limit_error", message: "fixture throttle" } }, { status: 429, headers: { "retry-after": "0.1" } }); + } + return Response.json(MESSAGE); + }) as typeof fetch; + const { response } = await send(config, { ...BODY, stream: false }, { session_id: "review-headers-manual-race" }); + expect(response.status).toBe(200); + expect(sent).toHaveLength(2); + expect(sent[1]!.headers.get("authorization")).toBe(`Bearer ${credential(1).access}`); +}); diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index e439a0c0ac5..dffe17f646d 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -453,3 +453,32 @@ test("an ambiguous credentialGroups declaration is rejected on write, never orde expect(valid.ok).toBe(true); expect(valid.ok === true && valid.config.pool?.credentialGroups).toHaveLength(1); }); + + +test("malformed native pool preferences preserve providers on file load but reject candidate writes", () => { + for (const nativeMessages of ["false", null, 0]) { + const config = { + ...candidate(undefined), + providers: { xai: { ...candidate(undefined).providers.xai, apiKey: "fixture-key-preserved" } }, + anthropicAccountPool: { enabled: true, nativeMessages, stickyLimit: 3 }, + }; + writeFileSync(getConfigPath(), JSON.stringify(config), "utf8"); + const loaded = loadConfig(); + expect(loaded.providers.xai).toMatchObject({ note: "keep me", baseUrl: "https://api.x.ai/v1", apiKey: "fixture-key-preserved" }); + expect(loaded.anthropicAccountPool).toMatchObject({ enabled: true, nativeMessages: false, stickyLimit: 3 }); + expect(readConfigDiagnostics().config.providers.xai).toMatchObject({ note: "keep me" }); + const rejected = validateConfigCandidate(config); + expect(rejected.ok).toBe(false); + if (!rejected.ok) expect(rejected.error).toContain("anthropicAccountPool.nativeMessages"); + } +}); + +test("malformed native pool blocks preserve unrelated providers on file load", () => { + for (const anthropicAccountPool of [null, "false", 0]) { + const config = { ...candidate(undefined), anthropicAccountPool }; + writeFileSync(getConfigPath(), JSON.stringify(config), "utf8"); + expect(loadConfig().providers.xai).toMatchObject({ note: "keep me", baseUrl: "https://api.x.ai/v1" }); + expect(readConfigDiagnostics().config.providers.xai).toMatchObject({ note: "keep me" }); + expect(validateConfigCandidate(config).ok).toBe(false); + } +}); diff --git a/tests/config/protocol-settings.test.ts b/tests/config/protocol-settings.test.ts index d1ce74fd40c..c023da23d8c 100644 --- a/tests/config/protocol-settings.test.ts +++ b/tests/config/protocol-settings.test.ts @@ -11,6 +11,8 @@ import { resolveApiSurfaceSettings, resolveProtocolSettings, } from "../../src/protocols/settings"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { validateConfigCandidate } from "../../src/config/diagnostics"; import type { OcxConfig } from "../../src/types"; function cfg(extra: Record): OcxConfig { @@ -77,3 +79,49 @@ describe("protocol settings resolution", () => { expect(base).toMatch(/^p1-[0-9a-f]{8}$/); }); }); + + +describe("Anthropic pool native Messages preference", () => { + test("enabled pools default both native switches on without changing stored rollout", () => { + const config = cfg({ anthropicAccountPool: { enabled: true }, protocols: { rollout: { managedMessagesNative: false } } }); + expect(resolveProtocolSettings(config).rollout).toMatchObject({ managedMessagesNative: true, managedMessagesNativeOAuth: true }); + expect(config.protocols?.rollout?.managedMessagesNative).toBe(false); + expect(protocolPolicyRevision(config)).not.toBe(protocolPolicyRevision(cfg({}))); + }); + test("an enabled pool's explicit false selects legacy; true selects native", () => { + for (const nativeMessages of [false, true]) expect(resolveProtocolSettings(cfg({ anthropicAccountPool: { enabled: true, nativeMessages }, protocols: { rollout: { managedMessagesNative: true, managedMessagesNativeOAuth: true } } })).rollout).toMatchObject({ managedMessagesNative: nativeMessages, managedMessagesNativeOAuth: nativeMessages }); + }); + test("disabled and absent pools retain explicit protocol behavior", () => { + for (const pool of [undefined, { enabled: false, nativeMessages: true }, { enabled: false, nativeMessages: false }]) { + expect(resolveProtocolSettings(cfg({ anthropicAccountPool: pool })).rollout.managedMessagesNative).toBe(false); + expect(resolveProtocolSettings(cfg({ anthropicAccountPool: pool, protocols: { rollout: { managedMessagesNative: true, managedMessagesNativeOAuth: true } } })).rollout.managedMessagesNativeOAuth).toBe(true); + } + }); +}); + + +test("the config schema validates nativeMessages as a boolean while retaining pool fields", () => { + const poolSchema = configSchema.shape.anthropicAccountPool; + for (const nativeMessages of [true, false]) { + const result = poolSchema.safeParse({ enabled: true, nativeMessages, stickyLimit: 3 }); + expect(result.success).toBe(true); + if (result.success) expect(result.data).toMatchObject({ nativeMessages, stickyLimit: 3 }); + } + for (const nativeMessages of ["false", null, 0]) { + expect(poolSchema.parse({ enabled: true, nativeMessages, stickyLimit: 3 })).toMatchObject({ nativeMessages: false, stickyLimit: 3 }); + const candidate = validateConfigCandidate({ anthropicAccountPool: { nativeMessages } }); + expect(candidate.ok).toBe(false); + if (!candidate.ok) expect(candidate.error).toContain("anthropicAccountPool.nativeMessages"); + } + for (const anthropicAccountPool of [null, "false", 0]) { + expect(poolSchema.parse(anthropicAccountPool)).toBeUndefined(); + expect(validateConfigCandidate({ anthropicAccountPool }).ok).toBe(false); + } +}); + +test("policy revision records explicit native flags even when Anthropic pool defaults mask them", () => { + const config = { providers: {}, anthropicAccountPool: { enabled: true } } as OcxConfig; + const before = protocolPolicyRevision(config); + config.protocols = { rollout: { managedMessagesNative: true } }; + expect(protocolPolicyRevision(config)).not.toBe(before); +}); diff --git a/tests/responses/messages-native-eligibility.test.ts b/tests/responses/messages-native-eligibility.test.ts index 2bd3a0d6049..ba0649c1ba3 100644 --- a/tests/responses/messages-native-eligibility.test.ts +++ b/tests/responses/messages-native-eligibility.test.ts @@ -126,3 +126,19 @@ describe("planner mirrors the native Messages rule", () => { expect(combo.candidates[0]).toMatchObject({ provider: "anth", nativeEligible: false, declineReasons: ["combo-or-policy-route"] }); }); }); + +test("Anthropic pool defaults do not change native eligibility for another provider", () => { + const config = { providers: {}, anthropicAccountPool: { enabled: true } } as unknown as OcxConfig; + const other = { providerName: "compatible", modelId: "claude-fixture", provider: { adapter: "anthropic", authMode: "key", baseUrl: "https://compatible.example" } } as RouteResult; + expect(nativeMessagesDeclineReason(other, { messages: [] }, config)).toBe("rollout-disabled"); +}); + +test("Anthropic legacy preference preserves another provider's explicit native preview and count", async () => { + const conf = config(ON); + conf.anthropicAccountPool = { enabled: true, nativeMessages: false }; + const plan = previewProtocolPlan(conf, { model: "anth/claude-x", inbound: "messages", features: [] }); + expect(plan.mode).toBe("native"); + const { nativeMessagesCountBody } = await import("../../src/server/messages-native"); + const source = { model: "anth/claude-x", messages: [{ role: "user", content: "fixture" }] }; + expect(nativeMessagesCountBody(conf, undefined, source, {})).toMatchObject({ model: "claude-x", messages: source.messages }); +}); diff --git a/tests/responses/messages-native-oauth-eligibility.test.ts b/tests/responses/messages-native-oauth-eligibility.test.ts index 6c81a84900d..37a7dc77a7e 100644 --- a/tests/responses/messages-native-oauth-eligibility.test.ts +++ b/tests/responses/messages-native-oauth-eligibility.test.ts @@ -1,8 +1,8 @@ /** * Which Anthropic OAuth routes take the managed native Messages lane (PF-10): the * `managedMessagesNativeOAuth` switch and its dependency on `managedMessagesNative`, the - * `anthropic` provider on `api.anthropic.com` only, a pooled account set declining with - * `oauth-account-pool`, and a planner that judges all of it from config without touching the + * `anthropic` provider on `api.anthropic.com` only, pooled accounts admitted at dispatch, + * and a planner that judges eligibility from config without touching the * OAuth store or the network. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; @@ -91,10 +91,10 @@ describe("OAuth native Messages eligibility", () => { } }); - test("a pooled account set declines: the opt-in pool from config, the quorum from the sender", () => { + test("a pooled account set is native: selection and recovery are dispatch-owned", () => { const pooled = config({ ...BOTH, anthropicAccountPool: { enabled: true } } as Partial); - expect(nativeMessagesDeclineReason(route(), BODY, pooled)).toBe("oauth-account-pool"); - expect(nativeMessagesDeclineReason(route(), BODY, config(BOTH), { oauthFailoverQuorum: true })).toBe("oauth-account-pool"); + expect(nativeMessagesDeclineReason(route(), BODY, pooled)).toBeUndefined(); + expect(nativeMessagesDeclineReason(route(), BODY, config(BOTH), { oauthFailoverQuorum: true })).toBeUndefined(); expect(nativeMessagesDeclineReason(route(), BODY, config(BOTH), { oauthFailoverQuorum: false })).toBeUndefined(); }); }); @@ -106,9 +106,9 @@ describe("the planner judges OAuth from config alone", () => { expect(existsSync(getAuthStorePath())).toBe(false); }); - test("a configured pool previews as oauth-account-pool", () => { + test("a configured pool previews native without reading account state", () => { const pooled = config({ ...BOTH, anthropicAccountPool: { enabled: true } } as Partial); const plan = buildProtocolPlanSnapshot(pooled, { model: "anthropic/claude-o", inbound: "messages", features: [] }); - expect(plan.candidates[0]).toMatchObject({ nativeEligible: false, declineReasons: ["oauth-account-pool"] }); + expect(plan.candidates[0]).toMatchObject({ nativeEligible: true, declineReasons: [] }); }); }); diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index c1a266e9698..28f5a772e19 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleCodexAuthAPI } from "../../src/codex/auth-api"; +import { setPersistedConfigMutationBeforeCommitForTests } from "../../src/config/persisted-mutation"; import { loadConfig, saveConfig } from "../../src/config"; import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; @@ -831,7 +832,7 @@ describe("unified pool-settings contract (#695 wp5c)", () => { try { for (const [provider, kind, supported] of [ ["openai", "codex", ["strategy", "stickyLimit", "autoSwitchThreshold"]], - ["anthropic", "anthropic", ["enabled", "strategy", "stickyLimit", "autoSwitchThreshold", "quotaWindow", "routes"]], + ["anthropic", "anthropic", ["enabled", "strategy", "stickyLimit", "autoSwitchThreshold", "quotaWindow", "routes", "nativeMessages"]], ["google-antigravity", "generic", ["enabled", "strategy", "stickyLimit", "autoSwitchThreshold"]], ] as const) { const res = await fetch(new URL(`/api/pool/settings?provider=${provider}`, server.url)); @@ -841,7 +842,7 @@ describe("unified pool-settings contract (#695 wp5c)", () => { // An unsupported field is a declared null, not an absence, // which is the whole difference between a consolidation and a fourth contract. expect(Object.keys(dto).sort()).toEqual([ - "autoSwitchThreshold", "enabled", "enabledEffective", "kind", "maxConcurrentPerAccount", + "autoSwitchThreshold", "enabled", "enabledEffective", "kind", "maxConcurrentPerAccount", "nativeMessages", "provider", "quotaWindow", "routes", "stickyLimit", "strategy", "supported", ]); expect(dto.kind).toBe(kind); @@ -933,4 +934,50 @@ describe("unified pool-settings contract (#695 wp5c)", () => { await server.stop(true); } }); + test("Anthropic native preference defaults true, persists false and rejects unsupported or malformed writes", async () => { + const server = startServer(0); + try { + const initial = await (await fetch(new URL("/api/pool/settings?provider=anthropic", server.url))).json(); + expect(initial.nativeMessages).toBe(true); + for (const endpoint of ["/api/pool/settings", "/api/oauth/accounts/pool"]) { + const write = await fetch(new URL(endpoint, server.url), { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider: "anthropic", enabled: true, nativeMessages: false }) }); + expect(write.status).toBe(200); + expect((await write.json()).nativeMessages).toBe(false); + expect(loadConfig().anthropicAccountPool?.nativeMessages).toBe(false); + for (const payload of [{ provider: "anthropic", nativeMessages: "false" }, { provider: "google-antigravity", nativeMessages: true }, { provider: "openai", nativeMessages: true }]) { + const invalid = await fetch(new URL(endpoint, server.url), { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) }); + expect(invalid.status).toBe(400); + await invalid.text(); + } + } + } finally { await server.stop(true); } + }); + + test("native preference writes rebase competing pool edits and fail closed without changing the live DTO", async () => { + const server = startServer(0); + try { + const configPath = join(dir, "config.json"); + const write = async (endpoint: string, nativeMessages: boolean) => fetch(new URL(endpoint, server.url), { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider: "anthropic", nativeMessages }) }); + for (const endpoint of ["/api/pool/settings", "/api/oauth/accounts/pool"]) { + setPersistedConfigMutationBeforeCommitForTests(() => { + const current = JSON.parse(readFileSync(configPath, "utf8")); + current.anthropicAccountPool = { ...current.anthropicAccountPool, stickyLimit: 7 }; + writeFileSync(configPath, JSON.stringify(current)); + }); + const saved = await write(endpoint, false); + expect(saved.status).toBe(200); + await saved.text(); + expect(loadConfig().anthropicAccountPool).toMatchObject({ nativeMessages: false, stickyLimit: 7 }); + const validBytes = readFileSync(configPath, "utf8"); + setPersistedConfigMutationBeforeCommitForTests(() => writeFileSync(configPath, "{fixture-invalid")); + const refused = await write(endpoint, true); + expect(refused.status).toBe(409); + await refused.text(); + const live = await (await fetch(new URL("/api/pool/settings?provider=anthropic", server.url))).json(); + expect(live.nativeMessages).toBe(false); + writeFileSync(configPath, validBytes); + } + } finally { setPersistedConfigMutationBeforeCommitForTests(null); await server.stop(true); } + }); + });