diff --git a/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts b/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts index b2484ff909..0ee2ad3f78 100644 --- a/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts +++ b/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts @@ -3,7 +3,8 @@ import { sleep } from '$lib/utils' import { editor as meditor, Position, languages, type IDisposable } from 'monaco-editor' import { LRUCache } from 'lru-cache' import { autocompleteRequest } from './request' -import { FIM_MAX_TOKENS, getModelContextWindow } from '../lib' +import { FIM_MAX_TOKENS } from '../lib' +import { getModelContextWindow } from '../modelConfig' import { setGlobalCSS } from '../shared' import { supportsAutocomplete } from '../utils' import { get } from 'svelte/store' diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 980376f64b..6eb1374ed9 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -27,6 +27,7 @@ import type { ContextElement } from './context' import ChatQuickActions from './ChatQuickActions.svelte' import ProviderModelSelector from './ProviderModelSelector.svelte' + import ContextUsageIndicator from './ContextUsageIndicator.svelte' import AIChatSettingsMenu from './AIChatSettingsMenu.svelte' import ChatMode from './ChatMode.svelte' import DatatableCreationPolicy from './DatatableCreationPolicy.svelte' @@ -535,6 +536,8 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {#if inputPreface} {@render inputPreface()} {/if} + + (false) displayMessages = $state([]) messages = $state([]) + /** Provider-reported context size of the last committed turn (prompt + + * completion of its latest completion — exact, includes system prompt and + * tools), or undefined whenever no report describes the current history + * (provider never reported, turn failed, history rewound). Never holds a + * guess: readers go through `contextTokens`, which estimates lazily. */ + contextUsage = $state(undefined) autonomyMode = $state(getPersistedAutonomyMode()) autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode)) autoAcceptEditsActive = $derived( @@ -267,47 +281,89 @@ export class AIChatManager { open = $derived(chatState.size > 0) - checkTokenUsageOverLimit = (messages: ChatCompletionMessageParam[]) => { - const estimatedTokens = messages.reduce((acc, message) => { - // one token is ~ 4 characters + // one token is ~ 4 characters + private estimateMessagesTokens = (messages: ChatCompletionMessageParam[]) => { + return messages.reduce((acc, message) => { const tokenPerCharacter = 4 - // handle content - if (message.content) { + if (typeof message.content === 'string') { acc += message.content.length / tokenPerCharacter + } else if (message.content) { + acc += JSON.stringify(message.content).length / tokenPerCharacter } - // Handle tool calls if (message.role === 'assistant' && message.tool_calls) { acc += JSON.stringify(message.tool_calls).length / tokenPerCharacter } return acc }, 0) - const model = getCurrentModel() - const modelContextWindow = getModelContextWindow(model.model) - return ( - estimatedTokens > - modelContextWindow - - Math.max(modelContextWindow * MAX_TOKENS_THRESHOLD_PERCENTAGE, MAX_TOKENS_HARD_LIMIT) - ) } - deleteOldestMessage = (messages: ChatCompletionMessageParam[], maxDepth: number = 10) => { - if (maxDepth <= 0 || messages.length <= 1) { - return messages - } - const removed = messages.shift() + /** Estimated tokens of the parts the messages array doesn't carry: the + * current system prompt and tool definitions. */ + private estimateOverheadTokens = () => { + const tokenPerCharacter = 4 + const systemTokens = + typeof this.systemMessage.content === 'string' + ? this.systemMessage.content.length / tokenPerCharacter + : 0 + const toolTokens = + this.tools.length > 0 + ? JSON.stringify(this.tools.map((t) => t.def)).length / tokenPerCharacter + : 0 + return systemTokens + toolTokens + } - // if the removed message is an assistant with tool calls, we need to delete correspding tool response. - if (removed?.role === 'assistant' && removed.tool_calls) { - if (messages.length > 0 && messages[0]?.role === 'tool') { - messages.shift() + /** + * chars/4 estimate of the full context as currently stored: messages plus + * the system prompt and tool definitions the next request would carry. + * Recomputed from scratch at each read — never accumulated — so errors + * don't compound. + */ + private estimateWholeContextTokens = () => + Math.round(this.estimateMessagesTokens(this.messages) + this.estimateOverheadTokens()) + + /** + * How full the context is right now — the single fallback rule, shared by + * the compaction trigger and the usage indicator: the provider's exact + * report when one describes the current history, a fresh estimate + * otherwise. Estimating at the read point (rather than writing estimates + * into `contextUsage`) means no code path that mutates history can leave + * a stale or missing value behind. + */ + contextTokens = $derived.by(() => this.contextUsage ?? this.estimateWholeContextTokens()) + + /** + * Drop-oldest compaction. Deletes messages from the front of the STORED + * history (the API messages — displayMessages keep the full conversation + * for the user) until at least `tokensToFree` estimated tokens are freed + * AND the remaining history starts on a user message: a leading tool + * result or assistant turn would dangle without the messages that + * introduced it. The most recent user message is never dropped. Returns + * the estimated tokens freed. + */ + compactOldestMessages = (tokensToFree: number): number => { + const last = this.messages.length - 1 + let drop = 0 + let freed = 0 + while (drop < last) { + if (freed >= tokensToFree && this.messages[drop].role === 'user') { + break } + freed += this.estimateMessagesTokens([this.messages[drop]]) + drop++ } - - // keep deleting messages until we are under the limit - if (this.checkTokenUsageOverLimit(messages)) { - return this.deleteOldestMessage(messages, maxDepth - 1) + if (drop === 0) { + return 0 } - return messages + this.messages = this.messages.slice(drop) + // User display messages carry the index of their API message so restart + // can rewind to it; re-base them on the compacted history. A message + // whose API counterpart was dropped clamps to 0: everything before it + // was dropped too (compaction only removes prefixes), so restarting + // from it restarts from an empty history. + this.displayMessages = this.displayMessages.map((m) => + m.role === 'user' ? { ...m, index: Math.max(0, m.index - drop) } : m + ) + return freed } loadApiTools = async () => { @@ -799,7 +855,7 @@ export class AIChatManager { } } }) - return result.addedMessages + return result } catch (err) { console.log('chatRequest error', err) console.error('chatRequest error', err) @@ -1062,18 +1118,43 @@ export class AIChatManager { break } + // Size of the request about to go out: contextTokens (provider report + // when current, fresh chars/4 estimate otherwise) plus the message + // being added below. Must be read BEFORE the push — the estimate path + // covers the stored history, so pushing first would double-count the + // new message. + const projectedContextTokens = this.contextTokens + this.estimateMessagesTokens([userMessage]) + this.messages.push(userMessage) - const modelLenAfterUser = this.messages.length - await this.historyManager.saveChat(this.displayMessages, this.messages) + await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) this.currentReply = '' this.currentReasoning = '' this.currentReasoningActive = false - let trimmedMessages = [...this.messages] - if (this.checkTokenUsageOverLimit(trimmedMessages)) { - trimmedMessages = this.deleteOldestMessage(trimmedMessages) + // Compaction trigger. Without a known context window there is no limit + // to enforce, so compaction stays off rather than guessing one. + const contextWindow = model ? getKnownModelContextWindow(model.model) : undefined + if ( + contextWindow !== undefined && + projectedContextTokens >= contextWindow * COMPACTION_TRIGGER_RATIO + ) { + const freed = this.compactOldestMessages( + projectedContextTokens - contextWindow * COMPACTION_TARGET_RATIO + ) + // A report stays meaningful only debited by what was dropped; the + // estimate path needs no bookkeeping — the next read re-estimates + // the compacted history. chars/4 can underestimate the freed + // tokens, which errs toward compacting again — never toward + // overflowing. + if (this.contextUsage !== undefined) { + this.contextUsage = Math.max(0, this.contextUsage - freed) + } + await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) } + // Rollback anchor for restoreUnsentTurn: captured after compaction so it + // indexes into the (possibly compacted) stored history. + const modelLenAfterUser = this.messages.length const params: { messages: ChatCompletionMessageParam[] @@ -1083,7 +1164,7 @@ export class AIChatManager { onMessageEnd: () => void } } = { - messages: trimmedMessages, + messages: [...this.messages], abortController: this.abortController, callbacks: { onNewToken: (token) => (this.currentReply += token), @@ -1159,7 +1240,7 @@ export class AIChatManager { await this.loadApiTools() } - await this.chatRequest({ + const result = await this.chatRequest({ ...params, addedMessages: collectedMessages, onWebSearchUnavailable: () => { @@ -1180,7 +1261,14 @@ export class AIChatManager { if (this.autoAcceptEditsActive) { this.acceptPendingFlowEdits() } - await this.historyManager.saveChat(this.displayMessages, this.messages) + // The report from the last completed iteration still describes the + // stored history it was sent with (the kept partial tail is a small + // undercount the trigger headroom absorbs). Without one, clear the + // stale value — readers estimate via contextTokens. + this.contextUsage = result?.lastIterationUsage + ? result.lastIterationUsage.prompt + result.lastIterationUsage.completion + : undefined + await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) // Still counts as the saved first turn — skipping the hook here would // permanently miss it (the next turn isn't "first" anymore). if (isFirstUserTurn && this.afterFirstTurnSaved) { @@ -1191,6 +1279,8 @@ export class AIChatManager { } else if (wasAborted || !hasUsableOutput) { // Cancelled before anything usable, or the model returned nothing // (or only reasoning) — treat the turn as unsent (matches Claude Code). + // contextUsage is left as-is: the turn is rolled back, so the last + // report (pre-turn, possibly debited by compaction) still stands. this.restoreUnsentTurn(displayLenAfterUser, modelLenAfterUser, sentInstructions, sentPastes) if (this.displayMessages.length === 0) { // saveChat no-ops on an empty transcript; the chat persisted earlier @@ -1198,7 +1288,7 @@ export class AIChatManager { // user message on reload. Remove it instead. this.historyManager.deletePastChat(this.historyManager.getCurrentChatId()) } else { - await this.historyManager.saveChat(this.displayMessages, this.messages) + await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) } if (!wasAborted) { sendUserToast('The model returned no response — your message was restored to the input.') @@ -1206,10 +1296,18 @@ export class AIChatManager { } else { // Clean turn with output → commit as-is. this.messages = [...this.messages, ...collectedMessages] + // The provider's report describes the stored history exactly: + // compaction mutates it before sending, so what was sent IS what is + // stored — no anchoring or index bookkeeping needed. Without a + // report, clear the now-stale value — readers estimate via + // contextTokens. + this.contextUsage = result?.lastIterationUsage + ? result.lastIterationUsage.prompt + result.lastIterationUsage.completion + : undefined if (this.autoAcceptEditsActive) { this.acceptPendingFlowEdits() } - await this.historyManager.saveChat(this.displayMessages, this.messages) + await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) if (isFirstUserTurn && this.afterFirstTurnSaved) { void Promise.resolve(this.afterFirstTurnSaved()).catch((e) => { console.error('AIChatManager afterFirstTurnSaved hook failed', e) @@ -1223,8 +1321,13 @@ export class AIChatManager { // re-committing would duplicate the turn's messages. if (!turnOutcomeHandled) { this.commitInterruptedTurn(collectedMessages, partialReply) + // Any prior report no longer describes the history (a partial turn + // was just committed); clear it so readers estimate instead. When + // the failure WAS a context-length error, that high estimate forces + // compaction on the next send instead of failing the same way again. + this.contextUsage = undefined try { - await this.historyManager.saveChat(this.displayMessages, this.messages) + await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) } catch (saveErr) { console.error('Failed to persist partial chat after error', saveErr) } @@ -1286,6 +1389,12 @@ export class AIChatManager { this.messages = this.messages.slice(0, actualMessageIndex) + // The last report described the pre-rewind history; clear it. Readers + // fall back to estimating the rewound history (contextTokens), so the + // compaction trigger stays armed — e.g. for Retry after a context-length + // error, which rewinds through here. + this.contextUsage = undefined + // Resend the request with the same instructions this.instructions = newContent ?? userMessage.content this.sendRequest({ pastes: pastes ?? userMessage.pastes }) @@ -1319,9 +1428,10 @@ export class AIChatManager { saveAndClear = async () => { this.cancel('saveAndClear') - await this.historyManager.save(this.displayMessages, this.messages) + await this.historyManager.save(this.displayMessages, this.messages, this.contextUsage) this.displayMessages = [] this.messages = [] + this.contextUsage = undefined } loadPastChat = async (id: string) => { @@ -1329,6 +1439,7 @@ export class AIChatManager { if (chat) { this.displayMessages = chat.displayMessages this.messages = chat.actualMessages + this.contextUsage = normalizeContextUsage(chat.contextUsage) this.#automaticScroll = true } } diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 0eee2fdb00..672a9acc6c 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -52,7 +52,6 @@ vi.mock('$lib/aiStore', () => ({ })) vi.mock('../lib', () => ({ - getModelContextWindow: () => 128000, workspaceAIClients: { subscribe: () => () => undefined, getOpenaiClient: mocks.getOpenaiClient, @@ -311,6 +310,264 @@ describe('AIChatManager persisted autonomy default', () => { }) }) +describe('AIChatManager context compaction', () => { + // claude-sonnet-4-6 resolves to a known 1M window (modelConfig is + // unmocked): compaction triggers at a projected 800k and drops head + // messages until ~700k. + const anthropicModel = { provider: 'anthropic', model: 'claude-sonnet-4-6' } + + // The turn-outcome handling rolls back turns with no usable output, so every + // sendRequest here must produce a reply to take the clean-commit path. + const replyWith = ( + reply: string, + lastIterationUsage: { prompt: number; completion: number; total: number } | null = null + ) => + mocks.runChatLoop.mockImplementation(async (config: any) => { + const message = { role: 'assistant' as const, content: reply } + config.addedMessages?.push(message) + return { + addedMessages: [message], + tokenUsage: lastIterationUsage ?? { prompt: 0, completion: 0, total: 0 }, + lastIterationUsage, + hitMaxIterations: false + } + }) + + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + mocks.getCurrentModel.mockReturnValue(anthropicModel) + mocks.tryGetCurrentModel.mockReturnValue(anthropicModel) + replyWith('done') + }) + + it('compacts the stored history before sending once reported usage projects over the trigger', async () => { + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(400_000) }, // ~100k estimated tokens + { role: 'assistant', content: 'b'.repeat(400_000) }, // ~100k + { role: 'user', content: 'c'.repeat(400) }, + { role: 'assistant', content: 'd'.repeat(400) } + ] + // Provider fact: 850k used. Projected past the 800k trigger, so ~150k + // must be freed to come back to the 700k target — the first user + + // assistant pair (~200k estimated). + manager.contextUsage = 850_000 + manager.instructions = 'next question' + const saveChat = vi.spyOn(manager.historyManager, 'saveChat') + + await manager.sendRequest() + + const sent = mocks.runChatLoop.mock.calls[0][0].messages + expect(sent.length).toBe(3) + expect(sent[0]).toMatchObject({ role: 'user', content: 'c'.repeat(400) }) + // The mutation is on the stored history, not a per-send copy: the head + // pair is gone for good and the turn's reply was committed on top + expect(manager.messages.length).toBe(4) + expect(manager.messages[0]).toMatchObject({ role: 'user', content: 'c'.repeat(400) }) + // Mid-turn, the report is debited by the freed estimate (visible in the + // compaction-time save) so a rolled-back turn keeps a consistent value + expect(saveChat).toHaveBeenCalledWith(expect.anything(), expect.anything(), 650_000) + // At commit, the no-report turn clears the stored value; the readable + // number falls back to estimating the now-tiny compacted history + expect(manager.contextUsage).toBeUndefined() + expect(manager.contextTokens).toBeGreaterThan(0) + expect(manager.contextTokens).toBeLessThan(50_000) + // The display message for the sent prompt re-bases onto the compacted history + const userDisplay = manager.displayMessages.find((m) => m.role === 'user') + expect(userDisplay && 'index' in userDisplay ? userDisplay.index : undefined).toBe(2) + }) + + it('updates the reported usage after every send, including compacted ones', async () => { + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(400_000) }, + { role: 'assistant', content: 'b'.repeat(400_000) }, + { role: 'user', content: 'c'.repeat(400) } + ] + manager.contextUsage = 850_000 + manager.instructions = 'next question' + replyWith('done', { prompt: 720_000, completion: 1_000, total: 721_000 }) + + await manager.sendRequest() + + // The report describes exactly what was sent (the compacted history), so + // it replaces the debited estimate wholesale. + expect(manager.contextUsage).toBe(721_000) + }) + + it('does not compact while the estimated context stays under the trigger', async () => { + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(400_000) }, + { role: 'assistant', content: 'b'.repeat(400_000) } + ] + // no report: the trigger runs off the ~200k estimate, well under 800k + manager.instructions = 'next question' + + await manager.sendRequest() + + expect(mocks.runChatLoop.mock.calls[0][0].messages.length).toBe(3) + }) + + it('compacts off the estimate alone when no report ever arrived', async () => { + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(1_600_000) }, // ~400k estimated tokens + { role: 'assistant', content: 'b'.repeat(1_600_000) }, // ~400k + { role: 'user', content: 'c'.repeat(400) }, + { role: 'assistant', content: 'd'.repeat(400) } + ] + // ~800k estimated with no provider report ever seen (e.g. a gateway that + // strips usage): the lazily-estimated projection trips the 800k trigger + // and frees down to ~700k — the first user + assistant pair goes + manager.instructions = 'next question' + + await manager.sendRequest() + + const sent = mocks.runChatLoop.mock.calls[0][0].messages + expect(sent.length).toBe(3) + expect(sent[0]).toMatchObject({ role: 'user', content: 'c'.repeat(400) }) + }) + + it('estimates lazily instead of storing a guess when the provider reports no usage', async () => { + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(400_000) }, + { role: 'assistant', content: 'b'.repeat(400_000) } + ] + manager.instructions = 'next question' + + await manager.sendRequest() // replyWith('done') reports no usage + + // the stored value stays a pure provider fact… + expect(manager.contextUsage).toBeUndefined() + // …while the readable number estimates the stored context: ~200k for the + // messages plus the real navigator system prompt, tool defs and the small + // new-turn messages; the prompt templates aren't pinned here, so assert + // the magnitude rather than the byte count + expect(manager.contextTokens).toBeGreaterThan(200_000) + expect(manager.contextTokens).toBeLessThan(250_000) + }) + + it('prefers the provider report over the estimate once one arrives', async () => { + const manager = new AIChatManager() + manager.messages = [{ role: 'user', content: 'a'.repeat(400) }] + manager.instructions = 'first' + await manager.sendRequest() + expect(manager.contextUsage).toBeUndefined() + expect(manager.contextTokens).toBeGreaterThan(0) + + replyWith('done', { prompt: 1_234, completion: 56, total: 1_290 }) + manager.instructions = 'second' + await manager.sendRequest() + expect(manager.contextUsage).toBe(1_290) + expect(manager.contextTokens).toBe(1_290) + }) + + it('does not compact when the model context window is unknown', async () => { + mocks.getCurrentModel.mockReturnValue({ provider: 'custom', model: 'mystery-model-9000' }) + mocks.tryGetCurrentModel.mockReturnValue({ provider: 'custom', model: 'mystery-model-9000' }) + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(400_000) }, + { role: 'assistant', content: 'b'.repeat(400_000) } + ] + manager.contextUsage = 10_000_000 + manager.instructions = 'next question' + + await manager.sendRequest() + + expect(mocks.runChatLoop.mock.calls[0][0].messages.length).toBe(3) + }) + + it('never drops the most recent message', () => { + const manager = new AIChatManager() + manager.messages = [{ role: 'user', content: 'a'.repeat(400_000) }] + expect(manager.compactOldestMessages(Number.MAX_SAFE_INTEGER)).toBe(0) + expect(manager.messages.length).toBe(1) + }) + + it('keeps dropping past dangling turns so the history restarts on a user message', () => { + const manager = new AIChatManager() + manager.messages = [ + { + role: 'assistant', + content: 'calling tools', + tool_calls: [ + { id: '1', type: 'function', function: { name: 'x', arguments: '{}' } }, + { id: '2', type: 'function', function: { name: 'y', arguments: '{}' } } + ] + }, + { role: 'tool', content: 'result 1', tool_call_id: '1' }, + { role: 'tool', content: 'result 2', tool_call_id: '2' }, + { role: 'user', content: 'follow-up' }, + { role: 'user', content: 'latest' } + ] + // Freeing 1 token is satisfied by the first drop alone, but the tool + // results would dangle without their assistant tool_calls message + manager.compactOldestMessages(1) + expect(manager.messages.map((m) => m.role)).toEqual(['user', 'user']) + }) + + it('re-bases display message indices and clamps fully-compacted ones to 0', () => { + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(400) }, // ~100 estimated tokens + { role: 'assistant', content: 'b'.repeat(400) }, // ~100 + { role: 'user', content: 'c' }, + { role: 'user', content: 'd' } + ] + manager.displayMessages = [ + { role: 'user', content: 'first', index: 0 }, + { role: 'assistant', content: 'answer' }, + { role: 'user', content: 'second', index: 2 }, + { role: 'user', content: 'third', index: 3 } + ] + manager.compactOldestMessages(150) + expect(manager.messages.map((m) => m.content)).toEqual(['c', 'd']) + expect(manager.displayMessages.map((m) => ('index' in m ? m.index : undefined))).toEqual([ + 0, + undefined, + 0, + 1 + ]) + }) + + it('falls back to estimating the rewound history after a rewind', () => { + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(400) }, // ~100 estimated tokens + { role: 'assistant', content: 'b'.repeat(400) }, // ~100 + { role: 'user', content: 'q2' }, + { role: 'assistant', content: 'a2' } + ] + manager.displayMessages = [ + { role: 'user', content: 'q1', index: 0 }, + { role: 'assistant', content: 'a1' }, + { role: 'user', content: 'q2', index: 2 }, + { role: 'assistant', content: 'a2' } + ] + // A report that described the pre-rewind history must not survive the + // rewind as-is… + manager.contextUsage = 999_999 + manager.restartGeneration(2) + expect(manager.contextUsage).toBeUndefined() + // …but the readable number stays armed by estimating what remains (the + // two surviving messages, plus the prompt/tools the resend installed), + // so e.g. Retry after a context-length error still compacts + expect(manager.contextTokens).toBeGreaterThanOrEqual(200) + expect(manager.contextTokens).toBeLessThan(50_000) + }) + + it('clears the reported usage when saveAndClear resets the conversation', async () => { + const manager = new AIChatManager() + manager.contextUsage = 1000 + await manager.saveAndClear() + expect(manager.contextUsage).toBeUndefined() + }) +}) + const assistantToolCall = (id: string): ChatCompletionMessageParam => ({ role: 'assistant', content: '', @@ -325,8 +582,9 @@ const toolResult = (id: string): ChatCompletionMessageParam => ({ describe('AIChatManager sendRequest lifecycle', () => { beforeEach(() => { localStorage.clear() - // checkTokenUsageOverLimit reads getCurrentModel().model, so it must be a - // real object (the file-level beforeEach defaults it to undefined). + // The send path reads the current model (request logging + context window + // lookup), so it must be a real object (the file-level beforeEach defaults + // it to undefined). 'test-model' has no known window → compaction stays off. mocks.getCurrentModel.mockReturnValue({ model: 'test-model', provider: 'openai' }) }) @@ -340,6 +598,7 @@ describe('AIChatManager sendRequest lifecycle', () => { vi.mocked(runChatLoop).mockResolvedValue({ addedMessages: [], tokenUsage: {} as any, + lastIterationUsage: null, hitMaxIterations: false }) @@ -366,7 +625,12 @@ describe('AIChatManager sendRequest lifecycle', () => { config.callbacks.onReasoningStart?.() config.callbacks.onReasoningDelta?.('hmm...') config.callbacks.onMessageEnd() - return { addedMessages: [], tokenUsage: {} as any, hitMaxIterations: false } + return { + addedMessages: [], + tokenUsage: {} as any, + lastIterationUsage: null, + hitMaxIterations: false + } }) manager.instructions = 'do a thing' @@ -387,7 +651,12 @@ describe('AIChatManager sendRequest lifecycle', () => { vi.mocked(runChatLoop).mockImplementation(async (config) => { config.callbacks.onNewToken('hello') config.callbacks.onMessageEnd() - return { addedMessages: [], tokenUsage: {} as any, hitMaxIterations: false } + return { + addedMessages: [], + tokenUsage: {} as any, + lastIterationUsage: null, + hitMaxIterations: false + } }) manager.instructions = 'do a thing' @@ -543,6 +812,7 @@ describe('AIChatManager sendRequest lifecycle', () => { return { addedMessages: config.addedMessages!, tokenUsage: {} as any, + lastIterationUsage: null, hitMaxIterations: false } }) @@ -573,6 +843,7 @@ describe('AIChatManager sendRequest lifecycle', () => { vi.mocked(runChatLoop).mockResolvedValue({ addedMessages: [], tokenUsage: {} as any, + lastIterationUsage: null, hitMaxIterations: false }) const deletePastChat = vi.spyOn(manager.historyManager, 'deletePastChat') diff --git a/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte new file mode 100644 index 0000000000..4fe1c342f0 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte @@ -0,0 +1,45 @@ + + +{#if visible} +
+ + context window usage: ~{formatTokenCount(usedTokens)}{contextWindow + ? ` / ${formatTokenCount(contextWindow)}` + : ''} + +
+{/if} diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts index a2400b88df..365b85e515 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts @@ -3,6 +3,7 @@ import type { DisplayMessage } from './shared' import { expanded, messageDraft } from './chatDraft' import { createLongHash } from '$lib/editorLangUtils' import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' +import type { PersistedContextUsage } from './tokenUsage' interface ChatSchema extends IDBSchema { chats: { key: string @@ -13,6 +14,9 @@ interface ChatSchema extends IDBSchema { title: string lastModified: number sessionId?: string + // New writes store the plain reported token count; chats persisted by + // earlier versions may still hold the legacy anchor object. + contextUsage?: PersistedContextUsage } } } @@ -29,6 +33,7 @@ export default class HistoryManager { id: string lastModified: number sessionId?: string + contextUsage?: PersistedContextUsage } > = $state({}) @@ -105,7 +110,11 @@ export default class HistoryManager { return Object.values(this.savedChats) } - async saveChat(displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[]) { + async saveChat( + displayMessages: DisplayMessage[], + messages: ChatCompletionMessageParam[], + contextUsage?: number + ) { if (displayMessages.length > 0) { // Expand any collapsed-paste tokens so the title is readable text, not // the chip label + its zero-width id chars. @@ -120,7 +129,8 @@ export default class HistoryManager { title, id: this.currentChatId, lastModified: Date.now(), - ...(this.sessionId ? { sessionId: this.sessionId } : {}) + ...(this.sessionId ? { sessionId: this.sessionId } : {}), + ...(contextUsage !== undefined ? { contextUsage } : {}) } this.savedChats = { ...this.savedChats, @@ -133,8 +143,12 @@ export default class HistoryManager { } } - async save(displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[]) { - await this.saveChat(displayMessages, messages) + async save( + displayMessages: DisplayMessage[], + messages: ChatCompletionMessageParam[], + contextUsage?: number + ) { + await this.saveChat(displayMessages, messages, contextUsage) this.currentChatId = createLongHash() } diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts index 9e19a52cb4..9c1863ae54 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts @@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({ parseOpenAIResponsesCompletion: vi.fn(), getAnthropicCompletion: vi.fn(), parseAnthropicCompletion: vi.fn(), - resolveEffectiveReasoning: vi.fn() + resolveRequestReasoning: vi.fn() })) vi.mock('../lib', () => ({ @@ -22,7 +22,7 @@ vi.mock('../lib', () => ({ })) vi.mock('../reasoningRegistry', () => ({ - resolveEffectiveReasoning: mocks.resolveEffectiveReasoning + resolveRequestReasoning: mocks.resolveRequestReasoning })) vi.mock('./openai-responses', () => ({ @@ -83,7 +83,7 @@ describe('runChatLoop web search fallback', () => { mocks.providerSupportsWebSearch.mockImplementation( (provider) => provider === 'openai' || provider === 'anthropic' ) - mocks.resolveEffectiveReasoning.mockReturnValue(undefined) + mocks.resolveRequestReasoning.mockReturnValue(undefined) mocks.parseOpenAICompletion.mockResolvedValue({ shouldContinue: false, tokenUsage @@ -255,9 +255,7 @@ describe('runChatLoop web search fallback', () => { ) .mockResolvedValue({}) - await runChatLoop( - createConfig({ workspace, callbacks, modelProvider, onWebSearchUnavailable }) - ) + await runChatLoop(createConfig({ workspace, callbacks, modelProvider, onWebSearchUnavailable })) expect(mocks.getAnthropicCompletion).toHaveBeenCalledTimes(2) expect(mocks.getAnthropicCompletion.mock.calls[0][3]).toEqual( @@ -293,6 +291,46 @@ describe('runChatLoop web search fallback', () => { }) }) +describe('runChatLoop lastIterationUsage', () => { + beforeEach(() => { + vi.resetAllMocks() + mocks.resolveRequestReasoning.mockReturnValue(undefined) + }) + + it('keeps the usage of the last completion that reported it', async () => { + const workspace = `workspace-${randomUUID()}` + mocks.getOpenAIResponsesCompletion.mockResolvedValue({}) + mocks.parseOpenAIResponsesCompletion + .mockResolvedValueOnce({ + shouldContinue: true, + tokenUsage: { prompt: 1000, completion: 50, total: 1050 } + }) + .mockResolvedValueOnce({ + shouldContinue: false, + tokenUsage: { prompt: 1200, completion: 80, total: 1280 } + }) + + const result = await runChatLoop({ ...createConfig({ workspace }), maxIterations: 2 }) + + expect(result.lastIterationUsage).toEqual({ prompt: 1200, completion: 80, total: 1280 }) + // the aggregate keeps summing across iterations + expect(result.tokenUsage).toEqual({ prompt: 2200, completion: 130, total: 2330 }) + }) + + it('ignores empty usage reports and returns null when none are real', async () => { + const workspace = `workspace-${randomUUID()}` + mocks.getOpenAIResponsesCompletion.mockResolvedValue({}) + mocks.parseOpenAIResponsesCompletion.mockResolvedValue({ + shouldContinue: false, + tokenUsage: { prompt: 0, completion: 0, total: 0 } + }) + + const result = await runChatLoop(createConfig({ workspace })) + + expect(result.lastIterationUsage).toBeNull() + }) +}) + // Builders for the message shapes the chat loop accumulates. const assistant = (content: string): ChatCompletionMessageParam => ({ role: 'assistant', content }) const assistantTools = (...ids: string[]): ChatCompletionMessageParam => ({ diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts index b00f145bae..0e5e4923d7 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -59,7 +59,9 @@ export interface ChatLoopConfig { export interface ChatLoopResult { addedMessages: ChatCompletionMessageParam[] + /** Sum of usage across all loop iterations (suitable for cost accounting). */ tokenUsage: ChatTokenUsage + lastIterationUsage: ChatTokenUsage | null hitMaxIterations: boolean } @@ -215,9 +217,18 @@ export async function runChatLoop(config: ChatLoopConfig): Promise { + tokenUsage = addChatTokenUsage(tokenUsage, usage) + // Some providers/paths report no usage (prompt 0); keep the last real one. + if (usage && usage.prompt > 0) { + lastIterationUsage = usage + } + } + while (true) { if (maxIterations !== undefined && iterations >= maxIterations) { hitMaxIterations = true @@ -283,7 +294,7 @@ export async function runChatLoop(config: ChatLoopConfig): Promise { }) }) }) + +describe('model context windows', () => { + it('maps Sonnet/Opus 4.6+ Claude models to the 1M window', () => { + expect(getKnownModelContextWindow('claude-sonnet-4-6')).toBe(1000000) + expect(getKnownModelContextWindow('claude-opus-4-6')).toBe(1000000) + expect(getKnownModelContextWindow('claude-opus-4-8')).toBe(1000000) + expect(getKnownModelContextWindow('anthropic.claude-sonnet-4-6-v1:0')).toBe(1000000) + }) + + it('keeps Haiku and older Claude models at 200K', () => { + expect(getKnownModelContextWindow('claude-haiku-4-5')).toBe(200000) + expect(getKnownModelContextWindow('global.anthropic.claude-haiku-4-5-20251001-v1:0')).toBe( + 200000 + ) + expect(getKnownModelContextWindow('claude-3-5-sonnet-latest')).toBe(200000) + expect(getKnownModelContextWindow('claude-sonnet-4-5-20250929')).toBe(200000) + expect(getKnownModelContextWindow('claude-opus-4-1')).toBe(200000) + // date-suffixed base ids without a minor version: the date must not be + // captured as the version + expect(getKnownModelContextWindow('claude-sonnet-4-20250514')).toBe(200000) + expect(getKnownModelContextWindow('anthropic.claude-sonnet-4-20250514-v1:0')).toBe(200000) + }) + + it('keeps base GPT-5 models at 400K while GPT-5.4+ get the 1M window', () => { + expect(getKnownModelContextWindow('gpt-5')).toBe(400000) + expect(getKnownModelContextWindow('gpt-5-mini')).toBe(400000) + expect(getKnownModelContextWindow('gpt-5.2')).toBe(400000) + expect(getKnownModelContextWindow('gpt-5.4')).toBe(1000000) + expect(getKnownModelContextWindow('gpt-5.5')).toBe(1000000) + }) + + it('maps recent Gemini and DeepSeek models to the 1M window', () => { + expect(getKnownModelContextWindow('gemini-3.1-pro')).toBe(1000000) + expect(getKnownModelContextWindow('gemini-3-flash')).toBe(1000000) + expect(getKnownModelContextWindow('gemini-2.5-flash')).toBe(1000000) + expect(getKnownModelContextWindow('deepseek-v4-pro')).toBe(1000000) + expect(getKnownModelContextWindow('deepseek-chat')).toBe(1000000) + expect(getKnownModelContextWindow('deepseek-reasoner')).toBe(1000000) + }) + + it('returns undefined for unrecognized models, 128K via the defaulting wrapper', () => { + expect(getKnownModelContextWindow('some-custom-model')).toBeUndefined() + expect(getModelContextWindow('some-custom-model')).toBe(128000) + }) +}) diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index 5ec3f748b2..ffa4e5db24 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -287,21 +287,6 @@ export function getModelMaxTokens(provider: AIProvider, model: string) { return 8192 } -export function getModelContextWindow(model: string) { - if (model.includes('gpt-4.1') || model.includes('gemini')) { - return 1000000 - } else if (model.includes('gpt-5')) { - return 400000 - } else if (model.includes('gpt-4o') || model.includes('llama-3.3')) { - return 128000 - } else if (model.includes('claude') || model.includes('o4-mini') || model.includes('o3')) { - return 200000 - } else if (model.includes('codestral')) { - return 32000 - } else { - return 128000 - } -} function getModelSpecificConfig( modelProvider: AIProviderModel, diff --git a/frontend/src/lib/components/copilot/modelConfig.ts b/frontend/src/lib/components/copilot/modelConfig.ts index 726ff4e387..4ab08ce02c 100644 --- a/frontend/src/lib/components/copilot/modelConfig.ts +++ b/frontend/src/lib/components/copilot/modelConfig.ts @@ -9,3 +9,52 @@ export function requiresMaxCompletionTokens(model: string) { const baseModel = normalizedModel.split('/').pop() ?? normalizedModel return baseModel.startsWith('gpt-5') || /^o\d/.test(baseModel) } + +// Context windows of the models we know, most specific entry first — the first +// name included in the model id wins, so provider-prefixed and date-suffixed +// ids (anthropic.claude-sonnet-4-6-...-v1:0, gpt-5.2-2026-01-01) still resolve. +// Conservative family fallbacks sit below the explicit entries; models not +// listed at all resolve to undefined, which disables auto-trimming and the +// indicator denominator. +const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [ + // Anthropic — Sonnet/Opus 4.6+ ship a 1M window at standard pricing (GA); + // Haiku, older Claude models (3.x, 4.0, 4.1, 4.5) and date-suffixed Claude 4 + // base ids (claude-sonnet-4-20250514) fall through to 200K + ['claude-fable-5', 1_000_000], + ['claude-opus-4-8', 1_000_000], + ['claude-opus-4-7', 1_000_000], + ['claude-opus-4-6', 1_000_000], + ['claude-sonnet-4-6', 1_000_000], + ['claude', 200_000], + // OpenAI — gpt-5 covers the base family (-mini / -nano) and the 5.1/5.2 + // revisions, all 400K; only 5.4+ moved to 1M + ['gpt-5.5', 1_000_000], + ['gpt-5.4', 1_000_000], + ['gpt-5', 400_000], + ['gpt-4.1', 1_000_000], + ['gpt-4o', 128_000], + ['o4-mini', 200_000], + ['o3', 200_000], + // Google — the 2.5 / 3 / 3.1 Gemini families are all 1M + ['gemini-3.1', 1_000_000], + ['gemini-3', 1_000_000], + ['gemini-2.5', 1_000_000], + // DeepSeek — the V4 family is 1M; deepseek-chat / deepseek-reasoner are + // aliases of V4-Flash since April 2026 + ['deepseek-v4', 1_000_000], + ['deepseek-chat', 1_000_000], + ['deepseek-reasoner', 1_000_000], + ['deepseek', 128_000], + // Others + ['llama', 128_000], + ['codestral', 32_000] +] + +export function getKnownModelContextWindow(model: string): number | undefined { + return MODEL_CONTEXT_WINDOWS.find(([name]) => model.includes(name))?.[1] +} + +export function getModelContextWindow(model: string) { + // Trim/compaction logic needs a number; assume a conservative window when unknown. + return getKnownModelContextWindow(model) ?? 128000 +}