From 9ea5484fa19d72c02ef363a011f60aec6e05641b Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:58:16 -0500 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + extensions/anthropic/index.test.ts | 33 +++++++++ extensions/anthropic/register.runtime.ts | 87 +++++++++++++++++++++++- extensions/telegram/src/bot.ts | 26 ++++++- src/agents/context.lookup.test.ts | 15 ++++ src/agents/context.test.ts | 72 ++++++++++++++++++++ src/agents/context.ts | 48 +++++++++++-- src/auto-reply/status.test.ts | 20 ++++++ 8 files changed, 293 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b752594221..d290b60b10f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:` 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. diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index 220c1c2365a..c5ba0622acf 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -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({ diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index 621d54e655c..3bc7042bce2 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -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() diff --git a/extensions/telegram/src/bot.ts b/extensions/telegram/src/bot.ts index 9f9e3313321..c0f10010a77 100644 --- a/extensions/telegram/src/bot.ts +++ b/extensions/telegram/src/bot.ts @@ -75,6 +75,12 @@ type TelegramCompatFetch = ( input: TelegramFetchInput, init?: TelegramFetchInit, ) => ReturnType; +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) => + 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 | 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) { diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index 09888d4bab3..1bf9341e2b7 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -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, diff --git a/src/agents/context.test.ts b/src/agents/context.test.ts index c32a96d752a..b114b6b0d19 100644 --- a/src/agents/context.test.ts +++ b/src/agents/context.test.ts @@ -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(); + 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(); + 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(); + 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(); + 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: { diff --git a/src/agents/context.ts b/src/agents/context.ts index 44b734a7ac6..3ad6f564d44 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -25,6 +25,7 @@ type ModelsConfig = { providers?: Record }; 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/ diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 4cae66bcd66..d40a0068d4c 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -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",