From f03ff397540692cec38f860d6b42b8042031b2c3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 23 Feb 2026 12:29:09 -0500 Subject: [PATCH] Providers: skip context1m beta for Anthropic OAuth tokens (#24620) * Providers: skip context1m beta for Anthropic OAuth tokens * Tests: cover OAuth context1m beta skip behavior * Docs: note context1m OAuth incompatibility * Agents: add context1m-aware context token resolver * Agents: cover context1m context-token resolver * Commands: apply context1m-aware context tokens in session store * Commands: apply context1m-aware context tokens in status summary * Status: resolve context tokens with context1m model params * Status: test context1m status context display --- CHANGELOG.md | 1 + docs/providers/anthropic.md | 4 + docs/reference/token-use.md | 4 + src/agents/context.test.ts | 51 ++++++++++- src/agents/context.ts | 84 +++++++++++++++++++ .../pi-embedded-runner-extraparams.test.ts | 4 +- src/agents/pi-embedded-runner/extra-params.ts | 16 +++- src/auto-reply/status.test.ts | 30 +++++++ src/auto-reply/status.ts | 47 ++++++++--- src/commands/agent/session-store.ts | 10 ++- src/commands/status.summary.ts | 20 +++-- 11 files changed, 248 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a358a780d4..f3aefd62e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Providers/Anthropic: skip `context-1m-*` beta injection for OAuth/subscription tokens (`sk-ant-oat-*`) while preserving OAuth-required betas, avoiding Anthropic 401 auth failures when `params.context1m` is enabled. (#10647, #20354) Thanks @ClumsyWizardHands and @dcruver. - Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc. - Agents/Reasoning: when model-default thinking is active (for example `thinking=low`), keep auto-reasoning disabled unless explicitly enabled, preventing `Reasoning:` thinking-block leakage in channel replies. (#24335, #24290) thanks @Kay-051. - Agents/Reasoning: avoid classifying provider reasoning-required errors as context overflows so these failures no longer trigger compaction-style overflow recovery. (#24593) Thanks @vincentkoc. diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index 6f9759b3b2f..b12780ff022 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -101,6 +101,10 @@ with `params.context1m: true` for supported Opus/Sonnet models. OpenClaw maps this to `anthropic-beta: context-1m-2025-08-07` on Anthropic requests. +Note: Anthropic currently rejects `context-1m-*` beta requests when using +OAuth/subscription tokens (`sk-ant-oat-*`). OpenClaw automatically skips the +context1m beta header for OAuth auth and keeps the required OAuth betas. + ## Option B: Claude setup-token **Best for:** using your Claude subscription. diff --git a/docs/reference/token-use.md b/docs/reference/token-use.md index 7f04e19650f..8e05d6ba638 100644 --- a/docs/reference/token-use.md +++ b/docs/reference/token-use.md @@ -125,6 +125,10 @@ agents: This maps to Anthropic's `context-1m-2025-08-07` beta header. +If you authenticate Anthropic with OAuth/subscription tokens (`sk-ant-oat-*`), +OpenClaw skips the `context-1m-*` beta header because Anthropic currently +rejects that combination with HTTP 401. + ## Tips for reducing token pressure - Use `/compact` to summarize long sessions. diff --git a/src/agents/context.test.ts b/src/agents/context.test.ts index 34354fc85cd..083fc5a8425 100644 --- a/src/agents/context.test.ts +++ b/src/agents/context.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { applyConfiguredContextWindows, applyDiscoveredContextWindows } from "./context.js"; +import { + ANTHROPIC_CONTEXT_1M_TOKENS, + applyConfiguredContextWindows, + applyDiscoveredContextWindows, + resolveContextTokensForModel, +} from "./context.js"; import { createSessionManagerRuntimeRegistry } from "./pi-extensions/session-manager-runtime-registry.js"; describe("applyDiscoveredContextWindows", () => { @@ -75,3 +80,47 @@ describe("createSessionManagerRuntimeRegistry", () => { expect(registry.get(123)).toBeNull(); }); }); + +describe("resolveContextTokensForModel", () => { + it("returns 1M context when anthropic context1m is enabled for opus/sonnet", () => { + const result = resolveContextTokensForModel({ + cfg: { + agents: { + defaults: { + models: { + "anthropic/claude-opus-4-6": { + params: { context1m: true }, + }, + }, + }, + }, + }, + provider: "anthropic", + model: "claude-opus-4-6", + fallbackContextTokens: 200_000, + }); + + expect(result).toBe(ANTHROPIC_CONTEXT_1M_TOKENS); + }); + + it("does not force 1M context for non-opus/sonnet Anthropic models", () => { + const result = resolveContextTokensForModel({ + cfg: { + agents: { + defaults: { + models: { + "anthropic/claude-haiku-3-5": { + params: { context1m: true }, + }, + }, + }, + }, + }, + provider: "anthropic", + model: "claude-haiku-3-5", + fallbackContextTokens: 200_000, + }); + + expect(result).toBe(200_000); + }); +}); diff --git a/src/agents/context.ts b/src/agents/context.ts index ddfeb512e48..2cb0f5296fa 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -2,6 +2,7 @@ // the agent reports a model id. This includes custom models.json entries. import { loadConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/config.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; @@ -13,6 +14,10 @@ type ModelRegistryLike = { type ConfigModelEntry = { id?: string; contextWindow?: number }; type ProviderConfigEntry = { models?: ConfigModelEntry[] }; type ModelsConfig = { providers?: Record }; +type AgentModelEntry = { params?: Record }; + +const ANTHROPIC_1M_MODEL_PREFIXES = ["claude-opus-4", "claude-sonnet-4"] as const; +export const ANTHROPIC_CONTEXT_1M_TOKENS = 1_048_576; export function applyDiscoveredContextWindows(params: { cache: Map; @@ -109,3 +114,82 @@ export function lookupContextTokens(modelId?: string): number | undefined { void loadPromise; return MODEL_CACHE.get(modelId); } + +function resolveConfiguredModelParams( + cfg: OpenClawConfig | undefined, + provider: string, + model: string, +): Record | undefined { + const models = cfg?.agents?.defaults?.models; + if (!models) { + return undefined; + } + const key = `${provider}/${model}`.trim().toLowerCase(); + for (const [rawKey, entry] of Object.entries(models)) { + if (rawKey.trim().toLowerCase() === key) { + const params = (entry as AgentModelEntry | undefined)?.params; + return params && typeof params === "object" ? params : undefined; + } + } + return undefined; +} + +function resolveProviderModelRef(params: { + provider?: string; + model?: string; +}): { provider: string; model: string } | undefined { + const modelRaw = params.model?.trim(); + if (!modelRaw) { + return undefined; + } + const providerRaw = params.provider?.trim(); + if (providerRaw) { + return { provider: providerRaw.toLowerCase(), model: modelRaw }; + } + const slash = modelRaw.indexOf("/"); + if (slash <= 0) { + return undefined; + } + const provider = modelRaw.slice(0, slash).trim().toLowerCase(); + const model = modelRaw.slice(slash + 1).trim(); + if (!provider || !model) { + return undefined; + } + return { provider, model }; +} + +function isAnthropic1MModel(provider: string, model: string): boolean { + if (provider !== "anthropic") { + return false; + } + const normalized = model.trim().toLowerCase(); + const modelId = normalized.includes("/") + ? (normalized.split("/").at(-1) ?? normalized) + : normalized; + return ANTHROPIC_1M_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix)); +} + +export function resolveContextTokensForModel(params: { + cfg?: OpenClawConfig; + provider?: string; + model?: string; + contextTokensOverride?: number; + fallbackContextTokens?: number; +}): number | undefined { + if (typeof params.contextTokensOverride === "number" && params.contextTokensOverride > 0) { + return params.contextTokensOverride; + } + + const ref = resolveProviderModelRef({ + provider: params.provider, + model: params.model, + }); + if (ref) { + const modelParams = resolveConfiguredModelParams(params.cfg, ref.provider, ref.model); + if (modelParams?.context1m === true && isAnthropic1MModel(ref.provider, ref.model)) { + return ANTHROPIC_CONTEXT_1M_TOKENS; + } + } + + return lookupContextTokens(params.model) ?? params.fallbackContextTokens; +} diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 184f1119480..433bd816d6b 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -179,7 +179,7 @@ describe("applyExtraParamsToAgent", () => { }); }); - it("preserves oauth-2025-04-20 beta when context1m is enabled with an OAuth token", () => { + it("skips context1m beta for OAuth tokens but preserves OAuth-required betas", () => { const calls: Array = []; const baseStreamFn: StreamFn = (_model, _context, options) => { calls.push(options); @@ -220,7 +220,7 @@ describe("applyExtraParamsToAgent", () => { // Must include the OAuth-required betas so they aren't stripped by pi-ai's mergeHeaders expect(betaHeader).toContain("oauth-2025-04-20"); expect(betaHeader).toContain("claude-code-20250219"); - expect(betaHeader).toContain("context-1m-2025-08-07"); + expect(betaHeader).not.toContain("context-1m-2025-08-07"); }); it("merges existing anthropic-beta headers with configured betas", () => { diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 04cd95b4d37..3f69b5d5534 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -276,13 +276,25 @@ function createAnthropicBetaHeadersWrapper( ): StreamFn { const underlying = baseStreamFn ?? streamSimple; return (model, context, options) => { + const isOauth = isAnthropicOAuthApiKey(options?.apiKey); + const requestedContext1m = betas.includes(ANTHROPIC_CONTEXT_1M_BETA); + const effectiveBetas = + isOauth && requestedContext1m + ? betas.filter((beta) => beta !== ANTHROPIC_CONTEXT_1M_BETA) + : betas; + if (isOauth && requestedContext1m) { + log.warn( + `ignoring context1m for OAuth token auth on ${model.provider}/${model.id}; Anthropic rejects context-1m beta with OAuth auth`, + ); + } + // Preserve the betas pi-ai's createClient would inject for the given token type. // Without this, our options.headers["anthropic-beta"] overwrites the pi-ai // defaultHeaders via Object.assign, stripping critical betas like oauth-2025-04-20. - const piAiBetas = isAnthropicOAuthApiKey(options?.apiKey) + const piAiBetas = isOauth ? (PI_AI_OAUTH_ANTHROPIC_BETAS as readonly string[]) : (PI_AI_DEFAULT_ANTHROPIC_BETAS as readonly string[]); - const allBetas = [...new Set([...piAiBetas, ...betas])]; + const allBetas = [...new Set([...piAiBetas, ...effectiveBetas])]; return underlying(model, context, { ...options, headers: mergeAnthropicBetaHeader(options?.headers, allBetas), diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index a8d88a18171..78d2ba29b5b 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -120,6 +120,36 @@ describe("buildStatusMessage", () => { expect(normalized).toContain("channel override"); }); + it("shows 1M context window when anthropic context1m is enabled", () => { + const text = buildStatusMessage({ + config: { + agents: { + defaults: { + model: "anthropic/claude-opus-4-6", + models: { + "anthropic/claude-opus-4-6": { + params: { context1m: true }, + }, + }, + }, + }, + } as unknown as OpenClawConfig, + agent: { + model: "anthropic/claude-opus-4-6", + }, + sessionEntry: { + sessionId: "ctx1m", + updatedAt: 0, + totalTokens: 200_000, + }, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + }); + + expect(normalizeTestText(text)).toContain("Context: 200k/1.0m"); + }); + it("uses per-agent sandbox config when config and session key are provided", () => { const text = buildStatusMessage({ config: { diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 399997ea291..1a777e01426 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { lookupContextTokens } from "../agents/context.js"; +import { resolveContextTokensForModel } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveModelAuthMode } from "../agents/model-auth.js"; import { @@ -398,12 +398,29 @@ const formatVoiceModeLine = ( export function buildStatusMessage(args: StatusArgs): string { const now = args.now ?? Date.now(); const entry = args.sessionEntry; + const selectionConfig = { + agents: { + defaults: args.agent ?? {}, + }, + } as OpenClawConfig; + const contextConfig = args.config + ? ({ + ...args.config, + agents: { + ...args.config.agents, + defaults: { + ...args.config.agents?.defaults, + ...args.agent, + }, + }, + } as OpenClawConfig) + : ({ + agents: { + defaults: args.agent ?? {}, + }, + } as OpenClawConfig); const resolved = resolveConfiguredModelRef({ - cfg: { - agents: { - defaults: args.agent ?? {}, - }, - } as OpenClawConfig, + cfg: selectionConfig, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); @@ -417,10 +434,13 @@ export function buildStatusMessage(args: StatusArgs): string { let activeProvider = modelRefs.active.provider; let activeModel = modelRefs.active.model; let contextTokens = - entry?.contextTokens ?? - args.agent?.contextTokens ?? - lookupContextTokens(activeModel) ?? - DEFAULT_CONTEXT_TOKENS; + resolveContextTokensForModel({ + cfg: contextConfig, + provider: activeProvider, + model: activeModel, + contextTokensOverride: entry?.contextTokens ?? args.agent?.contextTokens, + fallbackContextTokens: DEFAULT_CONTEXT_TOKENS, + }) ?? DEFAULT_CONTEXT_TOKENS; let inputTokens = entry?.inputTokens; let outputTokens = entry?.outputTokens; @@ -457,7 +477,12 @@ export function buildStatusMessage(args: StatusArgs): string { } } if (!contextTokens && logUsage.model) { - contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens; + contextTokens = + resolveContextTokensForModel({ + cfg: contextConfig, + model: logUsage.model, + fallbackContextTokens: contextTokens ?? undefined, + }) ?? contextTokens; } if (!inputTokens || inputTokens === 0) { inputTokens = logUsage.input; diff --git a/src/commands/agent/session-store.ts b/src/commands/agent/session-store.ts index 21845742a6c..638a1c8eade 100644 --- a/src/commands/agent/session-store.ts +++ b/src/commands/agent/session-store.ts @@ -1,5 +1,5 @@ import { setCliSessionId } from "../../agents/cli-session.js"; -import { lookupContextTokens } from "../../agents/context.js"; +import { resolveContextTokensForModel } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { isCliProvider } from "../../agents/model-selection.js"; import { deriveSessionTotalTokens, hasNonzeroUsage } from "../../agents/usage.js"; @@ -42,7 +42,13 @@ export async function updateSessionStoreAfterAgentRun(params: { const modelUsed = result.meta.agentMeta?.model ?? fallbackModel ?? defaultModel; const providerUsed = result.meta.agentMeta?.provider ?? fallbackProvider ?? defaultProvider; const contextTokens = - params.contextTokensOverride ?? lookupContextTokens(modelUsed) ?? DEFAULT_CONTEXT_TOKENS; + resolveContextTokensForModel({ + cfg, + provider: providerUsed, + model: modelUsed, + contextTokensOverride: params.contextTokensOverride, + fallbackContextTokens: DEFAULT_CONTEXT_TOKENS, + }) ?? DEFAULT_CONTEXT_TOKENS; const entry = sessionStore[sessionKey] ?? { sessionId, diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index 4573da4bb1c..f1a71ca0a13 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -1,4 +1,4 @@ -import { lookupContextTokens } from "../agents/context.js"; +import { resolveContextTokensForModel } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import { loadConfig } from "../config/config.js"; @@ -105,9 +105,13 @@ export async function getStatusSummary( }); const configModel = resolved.model ?? DEFAULT_MODEL; const configContextTokens = - cfg.agents?.defaults?.contextTokens ?? - lookupContextTokens(configModel) ?? - DEFAULT_CONTEXT_TOKENS; + resolveContextTokensForModel({ + cfg, + provider: resolved.provider ?? DEFAULT_PROVIDER, + model: configModel, + contextTokensOverride: cfg.agents?.defaults?.contextTokens, + fallbackContextTokens: DEFAULT_CONTEXT_TOKENS, + }) ?? DEFAULT_CONTEXT_TOKENS; const now = Date.now(); const storeCache = new Map>(); @@ -132,7 +136,13 @@ export async function getStatusSummary( const resolvedModel = resolveSessionModelRef(cfg, entry, opts.agentIdOverride); const model = resolvedModel.model ?? configModel ?? null; const contextTokens = - entry?.contextTokens ?? lookupContextTokens(model) ?? configContextTokens ?? null; + resolveContextTokensForModel({ + cfg, + provider: resolvedModel.provider, + model, + contextTokensOverride: entry?.contextTokens, + fallbackContextTokens: configContextTokens ?? undefined, + }) ?? null; const total = resolveFreshSessionTotalTokens(entry); const totalTokensFresh = typeof entry?.totalTokens === "number" ? entry?.totalTokensFresh !== false : false;