diff --git a/CHANGELOG.md b/CHANGELOG.md index b182cfe..129e0ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,14 @@ version with its date and start a fresh empty `[Unreleased]` above it. count and logs per-stage details to the developer console, including the underlying file error (such as a permission denial) for each session history file that fails to load. +- The context usage meter updates again after each response: Qoder CLI + 1.1.21 changed its context-usage report to a percentage-based shape + without absolute token counts, which the meter could not read, so it + stayed stuck at its "appears after the first response" placeholder. +- The context usage meter keeps the context-window tier chosen in the + per-model editor after a response; previously the post-response + refresh silently fell back to the model catalog default (such as + 200K), so a 400K selection reverted to 200K once a message was sent. ## [1.0.4] - 2026-08-12 diff --git a/src/qoder/runtime/qoder-chat-runtime.ts b/src/qoder/runtime/qoder-chat-runtime.ts index 5da9b1c..ceb9ea4 100644 --- a/src/qoder/runtime/qoder-chat-runtime.ts +++ b/src/qoder/runtime/qoder-chat-runtime.ts @@ -59,6 +59,7 @@ import { getActiveQoderCliEdition, getQoderCliBinaryBaseName } from '../config/c import { loadSubagentFinalResult, loadSubagentToolCalls } from '../history/qoder-history-store'; import type { McpServerManager } from '../mcp/mcp-server-manager'; import { toQoderRuntimeModelId } from '../models/model-selection'; +import { qoderModelConfig } from '../models/qoder-model-config'; import { stripCurrentNoteContext } from '../prompt/context/prompt-context'; import { encodeQoderTurn } from '../prompt/qoder-turn-encoder'; import type { QoderHostContext } from '../qoder-host-context'; @@ -183,6 +184,10 @@ export class QoderChatRuntime implements ChatRuntime { getCurrentQuery: () => this.persistentQuery, getMessageChannel: () => this.messageChannel, getConfiguredModel: () => this.getScopedSettings().model, + getConfiguredContextWindow: () => { + const settings = this.getScopedSettings(); + return qoderModelConfig.getEffectiveContextWindowSize(settings.model, settings); + }, getSessionId: () => this.sessionManager.getSessionId(), onSessionInit: event => { const wasFork = this.pendingForkSession; diff --git a/src/qoder/runtime/qoder-response-router.ts b/src/qoder/runtime/qoder-response-router.ts index 897aa78..1ef89df 100644 --- a/src/qoder/runtime/qoder-response-router.ts +++ b/src/qoder/runtime/qoder-response-router.ts @@ -16,6 +16,8 @@ interface QoderResponseRouterDeps { getCurrentQuery: () => Query | null; getMessageChannel: () => QoderMessageChannel | null; getConfiguredModel: () => string; + /** Effective context window from the per-model override, if any. */ + getConfiguredContextWindow: () => number | undefined; getSessionId: () => string | null; onSessionInit: (event: SessionInitEvent) => void; onPlanModeEntered: () => void; @@ -137,6 +139,7 @@ export class QoderResponseRouter { query: this.deps.getCurrentQuery(), isCurrentQuery: query => this.deps.getCurrentQuery() === query, configuredModel: this.deps.getConfiguredModel(), + configuredContextWindow: this.deps.getConfiguredContextWindow(), sessionId: this.deps.getSessionId(), }); if (contextUsageChunk) { diff --git a/src/qoder/runtime/qoder-turn-tracker.ts b/src/qoder/runtime/qoder-turn-tracker.ts index 4d7155f..0af7561 100644 --- a/src/qoder/runtime/qoder-turn-tracker.ts +++ b/src/qoder/runtime/qoder-turn-tracker.ts @@ -16,9 +16,29 @@ interface ContextUsageRequest { query: Query | null; isCurrentQuery: (query: Query) => boolean; configuredModel: string; + /** Effective context window from the per-model editor override, if any. */ + configuredContextWindow?: number; sessionId: string | null; } +/** + * Wire shape returned by the qodercli `get_context_usage` control API + * (1.1.21+). Percentages are reported in percent units (5.4 === 5.4%); + * absolute token counts only appear when the CLI can provide them. + * The SDK's declared response type still mirrors an older shape, so the + * tracker reads the payload through this interface. + */ +interface CliContextUsagePayload { + model?: string; + tokenCountsAvailable?: boolean; + contextWindow?: { + usedPercentage?: number; + usedTokens?: number; + maxTokens?: number; + }; + categories?: Array<{ type?: string; tokens?: number; percentage?: number }>; +} + /** Owns metadata and usage state that lives for exactly one Qoder turn. */ export class QoderTurnTracker { private metadata: ChatTurnMetadata = {}; @@ -92,29 +112,56 @@ export class QoderTurnTracker { } try { - const response = await activeQuery.getContextUsage(); + const payload = await activeQuery.getContextUsage() as unknown as CliContextUsagePayload; if (!request.isCurrentQuery(activeQuery)) { return null; } const previousUsage = this.bufferedUsageChunk?.usage; const model = toQoderRuntimeModelId( - response.model || previousUsage?.model || request.configuredModel, + payload.model || previousUsage?.model || request.configuredModel, ); - const reportedContextWindow = [response.rawMaxTokens, response.maxTokens] - .find(value => Number.isFinite(value) && value > 0); + const rawMaxTokens = payload.contextWindow?.maxTokens; + const reportedMaxTokens = typeof rawMaxTokens === 'number' + && Number.isFinite(rawMaxTokens) && rawMaxTokens > 0 + ? rawMaxTokens + : undefined; + const hasReportedWindow = reportedMaxTokens !== undefined; const previousContextWindow = previousUsage?.model === model && previousUsage.contextWindow > 0 ? previousUsage.contextWindow : undefined; - const contextWindow = reportedContextWindow + // Without a CLI-reported window the configured tier is the source of + // truth; buffered chunks only carry catalog fallbacks. + const configuredContextWindow = Number.isFinite(request.configuredContextWindow) + && (request.configuredContextWindow as number) > 0 + ? request.configuredContextWindow + : undefined; + const contextWindow = reportedMaxTokens + ?? configuredContextWindow ?? previousContextWindow ?? getContextWindowSize(model); - const ratio = Number.isFinite(response.percentage) - ? Math.min(1, Math.max(0, response.percentage)) + + const rawUsedPercentage = payload.contextWindow?.usedPercentage; + const hasReportedRatio = typeof rawUsedPercentage === 'number' + && Number.isFinite(rawUsedPercentage); + const ratio = hasReportedRatio ? Math.min(1, Math.max(0, rawUsedPercentage / 100)) : 0; + + // Absolute counts are only meaningful when the CLI can provide them. + const categoryTokens = payload.tokenCountsAvailable === true + ? (payload.categories ?? []).reduce( + (sum, category) => sum + (typeof category.tokens === 'number' + && Number.isFinite(category.tokens) && category.tokens > 0 + ? category.tokens + : 0), + 0, + ) : 0; - const reportedTotalTokens = Number.isFinite(response.totalTokens) && response.totalTokens > 0 - ? response.totalTokens + const rawUsedTokens = payload.contextWindow?.usedTokens; + const usedTokens = typeof rawUsedTokens === 'number' + && Number.isFinite(rawUsedTokens) && rawUsedTokens > 0 + ? rawUsedTokens : 0; + const reportedTotalTokens = [usedTokens, categoryTokens].find(value => value > 0) ?? 0; const estimatedContextTokens = ratio > 0 ? Math.max(1, Math.round(contextWindow * ratio)) : 0; @@ -122,23 +169,18 @@ export class QoderTurnTracker { || estimatedContextTokens || previousUsage?.contextTokens || 0; - const apiUsage = response.apiUsage; return { type: 'usage', usage: { model, - inputTokens: apiUsage?.input_tokens || previousUsage?.inputTokens || 0, - cacheCreationInputTokens: apiUsage?.cache_creation_input_tokens - || previousUsage?.cacheCreationInputTokens - || 0, - cacheReadInputTokens: apiUsage?.cache_read_input_tokens - || previousUsage?.cacheReadInputTokens - || 0, + inputTokens: previousUsage?.inputTokens || 0, + cacheCreationInputTokens: previousUsage?.cacheCreationInputTokens || 0, + cacheReadInputTokens: previousUsage?.cacheReadInputTokens || 0, contextWindow, - contextWindowIsAuthoritative: reportedContextWindow !== undefined, + contextWindowIsAuthoritative: hasReportedWindow, contextTokens, - percentage: Number.isFinite(response.percentage) + percentage: hasReportedRatio ? Math.round(ratio * 100) : Math.min(100, Math.max(0, Math.round((contextTokens / contextWindow) * 100))), }, diff --git a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts index dd7dabb..5279686 100644 --- a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts +++ b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts @@ -1305,17 +1305,13 @@ describe('QoderChatRuntime', () => { it('should use getContextUsage percentage when Qoder CLI masks token counts', async () => { (service as any).persistentQuery = { getContextUsage: jest.fn().mockResolvedValue({ - totalTokens: 0, - maxTokens: 0, - rawMaxTokens: 0, - percentage: 0.125, model: 'performance', - apiUsage: { - input_tokens: 0, - output_tokens: 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, + tokenCountsAvailable: false, + contextWindow: { usedPercentage: 12.5 }, + categories: [ + { type: 'system_prompt', percentage: 2.5 }, + { type: 'messages', percentage: 10 }, + ], }), }; @@ -1342,20 +1338,59 @@ describe('QoderChatRuntime', () => { expect(onDone).toHaveBeenCalled(); }); + it('should honor the configured context-window tier when the CLI omits the window', async () => { + (mockPlugin as any).settings.model = 'performance'; + (mockPlugin as any).settings.qoder = { + discoveredModels: [{ + value: 'performance', + contextTiers: [ + { label: '200K', tokenCount: 200_000, isDefault: true }, + { label: '400K', tokenCount: 400_000, isDefault: false }, + ], + }], + modelOverrides: { performance: { contextWindow: 400_000 } }, + }; + (service as any).persistentQuery = { + getContextUsage: jest.fn().mockResolvedValue({ + model: 'performance', + tokenCountsAvailable: false, + contextWindow: { usedPercentage: 10 }, + categories: [], + }), + }; + + await (service as any).responseRouter.route({ + type: 'result', + subtype: 'success', + result: 'completed', + }); + + expect(onChunk).toHaveBeenCalledWith({ + type: 'usage', + usage: { + model: 'performance', + inputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + contextWindow: 400_000, + contextWindowIsAuthoritative: false, + contextTokens: 40_000, + percentage: 10, + }, + sessionId: null, + }); + }); + it('should prefer public context token counts when Qoder CLI returns them', async () => { (service as any).persistentQuery = { getContextUsage: jest.fn().mockResolvedValue({ - totalTokens: 12_000, - maxTokens: 280_000, - rawMaxTokens: 300_000, - percentage: 0.04, model: 'ultimate', - apiUsage: { - input_tokens: 10_000, - output_tokens: 500, - cache_creation_input_tokens: 1_000, - cache_read_input_tokens: 1_000, - }, + tokenCountsAvailable: true, + contextWindow: { usedPercentage: 4, usedTokens: 12_000, maxTokens: 300_000 }, + categories: [ + { type: 'system_prompt', tokens: 2_000, percentage: 0.7 }, + { type: 'messages', tokens: 10_000, percentage: 3.3 }, + ], }), }; @@ -1369,9 +1404,9 @@ describe('QoderChatRuntime', () => { type: 'usage', usage: { model: 'ultimate', - inputTokens: 10_000, - cacheCreationInputTokens: 1_000, - cacheReadInputTokens: 1_000, + inputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, contextWindow: 300_000, contextWindowIsAuthoritative: true, contextTokens: 12_000,