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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-03 10:00:52 +08:00
parent 8716faf2c1
commit 247da5072f
3 changed files with 173 additions and 23 deletions

View File

@ -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

View File

@ -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 = /<task-notification>\s*[\s\S]*?<\/task-notifi
// ============================================================================
export class SessionService {
private providerService = new ProviderService()
private readonly sessionListCacheTtlMs = 5_000
private readonly sessionListCache = new Map<string, {
expiresAt: number
@ -1288,7 +1296,43 @@ export class SessionService {
return `$${cost > 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<number | undefined> {
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<number> {
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)

View File

@ -61,33 +61,25 @@ function normalizeWindow(value: unknown): number | undefined {
return value
}
function parseConfiguredContextWindows(): Record<string, number> {
const raw = process.env[MODEL_CONTEXT_WINDOWS_ENV_KEY]
if (!raw?.trim()) {
function normalizeConfiguredContextWindows(parsed: unknown): Record<string, number> {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
const windows: Record<string, number> = {}
for (const [model, value] of Object.entries(parsed)) {
const normalized = normalizeWindow(value)
if (normalized !== undefined) {
windows[normalizeModelContextKey(model)] = normalized
}
const windows: Record<string, number> = {}
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<string, number>,
): 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<string, unknown>
return findConfiguredModelContextWindow(
model,
normalizeConfiguredContextWindows(parsed),
)
} catch {
return undefined
}
}
function parseConfiguredContextWindows(): Record<string, number> {
const raw = process.env[MODEL_CONTEXT_WINDOWS_ENV_KEY]
if (!raw?.trim()) {
return {}
}
try {
return normalizeConfiguredContextWindows(JSON.parse(raw) as Record<string, unknown>)
} 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]