fix: normalize opus 4.7 context window

Normalize Anthropic-owned Opus 4.7 context reporting to 1M while keeping inferred and bare discovery paths conservative.

- normalize Anthropic and claude-cli Opus 4.7 runtime/status context metadata to 1M
- keep inferred-provider and bare discovery ids on discovered conservative limits
- add regression coverage for provider, lookup, status, and discovery-cache paths
- keep the Telegram abort-signal wrapper typing narrow so changed-scope validation stays green
This commit is contained in:
Val Alexander
2026-04-22 14:58:16 -05:00
committed by GitHub
parent c542d42f6f
commit 9ea5484fa1
8 changed files with 293 additions and 9 deletions

View File

@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
- Gateway/pairing: treat any forwarded-header evidence (`Forwarded`, `X-Forwarded-*`, or `X-Real-IP`) as proxied WebSocket traffic before pairing locality checks, so reverse-proxy topologies cannot use the loopback shared-secret helper auto-pairing path.
- Agents/OpenAI: treat exact `NO_REPLY` assistant output as a deliberate silent reply in embedded runs, so GPT-5.4 turns with signed reasoning plus a silent final no longer surface a false incomplete-turn error.
- Auto-reply/streaming: preserve streamed reply directives through chunk boundaries and phase-aware `final_answer` delivery, so split `MEDIA:<path>` lines, voice tags, and reply targets reach channel delivery instead of leaking as text or being dropped. (#70243) Thanks @zqchris.
- Anthropic/Claude Opus 4.7: normalize Opus 4.7 and `claude-cli` Opus 4.7 variants to a 1M context window in resolved runtime metadata and active-agent status/context reporting, so they no longer inherit the stale 200k fallback. Thanks @BunsDev.
- Gateway/pairing webchat: render `/pair qr` replies as structured media instead of raw markdown text, preserve inline reply threading and silent-control handling on media replies, avoid persisting sensitive QR images into transcript history, and keep local webchat media embedding behind internal-only trust markers. (#70047) Thanks @BunsDev.
- Codex harness: default app-server runs to unchained local execution, so OpenAI heartbeats can use network and shell tools without stalling behind native Codex approvals or the workspace-write sandbox.
- Codex harness: apply the GPT-5 behavior and heartbeat prompt overlay to native Codex app-server runs, so `codex/gpt-5.x` sessions get the same follow-through, tool-use, and proactive heartbeat guidance as OpenAI GPT-5 runs.

View File

@@ -223,6 +223,8 @@ describe("anthropic provider replay hooks", () => {
id: "claude-opus-4-7",
api: "anthropic-messages",
reasoning: true,
contextWindow: 1_048_576,
contextTokens: 1_048_576,
});
expect(
provider.resolveThinkingProfile?.({
@@ -252,6 +254,37 @@ describe("anthropic provider replay hooks", () => {
).toBe(false);
});
it("normalizes exact claude opus 4.7 variants to 1M context", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
for (const [runtimeProvider, modelId] of [
["anthropic", "claude-opus-4-7"],
["claude-cli", "claude-opus-4.7-20260219"],
] as const) {
expect(
provider.normalizeResolvedModel?.({
provider: runtimeProvider,
modelId,
model: {
id: modelId,
name: "Claude Opus 4.7",
provider: runtimeProvider,
api: "anthropic-messages",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
contextTokens: 200_000,
maxTokens: 32_000,
},
} as never),
).toMatchObject({
contextWindow: 1_048_576,
contextTokens: 1_048_576,
});
}
});
it("resolves claude-cli synthetic oauth auth", async () => {
readClaudeCliCredentialsForRuntimeMock.mockReset();
readClaudeCliCredentialsForRuntimeMock.mockReturnValue({

View File

@@ -4,6 +4,7 @@ import type {
ProviderAuthContext,
ProviderAuthMethodNonInteractiveContext,
ProviderResolveDynamicModelContext,
ProviderNormalizeResolvedModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import {
@@ -44,6 +45,7 @@ const PROVIDER_ID = "anthropic";
const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-opus-4-7";
const ANTHROPIC_OPUS_47_MODEL_ID = "claude-opus-4-7";
const ANTHROPIC_OPUS_47_DOT_MODEL_ID = "claude-opus-4.7";
const ANTHROPIC_OPUS_47_CONTEXT_TOKENS = 1_048_576;
const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6";
const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6";
const ANTHROPIC_OPUS_47_TEMPLATE_MODEL_IDS = [
@@ -282,6 +284,75 @@ function supportsAnthropicAdaptiveThinking(modelId: string): boolean {
return shouldUseAnthropicAdaptiveThinkingDefault(modelId) || isAnthropicOpus47Model(modelId);
}
function hasConfiguredModelContextOverride(
config: ProviderNormalizeResolvedModelContext["config"],
provider: string,
modelId: string,
): boolean {
const providers = config?.models?.providers;
if (!providers || typeof providers !== "object") {
return false;
}
const normalizedProvider = normalizeLowercaseStringOrEmpty(provider);
const normalizedModelId = normalizeLowercaseStringOrEmpty(modelId);
for (const [providerId, providerConfig] of Object.entries(providers)) {
if (normalizeLowercaseStringOrEmpty(providerId) !== normalizedProvider) {
continue;
}
if (!Array.isArray(providerConfig?.models)) {
continue;
}
for (const model of providerConfig.models) {
if (
normalizeLowercaseStringOrEmpty(typeof model?.id === "string" ? model.id : "") !==
normalizedModelId
) {
continue;
}
if (
(typeof model?.contextTokens === "number" && model.contextTokens > 0) ||
(typeof model?.contextWindow === "number" && model.contextWindow > 0)
) {
return true;
}
}
}
return false;
}
function applyAnthropicOpus47ContextWindow(params: {
config?: ProviderNormalizeResolvedModelContext["config"];
provider: string;
modelId: string;
model: ProviderRuntimeModel;
}): ProviderRuntimeModel | undefined {
if (!isAnthropicOpus47Model(params.modelId)) {
return undefined;
}
if (hasConfiguredModelContextOverride(params.config, params.provider, params.modelId)) {
return undefined;
}
const nextContextWindow = Math.max(
params.model.contextWindow ?? 0,
ANTHROPIC_OPUS_47_CONTEXT_TOKENS,
);
const nextContextTokens =
typeof params.model.contextTokens === "number"
? Math.max(params.model.contextTokens, ANTHROPIC_OPUS_47_CONTEXT_TOKENS)
: ANTHROPIC_OPUS_47_CONTEXT_TOKENS;
if (
nextContextWindow === params.model.contextWindow &&
nextContextTokens === params.model.contextTokens
) {
return undefined;
}
return {
...params.model,
contextWindow: nextContextWindow,
contextTokens: nextContextTokens,
};
}
function matchesAnthropicModernModel(modelId: string): boolean {
const lower = normalizeLowercaseStringOrEmpty(modelId);
return ANTHROPIC_MODERN_MODEL_PREFIXES.some((prefix) => lower.startsWith(prefix));
@@ -486,7 +557,21 @@ export function buildAnthropicProvider(): ProviderPlugin {
normalizeConfig: ({ provider, providerConfig }) =>
normalizeAnthropicProviderConfigForProvider({ provider, providerConfig }),
applyConfigDefaults: ({ config, env }) => applyAnthropicConfigDefaults({ config, env }),
resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx),
resolveDynamicModel: (ctx) => {
const model = resolveAnthropicForwardCompatModel(ctx);
if (!model) {
return undefined;
}
return (
applyAnthropicOpus47ContextWindow({
config: ctx.config,
provider: ctx.provider,
modelId: ctx.modelId,
model,
}) ?? model
);
},
normalizeResolvedModel: (ctx) => applyAnthropicOpus47ContextWindow(ctx),
resolveSyntheticAuth: ({ provider }) =>
normalizeLowercaseStringOrEmpty(provider) === CLAUDE_CLI_BACKEND_ID
? resolveClaudeCliSyntheticAuth()

View File

@@ -75,6 +75,12 @@ type TelegramCompatFetch = (
input: TelegramFetchInput,
init?: TelegramFetchInit,
) => ReturnType<TelegramClientFetch>;
type TelegramAbortSignalLike = {
aborted: boolean;
reason?: unknown;
addEventListener: (type: "abort", listener: () => void, options?: { once?: boolean }) => void;
removeEventListener: (type: "abort", listener: () => void) => void;
};
function asTelegramClientFetch(
fetchImpl: TelegramCompatFetch | typeof globalThis.fetch,
@@ -86,6 +92,17 @@ function asTelegramCompatFetch(fetchImpl: TelegramClientFetch): TelegramCompatFe
return fetchImpl as unknown as TelegramCompatFetch;
}
function isTelegramAbortSignalLike(value: unknown): value is TelegramAbortSignalLike {
return (
typeof value === "object" &&
value !== null &&
"aborted" in value &&
typeof (value as { aborted?: unknown }).aborted === "boolean" &&
typeof (value as { addEventListener?: unknown }).addEventListener === "function" &&
typeof (value as { removeEventListener?: unknown }).removeEventListener === "function"
);
}
function readRequestUrl(input: TelegramFetchInput): string | null {
if (typeof input === "string") {
return input;
@@ -172,8 +189,11 @@ export function createTelegramBot(opts: TelegramBotOptions): TelegramBotInstance
// causing "signals[0] must be an instance of AbortSignal" errors).
finalFetch = (input: TelegramFetchInput, init?: TelegramFetchInit) => {
const controller = new AbortController();
const abortWith = (signal: AbortSignal) => controller.abort(signal.reason);
const shutdownSignal = opts.fetchAbortSignal;
const abortWith = (signal: Pick<TelegramAbortSignalLike, "reason">) =>
controller.abort(signal.reason);
const shutdownSignal = isTelegramAbortSignalLike(opts.fetchAbortSignal)
? opts.fetchAbortSignal
: undefined;
const onShutdown = () => {
if (shutdownSignal) {
abortWith(shutdownSignal);
@@ -183,7 +203,7 @@ export function createTelegramBot(opts: TelegramBotOptions): TelegramBotInstance
const requestTimeoutMs = resolveTelegramRequestTimeoutMs(method);
let requestTimeout: ReturnType<typeof setTimeout> | undefined;
let onRequestAbort: (() => void) | undefined;
const requestSignal = init?.signal;
const requestSignal = isTelegramAbortSignalLike(init?.signal) ? init.signal : undefined;
if (shutdownSignal?.aborted) {
abortWith(shutdownSignal);
} else if (shutdownSignal) {

View File

@@ -426,6 +426,21 @@ describe("lookupContextTokens", () => {
expect(explicitResult).toBe(2_000_000);
});
it("resolveContextTokensForModel(model-only) does not force 1M for inferred anthropic opus 4.7 ids", async () => {
mockDiscoveryDeps([{ id: "anthropic/claude-opus-4.7-20260219", contextWindow: 200_000 }]);
const { lookupContextTokens, resolveContextTokensForModel } = await importContextModule();
lookupContextTokens("anthropic/claude-opus-4.7-20260219");
await flushAsyncWarmup();
const result = resolveContextTokensForModel({
model: "anthropic/claude-opus-4.7-20260219",
fallbackContextTokens: 200_000,
});
expect(result).toBe(200_000);
});
it("resolveContextTokensForModel: qualified key beats bare min when provider is explicit (original #35976 fix)", async () => {
// Regression: when both "gemini-3.1-pro-preview" (bare, min=128k) AND
// "google-gemini-cli/gemini-3.1-pro-preview" (qualified, 1M) are in cache,

View File

@@ -60,6 +60,46 @@ describe("applyDiscoveredContextWindows", () => {
expect(cache.get("gpt-5.4")).toBe(272_000);
});
it("upgrades claude opus 4.7 variants to 1M when discovery still reports 200k", () => {
const cache = new Map<string, number>();
applyDiscoveredContextWindows({
cache,
models: [{ id: "claude-cli/claude-opus-4.7-20260219", contextWindow: 200_000 }],
});
expect(cache.get("claude-cli/claude-opus-4.7-20260219")).toBe(ANTHROPIC_CONTEXT_1M_TOKENS);
});
it("does not upgrade non-Anthropic opus 4.7 variants from discovery", () => {
const cache = new Map<string, number>();
applyDiscoveredContextWindows({
cache,
models: [{ id: "github-copilot/claude-opus-4.7", contextWindow: 128_000 }],
});
expect(cache.get("github-copilot/claude-opus-4.7")).toBe(128_000);
});
it("does not upgrade provider-qualified anthropic opus 4.7 discovery ids without verified ownership", () => {
const cache = new Map<string, number>();
applyDiscoveredContextWindows({
cache,
models: [{ id: "anthropic/claude-opus-4.7-20260219", contextWindow: 200_000 }],
});
expect(cache.get("anthropic/claude-opus-4.7-20260219")).toBe(200_000);
});
it("does not upgrade bare opus 4.7 discovery ids without verified ownership", () => {
const cache = new Map<string, number>();
applyDiscoveredContextWindows({
cache,
models: [{ id: "claude-opus-4.7", contextWindow: 128_000 }],
});
expect(cache.get("claude-opus-4.7")).toBe(128_000);
});
});
describe("applyConfiguredContextWindows", () => {
@@ -258,6 +298,38 @@ describe("resolveContextTokensForModel", () => {
expect(result).toBe(200_000);
});
it("returns 1M context for claude opus 4.7 variants without context1m", () => {
const result = resolveContextTokensForModel({
provider: "claude-cli",
model: "claude-opus-4.7-20260219",
fallbackContextTokens: 200_000,
allowAsyncLoad: false,
});
expect(result).toBe(ANTHROPIC_CONTEXT_1M_TOKENS);
});
it("does not force 1M context for non-Anthropic providers with opus 4.7 ids", () => {
const result = resolveContextTokensForModel({
provider: "github-copilot",
model: "claude-opus-4.7",
fallbackContextTokens: 128_000,
allowAsyncLoad: false,
});
expect(result).toBe(128_000);
});
it("does not force 1M context for model-only anthropic opus 4.7 ids", () => {
const result = resolveContextTokensForModel({
model: "anthropic/claude-opus-4.7-20260219",
fallbackContextTokens: 200_000,
allowAsyncLoad: false,
});
expect(result).toBe(200_000);
});
it("prefers per-model contextTokens config over contextWindow", () => {
const result = resolveContextTokensForModel({
cfg: {

View File

@@ -25,6 +25,7 @@ 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;
const CLAUDE_OPUS_47_MODEL_PREFIXES = ["claude-opus-4-7", "claude-opus-4.7"] as const;
export const ANTHROPIC_CONTEXT_1M_TOKENS = 1_048_576;
const CONFIG_LOAD_RETRY_POLICY: BackoffPolicy = {
initialMs: 1_000,
@@ -41,12 +42,15 @@ export function applyDiscoveredContextWindows(params: {
if (!model?.id) {
continue;
}
const contextTokens =
const discoveredContextTokens =
typeof model.contextTokens === "number"
? Math.trunc(model.contextTokens)
: typeof model.contextWindow === "number"
? Math.trunc(model.contextWindow)
: undefined;
const contextTokens = shouldUseDiscoveredAnthropicOpus47ContextWindow(model.id)
? ANTHROPIC_CONTEXT_1M_TOKENS
: discoveredContextTokens;
if (!contextTokens || contextTokens <= 0) {
continue;
}
@@ -374,13 +378,43 @@ function isAnthropic1MModel(provider: string, model: string): boolean {
if (provider !== "anthropic") {
return false;
}
const normalized = normalizeLowercaseStringOrEmpty(model);
const modelId = normalized.includes("/")
? (normalized.split("/").at(-1) ?? normalized)
: normalized;
const modelId = resolveModelFamilyId(model);
return ANTHROPIC_1M_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix));
}
function shouldUseAnthropicOpus47ContextWindow(params: {
provider?: string;
model: string;
}): boolean {
const provider = params.provider ? normalizeProviderId(params.provider) : "";
return (
(provider === "anthropic" || provider === "claude-cli") && isClaudeOpus47Model(params.model)
);
}
function shouldUseDiscoveredAnthropicOpus47ContextWindow(modelId: string): boolean {
if (!isClaudeOpus47Model(modelId)) {
return false;
}
const normalized = normalizeLowercaseStringOrEmpty(modelId);
const slash = normalized.indexOf("/");
if (slash < 0) {
return false;
}
const provider = normalizeProviderId(normalized.slice(0, slash));
return provider === "claude-cli";
}
function resolveModelFamilyId(modelId: string): string {
const normalized = normalizeLowercaseStringOrEmpty(modelId);
return normalized.includes("/") ? (normalized.split("/").at(-1) ?? normalized) : normalized;
}
function isClaudeOpus47Model(model: string): boolean {
const modelId = resolveModelFamilyId(model);
return CLAUDE_OPUS_47_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix));
}
export function resolveContextTokensForModel(params: {
cfg?: OpenClawConfig;
provider?: string;
@@ -422,6 +456,10 @@ export function resolveContextTokensForModel(params: {
}
}
if (explicitProvider && ref && shouldUseAnthropicOpus47ContextWindow(ref)) {
return ANTHROPIC_CONTEXT_1M_TOKENS;
}
// When provider is explicitly given and the model ID is bare (no slash),
// try the provider-qualified cache key BEFORE the bare key. Discovery
// entries are stored under qualified IDs (e.g. "google-gemini-cli/

View File

@@ -408,6 +408,26 @@ describe("buildStatusMessage", () => {
expect(normalizeTestText(text)).toContain("Context: 200k/1.0m");
});
it("shows 1M context window for claude opus 4.7 variants", () => {
const text = buildStatusMessage({
agent: {
model: "claude-cli/claude-opus-4.7-20260219",
},
sessionEntry: {
sessionId: "opus47",
updatedAt: 0,
totalTokens: 200_000,
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Context: 200k/1.0m");
expect(normalized).not.toContain("Context: 200k/200k");
});
it("recomputes context window from the active model after switching away from a smaller session override", () => {
const sessionEntry = {
sessionId: "switch-back",