diff --git a/electron/shared/providers/model-capabilities.ts b/electron/shared/providers/model-capabilities.ts index 32fac33b..615255be 100644 --- a/electron/shared/providers/model-capabilities.ts +++ b/electron/shared/providers/model-capabilities.ts @@ -1,50 +1,190 @@ export type ModelInputModality = 'text' | 'image'; +type ContextWindowRule = { + /** Human-readable family label; kept so the table reads as documentation. */ + label: string; + pattern: RegExp; + contextWindow: number; +}; + /** - * Conservative context-window defaults for well-known model families, applied - * to custom-provider model rows that would otherwise carry no `contextWindow`. + * Context-window defaults for well-known model families, applied to model rows + * that would otherwise carry no `contextWindow`. * * Why this matters: when a model row has neither `contextTokens` nor * `contextWindow`, OpenClaw's embedded runner skips preemptive compaction and * context-window guarding entirely, so long sessions only fail at the provider * with "Context overflow: prompt too large" instead of being compacted early. + * + * Accuracy matters in both directions. Under-reporting is not the safe choice: + * it makes the runner start preflight compaction long before it is needed, and + * a compaction that times out aborts the whole turn. Over-reporting pushes the + * failure to the provider as a hard overflow. Prefer the vendor's published + * figure for the family rather than a defensive guess. + * + * Ordering contract: rules are evaluated top-down and the first match wins, so + * a specific variant MUST appear above its family fallback. Note that `\b` + * treats `.` and `-` as boundaries, so /\bgpt-5\b/ also matches `gpt-5.6-sol`; + * the generation-specific rules above it are what keep that correct. */ -const CUSTOM_MODEL_CONTEXT_WINDOW_RULES: Array<{ pattern: RegExp; contextWindow: number }> = [ - { pattern: /\bgpt-5/, contextWindow: 272_000 }, - { pattern: /\b(?:gpt-4\.1|gpt-4o|o[134])\b/, contextWindow: 128_000 }, - { pattern: /\bclaude\b|\bclaude-/, contextWindow: 200_000 }, - { pattern: /\bgemini\b/, contextWindow: 1_048_576 }, - { pattern: /\bkimi\b|moonshot/, contextWindow: 256_000 }, - { pattern: /minimax/, contextWindow: 204_800 }, - { pattern: /\bglm-5(?:\.|\b)/, contextWindow: 1_000_000 }, - { pattern: /\bglm-4/, contextWindow: 200_000 }, +const CONTEXT_WINDOW_RULES: ContextWindowRule[] = [ + // ── OpenAI ────────────────────────────────────────────────────────────── + { label: 'GPT-5.6 Luna (low-latency tier)', pattern: /\bgpt-5\.6-luna\b/, contextWindow: 272_000 }, + { label: 'GPT-5.6 Sol / Terra', pattern: /\bgpt-5\.6\b/, contextWindow: 1_050_000 }, + { label: 'GPT-5.5', pattern: /\bgpt-5\.5\b/, contextWindow: 1_000_000 }, + { label: 'GPT-5 lightweight variants', pattern: /\bgpt-5[\w.]*-(?:mini|nano|turbo)\b/, contextWindow: 272_000 }, + { label: 'GPT-5 flagship', pattern: /\bgpt-5\b/, contextWindow: 400_000 }, + { label: 'GPT-4.x and o-series', pattern: /\b(?:gpt-4\.1|gpt-4o|o[134])\b/, contextWindow: 128_000 }, + + // ── Anthropic ─────────────────────────────────────────────────────────── + { label: 'Claude Fable 5 / Opus 5 / Sonnet 5', pattern: /\bclaude-(?:fable|opus|sonnet)-5\b/, contextWindow: 1_000_000 }, + { label: 'Claude Opus 4.8+', pattern: /\bclaude-opus-4[.-][89]\b/, contextWindow: 1_000_000 }, + { label: 'Claude Sonnet 4.6+', pattern: /\bclaude-sonnet-4[.-][6-9]\b/, contextWindow: 1_000_000 }, + { label: 'Claude Haiku and legacy Claude', pattern: /\bclaude\b|\bclaude-/, contextWindow: 200_000 }, + + // ── Google ────────────────────────────────────────────────────────────── + { label: 'Gemini 1.0 (pre-million era)', pattern: /\bgemini-1\.0\b/, contextWindow: 32_768 }, + { label: 'Gemini 1.5 and newer', pattern: /\bgemini\b/, contextWindow: 1_048_576 }, + + // ── DeepSeek ──────────────────────────────────────────────────────────── + // `deepseek-chat` / `deepseek-reasoner` are compatibility aliases that route + // to V4-Flash, so they inherit the V4 window rather than the V3 one. + { label: 'DeepSeek V3 / R1', pattern: /\bdeepseek-(?:v3|r1)\b/, contextWindow: 128_000 }, + { label: 'DeepSeek V4 and aliases', pattern: /\bdeepseek\b/, contextWindow: 1_000_000 }, + + // ── Moonshot / Kimi ───────────────────────────────────────────────────── + // Only K3 reached a million tokens; K2.x tops out at 262,144. + { label: 'Kimi K3', pattern: /\bkimi-k3\b/, contextWindow: 1_000_000 }, + { label: 'Kimi K2.x and other Moonshot', pattern: /\bkimi\b|moonshot/, contextWindow: 262_144 }, + + // ── Alibaba Qwen ──────────────────────────────────────────────────────── + { label: 'Qwen-Long (bulk document tier)', pattern: /\bqwen-long\b/, contextWindow: 10_000_000 }, + { label: 'Qwen 3.6+ hosted API', pattern: /\bqwen-?3\.[6-9]\b/, contextWindow: 1_000_000 }, + { label: 'Qwen 3.5 / Qwen3-Next', pattern: /\bqwen-?3\.5\b|\bqwen3-next\b/, contextWindow: 262_144 }, + { label: 'Qwen open-weight base', pattern: /\bqwen/, contextWindow: 131_072 }, + + // ── Z.AI GLM ──────────────────────────────────────────────────────────── + { label: 'GLM-5.2+', pattern: /\bglm-5\.[2-9]\b/, contextWindow: 1_000_000 }, + { label: 'GLM-5.0 / 5.1', pattern: /\bglm-5(?:\.[01])?\b/, contextWindow: 200_000 }, + { label: 'GLM-4.x', pattern: /\bglm-4/, contextWindow: 200_000 }, + + // ── MiniMax ───────────────────────────────────────────────────────────── + { label: 'MiniMax M3+', pattern: /\bminimax-m[3-9]\b/, contextWindow: 524_288 }, + { label: 'MiniMax M2.x and earlier', pattern: /minimax/, contextWindow: 204_800 }, ]; -/** Safe floor for unknown custom models: high enough to avoid compaction spam. */ -export const DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW = 131_072; +/** + * Fallback for hosted models we do not recognise. Set at the low end of the + * current frontier rather than at the old 128K floor: nearly every model a + * user can point a hosted provider at at this point clears 200K, and guessing + * too low triggers needless compaction on long sessions. + */ +export const DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW = 200_000; -export function inferCustomModelContextWindow(modelId: string): number { - const normalized = modelId.trim().toLowerCase(); - for (const rule of CUSTOM_MODEL_CONTEXT_WINDOW_RULES) { - if (rule.pattern.test(normalized)) return rule.contextWindow; - } - return DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW; +/** + * Ceiling for locally hosted runtimes (Ollama and friends). A local `qwen3` + * tag is a quantised small model, not the hosted flagship of the same name, so + * family rules must not hand it a frontier-sized window. Kept at 128K because + * ClawX seeds `compaction.reserveTokensFloor = 50000` — dropping the ceiling + * near or below that floor leaves the runner no usable budget. + */ +export const LOCAL_MODEL_CONTEXT_WINDOW = 131_072; + +/** + * Ceiling for ChatGPT subscription transports (`openai-chatgpt-responses`). + * + * OAuth against a ChatGPT plan does not get the API-tier window: the backend + * enforces a far smaller per-session budget than `gpt-5.6-sol`'s published + * 1.05M. OpenClaw's own Codex catalog hard-codes 272,000 for every model on + * this transport, so we mirror that figure rather than inventing our own. + * + * This matters because ClawX writes OAuth rows into `models.providers.openai` + * while OpenClaw's cap lives on its separate `codex` provider — nothing else + * would clamp the value we write. + */ +export const CHATGPT_OAUTH_CONTEXT_WINDOW = 272_000; + +/** Runtime provider keys are suffixed per instance, e.g. `ollama-a1b2c3`. */ +const LOCAL_PROVIDER_KEY_PATTERN = /^ollama(?:-|$)/; + +/** Current and legacy spellings of the ChatGPT subscription transport. */ +const SUBSCRIPTION_API_PROTOCOLS = new Set([ + 'openai-chatgpt-responses', + 'openai-codex-responses', +]); + +export type ModelCapabilityContext = { + /** OpenClaw runtime provider key, used to detect locally hosted models. */ + providerKey?: string; + /** `models.providers.*.api` value, used to detect subscription transports. */ + apiProtocol?: string; +}; + +/** + * Model ids reach us in several shapes: bare (`gpt-5.6-sol`), vendor-prefixed + * from aggregators (`openai/gpt-5.6-sol`, `deepseek-ai/DeepSeek-V3`), and + * Ollama-tagged (`qwen3:latest`). Patterns are written against the bare family + * name, so expose both forms and let callers test each. + */ +function normalizeModelId(modelId: string): { bare: string; full: string } { + const full = modelId.trim().toLowerCase(); + const withoutVendor = full.slice(full.lastIndexOf('/') + 1); + const [bare] = withoutVendor.split(':'); + return { bare: bare || full, full }; } +function matchesModelId(pattern: RegExp, modelId: string): boolean { + const { bare, full } = normalizeModelId(modelId); + return pattern.test(bare) || pattern.test(full); +} + +function isLocalProviderKey(providerKey: string | undefined): boolean { + return providerKey != null && LOCAL_PROVIDER_KEY_PATTERN.test(providerKey.trim().toLowerCase()); +} + +function isSubscriptionApiProtocol(apiProtocol: string | undefined): boolean { + return apiProtocol != null && SUBSCRIPTION_API_PROTOCOLS.has(apiProtocol.trim().toLowerCase()); +} + +/** + * Family rules describe what the vendor's API tier offers. The transport a + * given account actually uses can be far more restrictive, so clamp rather + * than trusting the published figure. + */ +function resolveContextWindowCeiling(context: ModelCapabilityContext): number { + const ceilings: number[] = []; + if (isLocalProviderKey(context.providerKey)) ceilings.push(LOCAL_MODEL_CONTEXT_WINDOW); + if (isSubscriptionApiProtocol(context.apiProtocol)) ceilings.push(CHATGPT_OAUTH_CONTEXT_WINDOW); + return ceilings.length > 0 ? Math.min(...ceilings) : Number.POSITIVE_INFINITY; +} + +export function inferCustomModelContextWindow( + modelId: string, + context: ModelCapabilityContext = {}, +): number { + const ceiling = resolveContextWindowCeiling(context); + + for (const rule of CONTEXT_WINDOW_RULES) { + if (matchesModelId(rule.pattern, modelId)) return Math.min(rule.contextWindow, ceiling); + } + + return Math.min(DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW, ceiling); +} + +const VISION_MODEL_PATTERNS: RegExp[] = [ + /\b(?:gpt-4o|gpt-4\.1|gpt-[5-9]|o[134])\b/, + /\bclaude-(?:3|4|fable|sonnet|opus|haiku)\b/, + /\bgemini\b/, + /\b(?:qwen[\w.-]*-?vl|qwen-vl)\b/, + /\b(?:vision|llava|pixtral|internvl|mllama|minicpm-v|glm-4v)\b/, + /(?:^|[-_/])vl(?:[-_/]|$)/, +]; + /** * Mirrors OpenClaw 2026.5.20 custom-provider onboarding inference. * Unknown models use the same conservative text-only fallback as non-interactive onboarding. */ export function inferCustomModelInputModalities(modelId: string): ModelInputModality[] { - const normalized = modelId.trim().toLowerCase(); - const supportsImageInput = ( - /\b(?:gpt-4o|gpt-4\.1|gpt-[5-9]|o[134])\b/.test(normalized) - || /\bclaude-(?:3|4|sonnet|opus|haiku)\b/.test(normalized) - || /\bgemini\b/.test(normalized) - || /\b(?:qwen[\w.-]*-?vl|qwen-vl)\b/.test(normalized) - || /\b(?:vision|llava|pixtral|internvl|mllama|minicpm-v|glm-4v)\b/.test(normalized) - || /(?:^|[-_/])vl(?:[-_/]|$)/.test(normalized) - ); - + const supportsImageInput = VISION_MODEL_PATTERNS.some((pattern) => matchesModelId(pattern, modelId)); return supportsImageInput ? ['text', 'image'] : ['text']; } diff --git a/electron/shared/providers/registry.ts b/electron/shared/providers/registry.ts index 90cae87e..16a6f523 100644 --- a/electron/shared/providers/registry.ts +++ b/electron/shared/providers/registry.ts @@ -138,7 +138,7 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 256000, + contextWindow: 262144, maxTokens: 8192, }, ], @@ -171,7 +171,7 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 256000, + contextWindow: 262144, maxTokens: 8192, }, ], diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 8e8c428c..c4260a98 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -926,7 +926,10 @@ function backfillCustomProviderModelContextWindows(config: Record { expect(models).toEqual([ expect.objectContaining({ id: 'gpt-5.5', - contextWindow: 272000, + contextWindow: 1000000, }), ]); }); @@ -2497,7 +2497,7 @@ describe('batchSyncConfigFields', () => { const custom = (providers['custom-enterpri'] as Record).models as Array>; const moonshot = (providers.moonshot as Record).models as Array>; - expect(custom[0]).toEqual(expect.objectContaining({ id: 'gpt-5.5', contextWindow: 272000 })); + expect(custom[0]).toEqual(expect.objectContaining({ id: 'gpt-5.5', contextWindow: 1000000 })); // Rows with explicit contextTokens are user-owned — leave untouched. expect(custom[1].contextWindow).toBeUndefined(); expect(custom[1].contextTokens).toBe(32000); diff --git a/tests/unit/provider-model-capabilities.test.ts b/tests/unit/provider-model-capabilities.test.ts index 4cb08111..9ebefc4a 100644 --- a/tests/unit/provider-model-capabilities.test.ts +++ b/tests/unit/provider-model-capabilities.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import { + CHATGPT_OAUTH_CONTEXT_WINDOW, DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW, + LOCAL_MODEL_CONTEXT_WINDOW, inferCustomModelContextWindow, inferCustomModelInputModalities, } from '@electron/shared/providers/model-capabilities'; @@ -9,10 +11,13 @@ import { describe('inferCustomModelInputModalities', () => { it.each([ 'gpt-4o', + 'gpt-5.6-sol', 'claude-opus-4-6', + 'claude-fable-5', 'gemini-3-flash', 'qwen2.5-vl', 'glm-4v', + 'openai/gpt-5.6-sol', ])('marks known vision model %s as image-capable', (modelId) => { expect(inferCustomModelInputModalities(modelId)).toEqual(['text', 'image']); }); @@ -29,21 +34,130 @@ describe('inferCustomModelInputModalities', () => { describe('inferCustomModelContextWindow', () => { it.each([ - ['gpt-5.5', 272_000], + // OpenAI: generation-specific rules must win over the bare gpt-5 family. + ['gpt-5.6-sol', 1_050_000], + ['gpt-5.6-terra', 1_050_000], + ['gpt-5.6-luna', 272_000], + ['gpt-5.5', 1_000_000], ['GPT-5.4-Mini', 272_000], + ['gpt-5', 400_000], ['gpt-4o', 128_000], + + // Anthropic + ['claude-fable-5', 1_000_000], + ['claude-opus-4-8', 1_000_000], + ['claude-sonnet-4-6', 1_000_000], ['claude-opus-4-6', 200_000], - ['gemini-3-flash', 1_048_576], - ['kimi-k2.6', 256_000], - ['MiniMax-M3', 204_800], + ['claude-haiku-4-5', 200_000], + + // Google + ['gemini-3.1-pro-preview', 1_048_576], + ['gemini-1.0-pro', 32_768], + + // DeepSeek: V4 and its aliases are 1M, V3 is not. + ['deepseek-v4-flash', 1_000_000], + ['deepseek-v4-pro', 1_000_000], + ['deepseek-chat', 1_000_000], + ['deepseek-v3', 128_000], + + // Moonshot: only K3 reached a million tokens. + ['kimi-k3', 1_000_000], + ['kimi-k2.6', 262_144], + + // Qwen + ['qwen-long', 10_000_000], + ['qwen3.6-plus', 1_000_000], + ['qwen3.5-397b', 262_144], + ['qwen3-next-80b', 262_144], + + // Z.AI GLM — mirrors the explicit rows in the provider registry. ['glm-5.2', 1_000_000], - ['glm-5.1', 1_000_000], + ['glm-5.1', 200_000], ['glm-4.7', 200_000], + + // MiniMax + ['MiniMax-M3', 524_288], + ['MiniMax-M2.7', 204_800], ])('maps known family %s to %d tokens', (modelId, expected) => { expect(inferCustomModelContextWindow(modelId)).toBe(expected); }); - it('falls back to the conservative default for unknown models', () => { + it.each([ + ['openai/gpt-5.6-sol', 1_050_000], + ['deepseek-ai/DeepSeek-V3', 128_000], + ['moonshotai/kimi-k3', 1_000_000], + ])('resolves the family behind vendor-prefixed id %s', (modelId, expected) => { + expect(inferCustomModelContextWindow(modelId)).toBe(expected); + }); + + it('falls back to the frontier-era default for unknown models', () => { expect(inferCustomModelContextWindow('unknown-private-model')).toBe(DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW); + expect(DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW).toBe(200_000); + }); + + describe('locally hosted providers', () => { + it('caps family inference so a local tag cannot inherit a frontier window', () => { + expect(inferCustomModelContextWindow('deepseek-v4-flash', { providerKey: 'ollama-a1b2c3' })) + .toBe(LOCAL_MODEL_CONTEXT_WINDOW); + }); + + it('strips the Ollama tag before matching the family', () => { + expect(inferCustomModelContextWindow('qwen3:latest', { providerKey: 'ollama-a1b2c3' })) + .toBe(131_072); + }); + + it('keeps a window smaller than the local ceiling', () => { + expect(inferCustomModelContextWindow('gpt-4o', { providerKey: 'ollama-a1b2c3' })) + .toBe(128_000); + }); + + it('does not cap hosted providers', () => { + expect(inferCustomModelContextWindow('deepseek-v4-flash', { providerKey: 'deepseek' })) + .toBe(1_000_000); + }); + }); + + describe('ChatGPT subscription transport', () => { + it.each([ + 'openai-chatgpt-responses', + 'openai-codex-responses', + ])('caps API-tier windows on %s', (apiProtocol) => { + expect(inferCustomModelContextWindow('gpt-5.6-sol', { providerKey: 'openai', apiProtocol })) + .toBe(CHATGPT_OAUTH_CONTEXT_WINDOW); + expect(inferCustomModelContextWindow('gpt-5.5', { providerKey: 'openai', apiProtocol })) + .toBe(CHATGPT_OAUTH_CONTEXT_WINDOW); + }); + + it('leaves the API-key transport at the published window', () => { + expect(inferCustomModelContextWindow('gpt-5.6-sol', { + providerKey: 'openai', + apiProtocol: 'openai-responses', + })).toBe(1_050_000); + }); + + it('keeps a window already below the subscription ceiling', () => { + expect(inferCustomModelContextWindow('gpt-4o', { + providerKey: 'openai', + apiProtocol: 'openai-chatgpt-responses', + })).toBe(128_000); + }); + + it('caps the unknown-model default too', () => { + expect(inferCustomModelContextWindow('some-internal-preview', { + providerKey: 'openai', + apiProtocol: 'openai-chatgpt-responses', + })).toBe(DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW); + expect(DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW).toBeLessThan(CHATGPT_OAUTH_CONTEXT_WINDOW); + }); + }); + + describe('MiniMax OAuth', () => { + // Device OAuth hits the same platform API as a key, so no transport cap. + it('keeps the platform window for oauth-backed MiniMax', () => { + expect(inferCustomModelContextWindow('MiniMax-M3', { + providerKey: 'minimax', + apiProtocol: 'anthropic-messages', + })).toBe(524_288); + }); }); });