From 247da5072f31435d29cc48cb44f49306ca78fffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 3 Jun 2026 10:00:52 +0800 Subject: [PATCH] fix: use provider context windows for transcript estimates Desktop chat can fall back to persisted transcript estimates when live inspection context is unavailable. That path previously only used built-in model windows, so custom provider settings such as MiniMax-M3 with a 1,000,000-token window still displayed the default 200,000-token estimate. Resolve transcript context windows from the session or active provider runtime env first, then fall back to built-ins. Constraint: Provider model context windows are stored in provider runtime env, not always in the server process env. Rejected: Change the built-in MiniMax-M3 window to 1,000,000 | upstream preset correctly keeps MiniMax-M3 at 204,800 by default, while user overrides must remain provider-specific. Confidence: high Scope-risk: moderate Directive: Keep transcript estimates aligned with session/provider runtime env before consulting built-in model defaults. Tested: git diff --check Tested: bun test src/server/__tests__/conversations.test.ts --test-name-pattern "active provider model context windows|Sonnet 4.6 transcript usage|low-trust media" Tested: bun test src/server/__tests__/provider-presets.test.ts src/server/__tests__/provider-runtime-env.test.ts src/server/__tests__/providers.test.ts src/utils/__tests__/context.test.ts src/utils/__tests__/contextBudget.test.ts Tested: bun test src/server/__tests__/conversations.test.ts src/server/__tests__/conversation-service.test.ts src/server/__tests__/providers.test.ts src/server/__tests__/provider-presets.test.ts src/server/__tests__/provider-runtime-env.test.ts src/utils/__tests__/context.test.ts src/utils/__tests__/contextBudget.test.ts Not-tested: bun run check:server blocked before execution by expired quarantine entries: server:cron-scheduler, server:providers-real, server:tasks, server:e2e:business-flow, server:e2e:full-flow --- src/server/__tests__/conversations.test.ts | 78 ++++++++++++++++++++++ src/server/services/sessionService.ts | 50 +++++++++++++- src/utils/model/modelContextWindows.ts | 68 +++++++++++++------ 3 files changed, 173 insertions(+), 23 deletions(-) diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 84ae1429..9a231652 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -530,6 +530,84 @@ describe('ConversationService', () => { } }) + it('should use active provider model context windows for transcript estimates', async () => { + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR + const previousNodeEnv = process.env.NODE_ENV + const previousModelContextWindows = process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS + const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-transcript-provider-')) + const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-workdir-provider-')) + process.env.CLAUDE_CONFIG_DIR = tmpConfigDir + process.env.NODE_ENV = 'development' + delete process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS + + try { + const providerService = new ProviderService() + const provider = await providerService.addProvider({ + presetId: 'minimax', + name: 'MiniMax', + apiKey: 'provider-key', + authStrategy: 'auth_token', + baseUrl: 'https://api.minimaxi.com/anthropic', + apiFormat: 'anthropic', + models: { + main: 'MiniMax-M3', + haiku: 'MiniMax-M3', + sonnet: 'MiniMax-M3', + opus: 'MiniMax-M3', + }, + modelContextWindows: { + 'MiniMax-M3': 1_000_000, + }, + }) + await providerService.activateProvider(provider.id) + + const svc = new SessionService() + const { sessionId } = await svc.createSession(workDir) + const found = await svc.findSessionFile(sessionId) + expect(found).not.toBeNull() + + await fs.appendFile(found!.filePath, JSON.stringify({ + type: 'assistant', + uuid: crypto.randomUUID(), + timestamp: '2026-04-27T12:00:00.000Z', + cwd: workDir, + version: '999.0.0-test', + message: { + role: 'assistant', + model: 'MiniMax-M3', + content: [{ type: 'text', text: 'hello' }], + usage: { + input_tokens: 100, + output_tokens: 20, + }, + }, + }) + '\n') + + const contextEstimate = await svc.getTranscriptContextEstimate(sessionId) + + expect(contextEstimate?.model).toBe('MiniMax-M3') + expect(contextEstimate?.rawMaxTokens).toBe(1_000_000) + } finally { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir + } + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previousNodeEnv + } + if (previousModelContextWindows === undefined) { + delete process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS + } else { + process.env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS = previousModelContextWindows + } + await fs.rm(tmpConfigDir, { recursive: true, force: true }) + await fs.rm(workDir, { recursive: true, force: true }) + } + }) + it('should not report transcript context as full for low-trust media usage spikes', async () => { const previousConfigDir = process.env.CLAUDE_CONFIG_DIR const previousNodeEnv = process.env.NODE_ENV diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 86ebb9dc..e19521d7 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -19,7 +19,12 @@ import { MODEL_CONTEXT_WINDOW_DEFAULT, getContextWindowForModel, getModelMaxOutputTokens, + is1mContextDisabled, } from '../../utils/context.js' +import { + MODEL_CONTEXT_WINDOWS_ENV_KEY, + getModelContextWindowFromEnvValue, +} from '../../utils/model/modelContextWindows.js' import { calculateContextBudget, getProviderUsageTrust, @@ -36,6 +41,7 @@ import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js' import { normalizeDriveRootPathForPlatform } from './windowsDrivePath.js' import { cleanSessionTitleSource } from '../../utils/sessionTitleText.js' import { roughTokenCountEstimationForMessages } from '../../services/tokenEstimation.js' +import { ProviderService } from './providerService.js' // ============================================================================ // Types @@ -269,6 +275,8 @@ const TASK_NOTIFICATION_BLOCK_RE = /\s*[\s\S]*?<\/task-notifi // ============================================================================ export class SessionService { + private providerService = new ProviderService() + private readonly sessionListCacheTtlMs = 5_000 private readonly sessionListCache = new Map 0.5 ? (Math.round(cost * 100) / 100).toFixed(2) : cost.toFixed(4)}` } - private getTranscriptContextWindow(model: string): number { + private async getProviderContextWindowForSession( + sessionId: string, + model: string, + ): Promise { + const launchInfo = await this.getSessionLaunchInfo(sessionId).catch(() => null) + const providerIds: string[] = [] + + if (typeof launchInfo?.runtimeProviderId === 'string') { + providerIds.push(launchInfo.runtimeProviderId) + } else if (launchInfo?.runtimeProviderId !== null) { + const { activeId } = await this.providerService.listProviders().catch(() => ({ activeId: null })) + if (activeId) providerIds.push(activeId) + } + + for (const providerId of providerIds) { + const env = await this.providerService.getProviderRuntimeEnv(providerId).catch(() => null) + const contextWindow = getModelContextWindowFromEnvValue( + model, + env?.[MODEL_CONTEXT_WINDOWS_ENV_KEY], + ) + if (contextWindow !== undefined) { + if (contextWindow > MODEL_CONTEXT_WINDOW_DEFAULT && is1mContextDisabled()) { + return MODEL_CONTEXT_WINDOW_DEFAULT + } + return contextWindow + } + } + + return undefined + } + + private async getTranscriptContextWindow(sessionId: string, model: string): Promise { + const providerContextWindow = await this.getProviderContextWindowForSession(sessionId, model) + if (providerContextWindow !== undefined) { + return providerContextWindow + } + try { return getContextWindowForModel(model) } catch (err) { @@ -1362,7 +1406,7 @@ export class SessionService { if (!latest) return null - const rawMaxTokens = this.getTranscriptContextWindow(latest.model) + const rawMaxTokens = await this.getTranscriptContextWindow(sessionId, latest.model) const promptTokens = latest.inputTokens + latest.cacheReadInputTokens + latest.cacheCreationInputTokens const transcriptMessages = entries.filter(entry => entry.type === 'user' || entry.type === 'assistant' || entry.type === 'attachment', @@ -1502,7 +1546,7 @@ export class SessionService { webSearchRequests: 0, costUSD: 0, costDisplay: '$0.0000', - contextWindow: this.getTranscriptContextWindow(model), + contextWindow: await this.getTranscriptContextWindow(sessionId, model), maxOutputTokens: getModelMaxOutputTokens(model).default, } models.set(model, modelUsage) diff --git a/src/utils/model/modelContextWindows.ts b/src/utils/model/modelContextWindows.ts index 755234fb..cd9dbdf7 100644 --- a/src/utils/model/modelContextWindows.ts +++ b/src/utils/model/modelContextWindows.ts @@ -61,33 +61,25 @@ function normalizeWindow(value: unknown): number | undefined { return value } -function parseConfiguredContextWindows(): Record { - const raw = process.env[MODEL_CONTEXT_WINDOWS_ENV_KEY] - if (!raw?.trim()) { +function normalizeConfiguredContextWindows(parsed: unknown): Record { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return {} } - try { - const parsed = JSON.parse(raw) as Record - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return {} + const windows: Record = {} + for (const [model, value] of Object.entries(parsed)) { + const normalized = normalizeWindow(value) + if (normalized !== undefined) { + windows[normalizeModelContextKey(model)] = normalized } - - const windows: Record = {} - for (const [model, value] of Object.entries(parsed)) { - const normalized = normalizeWindow(value) - if (normalized !== undefined) { - windows[normalizeModelContextKey(model)] = normalized - } - } - return windows - } catch { - return {} } + return windows } -function getConfiguredModelContextWindow(model: string): number | undefined { - const configured = parseConfiguredContextWindows() +function findConfiguredModelContextWindow( + model: string, + configured: Record, +): number | undefined { const normalizedModel = normalizeModelContextKey(model) const exact = configured[normalizedModel] if (exact !== undefined) { @@ -105,6 +97,42 @@ function getConfiguredModelContextWindow(model: string): number | undefined { return undefined } +export function getModelContextWindowFromEnvValue( + model: string, + raw: string | undefined, +): number | undefined { + if (!raw?.trim()) { + return undefined + } + + try { + const parsed = JSON.parse(raw) as Record + return findConfiguredModelContextWindow( + model, + normalizeConfiguredContextWindows(parsed), + ) + } catch { + return undefined + } +} + +function parseConfiguredContextWindows(): Record { + const raw = process.env[MODEL_CONTEXT_WINDOWS_ENV_KEY] + if (!raw?.trim()) { + return {} + } + + try { + return normalizeConfiguredContextWindows(JSON.parse(raw) as Record) + } catch { + return {} + } +} + +function getConfiguredModelContextWindow(model: string): number | undefined { + return findConfiguredModelContextWindow(model, parseConfiguredContextWindows()) +} + function getBuiltInModelContextWindow(model: string): number | undefined { const normalizedModel = normalizeModelContextKey(model) const exact = DIRECT_MODEL_CONTEXT_WINDOWS[normalizedModel]