Skip to content

Commit 9b977f0

Browse files
committed
fix: /usage command crash
1 parent 63d5e4b commit 9b977f0

File tree

4 files changed

+111
-62
lines changed

4 files changed

+111
-62
lines changed

source/commands/usage.tsx

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,25 @@ export const usageCommand: Command = {
3131
) => {
3232
const {provider, model, getMessageTokens} = metadata;
3333

34-
// Create tokenizer for accurate breakdown
35-
const tokenizer = createTokenizer(provider, model);
34+
let tokenizer;
35+
let tokenizerName = 'fallback';
36+
37+
try {
38+
// Create tokenizer for accurate breakdown
39+
tokenizer = createTokenizer(provider, model);
40+
tokenizerName = tokenizer.getName();
41+
} catch {
42+
// Fallback to a simple tokenizer if creation fails
43+
tokenizer = {
44+
encode: (text: string) => Math.ceil((text || '').length / 4),
45+
countTokens: (message: Message) =>
46+
Math.ceil(
47+
((message.content || '') + (message.role || '')).length / 4,
48+
),
49+
getName: () => 'fallback',
50+
};
51+
tokenizerName = 'fallback (error)';
52+
}
3653

3754
// Generate the system prompt to include in token calculation
3855
const toolManager = getToolManager();
@@ -51,18 +68,26 @@ export const usageCommand: Command = {
5168
[systemMessage, ...messages],
5269
tokenizer,
5370
message => {
54-
// For system message, always use tokenizer directly to avoid cache misses
55-
if (message.role === 'system') {
56-
return tokenizer.countTokens(message);
71+
try {
72+
// For system message, always use tokenizer directly to avoid cache misses
73+
if (message.role === 'system') {
74+
return tokenizer.countTokens(message);
75+
}
76+
// For other messages, use cached token counts
77+
const tokens = getMessageTokens(message);
78+
// Ensure we always return a valid number
79+
return typeof tokens === 'number' && !Number.isNaN(tokens)
80+
? tokens
81+
: 0;
82+
} catch {
83+
// Fallback to simple estimation if tokenization fails
84+
return Math.ceil(
85+
((message.content || '') + (message.role || '')).length / 4,
86+
);
5787
}
58-
// For other messages, use cached token counts
59-
return getMessageTokens(message);
6088
},
6189
);
6290

63-
// Extract tokenizer name before cleanup
64-
const tokenizerName = tokenizer.getName();
65-
6691
// Clean up tokenizer resources
6792
if (tokenizer.free) {
6893
tokenizer.free();

source/components/usage/progress-bar.spec.tsx

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -297,20 +297,17 @@ test('ProgressBar total width equals specified width', t => {
297297
t.is(totalChars, width);
298298
});
299299

300-
test('ProgressBar handles zero width', t => {
300+
test('ProgressBar handles zero width with fallback to minimum', t => {
301301
const {lastFrame} = render(
302302
<ProgressBar percent={50} width={0} color="#00ff00" />,
303303
);
304304

305305
const output = lastFrame();
306-
// Output should be an empty string or have no progress characters
307-
if (output) {
308-
t.falsy(output.includes('█'));
309-
t.falsy(output.includes('░'));
310-
} else {
311-
// Empty string is acceptable for zero width
312-
t.is(output, '');
313-
}
306+
t.truthy(output);
307+
// Zero width should fall back to minimum width (10) to prevent crashes
308+
// 50% of 10 = 5 filled, 5 empty
309+
t.is(output!.match(//g)?.length, 5);
310+
t.is(output!.match(//g)?.length, 5);
314311
});
315312

316313
// ============================================================================

source/components/usage/progress-bar.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,14 @@ interface ProgressBarProps {
1414
* Renders an ASCII progress bar
1515
*/
1616
export function ProgressBar({percent, width, color}: ProgressBarProps) {
17-
const clampedPercent = Math.min(100, Math.max(0, percent));
18-
const filledWidth = Math.round((width * clampedPercent) / 100);
19-
const emptyWidth = width - filledWidth;
17+
// Ensure values are valid numbers to prevent crashes
18+
const safePercent = Number.isFinite(percent) ? percent : 0;
19+
const safeWidth =
20+
Number.isFinite(width) && width > 0 ? Math.floor(width) : 10;
21+
22+
const clampedPercent = Math.min(100, Math.max(0, safePercent));
23+
const filledWidth = Math.round((safeWidth * clampedPercent) / 100);
24+
const emptyWidth = safeWidth - filledWidth;
2025

2126
const filledBar = '█'.repeat(filledWidth);
2227
const emptyBar = '░'.repeat(emptyWidth);

source/models/models-dev-client.ts

Lines changed: 62 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -227,18 +227,22 @@ async function findModelById(modelId: string): Promise<ModelInfo | null> {
227227

228228
// Search through all providers
229229
for (const [_providerId, provider] of Object.entries(data)) {
230+
// Skip malformed provider entries
231+
if (!provider || typeof provider !== 'object' || !provider.models) {
232+
continue;
233+
}
230234
const model = provider.models[modelId];
231235
if (model) {
232236
return {
233237
id: model.id,
234238
name: model.name,
235239
provider: provider.name,
236-
contextLimit: model.limit.context,
237-
outputLimit: model.limit.output,
238-
supportsToolCalls: model.tool_call,
240+
contextLimit: model.limit?.context ?? null,
241+
outputLimit: model.limit?.output ?? null,
242+
supportsToolCalls: model.tool_call ?? false,
239243
cost: {
240-
input: model.cost.input,
241-
output: model.cost.output,
244+
input: model.cost?.input ?? 0,
245+
output: model.cost?.output ?? 0,
242246
},
243247
};
244248
}
@@ -266,21 +270,29 @@ async function findModelByName(modelName: string): Promise<ModelInfo | null> {
266270

267271
// Search through all providers
268272
for (const [_providerId, provider] of Object.entries(data)) {
273+
// Skip malformed provider entries
274+
if (!provider || typeof provider !== 'object' || !provider.models) {
275+
continue;
276+
}
269277
for (const [_modelId, model] of Object.entries(provider.models)) {
278+
// Skip malformed model entries
279+
if (!model || typeof model !== 'object') {
280+
continue;
281+
}
270282
if (
271-
model.id.toLowerCase().includes(lowerName) ||
272-
model.name.toLowerCase().includes(lowerName)
283+
(model.id && model.id.toLowerCase().includes(lowerName)) ||
284+
(model.name && model.name.toLowerCase().includes(lowerName))
273285
) {
274286
return {
275287
id: model.id,
276288
name: model.name,
277289
provider: provider.name,
278-
contextLimit: model.limit.context,
279-
outputLimit: model.limit.output,
280-
supportsToolCalls: model.tool_call,
290+
contextLimit: model.limit?.context ?? null,
291+
outputLimit: model.limit?.output ?? null,
292+
supportsToolCalls: model.tool_call ?? false,
281293
cost: {
282-
input: model.cost.input,
283-
output: model.cost.output,
294+
input: model.cost?.input ?? 0,
295+
output: model.cost?.output ?? 0,
284296
},
285297
};
286298
}
@@ -297,38 +309,48 @@ async function findModelByName(modelName: string): Promise<ModelInfo | null> {
297309
export async function getModelContextLimit(
298310
modelId: string,
299311
): Promise<number | null> {
300-
// Try Ollama fallback first with original model ID (before normalization)
301-
// This handles cloud models like gpt-oss:20b-cloud
302-
const ollamaLimitOriginal = getOllamaFallbackContextLimit(modelId);
303-
if (ollamaLimitOriginal) {
304-
return ollamaLimitOriginal;
305-
}
312+
try {
313+
// Try Ollama fallback first with original model ID (before normalization)
314+
// This handles cloud models like gpt-oss:20b-cloud
315+
const ollamaLimitOriginal = getOllamaFallbackContextLimit(modelId);
316+
if (ollamaLimitOriginal) {
317+
return ollamaLimitOriginal;
318+
}
306319

307-
// Strip :cloud or -cloud suffix if present (Ollama cloud models)
308-
const normalizedModelId =
309-
modelId.endsWith(':cloud') || modelId.endsWith('-cloud')
310-
? modelId.slice(0, -6) // Remove ":cloud" or "-cloud"
311-
: modelId;
320+
// Strip :cloud or -cloud suffix if present (Ollama cloud models)
321+
const normalizedModelId =
322+
modelId.endsWith(':cloud') || modelId.endsWith('-cloud')
323+
? modelId.slice(0, -6) // Remove ":cloud" or "-cloud"
324+
: modelId;
312325

313-
// Try exact ID match first
314-
let modelInfo = await findModelById(normalizedModelId);
326+
// Try exact ID match first
327+
let modelInfo = await findModelById(normalizedModelId);
315328

316-
// Try partial name match if exact match fails
317-
if (!modelInfo) {
318-
modelInfo = await findModelByName(normalizedModelId);
319-
}
329+
// Try partial name match if exact match fails
330+
if (!modelInfo) {
331+
modelInfo = await findModelByName(normalizedModelId);
332+
}
320333

321-
// If found in models.dev, return that
322-
if (modelInfo) {
323-
return modelInfo.contextLimit;
324-
}
334+
// If found in models.dev, return that
335+
if (modelInfo) {
336+
return modelInfo.contextLimit;
337+
}
325338

326-
// Fall back to Ollama model defaults with normalized ID
327-
const ollamaLimit = getOllamaFallbackContextLimit(normalizedModelId);
328-
if (ollamaLimit) {
329-
return ollamaLimit;
330-
}
339+
// Fall back to Ollama model defaults with normalized ID
340+
const ollamaLimit = getOllamaFallbackContextLimit(normalizedModelId);
341+
if (ollamaLimit) {
342+
return ollamaLimit;
343+
}
331344

332-
// No context limit found
333-
return null;
345+
// No context limit found
346+
return null;
347+
} catch (error) {
348+
// Log error but don't crash - just return null
349+
const logger = getLogger();
350+
logger.error(
351+
{error: formatError(error), modelId},
352+
'Error getting model context limit',
353+
);
354+
return null;
355+
}
334356
}

0 commit comments

Comments
 (0)