Skip to content

Commit bcda153

Browse files
luoxuanzaoQoder-AI
andauthored
fix: restore the context usage meter on CLI 1.1.21 and keep the chosen context-window tier (#27)
* fix: read the CLI 1.1.21 context-usage response shape qodercli 1.1.21 changed the get_context_usage control response from flat token counts (totalTokens/maxTokens/rawMaxTokens/percentage) to a percentage-based shape (contextWindow.usedPercentage, categories, tokenCountsAvailable). The turn tracker kept reading the old fields, so every lookup came back undefined and the context usage meter was stuck at its "appears after the first response" placeholder. Parse the new shape: percentage drives the meter directly, absolute token counts are used when the CLI reports them (tokenCountsAvailable), and window size falls back to the previous turn or the model catalog. Router tests now mock the new wire shape. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> * fix: keep the chosen context-window tier in the usage meter The post-response context-usage refresh fell back to the model catalog default window whenever the CLI omitted maxTokens, so a tier chosen in the per-model editor (such as 400K) reverted to the default (200K) after the first message. Route the effective per-model context window into the turn tracker and prefer it over the catalog fallback when the CLI reports no absolute window. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> --------- Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent 22ffd48 commit bcda153

5 files changed

Lines changed: 135 additions & 42 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ version with its date and start a fresh empty `[Unreleased]` above it.
6262
count and logs per-stage details to the developer console, including
6363
the underlying file error (such as a permission denial) for each
6464
session history file that fails to load.
65+
- The context usage meter updates again after each response: Qoder CLI
66+
1.1.21 changed its context-usage report to a percentage-based shape
67+
without absolute token counts, which the meter could not read, so it
68+
stayed stuck at its "appears after the first response" placeholder.
69+
- The context usage meter keeps the context-window tier chosen in the
70+
per-model editor after a response; previously the post-response
71+
refresh silently fell back to the model catalog default (such as
72+
200K), so a 400K selection reverted to 200K once a message was sent.
6573

6674
## [1.0.4] - 2026-08-12
6775

‎src/qoder/runtime/qoder-chat-runtime.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import { getActiveQoderCliEdition, getQoderCliBinaryBaseName } from '../config/c
5959
import { loadSubagentFinalResult, loadSubagentToolCalls } from '../history/qoder-history-store';
6060
import type { McpServerManager } from '../mcp/mcp-server-manager';
6161
import { toQoderRuntimeModelId } from '../models/model-selection';
62+
import { qoderModelConfig } from '../models/qoder-model-config';
6263
import { stripCurrentNoteContext } from '../prompt/context/prompt-context';
6364
import { encodeQoderTurn } from '../prompt/qoder-turn-encoder';
6465
import type { QoderHostContext } from '../qoder-host-context';
@@ -183,6 +184,10 @@ export class QoderChatRuntime implements ChatRuntime {
183184
getCurrentQuery: () => this.persistentQuery,
184185
getMessageChannel: () => this.messageChannel,
185186
getConfiguredModel: () => this.getScopedSettings().model,
187+
getConfiguredContextWindow: () => {
188+
const settings = this.getScopedSettings();
189+
return qoderModelConfig.getEffectiveContextWindowSize(settings.model, settings);
190+
},
186191
getSessionId: () => this.sessionManager.getSessionId(),
187192
onSessionInit: event => {
188193
const wasFork = this.pendingForkSession;

‎src/qoder/runtime/qoder-response-router.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ interface QoderResponseRouterDeps {
1616
getCurrentQuery: () => Query | null;
1717
getMessageChannel: () => QoderMessageChannel | null;
1818
getConfiguredModel: () => string;
19+
/** Effective context window from the per-model override, if any. */
20+
getConfiguredContextWindow: () => number | undefined;
1921
getSessionId: () => string | null;
2022
onSessionInit: (event: SessionInitEvent) => void;
2123
onPlanModeEntered: () => void;
@@ -137,6 +139,7 @@ export class QoderResponseRouter {
137139
query: this.deps.getCurrentQuery(),
138140
isCurrentQuery: query => this.deps.getCurrentQuery() === query,
139141
configuredModel: this.deps.getConfiguredModel(),
142+
configuredContextWindow: this.deps.getConfiguredContextWindow(),
140143
sessionId: this.deps.getSessionId(),
141144
});
142145
if (contextUsageChunk) {

‎src/qoder/runtime/qoder-turn-tracker.ts‎

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,29 @@ interface ContextUsageRequest {
1616
query: Query | null;
1717
isCurrentQuery: (query: Query) => boolean;
1818
configuredModel: string;
19+
/** Effective context window from the per-model editor override, if any. */
20+
configuredContextWindow?: number;
1921
sessionId: string | null;
2022
}
2123

24+
/**
25+
* Wire shape returned by the qodercli `get_context_usage` control API
26+
* (1.1.21+). Percentages are reported in percent units (5.4 === 5.4%);
27+
* absolute token counts only appear when the CLI can provide them.
28+
* The SDK's declared response type still mirrors an older shape, so the
29+
* tracker reads the payload through this interface.
30+
*/
31+
interface CliContextUsagePayload {
32+
model?: string;
33+
tokenCountsAvailable?: boolean;
34+
contextWindow?: {
35+
usedPercentage?: number;
36+
usedTokens?: number;
37+
maxTokens?: number;
38+
};
39+
categories?: Array<{ type?: string; tokens?: number; percentage?: number }>;
40+
}
41+
2242
/** Owns metadata and usage state that lives for exactly one Qoder turn. */
2343
export class QoderTurnTracker {
2444
private metadata: ChatTurnMetadata = {};
@@ -92,53 +112,75 @@ export class QoderTurnTracker {
92112
}
93113

94114
try {
95-
const response = await activeQuery.getContextUsage();
115+
const payload = await activeQuery.getContextUsage() as unknown as CliContextUsagePayload;
96116
if (!request.isCurrentQuery(activeQuery)) {
97117
return null;
98118
}
99119

100120
const previousUsage = this.bufferedUsageChunk?.usage;
101121
const model = toQoderRuntimeModelId(
102-
response.model || previousUsage?.model || request.configuredModel,
122+
payload.model || previousUsage?.model || request.configuredModel,
103123
);
104-
const reportedContextWindow = [response.rawMaxTokens, response.maxTokens]
105-
.find(value => Number.isFinite(value) && value > 0);
124+
const rawMaxTokens = payload.contextWindow?.maxTokens;
125+
const reportedMaxTokens = typeof rawMaxTokens === 'number'
126+
&& Number.isFinite(rawMaxTokens) && rawMaxTokens > 0
127+
? rawMaxTokens
128+
: undefined;
129+
const hasReportedWindow = reportedMaxTokens !== undefined;
106130
const previousContextWindow = previousUsage?.model === model && previousUsage.contextWindow > 0
107131
? previousUsage.contextWindow
108132
: undefined;
109-
const contextWindow = reportedContextWindow
133+
// Without a CLI-reported window the configured tier is the source of
134+
// truth; buffered chunks only carry catalog fallbacks.
135+
const configuredContextWindow = Number.isFinite(request.configuredContextWindow)
136+
&& (request.configuredContextWindow as number) > 0
137+
? request.configuredContextWindow
138+
: undefined;
139+
const contextWindow = reportedMaxTokens
140+
?? configuredContextWindow
110141
?? previousContextWindow
111142
?? getContextWindowSize(model);
112-
const ratio = Number.isFinite(response.percentage)
113-
? Math.min(1, Math.max(0, response.percentage))
143+
144+
const rawUsedPercentage = payload.contextWindow?.usedPercentage;
145+
const hasReportedRatio = typeof rawUsedPercentage === 'number'
146+
&& Number.isFinite(rawUsedPercentage);
147+
const ratio = hasReportedRatio ? Math.min(1, Math.max(0, rawUsedPercentage / 100)) : 0;
148+
149+
// Absolute counts are only meaningful when the CLI can provide them.
150+
const categoryTokens = payload.tokenCountsAvailable === true
151+
? (payload.categories ?? []).reduce(
152+
(sum, category) => sum + (typeof category.tokens === 'number'
153+
&& Number.isFinite(category.tokens) && category.tokens > 0
154+
? category.tokens
155+
: 0),
156+
0,
157+
)
114158
: 0;
115-
const reportedTotalTokens = Number.isFinite(response.totalTokens) && response.totalTokens > 0
116-
? response.totalTokens
159+
const rawUsedTokens = payload.contextWindow?.usedTokens;
160+
const usedTokens = typeof rawUsedTokens === 'number'
161+
&& Number.isFinite(rawUsedTokens) && rawUsedTokens > 0
162+
? rawUsedTokens
117163
: 0;
164+
const reportedTotalTokens = [usedTokens, categoryTokens].find(value => value > 0) ?? 0;
118165
const estimatedContextTokens = ratio > 0
119166
? Math.max(1, Math.round(contextWindow * ratio))
120167
: 0;
121168
const contextTokens = reportedTotalTokens
122169
|| estimatedContextTokens
123170
|| previousUsage?.contextTokens
124171
|| 0;
125-
const apiUsage = response.apiUsage;
126172

127173
return {
128174
type: 'usage',
129175
usage: {
130176
model,
131-
inputTokens: apiUsage?.input_tokens || previousUsage?.inputTokens || 0,
132-
cacheCreationInputTokens: apiUsage?.cache_creation_input_tokens
133-
|| previousUsage?.cacheCreationInputTokens
134-
|| 0,
135-
cacheReadInputTokens: apiUsage?.cache_read_input_tokens
136-
|| previousUsage?.cacheReadInputTokens
137-
|| 0,
177+
inputTokens: previousUsage?.inputTokens || 0,
178+
cacheCreationInputTokens: previousUsage?.cacheCreationInputTokens || 0,
179+
cacheReadInputTokens: previousUsage?.cacheReadInputTokens || 0,
138180
contextWindow,
139-
contextWindowIsAuthoritative: reportedContextWindow !== undefined,
181+
contextWindowIsAuthoritative: hasReportedWindow,
140182
contextTokens,
141-
percentage: Number.isFinite(response.percentage)
183+
percentage: hasReportedRatio
142184
? Math.round(ratio * 100)
143185
: Math.min(100, Math.max(0, Math.round((contextTokens / contextWindow) * 100))),
144186
},

‎tests/unit/qoder/runtime/qoder-chat-runtime.test.ts‎

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1305,17 +1305,13 @@ describe('QoderChatRuntime', () => {
13051305
it('should use getContextUsage percentage when Qoder CLI masks token counts', async () => {
13061306
(service as any).persistentQuery = {
13071307
getContextUsage: jest.fn().mockResolvedValue({
1308-
totalTokens: 0,
1309-
maxTokens: 0,
1310-
rawMaxTokens: 0,
1311-
percentage: 0.125,
13121308
model: 'performance',
1313-
apiUsage: {
1314-
input_tokens: 0,
1315-
output_tokens: 0,
1316-
cache_creation_input_tokens: 0,
1317-
cache_read_input_tokens: 0,
1318-
},
1309+
tokenCountsAvailable: false,
1310+
contextWindow: { usedPercentage: 12.5 },
1311+
categories: [
1312+
{ type: 'system_prompt', percentage: 2.5 },
1313+
{ type: 'messages', percentage: 10 },
1314+
],
13191315
}),
13201316
};
13211317

@@ -1342,20 +1338,59 @@ describe('QoderChatRuntime', () => {
13421338
expect(onDone).toHaveBeenCalled();
13431339
});
13441340

1341+
it('should honor the configured context-window tier when the CLI omits the window', async () => {
1342+
(mockPlugin as any).settings.model = 'performance';
1343+
(mockPlugin as any).settings.qoder = {
1344+
discoveredModels: [{
1345+
value: 'performance',
1346+
contextTiers: [
1347+
{ label: '200K', tokenCount: 200_000, isDefault: true },
1348+
{ label: '400K', tokenCount: 400_000, isDefault: false },
1349+
],
1350+
}],
1351+
modelOverrides: { performance: { contextWindow: 400_000 } },
1352+
};
1353+
(service as any).persistentQuery = {
1354+
getContextUsage: jest.fn().mockResolvedValue({
1355+
model: 'performance',
1356+
tokenCountsAvailable: false,
1357+
contextWindow: { usedPercentage: 10 },
1358+
categories: [],
1359+
}),
1360+
};
1361+
1362+
await (service as any).responseRouter.route({
1363+
type: 'result',
1364+
subtype: 'success',
1365+
result: 'completed',
1366+
});
1367+
1368+
expect(onChunk).toHaveBeenCalledWith({
1369+
type: 'usage',
1370+
usage: {
1371+
model: 'performance',
1372+
inputTokens: 0,
1373+
cacheCreationInputTokens: 0,
1374+
cacheReadInputTokens: 0,
1375+
contextWindow: 400_000,
1376+
contextWindowIsAuthoritative: false,
1377+
contextTokens: 40_000,
1378+
percentage: 10,
1379+
},
1380+
sessionId: null,
1381+
});
1382+
});
1383+
13451384
it('should prefer public context token counts when Qoder CLI returns them', async () => {
13461385
(service as any).persistentQuery = {
13471386
getContextUsage: jest.fn().mockResolvedValue({
1348-
totalTokens: 12_000,
1349-
maxTokens: 280_000,
1350-
rawMaxTokens: 300_000,
1351-
percentage: 0.04,
13521387
model: 'ultimate',
1353-
apiUsage: {
1354-
input_tokens: 10_000,
1355-
output_tokens: 500,
1356-
cache_creation_input_tokens: 1_000,
1357-
cache_read_input_tokens: 1_000,
1358-
},
1388+
tokenCountsAvailable: true,
1389+
contextWindow: { usedPercentage: 4, usedTokens: 12_000, maxTokens: 300_000 },
1390+
categories: [
1391+
{ type: 'system_prompt', tokens: 2_000, percentage: 0.7 },
1392+
{ type: 'messages', tokens: 10_000, percentage: 3.3 },
1393+
],
13591394
}),
13601395
};
13611396

@@ -1369,9 +1404,9 @@ describe('QoderChatRuntime', () => {
13691404
type: 'usage',
13701405
usage: {
13711406
model: 'ultimate',
1372-
inputTokens: 10_000,
1373-
cacheCreationInputTokens: 1_000,
1374-
cacheReadInputTokens: 1_000,
1407+
inputTokens: 0,
1408+
cacheCreationInputTokens: 0,
1409+
cacheReadInputTokens: 0,
13751410
contextWindow: 300_000,
13761411
contextWindowIsAuthoritative: true,
13771412
contextTokens: 12_000,

0 commit comments

Comments
 (0)