Skip to content

Commit 399f5bc

Browse files
fix: refresh model context metadata safely
Cap configured session context overrides by the selected model's known context window, refresh provider/model metadata consistently, and preserve the fixed Anthropic 1M context contract. Fixes #39857 Co-authored-by: Kros Dai <[email protected]>
1 parent 34678d8 commit 399f5bc

37 files changed

Lines changed: 2824 additions & 780 deletions

src/agents/command/session-store.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,55 @@ describe("updateSessionStoreAfterAgentRun", () => {
368368
});
369369
});
370370

371+
it("caps configured context override by the resolved runtime model window", async () => {
372+
await withTempSessionStore(async ({ storePath }) => {
373+
const cfg = {
374+
models: {
375+
providers: {
376+
openai: {
377+
models: [{ id: "gpt-5.5", contextWindow: 272_000 }],
378+
},
379+
},
380+
},
381+
} as unknown as OpenClawConfig;
382+
const sessionKey = "agent:main:explicit:test-capped-context-override";
383+
const sessionId = "test-capped-context-override-session";
384+
const sessionStore: Record<string, SessionEntry> = {
385+
[sessionKey]: {
386+
sessionId,
387+
updatedAt: 1,
388+
},
389+
};
390+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
391+
392+
const result: EmbeddedAgentRunResult = {
393+
meta: {
394+
durationMs: 1,
395+
agentMeta: {
396+
sessionId,
397+
provider: "openai",
398+
model: "gpt-5.5",
399+
},
400+
},
401+
};
402+
403+
await updateSessionStoreAfterAgentRun({
404+
cfg,
405+
contextTokensOverride: 1_000_000,
406+
sessionId,
407+
sessionKey,
408+
storePath,
409+
sessionStore,
410+
defaultProvider: "openai",
411+
defaultModel: "gpt-5.5",
412+
result,
413+
});
414+
415+
expect(sessionStore[sessionKey]?.contextTokens).toBe(272_000);
416+
expect(loadSessionStore(storePath)[sessionKey]?.contextTokens).toBe(272_000);
417+
});
418+
});
419+
371420
it("clears the embedded harness pin after a CLI run", async () => {
372421
await withTempSessionStore(async ({ storePath }) => {
373422
const cfg = {

src/agents/command/session-store.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -112,15 +112,14 @@ export async function updateSessionStoreAfterAgentRun(params: {
112112
const contextTokens =
113113
runtimeContextTokens !== undefined
114114
? runtimeContextTokens
115-
: typeof params.contextTokensOverride === "number" && params.contextTokensOverride > 0
116-
? params.contextTokensOverride
117-
: ((await getContextModule()).resolveContextTokensForModel({
118-
cfg,
119-
provider: providerUsed,
120-
model: modelUsed,
121-
fallbackContextTokens: DEFAULT_CONTEXT_TOKENS,
122-
allowAsyncLoad: false,
123-
}) ?? DEFAULT_CONTEXT_TOKENS);
115+
: ((await getContextModule()).resolveContextTokensForModel({
116+
cfg,
117+
provider: providerUsed,
118+
model: modelUsed,
119+
contextTokensOverride: params.contextTokensOverride,
120+
fallbackContextTokens: DEFAULT_CONTEXT_TOKENS,
121+
allowAsyncLoad: false,
122+
}) ?? DEFAULT_CONTEXT_TOKENS);
124123

125124
const preserveUserFacingRunState = params.preserveUserFacingSessionModelState === true;
126125
const preserveRuntimeModel = params.preserveRuntimeModel === true || preserveUserFacingRunState;

src/agents/context-cache.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,41 @@
11
/** Process-local model context window cache keyed by model id. */
22
export const MODEL_CONTEXT_TOKEN_CACHE = new Map<string, number>();
3+
export const MODEL_CONFIGURED_CONTEXT_TOKEN_CACHE = new Map<string, number>();
4+
export const MODEL_CONTEXT_WINDOW_CACHE = new Map<string, number>();
5+
6+
const PROVIDER_CONTEXT_TOKEN_CACHE_PREFIX = "\0provider:";
7+
8+
/** Internal cache key for discovery metadata with verified provider ownership. */
9+
export function providerContextTokenCacheKey(provider: string, modelId: string): string {
10+
return `${PROVIDER_CONTEXT_TOKEN_CACHE_PREFIX}${provider}\0${modelId}`;
11+
}
312

413
/** Looks up cached context-token count for a model id. */
514
export function lookupCachedContextTokens(modelId?: string): number | undefined {
615
if (!modelId) {
716
return undefined;
817
}
9-
return MODEL_CONTEXT_TOKEN_CACHE.get(modelId);
18+
return (
19+
MODEL_CONFIGURED_CONTEXT_TOKEN_CACHE.get(modelId) ?? MODEL_CONTEXT_TOKEN_CACHE.get(modelId)
20+
);
21+
}
22+
23+
/** Looks up a configured native context window without treating it as an effective runtime cap. */
24+
export function lookupCachedContextWindow(modelId?: string): number | undefined {
25+
if (!modelId) {
26+
return undefined;
27+
}
28+
return MODEL_CONTEXT_WINDOW_CACHE.get(modelId);
29+
}
30+
31+
/** Returns the lowest positive context limit from independently sourced metadata. */
32+
export function minPositiveContextTokens(...values: Array<number | undefined>): number | undefined {
33+
let result: number | undefined;
34+
for (const value of values) {
35+
if (typeof value !== "number" || value <= 0) {
36+
continue;
37+
}
38+
result = result === undefined ? value : Math.min(result, value);
39+
}
40+
return result;
1041
}

src/agents/context-resolution.ts

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
2+
import type { OpenClawConfig } from "../config/types.openclaw.js";
3+
import {
4+
lookupCachedContextTokens,
5+
lookupCachedContextWindow,
6+
minPositiveContextTokens,
7+
providerContextTokenCacheKey,
8+
} from "./context-cache.js";
9+
import { normalizeProviderId } from "./model-selection.js";
10+
11+
type ConfigModelEntry = { id?: string; contextWindow?: number; contextTokens?: number };
12+
type ProviderConfigEntry = {
13+
contextWindow?: number;
14+
contextTokens?: number;
15+
models?: ConfigModelEntry[];
16+
};
17+
export type ModelsConfig = {
18+
providers?: Record<string, ProviderConfigEntry | undefined>;
19+
};
20+
21+
export type ContextTokenResolutionParams = {
22+
cfg?: OpenClawConfig;
23+
sourceCfg?: OpenClawConfig | null;
24+
provider?: string;
25+
model?: string;
26+
contextTokensOverride?: number;
27+
fallbackContextTokens?: number;
28+
modelContextWindow?: number;
29+
modelContextTokens?: number;
30+
allowAsyncLoad?: boolean;
31+
};
32+
33+
const ANTHROPIC_GA_1M_MODEL_PREFIXES = [
34+
"claude-opus-4-8",
35+
"claude-opus-4.8",
36+
"claude-opus-4-6",
37+
"claude-opus-4.6",
38+
"claude-opus-4-7",
39+
"claude-opus-4.7",
40+
"claude-sonnet-4-6",
41+
"claude-sonnet-4.6",
42+
] as const;
43+
export const ANTHROPIC_CONTEXT_1M_TOKENS = 1_048_576;
44+
export const ANTHROPIC_VERTEX_CONTEXT_1M_TOKENS = 1_000_000;
45+
export const ANTHROPIC_FABLE_CONTEXT_TOKENS = 1_000_000;
46+
47+
type ConfiguredContextTokens = {
48+
value: number;
49+
source: "contextTokens" | "contextWindow";
50+
};
51+
52+
function resolveProviderModelRef(params: {
53+
provider?: string;
54+
model?: string;
55+
}): { provider: string; model: string } | undefined {
56+
const modelRaw = params.model?.trim();
57+
if (!modelRaw) {
58+
return undefined;
59+
}
60+
const providerRaw = params.provider?.trim();
61+
if (providerRaw) {
62+
const provider = normalizeProviderId(providerRaw);
63+
return provider ? { provider, model: modelRaw } : undefined;
64+
}
65+
const slash = modelRaw.indexOf("/");
66+
if (slash <= 0) {
67+
return undefined;
68+
}
69+
const provider = normalizeProviderId(modelRaw.slice(0, slash));
70+
const model = modelRaw.slice(slash + 1).trim();
71+
return provider && model ? { provider, model } : undefined;
72+
}
73+
74+
function resolveConfiguredProviderContextTokens(
75+
cfg: OpenClawConfig | null | undefined,
76+
provider: string,
77+
model: string,
78+
): ConfiguredContextTokens | undefined {
79+
const providers = (cfg?.models as ModelsConfig | undefined)?.providers;
80+
if (!providers) {
81+
return undefined;
82+
}
83+
84+
function readProviderContextTokens(
85+
providerConfig: ProviderConfigEntry | undefined,
86+
): ConfiguredContextTokens | undefined {
87+
if (typeof providerConfig?.contextTokens === "number" && providerConfig.contextTokens > 0) {
88+
return { value: providerConfig.contextTokens, source: "contextTokens" };
89+
}
90+
if (typeof providerConfig?.contextWindow === "number" && providerConfig.contextWindow > 0) {
91+
return { value: providerConfig.contextWindow, source: "contextWindow" };
92+
}
93+
return undefined;
94+
}
95+
96+
function findContextTokens(
97+
matchProviderId: (id: string) => boolean,
98+
): ConfiguredContextTokens | undefined {
99+
for (const [providerId, providerConfig] of Object.entries(providers!)) {
100+
if (!matchProviderId(providerId)) {
101+
continue;
102+
}
103+
if (Array.isArray(providerConfig?.models)) {
104+
for (const entry of providerConfig.models) {
105+
const entryId = typeof entry?.id === "string" ? entry.id : "";
106+
const slash = entryId.indexOf("/");
107+
const prefixedProvider = slash > 0 ? normalizeProviderId(entryId.slice(0, slash)) : "";
108+
const bareEntryId = slash > 0 ? entryId.slice(slash + 1).trim() : "";
109+
const modelMatches =
110+
entryId === model ||
111+
(prefixedProvider === normalizeProviderId(providerId) && bareEntryId === model);
112+
if (modelMatches && typeof entry.contextTokens === "number" && entry.contextTokens > 0) {
113+
return { value: entry.contextTokens, source: "contextTokens" };
114+
}
115+
if (modelMatches && typeof entry.contextWindow === "number" && entry.contextWindow > 0) {
116+
return { value: entry.contextWindow, source: "contextWindow" };
117+
}
118+
}
119+
}
120+
const providerContextTokens = readProviderContextTokens(providerConfig);
121+
if (providerContextTokens) {
122+
return providerContextTokens;
123+
}
124+
}
125+
return undefined;
126+
}
127+
128+
// Match exact config keys before normalized aliases so one provider cannot
129+
// inherit another provider's context cap based on object iteration order.
130+
const exactResult = findContextTokens(
131+
(id) => normalizeLowercaseStringOrEmpty(id) === normalizeLowercaseStringOrEmpty(provider),
132+
);
133+
if (exactResult !== undefined) {
134+
return exactResult;
135+
}
136+
const normalizedProvider = normalizeProviderId(provider);
137+
return findContextTokens((id) => normalizeProviderId(id) === normalizedProvider);
138+
}
139+
140+
function resolveModelFamilyId(modelId: string): string {
141+
const normalized = normalizeLowercaseStringOrEmpty(modelId);
142+
return normalized.includes("/") ? (normalized.split("/").at(-1) ?? normalized) : normalized;
143+
}
144+
145+
export function resolveAnthropicFixedContextWindow(
146+
provider: string,
147+
model: string,
148+
): number | undefined {
149+
const modelId = resolveModelFamilyId(model);
150+
if (
151+
(provider === "anthropic" || provider === "anthropic-vertex") &&
152+
modelId.startsWith("claude-fable-5")
153+
) {
154+
return ANTHROPIC_FABLE_CONTEXT_TOKENS;
155+
}
156+
if (provider !== "anthropic" && provider !== "anthropic-vertex" && provider !== "claude-cli") {
157+
return undefined;
158+
}
159+
if (!ANTHROPIC_GA_1M_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix))) {
160+
return undefined;
161+
}
162+
return provider === "anthropic-vertex"
163+
? ANTHROPIC_VERTEX_CONTEXT_1M_TOKENS
164+
: ANTHROPIC_CONTEXT_1M_TOKENS;
165+
}
166+
167+
export function resolveContextTokensForModelFromCache(
168+
params: ContextTokenResolutionParams,
169+
lookupContextTokens: (modelId?: string) => number | undefined = lookupCachedContextTokens,
170+
lookupContextWindow: (modelId?: string) => number | undefined = lookupCachedContextWindow,
171+
): number | undefined {
172+
const ref = resolveProviderModelRef(params);
173+
const override =
174+
typeof params.contextTokensOverride === "number" && params.contextTokensOverride > 0
175+
? params.contextTokensOverride
176+
: undefined;
177+
const capOverride = (contextTokens: number) =>
178+
override !== undefined ? Math.min(override, contextTokens) : contextTokens;
179+
const explicitProvider = params.provider?.trim();
180+
181+
if (ref && explicitProvider) {
182+
const configuredWindow = resolveConfiguredProviderContextTokens(
183+
params.cfg,
184+
explicitProvider,
185+
ref.model,
186+
);
187+
const sourceConfig = params.sourceCfg === undefined ? params.cfg : params.sourceCfg;
188+
const sourceConfiguredWindow = resolveConfiguredProviderContextTokens(
189+
sourceConfig,
190+
explicitProvider,
191+
ref.model,
192+
);
193+
const fixedContextWindow = resolveAnthropicFixedContextWindow(ref.provider, ref.model);
194+
const providerResult = lookupContextTokens(
195+
providerContextTokenCacheKey(normalizeProviderId(ref.provider), ref.model),
196+
);
197+
const providerWindow = lookupContextWindow(
198+
providerContextTokenCacheKey(normalizeProviderId(ref.provider), ref.model),
199+
);
200+
const modelContextTokens =
201+
typeof params.modelContextTokens === "number" && params.modelContextTokens > 0
202+
? params.modelContextTokens
203+
: undefined;
204+
const modelContextWindow =
205+
typeof params.modelContextWindow === "number" && params.modelContextWindow > 0
206+
? params.modelContextWindow
207+
: undefined;
208+
const runtimeCap = minPositiveContextTokens(
209+
providerResult,
210+
modelContextTokens,
211+
fixedContextWindow === undefined ? providerWindow : undefined,
212+
fixedContextWindow === undefined ? modelContextWindow : undefined,
213+
);
214+
if (configuredWindow) {
215+
if (configuredWindow.source === "contextTokens") {
216+
const effectiveCap =
217+
fixedContextWindow === undefined
218+
? configuredWindow.value
219+
: Math.min(configuredWindow.value, fixedContextWindow);
220+
return capOverride(effectiveCap);
221+
}
222+
const authoredContextWindow =
223+
sourceConfiguredWindow?.source === "contextWindow"
224+
? sourceConfiguredWindow.value
225+
: undefined;
226+
// Runtime config fills omitted contextWindow values with 200k. Only an
227+
// authored window may lower a fixed provider contract; contextTokens is
228+
// always an explicit effective-cap override above.
229+
if (fixedContextWindow !== undefined && authoredContextWindow === undefined) {
230+
const effectiveCap =
231+
runtimeCap === undefined ? fixedContextWindow : Math.min(runtimeCap, fixedContextWindow);
232+
return capOverride(effectiveCap);
233+
}
234+
if (fixedContextWindow !== undefined) {
235+
const effectiveCap = minPositiveContextTokens(
236+
authoredContextWindow,
237+
fixedContextWindow,
238+
runtimeCap,
239+
);
240+
return effectiveCap === undefined ? undefined : capOverride(effectiveCap);
241+
}
242+
if (runtimeCap !== undefined) {
243+
return capOverride(Math.min(configuredWindow.value, runtimeCap));
244+
}
245+
return capOverride(configuredWindow.value);
246+
}
247+
if (runtimeCap !== undefined) {
248+
const effectiveCap =
249+
fixedContextWindow === undefined ? runtimeCap : Math.min(runtimeCap, fixedContextWindow);
250+
return capOverride(effectiveCap);
251+
}
252+
if (fixedContextWindow !== undefined) {
253+
return capOverride(fixedContextWindow);
254+
}
255+
}
256+
257+
// Model-only calls use the raw discovery key. With an explicit provider,
258+
// slash-containing raw keys lack ownership provenance and cannot lower an override.
259+
const bareResult = lookupContextTokens(params.model);
260+
const bareWindow = lookupContextWindow(params.model);
261+
const bareCap = minPositiveContextTokens(bareResult, bareWindow);
262+
if (bareCap !== undefined) {
263+
const ambiguousSlashId = Boolean(explicitProvider && ref?.model.includes("/"));
264+
return ambiguousSlashId && override !== undefined ? override : capOverride(bareCap);
265+
}
266+
267+
return override ?? params.fallbackContextTokens;
268+
}

0 commit comments

Comments
 (0)