Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/qoder/runtime/qoder-chat-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/qoder/runtime/qoder-response-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
80 changes: 61 additions & 19 deletions src/qoder/runtime/qoder-turn-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -92,53 +112,75 @@ 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;
const contextTokens = reportedTotalTokens
|| 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))),
},
Expand Down
81 changes: 58 additions & 23 deletions tests/unit/qoder/runtime/qoder-chat-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
],
}),
};

Expand All @@ -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 },
],
}),
};

Expand All @@ -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,
Expand Down
Loading