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
This commit is contained in:
Vincent Koc
2026-02-23 12:29:09 -05:00
committed by GitHub
parent 28377e1b7a
commit f03ff39754
11 changed files with 248 additions and 23 deletions

View File

@@ -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);
});
});

View File

@@ -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<string, ProviderConfigEntry | undefined> };
type AgentModelEntry = { params?: Record<string, unknown> };
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<string, number>;
@@ -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<string, unknown> | 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;
}

View File

@@ -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<SimpleStreamOptions | undefined> = [];
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", () => {

View File

@@ -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),