Skip to content

Commit 405eb5e

Browse files
authored
Merge pull request #978 from vybestack/issue684
fix: API stats tracking and theme violations in /stats model - fixed #684
2 parents c6aca68 + af48f1c commit 405eb5e

File tree

6 files changed

+611
-10
lines changed

6 files changed

+611
-10
lines changed

packages/cli/src/ui/components/ModelStatsDisplay.test.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
99
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
1010
import * as SessionContext from '../contexts/SessionContext.js';
1111
import { SessionMetrics } from '../contexts/SessionContext.js';
12+
import { Colors } from '../colors.js';
1213

1314
// Mock the context to provide controlled data for testing
1415
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
@@ -249,4 +250,98 @@ describe('<ModelStatsDisplay />', () => {
249250
expect(output).not.toContain('gemini-2.5-flash');
250251
expect(output).toMatchSnapshot();
251252
});
253+
254+
/**
255+
* Issue #684: Theme violation tests
256+
* All text in ModelStatsDisplay should use theme colors, not default white.
257+
*/
258+
describe('theme compliance', () => {
259+
it('should use Colors.Foreground for section headers (API, Tokens)', () => {
260+
// Verify that Colors.Foreground is defined (theme is available)
261+
expect(Colors.Foreground).toBeDefined();
262+
expect(typeof Colors.Foreground).toBe('string');
263+
264+
const { lastFrame } = renderWithMockedStats({
265+
models: {
266+
'test-model': {
267+
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
268+
tokens: {
269+
prompt: 10,
270+
candidates: 20,
271+
total: 30,
272+
cached: 0,
273+
thoughts: 0,
274+
tool: 0,
275+
},
276+
},
277+
},
278+
tools: {
279+
totalCalls: 0,
280+
totalSuccess: 0,
281+
totalFail: 0,
282+
totalDurationMs: 0,
283+
totalDecisions: { accept: 0, reject: 0, modify: 0 },
284+
byName: {},
285+
},
286+
});
287+
288+
const output = lastFrame();
289+
// Section headers should be present
290+
expect(output).toContain('API');
291+
expect(output).toContain('Tokens');
292+
// The output includes ANSI codes from ink, verify structure
293+
expect(output).toMatchSnapshot();
294+
});
295+
296+
it('should use Colors.Foreground for the empty state message', () => {
297+
const { lastFrame } = renderWithMockedStats({
298+
models: {},
299+
tools: {
300+
totalCalls: 0,
301+
totalSuccess: 0,
302+
totalFail: 0,
303+
totalDurationMs: 0,
304+
totalDecisions: { accept: 0, reject: 0, modify: 0 },
305+
byName: {},
306+
},
307+
});
308+
309+
const output = lastFrame();
310+
expect(output).toContain('No API calls have been made in this session.');
311+
// The text should include color codes from the theme
312+
expect(output).toMatchSnapshot();
313+
});
314+
315+
it('should use Colors.Foreground for stat row values', () => {
316+
const { lastFrame } = renderWithMockedStats({
317+
models: {
318+
'test-model': {
319+
api: { totalRequests: 5, totalErrors: 0, totalLatencyMs: 500 },
320+
tokens: {
321+
prompt: 100,
322+
candidates: 200,
323+
total: 300,
324+
cached: 0,
325+
thoughts: 0,
326+
tool: 0,
327+
},
328+
},
329+
},
330+
tools: {
331+
totalCalls: 0,
332+
totalSuccess: 0,
333+
totalFail: 0,
334+
totalDurationMs: 0,
335+
totalDecisions: { accept: 0, reject: 0, modify: 0 },
336+
byName: {},
337+
},
338+
});
339+
340+
const output = lastFrame();
341+
// Values should be present with theme colors
342+
expect(output).toContain('5'); // Requests count
343+
expect(output).toContain('100'); // Prompt tokens
344+
expect(output).toMatchSnapshot();
345+
});
346+
});
252347
});

packages/cli/src/ui/components/ModelStatsDisplay.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ interface StatRowProps {
2525
isSection?: boolean;
2626
}
2727

28+
/**
29+
* StatRow component for displaying a labeled row of values.
30+
* Issue #684: Fixed theme violations - all text now uses theme colors.
31+
*/
2832
const StatRow: React.FC<StatRowProps> = ({
2933
title,
3034
values,
@@ -33,13 +37,16 @@ const StatRow: React.FC<StatRowProps> = ({
3337
}) => (
3438
<Box>
3539
<Box width={METRIC_COL_WIDTH}>
36-
<Text bold={isSection} color={isSection ? undefined : Colors.LightBlue}>
40+
<Text
41+
bold={isSection}
42+
color={isSection ? Colors.Foreground : Colors.LightBlue}
43+
>
3744
{isSubtle ? ` ↳ ${title}` : title}
3845
</Text>
3946
</Box>
4047
{values.map((value, index) => (
4148
<Box width={MODEL_COL_WIDTH} key={index}>
42-
<Text>{value}</Text>
49+
<Text color={Colors.Foreground}>{value}</Text>
4350
</Box>
4451
))}
4552
</Box>

packages/core/src/providers/LoggingProviderWrapper.ts

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,16 @@ import {
2222
logConversationResponse,
2323
logTokenUsage,
2424
logApiRequest,
25+
logApiResponse,
26+
logApiError,
2527
} from '../telemetry/loggers.js';
2628
import {
2729
ConversationRequestEvent,
2830
ConversationResponseEvent,
2931
TokenUsageEvent,
3032
ApiRequestEvent,
33+
ApiResponseEvent,
34+
ApiErrorEvent,
3135
} from '../telemetry/types.js';
3236
import { getConversationFileWriter } from '../storage/ConversationFileWriter.js';
3337
import { ProviderPerformanceTracker } from './logging/ProviderPerformanceTracker.js';
@@ -613,13 +617,25 @@ export class LoggingProviderWrapper implements IProvider {
613617

614618
// Always process stream to extract token metrics
615619
// If logging not enabled, process for metrics only
620+
// Resolve the model name for telemetry - use resolved model, not provider default
621+
const resolvedModelName =
622+
normalizedOptions.resolved?.model || this.wrapped.getDefaultModel();
616623
if (!activeConfig?.getConversationLoggingEnabled()) {
617-
yield* this.processStreamForMetrics(activeConfig, stream);
624+
yield* this.processStreamForMetrics(
625+
activeConfig,
626+
stream,
627+
resolvedModelName,
628+
);
618629
return;
619630
}
620631

621632
// Log the response stream (which also processes metrics)
622-
yield* this.logResponseStream(activeConfig, stream, promptId);
633+
yield* this.logResponseStream(
634+
activeConfig,
635+
stream,
636+
promptId,
637+
resolvedModelName,
638+
);
623639
}
624640

625641
private async logRequest(
@@ -670,10 +686,12 @@ export class LoggingProviderWrapper implements IProvider {
670686
/**
671687
* Process stream to extract token metrics without logging
672688
* @plan PLAN-20250909-TOKTRACK
689+
* @issue #684 - Fixed: Now logs API response telemetry for /stats model
673690
*/
674691
private async *processStreamForMetrics(
675692
config: Config | undefined,
676693
stream: AsyncIterableIterator<IContent>,
694+
modelName: string,
677695
): AsyncIterableIterator<IContent> {
678696
const startTime = performance.now();
679697
let latestTokenUsage: UsageStats | undefined;
@@ -692,11 +710,39 @@ export class LoggingProviderWrapper implements IProvider {
692710
}
693711

694712
// Process metrics if we have token usage
695-
if (latestTokenUsage) {
696-
const duration = performance.now() - startTime;
697-
const tokenCounts =
698-
this.extractTokenCountsFromTokenUsage(latestTokenUsage);
713+
const duration = performance.now() - startTime;
714+
const tokenCounts = latestTokenUsage
715+
? this.extractTokenCountsFromTokenUsage(latestTokenUsage)
716+
: {
717+
input_token_count: 0,
718+
output_token_count: 0,
719+
cached_content_token_count: 0,
720+
thoughts_token_count: 0,
721+
tool_token_count: 0,
722+
cache_read_input_tokens: 0,
723+
cache_creation_input_tokens: null,
724+
};
725+
726+
// Issue #684: Log API response telemetry for /stats model tracking
727+
if (config) {
728+
// Create event and set token counts directly since constructor doesn't support raw counts
729+
const event = new ApiResponseEvent(
730+
modelName,
731+
duration,
732+
'', // promptId - not available in metrics-only path
733+
);
734+
event.input_token_count = tokenCounts.input_token_count;
735+
event.output_token_count = tokenCounts.output_token_count;
736+
event.cached_content_token_count =
737+
tokenCounts.cached_content_token_count;
738+
event.thoughts_token_count = tokenCounts.thoughts_token_count;
739+
event.tool_token_count = tokenCounts.tool_token_count;
740+
event.total_token_count =
741+
tokenCounts.input_token_count + tokenCounts.output_token_count;
742+
logApiResponse(config, event);
743+
}
699744

745+
if (latestTokenUsage) {
700746
// Accumulate token usage for session tracking
701747
this.accumulateTokenUsage(tokenCounts, config);
702748

@@ -713,6 +759,24 @@ export class LoggingProviderWrapper implements IProvider {
713759
// Record error in performance tracker
714760
const duration = performance.now() - startTime;
715761
this.performanceTracker.recordError(duration, String(error));
762+
763+
// Issue #684: Log API error telemetry
764+
if (config) {
765+
const errorMessage =
766+
error instanceof Error ? error.message : String(error);
767+
logApiError(
768+
config,
769+
new ApiErrorEvent(
770+
modelName,
771+
errorMessage,
772+
duration,
773+
'', // promptId
774+
undefined, // auth_type
775+
'stream_error', // error_type
776+
undefined, // status_code
777+
),
778+
);
779+
}
716780
throw error;
717781
}
718782
}
@@ -721,6 +785,7 @@ export class LoggingProviderWrapper implements IProvider {
721785
config: Config,
722786
stream: AsyncIterableIterator<IContent>,
723787
promptId: string,
788+
modelName: string,
724789
): AsyncIterableIterator<IContent> {
725790
const startTime = performance.now();
726791
let responseContent = '';
@@ -756,6 +821,7 @@ export class LoggingProviderWrapper implements IProvider {
756821
false,
757822
error,
758823
latestTokenUsage,
824+
modelName,
759825
);
760826
throw error;
761827
}
@@ -770,6 +836,7 @@ export class LoggingProviderWrapper implements IProvider {
770836
true,
771837
undefined,
772838
latestTokenUsage,
839+
modelName,
773840
);
774841
}
775842
}
@@ -804,6 +871,7 @@ export class LoggingProviderWrapper implements IProvider {
804871
success: boolean,
805872
error?: unknown,
806873
tokenUsage?: UsageStats,
874+
modelName?: string,
807875
): Promise<void> {
808876
try {
809877
const redactedContent = this.redactor
@@ -845,6 +913,25 @@ export class LoggingProviderWrapper implements IProvider {
845913
),
846914
);
847915

916+
// Issue #684: Log API response telemetry for /stats model tracking
917+
const resolvedModelName = modelName ?? this.wrapped.getDefaultModel();
918+
const apiResponseEvent = new ApiResponseEvent(
919+
resolvedModelName,
920+
duration,
921+
promptId,
922+
);
923+
apiResponseEvent.input_token_count = tokenCounts.input_token_count;
924+
apiResponseEvent.output_token_count = tokenCounts.output_token_count;
925+
apiResponseEvent.cached_content_token_count =
926+
tokenCounts.cached_content_token_count;
927+
apiResponseEvent.thoughts_token_count = tokenCounts.thoughts_token_count;
928+
apiResponseEvent.tool_token_count = tokenCounts.tool_token_count;
929+
apiResponseEvent.total_token_count = totalTokens;
930+
if (!success && error) {
931+
apiResponseEvent.error = String(error);
932+
}
933+
logApiResponse(config, apiResponseEvent);
934+
848935
const event = new ConversationResponseEvent(
849936
this.wrapped.name,
850937
this.conversationId,

packages/core/src/providers/__tests__/LoadBalancingProvider.circuitbreaker.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,14 @@ import {
1414
type LoadBalancerSubProfile,
1515
} from '../LoadBalancingProvider.js';
1616
import { ProviderManager } from '../ProviderManager.js';
17+
import { SettingsService } from '../../settings/SettingsService.js';
18+
import { createRuntimeConfigStub } from '../../test-utils/runtime.js';
19+
import type { Config } from '../../config/config.js';
1720
import type { IContent } from '../../services/history/IContent.js';
1821

1922
describe('LoadBalancingProvider Circuit Breaker - Phase 2', () => {
23+
let settingsService: SettingsService;
24+
let runtimeConfig: Config;
2025
let providerManager: ProviderManager;
2126
let config: LoadBalancingProviderConfig;
2227
const subProfiles: LoadBalancerSubProfile[] = [
@@ -37,7 +42,12 @@ describe('LoadBalancingProvider Circuit Breaker - Phase 2', () => {
3742
];
3843

3944
beforeEach(() => {
40-
providerManager = new ProviderManager();
45+
settingsService = new SettingsService();
46+
runtimeConfig = createRuntimeConfigStub(settingsService);
47+
providerManager = new ProviderManager({
48+
settingsService,
49+
config: runtimeConfig,
50+
});
4151
config = {
4252
profileName: 'test-lb',
4353
strategy: 'failover',

packages/core/src/providers/__tests__/LoadBalancingProvider.timeout.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,14 @@ import {
1414
type LoadBalancerSubProfile,
1515
} from '../LoadBalancingProvider.js';
1616
import { ProviderManager } from '../ProviderManager.js';
17+
import { SettingsService } from '../../settings/SettingsService.js';
18+
import { createRuntimeConfigStub } from '../../test-utils/runtime.js';
19+
import type { Config } from '../../config/config.js';
1720
import type { IContent } from '../../services/history/IContent.js';
1821

1922
describe('LoadBalancingProvider Timeout Wrapper - Phase 3', () => {
23+
let settingsService: SettingsService;
24+
let runtimeConfig: Config;
2025
let providerManager: ProviderManager;
2126
let config: LoadBalancingProviderConfig;
2227
const subProfiles: LoadBalancerSubProfile[] = [
@@ -38,7 +43,12 @@ describe('LoadBalancingProvider Timeout Wrapper - Phase 3', () => {
3843

3944
beforeEach(() => {
4045
vi.useFakeTimers();
41-
providerManager = new ProviderManager();
46+
settingsService = new SettingsService();
47+
runtimeConfig = createRuntimeConfigStub(settingsService);
48+
providerManager = new ProviderManager({
49+
settingsService,
50+
config: runtimeConfig,
51+
});
4252
config = {
4353
profileName: 'test-lb',
4454
strategy: 'failover',

0 commit comments

Comments
 (0)