mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user