mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-19 05:57:12 +01:00
Support Auto Tiers (#329463)
* Support Auto tiers * Make auto tiers an exp driven thing * Resolve comments
This commit is contained in:
@@ -4462,6 +4462,17 @@
|
||||
"advanced"
|
||||
]
|
||||
},
|
||||
"github.copilot.chat.autoModeTierOverride": {
|
||||
"type": [
|
||||
"string",
|
||||
"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.",
|
||||
"tags": [
|
||||
"advanced"
|
||||
]
|
||||
},
|
||||
"github.copilot.chat.anthropic.promptCaching.extendedTtl": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
@@ -5164,6 +5175,15 @@
|
||||
"onExp"
|
||||
]
|
||||
},
|
||||
"github.copilot.chat.autoMode.tiers.enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"markdownDescription": "%github.copilot.config.chat.autoMode.tiers.enabled%",
|
||||
"tags": [
|
||||
"advanced",
|
||||
"onExp"
|
||||
]
|
||||
},
|
||||
"github.copilot.chat.agent.modelDetails.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
|
||||
@@ -431,6 +431,7 @@
|
||||
"github.copilot.config.cli.planExitMode.enabled": "Enable Plan Mode exit handling in Copilot CLI.",
|
||||
"github.copilot.config.cli.autoModel.enabled": "Enable the Auto model option in Copilot CLI, which automatically selects the best model for each request. Requires VS Code reload.",
|
||||
"github.copilot.config.chat.autoMode.v2.enabled": "Use the single-call Auto API to select the best model for each request. When disabled, model selection falls back to the previous two-call flow.",
|
||||
"github.copilot.config.chat.autoMode.tiers.enabled": "Choose a routing tier for the Auto model, biasing model selection toward cost, capability, or speed. When disabled, the service picks the routing profile.",
|
||||
"github.copilot.config.chat.agent.modelDetails.enabled": "Show model details (model name and request multiplier) on agent chat responses when using Copilot CLI or Claude agent in VS Code. Requires VS Code reload to update already loaded sessions.",
|
||||
"github.copilot.config.cli.planCommand.enabled": "Enable the /plan command in Copilot CLI to create implementation plans before coding.",
|
||||
"github.copilot.config.cli.lazyLoadSessionItem.enabled": "Enable lazy loading of session items in Copilot CLI. Requires VS Code reload.",
|
||||
|
||||
@@ -84,6 +84,51 @@ export function buildReasoningEffortSchemaProperty(effortLevels: readonly string
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the localized, title-cased picker label for an Auto routing tier.
|
||||
* Falls back to capitalizing an unknown value.
|
||||
*/
|
||||
export function getAutoModeTierLabel(tier: string): string {
|
||||
switch (tier) {
|
||||
case 'eco': return l10n.t('Eco');
|
||||
case 'balanced': return l10n.t('Balanced');
|
||||
case 'max': return l10n.t('Max');
|
||||
case 'fast': return l10n.t('Fast');
|
||||
default: return tier.charAt(0).toUpperCase() + tier.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the localized description shown in the picker hover for an Auto
|
||||
* routing tier. Falls back to the raw tier for unknown values.
|
||||
*/
|
||||
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 'fast': return l10n.t('Lowest latency models');
|
||||
default: return tier;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `tier` property descriptor for the Auto model's
|
||||
* {@link LanguageModelConfigurationSchema}. Rendered by the model picker the
|
||||
* same way thinking effort is, but labelled "Tier".
|
||||
*/
|
||||
export function buildAutoModeTierSchemaProperty(tiers: readonly string[], defaultTier: string): NonNullable<LanguageModelConfigurationSchema['properties']>[string] {
|
||||
return {
|
||||
type: 'string',
|
||||
title: l10n.t('Tier'),
|
||||
enum: [...tiers],
|
||||
enumItemLabels: tiers.map(getAutoModeTierLabel),
|
||||
enumDescriptions: tiers.map(getAutoModeTierDescription),
|
||||
default: defaultTier,
|
||||
group: 'navigation',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a description of the model's capabilities and intended use cases.
|
||||
* This is shown in the rich hover when selecting models.
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ChatFetchResponseType, ChatLocation, getErrorDetailsFromChatFetchError
|
||||
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
|
||||
import { getTextPart } from '../../../platform/chat/common/globalStringUtils';
|
||||
import { EmbeddingType, getWellKnownEmbeddingTypeInfo, IEmbeddingsComputer } from '../../../platform/embeddings/common/embeddingsComputer';
|
||||
import { AUTO_MODE_TIER_PROPERTY, defaultAutoModeTier, selectableAutoModeTiers } from '../../../platform/endpoint/common/autoModeTiers';
|
||||
import { ChatEndpointFamily, IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider';
|
||||
import { CustomDataPartMimeTypes } from '../../../platform/endpoint/common/endpointTypes';
|
||||
import { encodeStatefulMarker } from '../../../platform/endpoint/common/statefulMarkerContainer';
|
||||
@@ -44,7 +45,7 @@ import { IExtensionContribution } from '../../common/contributions';
|
||||
import { PromptRenderer } from '../../prompts/node/base/promptRenderer';
|
||||
import { isImageDataPart } from '../common/languageModelChatMessageHelpers';
|
||||
import { LanguageModelAccessPrompt } from './languageModelAccessPrompt';
|
||||
import { formatPricingLabel, formatTokenCount, getAutoModelDescription, getAutoModelDiscountLabel, getModelCapabilitiesDescription, buildReasoningEffortSchemaProperty } from '../common/languageModelAccess';
|
||||
import { formatPricingLabel, formatTokenCount, getAutoModelDescription, getAutoModelDiscountLabel, getModelCapabilitiesDescription, buildReasoningEffortSchemaProperty, buildAutoModeTierSchemaProperty } from '../common/languageModelAccess';
|
||||
|
||||
/**
|
||||
* Markers in the autoModelHint experiment variable that indicate the auto model
|
||||
@@ -125,13 +126,16 @@ function buildAutoRoutingContext(
|
||||
// Key by the calling extension. Like a panel conversation, the first prompt
|
||||
// picks the model and later ones reuse it, which bounds the cache at one
|
||||
// entry per extension.
|
||||
return { prompt, sessionId: `vscode.lm:${options.requestInitiator ?? 'unknown'}`, references };
|
||||
return { prompt, sessionId: `vscode.lm:${options.requestInitiator ?? 'unknown'}`, references, modelConfiguration: options.modelConfiguration };
|
||||
}
|
||||
|
||||
// Auto model delegates to different backends, so don't expose config pickers
|
||||
function buildConfigurationSchema(endpoint: IChatEndpoint, preferLongContext: boolean): { configurationSchema?: vscode.LanguageModelConfigurationSchema } {
|
||||
// Auto model delegates to different backends, so the only picker it exposes is
|
||||
// the routing tier; per-model options belong to the model it routes to.
|
||||
function buildConfigurationSchema(endpoint: IChatEndpoint, preferLongContext: boolean, autoTiersEnabled: boolean): { configurationSchema?: vscode.LanguageModelConfigurationSchema } {
|
||||
if (endpoint instanceof AutoChatEndpoint) {
|
||||
return {};
|
||||
return autoTiersEnabled
|
||||
? { configurationSchema: { properties: { [AUTO_MODE_TIER_PROPERTY]: buildAutoModeTierSchemaProperty(selectableAutoModeTiers, defaultAutoModeTier) } } }
|
||||
: {};
|
||||
}
|
||||
|
||||
const properties: Record<string, NonNullable<vscode.LanguageModelConfigurationSchema['properties']>[string]> = {};
|
||||
@@ -299,6 +303,11 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib
|
||||
void this._refreshUtilityOverrides();
|
||||
this._onDidChange.fire();
|
||||
}));
|
||||
this._register(this._automodeService.onDidChangeAutoModeTierSupport(() => {
|
||||
// Withdraws (or restores) the Auto model's tier picker, which is only
|
||||
// honored while routing goes through `POST /auto`.
|
||||
this._onDidChange.fire();
|
||||
}));
|
||||
}
|
||||
|
||||
private async _provideLanguageModelChatInfo(options: { silent: boolean }, token: vscode.CancellationToken): Promise<vscode.LanguageModelChatInformation[]> {
|
||||
@@ -329,6 +338,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib
|
||||
|
||||
const seenFamilies = new Set<string>();
|
||||
const preferLongContext = this._configurationService.getConfig(ConfigKey.PreferLongContext);
|
||||
const autoTiersEnabled = this._automodeService.areAutoModeTiersSupported();
|
||||
|
||||
for (const endpoint of chatEndpoints) {
|
||||
if (seenFamilies.has(endpoint.family) && !endpoint.showInModelPicker) {
|
||||
@@ -414,7 +424,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib
|
||||
imageInput: endpoint instanceof AutoChatEndpoint ? true : endpoint.supportsVision,
|
||||
toolCalling: endpoint.supportsToolCalls,
|
||||
},
|
||||
...buildConfigurationSchema(endpoint, preferLongContext),
|
||||
...buildConfigurationSchema(endpoint, preferLongContext, autoTiersEnabled),
|
||||
};
|
||||
|
||||
models.push(model);
|
||||
|
||||
+2
@@ -213,6 +213,8 @@ suite('LanguageModelAccess model info', () => {
|
||||
resolveAutoModeEndpoint: async () => endpoint,
|
||||
resolveAutoModePickerEndpoint: async () => endpoint,
|
||||
getAutoPickerMetadata: async () => undefined,
|
||||
areAutoModeTiersSupported: () => false,
|
||||
onDidChangeAutoModeTierSupport: Event.None,
|
||||
consumeLastRoutingDecision: () => undefined,
|
||||
invalidateRouterCache: () => { },
|
||||
} as unknown as IAutomodeService);
|
||||
|
||||
@@ -51,6 +51,7 @@ import { TestLogService } from '../../../platform/testing/common/testLogService'
|
||||
import { ITestProvider } from '../../../platform/testing/common/testProvider';
|
||||
import { IGithubAvailableEmbeddingTypesService, MockGithubAvailableEmbeddingTypesService } from '../../../platform/workspaceChunkSearch/common/githubAvailableEmbeddingTypes';
|
||||
import { IWorkspaceChunkSearchService, NullWorkspaceChunkSearchService } from '../../../platform/workspaceChunkSearch/node/workspaceChunkSearchService';
|
||||
import { Event } from '../../../util/vs/base/common/event';
|
||||
import { DisposableStore } from '../../../util/vs/base/common/lifecycle';
|
||||
import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/descriptors';
|
||||
import { ILanguageModelServer } from '../../agents/node/langModelServer';
|
||||
@@ -217,5 +218,11 @@ class NullAutomodeService implements IAutomodeService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
areAutoModeTiersSupported(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly onDidChangeAutoModeTierSupport = Event.None;
|
||||
|
||||
invalidateRouterCache(): void { }
|
||||
}
|
||||
|
||||
@@ -632,6 +632,13 @@ export namespace ConfigKey {
|
||||
* Experiment-based so it can be remotely disabled; an explicit user setting still wins.
|
||||
*/
|
||||
export const AutoModeV2Enabled = defineSetting<boolean>('chat.autoMode.v2.enabled', ConfigType.ExperimentBased, true, undefined, undefined, { experimentName: 'copilotchat.autoModeV2Enabled' });
|
||||
|
||||
/**
|
||||
* Offer routing tiers on the Auto model. Requires {@link AutoModeV2Enabled},
|
||||
* since `tier` is only understood by `POST /auto`. Off by default: while
|
||||
* disabled no tier is sent and the server picks its own routing profile.
|
||||
*/
|
||||
export const AutoModeTiersEnabled = defineSetting<boolean>('chat.autoMode.tiers.enabled', ConfigType.ExperimentBased, false, undefined, undefined, { experimentName: 'copilotchat.autoModeTiersEnabled' });
|
||||
export const CLIModelDetailsEnabled = defineSetting<boolean>('chat.agent.modelDetails.enabled', ConfigType.Simple, true);
|
||||
export const CLIPlanCommandEnabled = defineSetting<boolean>('chat.cli.planCommand.enabled', ConfigType.Simple, true);
|
||||
export const CLIChatLazyLoadSessionItem = defineSetting<boolean>('chat.cli.lazyLoadSessionItem.enabled', ConfigType.Simple, true);
|
||||
@@ -752,6 +759,13 @@ export namespace ConfigKey {
|
||||
/** Internal: override reasoning/thinking effort sent to model APIs (e.g. Responses API, Messages API). Used by evals. */
|
||||
export const ReasoningEffortOverride = defineSetting<string | null>('chat.reasoningEffortOverride', ConfigType.Simple, null);
|
||||
|
||||
/**
|
||||
* Internal: override the routing tier sent to `POST /auto`, ignoring both the
|
||||
* model picker and the tier inline chat defaults to. Unlike the picker this
|
||||
* accepts `fast`, so evals can exercise every profile.
|
||||
*/
|
||||
export const AutoModeTierOverride = defineSetting<string | null>('chat.autoModeTierOverride', ConfigType.Simple, null);
|
||||
|
||||
/**
|
||||
* When enabled, periodic keep-alive probes are sent during long-running tool calls
|
||||
* to keep the server-side prompt cache warm.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* 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 type AutoModeTier = typeof autoModeTiers[number];
|
||||
|
||||
/**
|
||||
* The tiers offered in the model picker. `fast` is excluded: it is the profile
|
||||
* inline chat falls back to when the user has not picked a tier, and is not
|
||||
* offered as a choice. It remains reachable through the internal
|
||||
* {@link ConfigKey.Advanced.AutoModeTierOverride} setting.
|
||||
*/
|
||||
export const selectableAutoModeTiers: readonly AutoModeTier[] = ['eco', 'balanced', 'max'];
|
||||
|
||||
/** The tier used when the user has not picked one. */
|
||||
export const defaultAutoModeTier: AutoModeTier = 'balanced';
|
||||
|
||||
/** 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';
|
||||
|
||||
/**
|
||||
* Narrows an untrusted value (persisted model configuration, or configuration
|
||||
* supplied by a third-party extension through the `vscode.lm` API) to a tier the
|
||||
* picker offers. `fast` is rejected so it stays an internal default rather than
|
||||
* something a caller can select.
|
||||
*/
|
||||
export function isSelectableAutoModeTier(value: unknown): value is AutoModeTier {
|
||||
return typeof value === 'string' && (selectableAutoModeTiers as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { ILogService } from '../../log/common/logService';
|
||||
import { Response } from '../../networking/common/fetcherService';
|
||||
import { IRequestLogger, LoggedRequestKind } from '../../requestLogger/common/requestLogger';
|
||||
import { ITelemetryService } from '../../telemetry/common/telemetry';
|
||||
import type { AutoModeTier } from '../common/autoModeTiers';
|
||||
import { ICAPIClientService } from '../common/capiClient';
|
||||
import type { IModelAPIResponse } from '../common/endpointProvider';
|
||||
|
||||
@@ -72,6 +73,8 @@ export class AutoV2Fetcher {
|
||||
multiTurn?: AutoV2MultiTurnState;
|
||||
conversationId?: string;
|
||||
vscodeRequestId?: string;
|
||||
/** Routing profile for the session. Omitted lets the server pick its own default. */
|
||||
tier?: AutoModeTier;
|
||||
/**
|
||||
* Set when the call only reads `discounted_costs` for the picker.
|
||||
* Keeps the placeholder prompt out of telemetry and the request log.
|
||||
@@ -87,6 +90,9 @@ export class AutoV2Fetcher {
|
||||
if (options.multiTurn) {
|
||||
requestBody.multi_turn = options.multiTurn;
|
||||
}
|
||||
if (options.tier) {
|
||||
requestBody.tier = options.tier;
|
||||
}
|
||||
|
||||
const copilotToken = (await this._authService.getCopilotToken()).token;
|
||||
const abortController = new AbortController();
|
||||
@@ -125,7 +131,7 @@ export class AutoV2Fetcher {
|
||||
if (!result.selected_model?.id) {
|
||||
throw new AutoV2Error('Auto response did not contain a selected model', response.status);
|
||||
}
|
||||
this._logService.trace(`[AutoV2Fetcher] Selected model: ${result.selected_model.id} (e2e_latency_ms: ${e2eLatencyMs}, expires_at: ${result.expires_at})`);
|
||||
this._logService.trace(`[AutoV2Fetcher] Selected model: ${result.selected_model.id} (tier: ${options.tier ?? 'server default'}, e2e_latency_ms: ${e2eLatencyMs}, expires_at: ${result.expires_at})`);
|
||||
|
||||
this._requestLogger.addEntry({
|
||||
type: LoggedRequestKind.MarkdownContentRequest,
|
||||
@@ -136,6 +142,7 @@ export class AutoV2Fetcher {
|
||||
`# Auto Mode Decision (POST /auto)`,
|
||||
`## Result`,
|
||||
`- **Selected Model**: ${result.selected_model.id}`,
|
||||
`- **Tier**: ${options.tier ?? 'server default'}`,
|
||||
`- **Expires At**: ${new Date(result.expires_at * 1000).toISOString()}`,
|
||||
`## Latency`,
|
||||
`- **E2E Latency**: ${e2eLatencyMs}ms`,
|
||||
@@ -154,6 +161,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." },
|
||||
"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." },
|
||||
@@ -166,6 +174,7 @@ export class AutoV2Fetcher {
|
||||
conversationId: options.conversationId ?? '',
|
||||
vscodeRequestId: options.vscodeRequestId ?? '',
|
||||
selectedModel: result.selected_model.id,
|
||||
tier: options.tier ?? '',
|
||||
},
|
||||
{
|
||||
e2eLatencyMs,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RequestType } from '@vscode/copilot-api';
|
||||
import type { ChatRequest } from 'vscode';
|
||||
import { FetchedValue } from '../../../shared-fetch-utils/common/fetchedValue';
|
||||
import { createServiceIdentifier } from '../../../util/common/services';
|
||||
import { Emitter, type Event } from '../../../util/vs/base/common/event';
|
||||
import { Disposable, DisposableMap, MutableDisposable } from '../../../util/vs/base/common/lifecycle';
|
||||
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { ChatLocation } from '../../../vscodeTypes';
|
||||
@@ -22,6 +23,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 { ICAPIClientService } from '../common/capiClient';
|
||||
import type { IChatModelCapabilities, IChatModelInformation } from '../common/endpointProvider';
|
||||
import { AutoChatEndpoint } from './autoChatEndpoint';
|
||||
@@ -42,6 +44,8 @@ interface AutoV2CacheEntry {
|
||||
/** UNIX seconds at which `sessionToken` expires. */
|
||||
expiresAt: number;
|
||||
lastRoutedPrompt?: string;
|
||||
/** Routing profile the session was resolved with; a change re-routes. `undefined` while tiers are disabled. */
|
||||
tier: AutoModeTier | undefined;
|
||||
turnCount: number;
|
||||
needsReEval: boolean;
|
||||
}
|
||||
@@ -118,6 +122,9 @@ class AutoModeTokenBank extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Surfaces that default to the latency-oriented tier rather than {@link defaultAutoModeTier}. */
|
||||
const inlineChatLocations: ReadonlySet<ChatLocation> = new Set([ChatLocation.Editor, ChatLocation.Terminal, ChatLocation.Notebook]);
|
||||
|
||||
/**
|
||||
* The subset of {@link ChatRequest} auto mode reads when routing. Callers that
|
||||
* have a real `ChatRequest` pass it directly; callers that do not (e.g. the
|
||||
@@ -131,6 +138,8 @@ export interface IAutoModeRoutingRequest {
|
||||
readonly sessionId?: string;
|
||||
readonly sessionResource?: { toString(): string };
|
||||
readonly references?: readonly { readonly value: unknown }[];
|
||||
/** The picker configuration for the Auto model, which carries the selected tier. */
|
||||
readonly modelConfiguration?: { readonly [key: string]: unknown };
|
||||
}
|
||||
|
||||
export interface AutoModeRoutingDecision {
|
||||
@@ -169,6 +178,19 @@ export interface IAutomodeService {
|
||||
*/
|
||||
getAutoPickerMetadata(): Promise<AutoModePickerMetadata | undefined>;
|
||||
|
||||
/**
|
||||
* Whether the Auto model should offer the tier picker. Tiers are a `POST /auto`
|
||||
* concept, so the picker has to be withdrawn once routing falls back to the
|
||||
* legacy flow. Changes are announced by {@link onDidChangeAutoModeTierSupport}.
|
||||
*/
|
||||
areAutoModeTiersSupported(): boolean;
|
||||
|
||||
/**
|
||||
* Fires when {@link areAutoModeTiersSupported} changes, so the Auto model's
|
||||
* configuration schema can be republished.
|
||||
*/
|
||||
readonly onDidChangeAutoModeTierSupport: Event<void>;
|
||||
|
||||
/**
|
||||
* Returns the routing decision from the last call to {@link resolveAutoModeEndpoint},
|
||||
* or `undefined` if the router was not used (e.g. skipped, fallback, or non-auto model).
|
||||
@@ -200,10 +222,16 @@ export class AutomodeService extends Disposable implements IAutomodeService {
|
||||
private static readonly AUTO_V2_DISCOUNTS_STORAGE_KEY = 'copilot.autoMode.v2.lastDiscountedCosts';
|
||||
/** Placeholder prompt used to read discounts. See {@link _probeAutoV2Discounts}. */
|
||||
private static readonly DISCOUNT_PROBE_PROMPT = 'MODEL_PICKER_DISCOUNT_RESOLUTION - REPLACE ME';
|
||||
/** Upper bound on live V2 sessions. See {@link _pruneAutoV2Cache}. */
|
||||
private static readonly AUTO_V2_CACHE_MAX_ENTRIES = 50;
|
||||
/** In-flight discount probe, so concurrent picker refreshes share one call. */
|
||||
private _autoV2DiscountProbe: Promise<void> | undefined;
|
||||
/** Session used only to read discounts for the picker on the legacy flow. */
|
||||
private readonly _pickerTokenBank = this._register(new MutableDisposable<AutoModeTokenBank>());
|
||||
private readonly _onDidChangeAutoModeTierSupport = this._register(new Emitter<void>());
|
||||
readonly onDidChangeAutoModeTierSupport = this._onDidChangeAutoModeTierSupport.event;
|
||||
/** Last announced {@link areAutoModeTiersSupported}. See {@link _updateAutoModeTierSupport}. */
|
||||
private _tierSupportAnnounced = false;
|
||||
|
||||
constructor(
|
||||
@ICAPIClientService private readonly _capiClientService: ICAPIClientService,
|
||||
@@ -219,15 +247,22 @@ export class AutomodeService extends Disposable implements IAutomodeService {
|
||||
) {
|
||||
super();
|
||||
this._lastAutoV2Discounts = this._extensionContext.globalState.get<Record<string, number>>(AutomodeService.AUTO_V2_DISCOUNTS_STORAGE_KEY);
|
||||
this._tierSupportAnnounced = this.areAutoModeTiersSupported();
|
||||
// Covers both settings and their experiment treatments: a treatment
|
||||
// refresh is published as a configuration change.
|
||||
this._register(this._configurationService.onDidChangeConfiguration(() => this._updateAutoModeTierSupport()));
|
||||
this._register(this._authService.onDidAuthenticationChange(() => {
|
||||
for (const entry of this._autoModelCache.values()) {
|
||||
entry.tokenBank.dispose();
|
||||
}
|
||||
this._autoModelCache.clear();
|
||||
this._autoV2Cache.clear();
|
||||
// All of this is scoped to the signed-in account.
|
||||
// All of this is scoped to the signed-in account. Tier support can come
|
||||
// back with the latch, but LanguageModelAccess already republishes
|
||||
// models on this same event, so there is nothing to announce here.
|
||||
this._setLastAutoV2Discounts(undefined);
|
||||
this._autoV2Unavailable = false;
|
||||
this._tierSupportAnnounced = this.areAutoModeTiersSupported();
|
||||
this._autoV2DiscountProbe = undefined;
|
||||
this._pickerTokenBank.clear();
|
||||
const keys = Array.from(this._reserveTokens.keys());
|
||||
@@ -257,7 +292,18 @@ export class AutomodeService extends Disposable implements IAutomodeService {
|
||||
return decision;
|
||||
}
|
||||
|
||||
private _setLastAutoV2Discounts(discounts: Record<string, number> | undefined): void {
|
||||
/**
|
||||
* Records the discounts shown on the Auto row in the picker. `tier` is the
|
||||
* profile the discounts came from: tiers route to different model pools and
|
||||
* so carry different discounts, while the picker has a single Auto row and no
|
||||
* tier context to qualify it with. Scope the label to the profile the picker
|
||||
* represents, so neither the internal `fast` tier (inline chat) nor another
|
||||
* tier's routing pass overwrites it.
|
||||
*/
|
||||
private _setLastAutoV2Discounts(discounts: Record<string, number> | undefined, tier?: AutoModeTier): void {
|
||||
if (tier !== undefined && tier !== defaultAutoModeTier) {
|
||||
return;
|
||||
}
|
||||
if (JSON.stringify(this._lastAutoV2Discounts) === JSON.stringify(discounts)) {
|
||||
return;
|
||||
}
|
||||
@@ -277,9 +323,18 @@ export class AutomodeService extends Disposable implements IAutomodeService {
|
||||
if (!this._autoV2DiscountProbe) {
|
||||
this._autoV2DiscountProbe = (async () => {
|
||||
try {
|
||||
const result = await this._autoV2Fetcher.getAutoDecision(AutomodeService.DISCOUNT_PROBE_PROMPT, { isDiscountProbe: true });
|
||||
const result = await this._autoV2Fetcher.getAutoDecision(AutomodeService.DISCOUNT_PROBE_PROMPT, {
|
||||
isDiscountProbe: true,
|
||||
// Read the same profile the label represents; see `_setLastAutoV2Discounts`.
|
||||
tier: this.areAutoModeTiersSupported() ? defaultAutoModeTier : undefined,
|
||||
});
|
||||
this._setLastAutoV2Discounts(result.discounted_costs);
|
||||
} catch (e) {
|
||||
// A 404 is a capability result, not a metadata failure: the
|
||||
// routing path treats it the same way.
|
||||
if (e instanceof AutoV2Error && e.status === 404) {
|
||||
this._markAutoV2Unavailable();
|
||||
}
|
||||
this._logService.warn(`[AutomodeService] Failed to probe auto discounts: ${(e as Error).message}`);
|
||||
}
|
||||
})();
|
||||
@@ -291,20 +346,25 @@ export class AutomodeService extends Disposable implements IAutomodeService {
|
||||
if (!knownEndpoints.length) {
|
||||
throw new Error('No auto mode endpoints provided.');
|
||||
}
|
||||
if (!this._isAutoV2Enabled()) {
|
||||
if (!this.isAutoV2Enabled()) {
|
||||
return this.resolveAutoModeEndpoint(undefined, knownEndpoints);
|
||||
}
|
||||
// Nothing to route without a prompt: wrap a representative endpoint for
|
||||
// its display metadata only. The picker hides per-model pricing for
|
||||
// Auto, so the wrapped model is not user-visible.
|
||||
const metadata = await this.getAutoPickerMetadata();
|
||||
// The probe above can latch V2 off (404), which changes what the picker
|
||||
// may advertise.
|
||||
if (!this.isAutoV2Enabled()) {
|
||||
return this.resolveAutoModeEndpoint(undefined, knownEndpoints);
|
||||
}
|
||||
const discountRange = metadata?.discountRange ?? { low: 0, high: 0 };
|
||||
const base = knownEndpoints.find(e => e.showInModelPicker) ?? knownEndpoints[0];
|
||||
return this._instantiationService.createInstance(AutoChatEndpoint, base, '', 0, discountRange);
|
||||
}
|
||||
|
||||
async getAutoPickerMetadata(): Promise<AutoModePickerMetadata | undefined> {
|
||||
if (this._isAutoV2Enabled()) {
|
||||
if (this.isAutoV2Enabled()) {
|
||||
// `/auto` requires a prompt, which the picker does not have. Prefer
|
||||
// the discounts observed on a real request; only when none have been
|
||||
// seen yet (first ever run) probe with a placeholder prompt.
|
||||
@@ -355,7 +415,7 @@ export class AutomodeService extends Disposable implements IAutomodeService {
|
||||
// leak to a consumer if this call takes a non-router path.
|
||||
this._lastRoutingDecision = undefined;
|
||||
|
||||
if (this._isAutoV2Enabled()) {
|
||||
if (this.isAutoV2Enabled()) {
|
||||
const v2Endpoint = await this._tryResolveWithAutoV2(chatRequest, knownEndpoints);
|
||||
if (v2Endpoint) {
|
||||
return v2Endpoint;
|
||||
@@ -489,10 +549,82 @@ export class AutomodeService extends Disposable implements IAutomodeService {
|
||||
return autoEndpoint;
|
||||
}
|
||||
|
||||
private _isAutoV2Enabled(): boolean {
|
||||
isAutoV2Enabled(): boolean {
|
||||
return !this._autoV2Unavailable && this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.AutoModeV2Enabled, this._expService);
|
||||
}
|
||||
|
||||
areAutoModeTiersSupported(): boolean {
|
||||
return this.isAutoV2Enabled() && this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.AutoModeTiersEnabled, this._expService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Latches V2 off for the rest of the session and withdraws the tier picker,
|
||||
* which would otherwise stay visible while the legacy flow silently ignores it.
|
||||
*/
|
||||
private _markAutoV2Unavailable(): void {
|
||||
if (this._autoV2Unavailable) {
|
||||
return;
|
||||
}
|
||||
this._autoV2Unavailable = true;
|
||||
this._updateAutoModeTierSupport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces a change in {@link areAutoModeTiersSupported}. Its inputs are the
|
||||
* two settings (and their experiment treatments) plus the V2 latch, so this
|
||||
* runs on every configuration change as well as after the latch flips.
|
||||
*/
|
||||
private _updateAutoModeTierSupport(): void {
|
||||
const supported = this.areAutoModeTiersSupported();
|
||||
if (supported !== this._tierSupportAnnounced) {
|
||||
this._tierSupportAnnounced = supported;
|
||||
this._onDidChangeAutoModeTierSupport.fire();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The routing profile to request for a turn, in precedence order: the
|
||||
* internal override setting, then an explicit picker selection, then the
|
||||
* pin inline surfaces trade routing depth for latency with.
|
||||
*
|
||||
* Returns `undefined` while tiers are disabled, which omits `tier` from the
|
||||
* request and leaves the routing profile to the service. The override is
|
||||
* honored either way, so evals can exercise tiers before the experiment
|
||||
* reaches them.
|
||||
*
|
||||
* The picker selection is honored on inline surfaces too. The schema is
|
||||
* published per model rather than per surface, so the tier chip renders in
|
||||
* inline chat as well; unconditionally pinning `fast` there would leave the
|
||||
* user a visible, persisted control that silently does nothing.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
private _resolveTier(chatRequest: IAutoModeRoutingRequest | undefined): AutoModeTier | undefined {
|
||||
const override = this._configurationService.getConfig(ConfigKey.Advanced.AutoModeTierOverride);
|
||||
if (override) {
|
||||
// The override is internal, so unlike the picker it may select `fast`.
|
||||
if ((autoModeTiers as readonly string[]).includes(override)) {
|
||||
return override as AutoModeTier;
|
||||
}
|
||||
this._logService.warn(`[AutomodeService] Ignoring auto tier override '${override}' — not one of [${autoModeTiers.join(', ')}].`);
|
||||
}
|
||||
if (!this.areAutoModeTiersSupported()) {
|
||||
return undefined;
|
||||
}
|
||||
const configured = chatRequest?.modelConfiguration?.[AUTO_MODE_TIER_PROPERTY];
|
||||
if (isSelectableAutoModeTier(configured) && configured !== defaultAutoModeTier) {
|
||||
return configured;
|
||||
}
|
||||
if (chatRequest?.location !== undefined && inlineChatLocations.has(chatRequest.location)) {
|
||||
return inlineChatAutoModeTier;
|
||||
}
|
||||
return defaultAutoModeTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves via `POST /auto`. Returns `undefined` when V2 cannot serve the
|
||||
* request, so the caller falls back to the legacy flow.
|
||||
@@ -500,18 +632,21 @@ export class AutomodeService extends Disposable implements IAutomodeService {
|
||||
private async _tryResolveWithAutoV2(chatRequest: IAutoModeRoutingRequest | undefined, knownEndpoints: IChatEndpoint[]): Promise<IChatEndpoint | undefined> {
|
||||
const conversationId = chatRequest?.sessionResource?.toString() ?? chatRequest?.sessionId ?? 'unknown';
|
||||
const prompt = chatRequest?.prompt?.trim();
|
||||
// `/auto` needs a prompt. Non-panel locations stay on the legacy flow,
|
||||
// which applies their location-specific model hints.
|
||||
if (!prompt?.length || conversationId === 'unknown' || !this._isRouterEnabled(chatRequest)) {
|
||||
// `/auto` only needs a prompt and a conversation to key the session on;
|
||||
// every surface routes, and the tier carries the surface's intent.
|
||||
if (!prompt?.length || conversationId === 'unknown') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tier = this._resolveTier(chatRequest);
|
||||
const entry = this._autoV2Cache.get(conversationId);
|
||||
// The token lasts 24h with no refresh, so reuse the endpoint for the rest
|
||||
// of the conversation. A turn that newly attaches an image must
|
||||
// re-resolve, since the cached model was picked without that constraint.
|
||||
// of the conversation. A turn that attaches an image to a text-only model
|
||||
// must re-resolve, as must a turn whose tier no longer matches the routing
|
||||
// profile the cached model was picked under.
|
||||
const cacheUsable = entry && !entry.needsReEval && entry.turnCount > 0
|
||||
&& !this._isAutoV2SessionExpired(entry)
|
||||
&& entry.tier === tier
|
||||
&& (!hasImage(chatRequest) || entry.endpoint.supportsVision);
|
||||
if (cacheUsable) {
|
||||
return entry.endpoint;
|
||||
@@ -522,8 +657,9 @@ export class AutomodeService extends Disposable implements IAutomodeService {
|
||||
hasImage: hasImage(chatRequest),
|
||||
conversationId,
|
||||
vscodeRequestId: chatRequest?.id,
|
||||
tier,
|
||||
});
|
||||
this._setLastAutoV2Discounts(result.discounted_costs);
|
||||
this._setLastAutoV2Discounts(result.discounted_costs, tier);
|
||||
|
||||
// Prefer local `/models` metadata: it carries fields `/auto` leaves
|
||||
// unset (token pricing, promos, SKU restrictions, thinking budgets).
|
||||
@@ -549,15 +685,22 @@ export class AutomodeService extends Disposable implements IAutomodeService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const endpoint = (entry?.endpoint && entry.sessionToken === result.session_token && entry.endpoint.model === selectedModel.model)
|
||||
const endpoint = (entry?.endpoint && entry.sessionToken === result.session_token && entry.endpoint.model === selectedModel.model && entry.tier === tier)
|
||||
? entry.endpoint
|
||||
: this._instantiationService.createInstance(AutoChatEndpoint, selectedModel, result.session_token, result.discounted_costs?.[selectedModel.model] || 0, this._calculateDiscountRange(result.discounted_costs));
|
||||
|
||||
// Only a genuinely new conversation needs room made for it; the `set`
|
||||
// below otherwise replaces an entry, and evicting would cost an
|
||||
// unrelated session.
|
||||
if (!this._autoV2Cache.has(conversationId)) {
|
||||
this._evictOldestAutoV2Sessions();
|
||||
}
|
||||
this._autoV2Cache.set(conversationId, {
|
||||
endpoint,
|
||||
sessionToken: result.session_token,
|
||||
expiresAt: result.expires_at,
|
||||
lastRoutedPrompt: prompt,
|
||||
tier,
|
||||
turnCount: (entry?.turnCount ?? 0) + (entry?.lastRoutedPrompt === prompt ? 0 : 1),
|
||||
needsReEval: false,
|
||||
});
|
||||
@@ -566,13 +709,14 @@ export class AutomodeService extends Disposable implements IAutomodeService {
|
||||
const reason = this._classifyAutoV2Failure(e);
|
||||
// A 404 means we are gated off; stop retrying on every turn.
|
||||
if (e instanceof AutoV2Error && e.status === 404) {
|
||||
this._autoV2Unavailable = true;
|
||||
this._markAutoV2Unavailable();
|
||||
this._logService.info(`[AutomodeService] Auto v2 endpoint unavailable (404); using the legacy flow for the rest of the session.`);
|
||||
}
|
||||
this._logService.error(`[AutomodeService] Auto v2 failed for conversation ${conversationId} (${reason}):`, (e as Error).message);
|
||||
this._sendAutoV2FallbackTelemetry(reason);
|
||||
// Prefer the last known good endpoint over the legacy round-trips.
|
||||
if (entry && !this._isAutoV2SessionExpired(entry) && (!hasImage(chatRequest) || entry.endpoint.supportsVision)) {
|
||||
// Prefer the last known good endpoint over the legacy round-trips, but
|
||||
// only when it still reflects the tier and vision needs of this turn.
|
||||
if (entry && entry.tier === tier && !entry.needsReEval && !this._isAutoV2SessionExpired(entry) && (!hasImage(chatRequest) || entry.endpoint.supportsVision)) {
|
||||
return entry.endpoint;
|
||||
}
|
||||
return undefined;
|
||||
@@ -615,6 +759,22 @@ export class AutomodeService extends Disposable implements IAutomodeService {
|
||||
return entry.expiresAt * 1000 - Date.now() < 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds the session cache. Inline chat starts a new session per invocation,
|
||||
* so without this the map grows for the life of the window with conversations
|
||||
* that will never be read again. Stale entries are already rejected when read,
|
||||
* so this only has to reclaim memory: evict oldest-first (Map keeps insertion
|
||||
* order) to make room for one more.
|
||||
*/
|
||||
private _evictOldestAutoV2Sessions(): void {
|
||||
for (const conversationId of this._autoV2Cache.keys()) {
|
||||
if (this._autoV2Cache.size < AutomodeService.AUTO_V2_CACHE_MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
this._autoV2Cache.delete(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
private _classifyAutoV2Failure(e: unknown): string {
|
||||
if (isAbortError(e)) {
|
||||
return 'autoV2Timeout';
|
||||
@@ -795,6 +955,10 @@ export class AutomodeService extends Disposable implements IAutomodeService {
|
||||
return fallbackEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gates the legacy router. Kept panel-only so the fallback path behaves
|
||||
* exactly as it did before `/auto`; V2 routes every surface.
|
||||
*/
|
||||
private _isRouterEnabled(chatRequest: IAutoModeRoutingRequest | undefined): boolean {
|
||||
const isPanelChat = !chatRequest?.location || chatRequest?.location === ChatLocation.Panel;
|
||||
return isPanelChat;
|
||||
|
||||
@@ -18,9 +18,10 @@ import { NullRequestLogger } from '../../../requestLogger/node/nullRequestLogger
|
||||
import { IExperimentationService, NullExperimentationService } from '../../../telemetry/common/nullExperimentationService';
|
||||
import { ITelemetryService } from '../../../telemetry/common/telemetry';
|
||||
import { createPngBytes } from '../../../image/common/test/testImageData';
|
||||
import { ConfigKey, IConfigurationService } from '../../../configuration/common/configurationService';
|
||||
import { BaseConfig, ConfigKey, IConfigurationService } from '../../../configuration/common/configurationService';
|
||||
import { DefaultsOnlyConfigurationService } from '../../../configuration/common/defaultsOnlyConfigurationService';
|
||||
import { InMemoryConfigurationService } from '../../../configuration/test/common/inMemoryConfigurationService';
|
||||
import { defaultAutoModeTier } from '../../common/autoModeTiers';
|
||||
import { ICAPIClientService } from '../../common/capiClient';
|
||||
import { AutomodeService } from '../automodeService';
|
||||
|
||||
@@ -1406,13 +1407,25 @@ describe('AutomodeService', () => {
|
||||
});
|
||||
});
|
||||
describe('single-call Auto endpoint (POST /auto)', () => {
|
||||
function enableAutoV2(): void {
|
||||
function enableAutoV2(overrides: Map<BaseConfig<unknown>, unknown> = new Map()): void {
|
||||
configurationService = new InMemoryConfigurationService(
|
||||
new DefaultsOnlyConfigurationService(),
|
||||
new Map([[ConfigKey.Advanced.AutoModeV2Enabled, true]]),
|
||||
new Map<BaseConfig<unknown>, unknown>([
|
||||
[ConfigKey.Advanced.AutoModeV2Enabled, true],
|
||||
...overrides,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/** Tiers are experiment-gated and off by default, so tier tests opt in. */
|
||||
function enableAutoV2WithTiers(): void {
|
||||
enableAutoV2(new Map<BaseConfig<unknown>, unknown>([[ConfigKey.Advanced.AutoModeTiersEnabled, true]]));
|
||||
}
|
||||
|
||||
function enableAutoV2WithTierOverride(override: string): void {
|
||||
enableAutoV2(new Map<BaseConfig<unknown>, unknown>([[ConfigKey.Advanced.AutoModeTierOverride, override]]));
|
||||
}
|
||||
|
||||
function makeAutoResponse(body: unknown, status = 200) {
|
||||
const serialized = JSON.stringify(body);
|
||||
return {
|
||||
@@ -1699,8 +1712,9 @@ describe('AutomodeService', () => {
|
||||
expect(second.model).toBe('gpt-4o-vision');
|
||||
});
|
||||
|
||||
it('does not call /auto for non-panel chat locations', async () => {
|
||||
enableAutoV2();
|
||||
it('routes inline chat through /auto with the fast tier', async () => {
|
||||
enableAutoV2WithTiers();
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
|
||||
mockAuto({
|
||||
session_token: 'auto-v2-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 86400,
|
||||
@@ -1708,16 +1722,424 @@ describe('AutomodeService', () => {
|
||||
});
|
||||
|
||||
automodeService = createService();
|
||||
const chatRequest: Partial<ChatRequest> = {
|
||||
for (const location of [ChatLocation.Editor, ChatLocation.Terminal, ChatLocation.Notebook]) {
|
||||
const result = await automodeService.resolveAutoModeEndpoint({
|
||||
location,
|
||||
prompt: 'test prompt',
|
||||
sessionId: `session-auto-v2-${location}`,
|
||||
} as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
expect(result.model).toBe('gpt-4o');
|
||||
}
|
||||
|
||||
const tiers = (mockCAPIClientService.makeRequest as ReturnType<typeof vi.fn>).mock.calls
|
||||
.filter(c => c[1]?.type === RequestType.Auto)
|
||||
.map(c => JSON.parse(c[0].body).tier);
|
||||
expect(tiers).toEqual(['fast', 'fast', 'fast']);
|
||||
});
|
||||
|
||||
// The workbench materializes the schema default into `modelConfiguration`,
|
||||
// so this — not an absent `modelConfiguration` — is what a real inline
|
||||
// request looks like for a user who never touched the tier picker.
|
||||
it('pins inline chat to the fast tier when the picker sits on its default', async () => {
|
||||
enableAutoV2WithTiers();
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
|
||||
mockAuto({
|
||||
session_token: 'auto-v2-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 86400,
|
||||
selected_model: { id: 'gpt-4o' },
|
||||
});
|
||||
|
||||
automodeService = createService();
|
||||
await automodeService.resolveAutoModeEndpoint({
|
||||
location: ChatLocation.Editor,
|
||||
prompt: 'inline turn',
|
||||
sessionId: 'session-auto-v2-inline-default',
|
||||
modelConfiguration: { tier: defaultAutoModeTier },
|
||||
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
|
||||
const autoCall = (mockCAPIClientService.makeRequest as ReturnType<typeof vi.fn>).mock.calls.find(c => c[1]?.type === RequestType.Auto);
|
||||
expect(JSON.parse(autoCall![0].body)).toEqual({ prompt: 'inline turn', tier: 'fast' });
|
||||
});
|
||||
|
||||
it('honors an explicit tier selection on inline surfaces', async () => {
|
||||
enableAutoV2WithTiers();
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
|
||||
mockAuto({
|
||||
session_token: 'auto-v2-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 86400,
|
||||
selected_model: { id: 'gpt-4o' },
|
||||
});
|
||||
|
||||
automodeService = createService();
|
||||
await automodeService.resolveAutoModeEndpoint({
|
||||
location: ChatLocation.Editor,
|
||||
prompt: 'test prompt',
|
||||
sessionId: 'session-auto-v2-editor'
|
||||
};
|
||||
sessionId: 'session-auto-v2-inline-tier',
|
||||
modelConfiguration: { tier: 'max' },
|
||||
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
|
||||
await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [mockChatEndpoint]);
|
||||
const autoCall = (mockCAPIClientService.makeRequest as ReturnType<typeof vi.fn>).mock.calls.find(c => c[1]?.type === RequestType.Auto);
|
||||
expect(JSON.parse(autoCall![0].body)).toEqual({ prompt: 'test prompt', tier: 'max' });
|
||||
});
|
||||
|
||||
const autoCalls = (mockCAPIClientService.makeRequest as ReturnType<typeof vi.fn>).mock.calls.filter(c => c[1]?.type === RequestType.Auto);
|
||||
expect(autoCalls).toHaveLength(0);
|
||||
it('sends the tier picked in the model configuration', async () => {
|
||||
enableAutoV2WithTiers();
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
|
||||
mockAuto({
|
||||
session_token: 'auto-v2-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 86400,
|
||||
selected_model: { id: 'gpt-4o' },
|
||||
});
|
||||
|
||||
automodeService = createService();
|
||||
await automodeService.resolveAutoModeEndpoint({
|
||||
location: ChatLocation.Panel,
|
||||
prompt: 'test prompt',
|
||||
sessionId: 'session-auto-v2-tier',
|
||||
modelConfiguration: { tier: 'max' },
|
||||
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
|
||||
const autoCall = (mockCAPIClientService.makeRequest as ReturnType<typeof vi.fn>).mock.calls.find(c => c[1]?.type === RequestType.Auto);
|
||||
expect(JSON.parse(autoCall![0].body)).toEqual({ prompt: 'test prompt', tier: 'max' });
|
||||
});
|
||||
|
||||
it('falls back to the default tier when the configured tier is not user selectable', async () => {
|
||||
enableAutoV2WithTiers();
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
|
||||
mockAuto({
|
||||
session_token: 'auto-v2-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 86400,
|
||||
selected_model: { id: 'gpt-4o' },
|
||||
});
|
||||
|
||||
automodeService = createService();
|
||||
await automodeService.resolveAutoModeEndpoint({
|
||||
location: ChatLocation.Panel,
|
||||
prompt: 'test prompt',
|
||||
sessionId: 'session-auto-v2-bad-tier',
|
||||
modelConfiguration: { tier: 'fast' },
|
||||
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
|
||||
const autoCall = (mockCAPIClientService.makeRequest as ReturnType<typeof vi.fn>).mock.calls.find(c => c[1]?.type === RequestType.Auto);
|
||||
expect(JSON.parse(autoCall![0].body)).toEqual({ prompt: 'test prompt', tier: 'balanced' });
|
||||
});
|
||||
|
||||
it('re-routes the conversation when the tier changes', async () => {
|
||||
enableAutoV2WithTiers();
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
|
||||
mockAuto({
|
||||
session_token: 'auto-v2-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 86400,
|
||||
selected_model: { id: 'gpt-4o' },
|
||||
});
|
||||
|
||||
automodeService = createService();
|
||||
const chatRequest = {
|
||||
location: ChatLocation.Panel,
|
||||
prompt: 'test prompt',
|
||||
sessionId: 'session-auto-v2-tier-change',
|
||||
modelConfiguration: { tier: 'eco' },
|
||||
} 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]);
|
||||
|
||||
const tiers = (mockCAPIClientService.makeRequest as ReturnType<typeof vi.fn>).mock.calls
|
||||
.filter(c => c[1]?.type === RequestType.Auto)
|
||||
.map(c => JSON.parse(c[0].body).tier);
|
||||
expect(tiers).toEqual(['eco', 'max']);
|
||||
});
|
||||
|
||||
it('lets the tier override win over the picker and the inline chat pin', async () => {
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
|
||||
mockAuto({
|
||||
session_token: 'auto-v2-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 86400,
|
||||
selected_model: { id: 'gpt-4o' },
|
||||
});
|
||||
|
||||
enableAutoV2WithTierOverride('eco');
|
||||
automodeService = createService();
|
||||
await automodeService.resolveAutoModeEndpoint({
|
||||
location: ChatLocation.Panel,
|
||||
prompt: 'panel turn',
|
||||
sessionId: 'session-override-panel',
|
||||
modelConfiguration: { tier: 'max' },
|
||||
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
await automodeService.resolveAutoModeEndpoint({
|
||||
location: ChatLocation.Editor,
|
||||
prompt: 'inline turn',
|
||||
sessionId: 'session-override-inline',
|
||||
} as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
|
||||
const tiers = (mockCAPIClientService.makeRequest as ReturnType<typeof vi.fn>).mock.calls
|
||||
.filter(c => c[1]?.type === RequestType.Auto)
|
||||
.map(c => JSON.parse(c[0].body).tier);
|
||||
expect(tiers).toEqual(['eco', 'eco']);
|
||||
});
|
||||
|
||||
// The override is an internal/eval knob, so unlike the picker it may target
|
||||
// the profile inline chat reserves for itself.
|
||||
it('allows the tier override to select the internal fast tier', async () => {
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
|
||||
mockAuto({
|
||||
session_token: 'auto-v2-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 86400,
|
||||
selected_model: { id: 'gpt-4o' },
|
||||
});
|
||||
|
||||
enableAutoV2WithTierOverride('fast');
|
||||
automodeService = createService();
|
||||
await automodeService.resolveAutoModeEndpoint({
|
||||
location: ChatLocation.Panel,
|
||||
prompt: 'panel turn',
|
||||
sessionId: 'session-override-fast',
|
||||
} as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
|
||||
const autoCall = (mockCAPIClientService.makeRequest as ReturnType<typeof vi.fn>).mock.calls.find(c => c[1]?.type === RequestType.Auto);
|
||||
expect(JSON.parse(autoCall![0].body)).toEqual({ prompt: 'panel turn', tier: 'fast' });
|
||||
});
|
||||
|
||||
it('ignores an unrecognized tier override', async () => {
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
|
||||
mockAuto({
|
||||
session_token: 'auto-v2-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 86400,
|
||||
selected_model: { id: 'gpt-4o' },
|
||||
});
|
||||
|
||||
enableAutoV2(new Map<BaseConfig<unknown>, unknown>([
|
||||
[ConfigKey.Advanced.AutoModeTiersEnabled, true],
|
||||
[ConfigKey.Advanced.AutoModeTierOverride, 'turbo'],
|
||||
]));
|
||||
automodeService = createService();
|
||||
await automodeService.resolveAutoModeEndpoint({
|
||||
location: ChatLocation.Panel,
|
||||
prompt: 'panel turn',
|
||||
sessionId: 'session-override-bogus',
|
||||
modelConfiguration: { tier: 'max' },
|
||||
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
|
||||
const autoCall = (mockCAPIClientService.makeRequest as ReturnType<typeof vi.fn>).mock.calls.find(c => c[1]?.type === RequestType.Auto);
|
||||
expect(JSON.parse(autoCall![0].body)).toEqual({ prompt: 'panel turn', tier: 'max' });
|
||||
});
|
||||
|
||||
it('withdraws tier support and announces it when /auto is gated off', async () => {
|
||||
enableAutoV2WithTiers();
|
||||
mockAuto({ error: 'not_found' }, 404);
|
||||
|
||||
automodeService = createService();
|
||||
expect(automodeService.areAutoModeTiersSupported()).toBe(true);
|
||||
|
||||
let announced = 0;
|
||||
const listener = automodeService.onDidChangeAutoModeTierSupport(() => announced++);
|
||||
await automodeService.resolveAutoModeEndpoint({
|
||||
location: ChatLocation.Panel,
|
||||
prompt: 'test prompt',
|
||||
sessionId: 'session-auto-v2-404',
|
||||
} as ChatRequest, [mockChatEndpoint]);
|
||||
listener.dispose();
|
||||
|
||||
expect({ announced, supported: automodeService.areAutoModeTiersSupported() }).toEqual({ announced: 1, supported: false });
|
||||
});
|
||||
|
||||
it('announces tier support when the setting changes', async () => {
|
||||
enableAutoV2();
|
||||
|
||||
automodeService = createService();
|
||||
expect(automodeService.areAutoModeTiersSupported()).toBe(false);
|
||||
|
||||
let announced = 0;
|
||||
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');
|
||||
listener.dispose();
|
||||
|
||||
expect({ announced, supported: automodeService.areAutoModeTiersSupported() }).toEqual({ announced: 1, supported: true });
|
||||
});
|
||||
|
||||
it('does not reuse a cached endpoint from a different tier when /auto fails', async () => {
|
||||
enableAutoV2WithTiers();
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
|
||||
mockAuto({
|
||||
session_token: 'auto-v2-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 86400,
|
||||
selected_model: { id: 'gpt-4o' },
|
||||
});
|
||||
|
||||
automodeService = createService();
|
||||
const chatRequest = {
|
||||
location: ChatLocation.Panel,
|
||||
prompt: 'first turn',
|
||||
sessionId: 'session-auto-v2-tier-error',
|
||||
modelConfiguration: { tier: 'eco' },
|
||||
} 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
|
||||
// handed back as though it satisfied the new tier.
|
||||
mockAuto({ error: 'server_error' }, 500);
|
||||
const second = await automodeService.resolveAutoModeEndpoint({
|
||||
...chatRequest,
|
||||
prompt: 'second turn',
|
||||
modelConfiguration: { tier: 'max' },
|
||||
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
|
||||
expect(second.model).toBe(mockChatEndpoint.model);
|
||||
});
|
||||
|
||||
// `/auto` does not promise a new session token when the tier changes, so
|
||||
// the endpoint (which bakes in the discount) cannot be reused across tiers.
|
||||
it('rebuilds the endpoint when the tier changes but the session token does not', async () => {
|
||||
enableAutoV2WithTiers();
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
|
||||
const autoResponse = (discount: number) => ({
|
||||
session_token: 'auto-v2-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 86400,
|
||||
selected_model: { id: 'gpt-4o' },
|
||||
discounted_costs: { 'gpt-4o': discount },
|
||||
});
|
||||
mockAuto(autoResponse(0.2));
|
||||
|
||||
automodeService = createService();
|
||||
const chatRequest = {
|
||||
location: ChatLocation.Panel,
|
||||
prompt: 'first turn',
|
||||
sessionId: 'session-auto-v2-tier-discount',
|
||||
modelConfiguration: { tier: 'eco' },
|
||||
} as unknown as ChatRequest;
|
||||
await automodeService.resolveAutoModeEndpoint(chatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
|
||||
mockAuto(autoResponse(0.9));
|
||||
await automodeService.resolveAutoModeEndpoint({
|
||||
...chatRequest,
|
||||
prompt: 'second turn',
|
||||
modelConfiguration: { tier: 'max' },
|
||||
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
|
||||
const discounts = (mockInstantiationService.createInstance as ReturnType<typeof vi.fn>).mock.calls.map(c => c[3]);
|
||||
expect(discounts).toEqual([0.2, 0.9]);
|
||||
});
|
||||
|
||||
it('does not evict an unrelated session when a cached conversation is rerouted', async () => {
|
||||
enableAutoV2WithTiers();
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
|
||||
mockAuto({
|
||||
session_token: 'auto-v2-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 86400,
|
||||
selected_model: { id: 'gpt-4o' },
|
||||
});
|
||||
const autoCallCount = () => (mockCAPIClientService.makeRequest as ReturnType<typeof vi.fn>).mock.calls.filter(c => c[1]?.type === RequestType.Auto).length;
|
||||
|
||||
automodeService = createService();
|
||||
const route = (sessionId: string, prompt: string, tier?: string) => automodeService.resolveAutoModeEndpoint({
|
||||
location: ChatLocation.Panel,
|
||||
prompt,
|
||||
sessionId,
|
||||
modelConfiguration: tier ? { tier } : undefined,
|
||||
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
|
||||
// Fill the cache to AUTO_V2_CACHE_MAX_ENTRIES, then reroute the newest
|
||||
// conversation: replacing its entry needs no room, so the oldest entry
|
||||
// must still answer from cache.
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await route(`session-${i}`, `turn ${i}`);
|
||||
}
|
||||
await route('session-49', 'retiered turn', 'max');
|
||||
|
||||
const callsBefore = autoCallCount();
|
||||
await route('session-0', 'follow up');
|
||||
|
||||
expect(autoCallCount()).toBe(callsBefore);
|
||||
});
|
||||
|
||||
it('keeps inline requests from overwriting the discount shown in the picker', async () => {
|
||||
enableAutoV2WithTiers();
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
|
||||
mockAuto({
|
||||
session_token: 'auto-v2-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 86400,
|
||||
selected_model: { id: 'gpt-4o' },
|
||||
discounted_costs: { 'gpt-4o': 0.2 },
|
||||
});
|
||||
|
||||
automodeService = createService();
|
||||
await automodeService.resolveAutoModeEndpoint({
|
||||
location: ChatLocation.Panel,
|
||||
prompt: 'panel turn',
|
||||
sessionId: 'session-discount-panel',
|
||||
} as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
|
||||
mockAuto({
|
||||
session_token: 'auto-v2-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 86400,
|
||||
selected_model: { id: 'gpt-4o' },
|
||||
discounted_costs: { 'gpt-4o': 0.9 },
|
||||
});
|
||||
await automodeService.resolveAutoModeEndpoint({
|
||||
location: ChatLocation.Editor,
|
||||
prompt: 'inline turn',
|
||||
sessionId: 'session-discount-inline',
|
||||
} as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
|
||||
expect(await automodeService.getAutoPickerMetadata()).toEqual({ discountRange: { low: 0.2, high: 0.2 } });
|
||||
});
|
||||
|
||||
// Tiers are experiment-gated, so until the experiment reaches a user the
|
||||
// request must look exactly as it did before tiers existed.
|
||||
it('omits the tier and hides the picker while tiers are disabled', async () => {
|
||||
enableAutoV2();
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
|
||||
mockAuto({
|
||||
session_token: 'auto-v2-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 86400,
|
||||
selected_model: { id: 'gpt-4o' },
|
||||
});
|
||||
|
||||
automodeService = createService();
|
||||
for (const location of [ChatLocation.Panel, ChatLocation.Editor]) {
|
||||
await automodeService.resolveAutoModeEndpoint({
|
||||
location,
|
||||
prompt: 'test prompt',
|
||||
sessionId: `session-tiers-off-${location}`,
|
||||
modelConfiguration: { tier: 'max' },
|
||||
} as unknown as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
}
|
||||
|
||||
const bodies = (mockCAPIClientService.makeRequest as ReturnType<typeof vi.fn>).mock.calls
|
||||
.filter(c => c[1]?.type === RequestType.Auto)
|
||||
.map(c => JSON.parse(c[0].body));
|
||||
expect({ bodies, supported: automodeService.areAutoModeTiersSupported() }).toEqual({
|
||||
bodies: [
|
||||
{ prompt: 'test prompt' },
|
||||
{ prompt: 'test prompt' },
|
||||
],
|
||||
supported: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Evals need to exercise tiers before the experiment reaches them.
|
||||
it('honors the tier override while tiers are disabled', async () => {
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
|
||||
mockAuto({
|
||||
session_token: 'auto-v2-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 86400,
|
||||
selected_model: { id: 'gpt-4o' },
|
||||
});
|
||||
|
||||
enableAutoV2WithTierOverride('max');
|
||||
automodeService = createService();
|
||||
await automodeService.resolveAutoModeEndpoint({
|
||||
location: ChatLocation.Panel,
|
||||
prompt: 'panel turn',
|
||||
sessionId: 'session-override-tiers-off',
|
||||
} as ChatRequest, [mockChatEndpoint, gpt4oEndpoint]);
|
||||
|
||||
const autoCall = (mockCAPIClientService.makeRequest as ReturnType<typeof vi.fn>).mock.calls.find(c => c[1]?.type === RequestType.Auto);
|
||||
expect(JSON.parse(autoCall![0].body)).toEqual({ prompt: 'panel turn', tier: 'max' });
|
||||
});
|
||||
|
||||
it('resolves the picker endpoint without touching the legacy session under V2', async () => {
|
||||
@@ -1773,6 +2195,22 @@ describe('AutomodeService', () => {
|
||||
expect(JSON.parse(autoCall![0].body)).toEqual({ prompt: 'MODEL_PICKER_DISCOUNT_RESOLUTION - REPLACE ME' });
|
||||
});
|
||||
|
||||
it('withdraws the tier picker when the discount probe is gated with a 404', async () => {
|
||||
enableAutoV2WithTiers();
|
||||
mockAuto({ error: 'not_found' }, 404);
|
||||
const gpt4oMiniEndpoint = createEndpoint('gpt-4o-mini', 'OpenAI');
|
||||
|
||||
automodeService = createService();
|
||||
const endpoint = await automodeService.resolveAutoModePickerEndpoint([gpt4oMiniEndpoint]);
|
||||
|
||||
const requestTypes = (mockCAPIClientService.makeRequest as ReturnType<typeof vi.fn>).mock.calls.map(c => c[1]?.type);
|
||||
expect({
|
||||
model: endpoint.model,
|
||||
tiersSupported: automodeService.areAutoModeTiersSupported(),
|
||||
usedLegacySession: requestTypes.includes(RequestType.AutoModels),
|
||||
}).toEqual({ model: 'gpt-4o-mini', tiersSupported: false, usedLegacySession: true });
|
||||
});
|
||||
|
||||
it('probes at most once even across concurrent picker refreshes', async () => {
|
||||
enableAutoV2();
|
||||
mockAuto({
|
||||
|
||||
+19
-10
@@ -19,14 +19,16 @@ import { IModelConfigurationAccess } from './modelPickerActionItem.js';
|
||||
|
||||
type ChatThinkingEffortChangeClassification = {
|
||||
owner: 'lramos15';
|
||||
comment: 'Reporting when the thinking effort is changed';
|
||||
model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model the thinking effort was changed for' };
|
||||
fromValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous thinking effort value' };
|
||||
toValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The new thinking effort value' };
|
||||
comment: 'Reporting when a model configuration value (e.g. thinking effort, or the Auto routing tier) is changed';
|
||||
model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model the configuration was changed for' };
|
||||
property: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The first-party configuration property that was changed (reasoningEffort, or tier for the Auto model); "unknown" for third-party providers, which choose their own keys' };
|
||||
fromValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous value of the configuration property' };
|
||||
toValue: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The new value of the configuration property' };
|
||||
};
|
||||
|
||||
type ChatThinkingEffortChangeEvent = {
|
||||
model: string | TelemetryTrustedValue<string>;
|
||||
property: string;
|
||||
fromValue: string;
|
||||
toValue: string;
|
||||
};
|
||||
@@ -79,7 +81,11 @@ export class ModelPickerConfiguration {
|
||||
? effortConfig.schema.enumItemLabels[enumIndex]
|
||||
: String(effortConfig.value);
|
||||
labelParts.push(effortLabel);
|
||||
ariaParts.push(localize('chat.modelPicker.effortAriaLabel', "Thinking Effort: {0}", effortLabel));
|
||||
// The group is generic, so producers name it: Copilot's Auto model uses it
|
||||
// for "Tier" while regular models use it for thinking effort.
|
||||
ariaParts.push(effortConfig.schema.title
|
||||
? localize('chat.modelPicker.navigationAriaLabel', "{0}: {1}", effortConfig.schema.title, effortLabel)
|
||||
: localize('chat.modelPicker.effortAriaLabel', "Thinking Effort: {0}", effortLabel));
|
||||
}
|
||||
if (tokensConfig && tokensConfig.value !== undefined) {
|
||||
const enumIndex = tokensConfig.schema.enum?.indexOf(tokensConfig.value) ?? -1;
|
||||
@@ -193,9 +199,9 @@ export class ModelPickerConfiguration {
|
||||
const defaultLabel = localize('models.configDefault', "Default");
|
||||
const appendConfigSection = (
|
||||
group: string,
|
||||
headerLabel: string,
|
||||
fallbackHeaderLabel: string,
|
||||
formatValueLabel: (value: unknown, enumLabel: string | undefined) => string,
|
||||
logChange: (value: unknown, previousValue: string) => void,
|
||||
logChange: (value: unknown, previousValue: string, key: string) => void,
|
||||
): void => {
|
||||
const config = this._getConfigProperty(group);
|
||||
if (!config) {
|
||||
@@ -206,7 +212,7 @@ export class ModelPickerConfiguration {
|
||||
if (items.length) {
|
||||
items.push({ kind: ActionListItemKind.Separator });
|
||||
}
|
||||
items.push({ kind: ActionListItemKind.Header, label: headerLabel });
|
||||
items.push({ kind: ActionListItemKind.Header, label: config.schema.title ?? fallbackHeaderLabel });
|
||||
for (let index = 0; index < enumValues.length; index++) {
|
||||
const value = enumValues[index];
|
||||
const isDefault = value === config.schema.default;
|
||||
@@ -223,7 +229,7 @@ export class ModelPickerConfiguration {
|
||||
tooltip: enumDescription ?? '',
|
||||
label: displayLabel,
|
||||
run: () => {
|
||||
logChange(value, previousValue);
|
||||
logChange(value, previousValue, config.key);
|
||||
return configurationAccess.setModelConfiguration(modelIdentifier, { [config.key]: value });
|
||||
}
|
||||
},
|
||||
@@ -243,8 +249,11 @@ export class ModelPickerConfiguration {
|
||||
'navigation',
|
||||
localize('chat.effort.header', "Thinking Effort"),
|
||||
(value, enumLabel) => enumLabel ?? String(value),
|
||||
(value, previousValue) => this._telemetryService.publicLog2<ChatThinkingEffortChangeEvent, ChatThinkingEffortChangeClassification>('chat.thinkingEffortChange', {
|
||||
(value, previousValue, key) => this._telemetryService.publicLog2<ChatThinkingEffortChangeEvent, ChatThinkingEffortChangeClassification>('chat.thinkingEffortChange', {
|
||||
model: model.metadata.vendor === 'copilot' ? new TelemetryTrustedValue(modelIdentifier) : 'unknown',
|
||||
// Third-party providers choose their own property keys, so only
|
||||
// first-party ones are reported as a controlled vocabulary.
|
||||
property: model.metadata.vendor === 'copilot' ? key : 'unknown',
|
||||
fromValue: previousValue,
|
||||
toValue: String(value),
|
||||
}),
|
||||
|
||||
@@ -168,7 +168,9 @@ export function getModelHoverContent(
|
||||
container.appendChild(contextSection);
|
||||
}
|
||||
|
||||
if (!isAuto && model.metadata.configurationSchema?.properties) {
|
||||
// Auto has no per-model pricing to show, but it does expose a routing tier,
|
||||
// so the configurable section is not gated on `isAuto`.
|
||||
if (model.metadata.configurationSchema?.properties) {
|
||||
const configButtons: { group: string; label: string }[] = [];
|
||||
const seenGroups = new Set<string>();
|
||||
for (const propSchema of Object.values(model.metadata.configurationSchema.properties)) {
|
||||
|
||||
+52
@@ -56,6 +56,40 @@ function createModel(options?: { readonly omitEffortDefault?: boolean; readonly
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a model shaped like Copilot's Auto entry: a single navigation group
|
||||
* that names itself "Tier" instead of reusing the thinking-effort wording.
|
||||
*/
|
||||
function createTierModel(): ILanguageModelChatMetadataAndIdentifier {
|
||||
return {
|
||||
identifier: 'copilot/auto',
|
||||
metadata: {
|
||||
extension: new ExtensionIdentifier('test.extension'),
|
||||
id: 'auto',
|
||||
name: 'Auto',
|
||||
vendor: 'copilot',
|
||||
version: '1.0',
|
||||
family: 'auto',
|
||||
maxInputTokens: 128000,
|
||||
maxOutputTokens: 4096,
|
||||
isDefaultForLocation: {},
|
||||
configurationSchema: {
|
||||
properties: {
|
||||
tier: {
|
||||
type: 'string',
|
||||
title: 'Tier',
|
||||
group: 'navigation',
|
||||
enum: ['eco', 'balanced', 'max'],
|
||||
enumItemLabels: ['Eco', 'Balanced', 'Max'],
|
||||
enumDescriptions: ['Cheaper models', 'Balances capability and cost', 'Most capable models'],
|
||||
default: 'balanced',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ILanguageModelChatMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the configuration button and opens the dropdown for `model`, then
|
||||
* returns a snapshot of everything the user can see: the button label, its
|
||||
@@ -169,4 +203,22 @@ suite('ModelPickerConfiguration', () => {
|
||||
ariaLabel: 'Configure',
|
||||
});
|
||||
});
|
||||
|
||||
// The navigation group is generic: Copilot's Auto model uses it for the
|
||||
// routing tier rather than thinking effort, and names it through `title`.
|
||||
test('names the navigation group after the schema title when one is given', () => {
|
||||
assert.deepStrictEqual(render(createTierModel(), { tier: 'max' }), {
|
||||
label: 'Max',
|
||||
ariaLabel: 'Tier: Max',
|
||||
listOptions: {
|
||||
reserveSubmenuSpace: false,
|
||||
},
|
||||
sections: [
|
||||
{ kind: ActionListItemKind.Header, label: 'Tier' },
|
||||
{ className: 'chat-model-picker-config-option', label: 'Eco', checked: false, ariaDescription: 'Cheaper models' },
|
||||
{ className: 'chat-model-picker-config-option', label: 'Balanced', checked: false, ariaDescription: 'Default, Balances capability and cost' },
|
||||
{ className: 'chat-model-picker-config-option', label: 'Max', checked: true, ariaDescription: 'Most capable models' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user