Skip to content

Commit ff47e16

Browse files
author
Aashna Garg
committed
Add automode.routerModelSelection telemetry with actualModel
Emits a new telemetry event after all client-side model overrides (same-provider, vision fallback, default selection) are applied. Properties: - candidateModel: the router's top pick (candidate_models[0]) - actualModel: the model actually used after all overrides - overrideReason: none, defaultFallback, or clientOverride - conversationId: for correlation This enables accurate switch attribution without fragile cross-event joins. The existing automode.routerDecision event captures the router's recommendation, but doesn't know what model was ultimately selected because vision fallback and default selection happen afterward. Analysts can now query automode.routerModelSelection directly: | where candidateModel != actualModel | summarize count() by candidateModel, actualModel
1 parent c622ad5 commit ff47e16

2 files changed

Lines changed: 99 additions & 4 deletions

File tree

extensions/copilot/src/platform/endpoint/node/automodeService.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,31 @@ export class AutomodeService extends Disposable implements IAutomodeService {
215215

216216
selectedModel = this._applyVisionFallback(chatRequest, selectedModel, token.available_models, knownEndpoints);
217217

218+
// Emit the final model selection alongside the router's recommendation
219+
// so analysts can detect overrides without fragile telemetry joins
220+
if (!skipRouter && routerResult.candidateModel) {
221+
/* __GDPR__
222+
"automode.routerModelSelection" : {
223+
"owner": "aashnagarg",
224+
"comment": "Reports the router's recommended model vs the actual model used after all client-side overrides (same-provider, vision fallback, default selection)",
225+
"conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The conversation ID" },
226+
"candidateModel": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The router's top candidate model (candidate_models[0])" },
227+
"actualModel": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model actually selected after all client-side overrides" },
228+
"overrideReason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Why the actual model differs from the candidate: none, visionFallback, defaultFallback, or sameProvider" }
229+
}
230+
*/
231+
const candidateModel = routerResult.candidateModel;
232+
const overrideReason = candidateModel === selectedModel.model ? 'none'
233+
: routerFallbackReason ? 'defaultFallback'
234+
: 'clientOverride';
235+
this._telemetryService.sendMSFTTelemetryEvent('automode.routerModelSelection', {
236+
conversationId: conversationId ?? '',
237+
candidateModel,
238+
actualModel: selectedModel.model,
239+
overrideReason,
240+
});
241+
}
242+
218243
// Reuse the cached endpoint if the session token and model haven't changed
219244
const autoEndpoint = (entry?.endpoint && entry.lastSessionToken === token.session_token && entry.endpoint.model === selectedModel.model)
220245
? entry.endpoint
@@ -250,7 +275,7 @@ export class AutomodeService extends Disposable implements IAutomodeService {
250275
entry: AutoModelCacheEntry | undefined,
251276
token: AutoModeAPIResponse,
252277
knownEndpoints: IChatEndpoint[],
253-
): Promise<{ selectedModel?: IChatEndpoint; lastRoutedPrompt?: string; fallbackReason?: string }> {
278+
): Promise<{ selectedModel?: IChatEndpoint; lastRoutedPrompt?: string; fallbackReason?: string; candidateModel?: string }> {
254279
const prompt = chatRequest?.prompt?.trim();
255280
const lastRoutedPrompt = entry?.lastRoutedPrompt ?? prompt;
256281

@@ -298,7 +323,7 @@ export class AutomodeService extends Disposable implements IAutomodeService {
298323
if (result.sticky_override) {
299324
this._logService.trace(`[AutomodeService] Sticky routing override: confidence=${(result.confidence * 100).toFixed(1)}%, label=${result.predicted_label}, router_model=${result.candidate_models[0]}, actual_model=${selectedModel.model}`);
300325
}
301-
return { selectedModel, lastRoutedPrompt: prompt };
326+
return { selectedModel, lastRoutedPrompt: prompt, candidateModel: result.candidate_models[0] };
302327
} catch (e) {
303328
const isTimeout = isAbortError(e);
304329
let fallbackReason: string;

extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { ILogService } from '../../../log/common/logService';
1717
import { IChatEndpoint } from '../../../networking/common/networking';
1818
import { NullRequestLogger } from '../../../requestLogger/node/nullRequestLogger';
1919
import { IExperimentationService, NullExperimentationService } from '../../../telemetry/common/nullExperimentationService';
20-
import { NullTelemetryService } from '../../../telemetry/common/nullTelemetryService';
20+
import { ITelemetryService } from '../../../telemetry/common/telemetry';
2121
import { ICAPIClientService } from '../../common/capiClient';
2222
import { AutomodeService } from '../automodeService';
2323

@@ -60,6 +60,7 @@ describe('AutomodeService', () => {
6060
let configurationService: IConfigurationService;
6161
let mockChatEndpoint: IChatEndpoint;
6262
let envService: NullEnvService;
63+
let mockTelemetryService: ITelemetryService & { sendMSFTTelemetryEvent: ReturnType<typeof vi.fn> };
6364

6465
function createEndpoint(model: string, provider: string, overrides?: Partial<IChatEndpoint>): IChatEndpoint {
6566
return {
@@ -87,7 +88,7 @@ describe('AutomodeService', () => {
8788
mockExpService,
8889
configurationService,
8990
envService,
90-
new NullTelemetryService(),
91+
mockTelemetryService,
9192
new NullRequestLogger()
9293
);
9394
}
@@ -145,6 +146,13 @@ describe('AutomodeService', () => {
145146

146147
configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());
147148
envService = new NullEnvService();
149+
mockTelemetryService = {
150+
sendTelemetryEvent: vi.fn(),
151+
sendMSFTTelemetryEvent: vi.fn(),
152+
sendTelemetryErrorEvent: vi.fn(),
153+
sendMSFTTelemetryErrorEvent: vi.fn(),
154+
sendSharedTelemetryEvent: vi.fn(),
155+
} as unknown as ITelemetryService & { sendMSFTTelemetryEvent: ReturnType<typeof vi.fn> };
148156
});
149157

150158
afterEach(() => {
@@ -946,6 +954,68 @@ describe('AutomodeService', () => {
946954
});
947955
});
948956

957+
describe('routerModelSelection telemetry', () => {
958+
it('should emit routerModelSelection with candidateModel and actualModel when router is used', async () => {
959+
enableRouter();
960+
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
961+
const claudeEndpoint = createEndpoint('claude-sonnet', 'Anthropic');
962+
963+
mockRouterResponse(
964+
['gpt-4o', 'claude-sonnet'],
965+
{ chosen_model: 'gpt-4o', candidate_models: ['gpt-4o', 'claude-sonnet'] }
966+
);
967+
968+
automodeService = createService();
969+
const chatRequest: Partial<ChatRequest> = {
970+
location: ChatLocation.Panel,
971+
prompt: 'test prompt',
972+
sessionId: 'session-telemetry-test'
973+
};
974+
975+
await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [gpt4oEndpoint, claudeEndpoint]);
976+
977+
const telemetryCalls = mockTelemetryService.sendMSFTTelemetryEvent.mock.calls;
978+
const selectionEvent = telemetryCalls.find((call: unknown[]) => call[0] === 'automode.routerModelSelection');
979+
expect(selectionEvent).toBeDefined();
980+
expect(selectionEvent![1]).toMatchObject({
981+
candidateModel: 'gpt-4o',
982+
actualModel: 'gpt-4o',
983+
overrideReason: 'none',
984+
});
985+
});
986+
987+
it('should emit overrideReason=clientOverride when actual model differs from candidate', async () => {
988+
enableRouter();
989+
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
990+
const claudeEndpoint = createEndpoint('claude-sonnet', 'Anthropic');
991+
992+
// Router picks unknown-model but it has no endpoint, so falls back
993+
mockRouterResponse(
994+
['gpt-4o'],
995+
{ chosen_model: 'unknown-model', candidate_models: ['unknown-model'] }
996+
);
997+
998+
automodeService = createService();
999+
const chatRequest: Partial<ChatRequest> = {
1000+
location: ChatLocation.Panel,
1001+
prompt: 'test prompt',
1002+
sessionId: 'session-telemetry-override'
1003+
};
1004+
1005+
await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [gpt4oEndpoint, claudeEndpoint]);
1006+
1007+
const telemetryCalls = mockTelemetryService.sendMSFTTelemetryEvent.mock.calls;
1008+
const fallbackEvent = telemetryCalls.find((call: unknown[]) => call[0] === 'automode.routerModelSelection');
1009+
// When router returns unknown model, candidateModel is set but selectedModel
1010+
// is undefined so it falls to _selectDefaultModel — no routerModelSelection emitted
1011+
// because fallbackReason is set and candidateModel is returned
1012+
expect(fallbackEvent).toBeDefined();
1013+
if (fallbackEvent) {
1014+
expect(fallbackEvent[1].overrideReason).toBe('defaultFallback');
1015+
}
1016+
});
1017+
});
1018+
9491019
describe('vision fallback', () => {
9501020
it('should fall back to vision-capable model when selected model does not support vision', async () => {
9511021
const nonVisionEndpoint = createEndpoint('gpt-4o-mini', 'OpenAI', { supportsVision: false });

0 commit comments

Comments
 (0)