Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion extensions/copilot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4389,7 +4389,7 @@
"null"
],
"default": null,
"markdownDescription": "Overrides the routing tier that the `Auto` model requests, ignoring both the tier picked in the model picker and the tier inline chat defaults to. Accepts `eco`, `balanced`, `max`, or `fast`. Used by evals.\n\n**Note**: This is an advanced debugging setting.",
"markdownDescription": "Overrides the routing tier that the `Auto` model requests, ignoring both the tier picked in the model picker and the tier inline chat defaults to. Accepts `efficiency`, `balance`, `intelligence`, or `fast`. Used by evals.\n\n**Note**: This is an advanced debugging setting.",
"tags": [
"advanced"
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ export function buildReasoningEffortSchemaProperty(effortLevels: readonly string
*/
export function getAutoModeTierLabel(tier: string): string {
switch (tier) {
case 'eco': return l10n.t('Efficiency');
case 'balanced': return l10n.t('Balance');
case 'max': return l10n.t('Intelligence');
case 'efficiency': return l10n.t('Efficiency');
case 'balance': return l10n.t('Balance');
case 'intelligence': return l10n.t('Intelligence');
case 'fast': return l10n.t('Fast');
default: return tier.charAt(0).toUpperCase() + tier.slice(1);
}
Expand All @@ -104,9 +104,9 @@ export function getAutoModeTierLabel(tier: string): string {
*/
export function getAutoModeTierDescription(tier: string): string {
switch (tier) {
case 'eco': return l10n.t('Cheaper models for everyday tasks');
case 'balanced': return l10n.t('Balances capability and cost');
case 'max': return l10n.t('Most capable models, higher cost');
case 'efficiency': return l10n.t('Cheaper models for everyday tasks');
case 'balance': return l10n.t('Balances capability and cost');
case 'intelligence': return l10n.t('Most capable models, higher cost');
case 'fast': return l10n.t('Lowest latency models');
default: return tier;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,7 @@ suite('reasoning effort schema', () => {
});

test('Kimi K3 prefers high when available', () => {
assert.strictEqual(pickDefaultReasoningEffort(['low', 'high', 'max'], 'kimi-k3'), 'high');
assert.strictEqual(pickDefaultReasoningEffort(['low', 'high', 'intelligence'], 'kimi-k3'), 'high');
Comment thread
lramos15 marked this conversation as resolved.
Outdated
});

test('non-claude family prefers medium when available', () => {
Expand Down Expand Up @@ -698,10 +698,10 @@ suite('auto mode tier schema', () => {
assert.deepStrictEqual(buildAutoModeTierSchemaProperty(selectableAutoModeTiers, defaultAutoModeTier), {
type: 'string',
title: 'Optimize for',
enum: ['eco', 'balanced', 'max'],
enum: ['efficiency', 'balance', 'intelligence'],
enumItemLabels: ['Efficiency', 'Balance', 'Intelligence'],
enumDescriptions: ['Cheaper models for everyday tasks', 'Balances capability and cost', 'Most capable models, higher cost'],
default: 'balanced',
default: 'balance',
group: 'navigation',
});
});
Expand Down
26 changes: 23 additions & 3 deletions extensions/copilot/src/platform/endpoint/common/autoModeTiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* Routing profiles accepted by `POST /auto`. A tier is picked per session and
* biases which models the router may choose from.
*/
export const autoModeTiers = ['eco', 'balanced', 'max', 'fast'] as const;
export const autoModeTiers = ['efficiency', 'balance', 'intelligence', 'fast'] as const;

export type AutoModeTier = typeof autoModeTiers[number];

Expand All @@ -17,17 +17,37 @@ export type AutoModeTier = typeof autoModeTiers[number];
* offered as a choice. It remains reachable through the internal
* {@link ConfigKey.Advanced.AutoModeTierOverride} setting.
*/
export const selectableAutoModeTiers: readonly AutoModeTier[] = ['eco', 'balanced', 'max'];
export const selectableAutoModeTiers: readonly AutoModeTier[] = ['efficiency', 'balance', 'intelligence'];

/** The tier used when the user has not picked one. */
export const defaultAutoModeTier: AutoModeTier = 'balanced';
export const defaultAutoModeTier: AutoModeTier = 'balance';

/** The tier inline chat defaults to; latency matters more than routing depth there. */
export const inlineChatAutoModeTier: AutoModeTier = 'fast';

/** Key the selected tier is stored under in the Auto model's configuration. */
export const AUTO_MODE_TIER_PROPERTY = 'tier';

/**
* Tier names retired in favour of the current ones. `POST /auto` still accepts
* them, but the agent runtime rejects them at its session boundary, so one
* vocabulary is used everywhere and these are mapped forward on read.
*/
const retiredAutoModeTiers: Readonly<Record<string, AutoModeTier>> = {
eco: 'efficiency',
balanced: 'balance',
max: 'intelligence',
};

/**
* Maps a retired tier name to its current one, leaving anything else untouched.
* Only raw inputs that bypass the picker schema need this, notably the override
* setting; a persisted picker value is already filtered against the live schema.
*/
export function normalizeAutoModeTier(value: string): string {
return retiredAutoModeTiers[value] ?? value;
}

/**
* Narrows an untrusted value (persisted model configuration, or configuration
* supplied by a third-party extension through the `vscode.lm` API) to a tier the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export class AutoV2Fetcher {
"conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The conversation ID in which the selection was made." },
"vscodeRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The VS Code chat request id in which the selection was made." },
"selectedModel": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model the server selected for this prompt." },
"tier": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The routing profile requested for this selection, e.g. eco, balanced, max, fast. Empty when none was requested." },
"tier": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The routing profile requested for this selection, e.g. efficiency, balance, intelligence, fast. Empty when none was requested." },
"e2eLatencyMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "The end-to-end latency of the auto request in milliseconds, including network overhead." },
"scoreReasoning": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Hydra per-dimension score for reasoning. -1 if not present in the response." },
"scoreCodeGen": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Hydra per-dimension score for code generation. -1 if not present in the response." },
Expand Down
15 changes: 9 additions & 6 deletions extensions/copilot/src/platform/endpoint/node/automodeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { IChatEndpoint } from '../../networking/common/networking';
import { IRequestLogger } from '../../requestLogger/common/requestLogger';
import { IExperimentationService } from '../../telemetry/common/nullExperimentationService';
import { ITelemetryService } from '../../telemetry/common/telemetry';
import { AUTO_MODE_TIER_PROPERTY, autoModeTiers, defaultAutoModeTier, inlineChatAutoModeTier, isSelectableAutoModeTier, type AutoModeTier } from '../common/autoModeTiers';
import { AUTO_MODE_TIER_PROPERTY, autoModeTiers, defaultAutoModeTier, inlineChatAutoModeTier, isSelectableAutoModeTier, normalizeAutoModeTier, type AutoModeTier } from '../common/autoModeTiers';
import { ICAPIClientService } from '../common/capiClient';
import type { IChatModelCapabilities, IChatModelInformation } from '../common/endpointProvider';
import { AutoChatEndpoint } from './autoChatEndpoint';
Expand Down Expand Up @@ -397,16 +397,19 @@ export class AutomodeService extends Disposable implements IAutomodeService {
*
* Only a non-default selection counts as explicit: the workbench materializes
* the schema default into `modelConfiguration` and strips a pick of the
* default back out when storing it, so a `balanced` entry cannot be told
* apart from "never picked" — reading it as a selection would make the inline
* pin below unreachable.
* default back out when storing it, so a `balance` entry cannot be told apart
* from "never picked" — reading it as a selection would make the inline pin
* below unreachable.
*/
private _resolveTier(chatRequest: IAutoModeRoutingRequest | undefined): AutoModeTier | undefined {
const override = this._configurationService.getConfig(ConfigKey.Advanced.AutoModeTierOverride);
if (override) {
// Normalized because the override is a raw string setting, so unlike a picker value it never
// passes through the schema filter that drops retired names.
const normalized = normalizeAutoModeTier(override);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Only the advanced override is normalized. Existing eco or max picker values can remain in scoped/profile model-configuration storage, are forwarded unchanged, and fail isSelectableAutoModeTier, silently falling back to balance in panel chat or fast inline. Please normalize the configured picker value before validation and add upgrade coverage for persisted aliases.

// The override is internal, so unlike the picker it may select `fast`.
if ((autoModeTiers as readonly string[]).includes(override)) {
return override as AutoModeTier;
if ((autoModeTiers as readonly string[]).includes(normalized)) {
return normalized as AutoModeTier;
}
this._logService.warn(`[AutomodeService] Ignoring auto tier override '${override}' — not one of [${autoModeTiers.join(', ')}].`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,10 +390,10 @@ describe('AutomodeService', () => {
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);

await Promise.all([
route('session-a', 'max'),
route('session-b', 'max'),
route('session-a', 'eco'),
route('session-a', 'max', [{ value: { mimeType: 'image/png' } }]),
route('session-a', 'intelligence'),
route('session-b', 'intelligence'),
route('session-a', 'efficiency'),
route('session-a', 'intelligence', [{ value: { mimeType: 'image/png' } }]),
]);

expect(autoCalls()).toHaveLength(4);
Expand Down Expand Up @@ -632,10 +632,10 @@ describe('AutomodeService', () => {
location: ChatLocation.Editor,
prompt: 'test prompt',
sessionId: 'session-auto-inline-tier',
modelConfiguration: { tier: 'max' },
modelConfiguration: { tier: 'intelligence' },
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);

expect(autoRequestBodies()).toEqual([{ prompt: 'test prompt', tier: 'max' }]);
expect(autoRequestBodies()).toEqual([{ prompt: 'test prompt', tier: 'intelligence' }]);
});

it('sends the tier picked in the model configuration', async () => {
Expand All @@ -648,10 +648,10 @@ describe('AutomodeService', () => {
location: ChatLocation.Panel,
prompt: 'test prompt',
sessionId: 'session-auto-tier',
modelConfiguration: { tier: 'max' },
modelConfiguration: { tier: 'intelligence' },
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);

expect(autoRequestBodies()).toEqual([{ prompt: 'test prompt', tier: 'max' }]);
expect(autoRequestBodies()).toEqual([{ prompt: 'test prompt', tier: 'intelligence' }]);
});

it('falls back to the default tier when the configured tier is not user selectable', async () => {
Expand All @@ -667,7 +667,7 @@ describe('AutomodeService', () => {
modelConfiguration: { tier: 'fast' },
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);

expect(autoRequestBodies()).toEqual([{ prompt: 'test prompt', tier: 'balanced' }]);
expect(autoRequestBodies()).toEqual([{ prompt: 'test prompt', tier: 'balance' }]);
});

it('re-routes the conversation when the tier changes', async () => {
Expand All @@ -680,35 +680,35 @@ describe('AutomodeService', () => {
location: ChatLocation.Panel,
prompt: 'test prompt',
sessionId: 'session-auto-tier-change',
modelConfiguration: { tier: 'eco' },
modelConfiguration: { tier: 'efficiency' },
} as unknown as ChatRequest;

await automodeService.resolveAutoModeEndpoint(chatRequest, [mockChatEndpoint, gpt4oEndpoint]);
await automodeService.resolveAutoModeEndpoint({ ...chatRequest, prompt: 'second turn' } as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
await automodeService.resolveAutoModeEndpoint({ ...chatRequest, prompt: 'third turn', modelConfiguration: { tier: 'max' } } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
await automodeService.resolveAutoModeEndpoint({ ...chatRequest, prompt: 'third turn', modelConfiguration: { tier: 'intelligence' } } as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);

expect(autoRequestBodies().map(b => b.tier)).toEqual(['eco', 'max']);
expect(autoRequestBodies().map(b => b.tier)).toEqual(['efficiency', 'intelligence']);
});

it('lets the tier override win over the picker and the inline chat pin', async () => {
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
mockAuto(autoResponse('gpt-4o'));

setTierOverride('eco');
setTierOverride('efficiency');
automodeService = createService();
await automodeService.resolveAutoModeEndpoint({
location: ChatLocation.Panel,
prompt: 'panel turn',
sessionId: 'session-override-panel',
modelConfiguration: { tier: 'max' },
modelConfiguration: { tier: 'intelligence' },
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
await automodeService.resolveAutoModeEndpoint({
location: ChatLocation.Editor,
prompt: 'inline turn',
sessionId: 'session-override-inline',
} as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);

expect(autoRequestBodies().map(b => b.tier)).toEqual(['eco', 'eco']);
expect(autoRequestBodies().map(b => b.tier)).toEqual(['efficiency', 'efficiency']);
});

// The override is an internal/eval knob, so unlike the picker it may target
Expand Down Expand Up @@ -741,10 +741,10 @@ describe('AutomodeService', () => {
location: ChatLocation.Panel,
prompt: 'panel turn',
sessionId: 'session-override-bogus',
modelConfiguration: { tier: 'max' },
modelConfiguration: { tier: 'intelligence' },
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);

expect(autoRequestBodies()).toEqual([{ prompt: 'panel turn', tier: 'max' }]);
expect(autoRequestBodies()).toEqual([{ prompt: 'panel turn', tier: 'intelligence' }]);
});

it('announces tier support when the setting changes', async () => {
Expand All @@ -755,7 +755,7 @@ describe('AutomodeService', () => {
const listener = automodeService.onDidChangeAutoModeTierSupport(() => announced++);
await configurationService.setConfig(ConfigKey.Advanced.AutoModeTiersEnabled, true);
// An unrelated change must not re-announce.
await configurationService.setConfig(ConfigKey.Advanced.AutoModeTierOverride, 'max');
await configurationService.setConfig(ConfigKey.Advanced.AutoModeTierOverride, 'intelligence');
listener.dispose();

expect({ announced, supported: automodeService.areAutoModeTiersSupported() }).toEqual({ announced: 1, supported: true });
Expand All @@ -771,18 +771,18 @@ describe('AutomodeService', () => {
location: ChatLocation.Panel,
prompt: 'first turn',
sessionId: 'session-auto-tier-error',
modelConfiguration: { tier: 'eco' },
modelConfiguration: { tier: 'efficiency' },
} as unknown as ChatRequest;
const first = await automodeService.resolveAutoModeEndpoint(chatRequest, [mockChatEndpoint, gpt4oEndpoint]);
expect(first.model).toBe('gpt-4o');

// The tier changes and the re-route fails: the eco endpoint must not be
// The tier changes and the re-route fails: the efficiency endpoint must not be
// handed back as though it satisfied the new tier.
mockAuto({ error: 'server_error' }, 500);
await expect(automodeService.resolveAutoModeEndpoint({
...chatRequest,
prompt: 'second turn',
modelConfiguration: { tier: 'max' },
modelConfiguration: { tier: 'intelligence' },
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint])).rejects.toThrow();
});

Expand All @@ -798,15 +798,15 @@ describe('AutomodeService', () => {
location: ChatLocation.Panel,
prompt: 'first turn',
sessionId: 'session-auto-tier-discount',
modelConfiguration: { tier: 'eco' },
modelConfiguration: { tier: 'efficiency' },
} as unknown as ChatRequest;
await automodeService.resolveAutoModeEndpoint(chatRequest, [mockChatEndpoint, gpt4oEndpoint]);

mockAuto(autoResponse('gpt-4o', { discounted_costs: { 'gpt-4o': 0.9 } }));
await automodeService.resolveAutoModeEndpoint({
...chatRequest,
prompt: 'second turn',
modelConfiguration: { tier: 'max' },
modelConfiguration: { tier: 'intelligence' },
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);

const discounts = (mockInstantiationService.createInstance as ReturnType<typeof vi.fn>).mock.calls.map(c => c[3]);
Expand All @@ -825,7 +825,7 @@ describe('AutomodeService', () => {
location,
prompt: 'test prompt',
sessionId: `session-tiers-off-${location}`,
modelConfiguration: { tier: 'max' },
modelConfiguration: { tier: 'intelligence' },
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
}

Expand All @@ -843,15 +843,32 @@ describe('AutomodeService', () => {
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
mockAuto(autoResponse('gpt-4o'));

setTierOverride('max');
setTierOverride('intelligence');
automodeService = createService();
await automodeService.resolveAutoModeEndpoint({
location: ChatLocation.Panel,
prompt: 'panel turn',
sessionId: 'session-override-tiers-off',
} as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);

expect(autoRequestBodies()).toEqual([{ prompt: 'panel turn', tier: 'max' }]);
expect(autoRequestBodies()).toEqual([{ prompt: 'panel turn', tier: 'intelligence' }]);
});

// The override is a raw string setting, so a config left on a retired name by an
// eval or an internal user must keep working rather than silently fall back.
it('maps a retired tier name in the override to its current one', async () => {
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
mockAuto(autoResponse('gpt-4o'));

setTierOverride('eco');
automodeService = createService();
await automodeService.resolveAutoModeEndpoint({
location: ChatLocation.Panel,
prompt: 'panel turn',
sessionId: 'session-override-retired',
} as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);

expect(autoRequestBodies()).toEqual([{ prompt: 'panel turn', tier: 'efficiency' }]);
});
});

Expand All @@ -875,7 +892,7 @@ describe('AutomodeService', () => {
for (let i = 0; i < 50; i++) {
await route(`session-${i}`, `turn ${i}`);
}
await route('session-49', 'retiered turn', 'max');
await route('session-49', 'retiered turn', 'intelligence');

const callsBefore = autoCalls().length;
await route('session-0', 'follow up');
Expand Down
Loading
Loading