Skip to content

Commit 2056350

Browse files
authored
show model details in footer for claude harness (#313452)
* show model details in footer for claude harness * address comments and refactor * remove extra check
1 parent c4b59bb commit 2056350

4 files changed

Lines changed: 234 additions & 19 deletions

File tree

extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,7 @@ export class ClaudeCodeModels extends Disposable implements IClaudeCodeModels {
113113

114114
public async resolveReasoningEffort(requestedModel: ParsedClaudeModelId | string | undefined, requestedReasoningEffort: string | undefined): Promise<EffortLevel | undefined> {
115115
const endpoint = await this.resolveEndpoint(requestedModel, undefined);
116-
if (!endpoint || !endpoint.supportsReasoningEffort || endpoint.supportsReasoningEffort.length === 0) {
117-
return undefined;
118-
}
119-
if (requestedReasoningEffort && isEffortLevel(requestedReasoningEffort) && endpoint.supportsReasoningEffort.includes(requestedReasoningEffort)) {
120-
return requestedReasoningEffort;
121-
}
122-
if (endpoint.supportsReasoningEffort.length === 1 && isEffortLevel(endpoint.supportsReasoningEffort[0])) {
123-
return endpoint.supportsReasoningEffort[0];
124-
}
125-
return undefined;
116+
return pickReasoningEffort(endpoint, requestedReasoningEffort);
126117
}
127118

128119
public async resolveEndpoint(requestedModel: ParsedClaudeModelId | string | undefined, fallbackModelId: ParsedClaudeModelId | undefined): Promise<IChatEndpoint | undefined> {
@@ -199,6 +190,30 @@ export function isEffortLevel(value: string): value is EffortLevel {
199190
return SUPPORTED_EFFORT_LEVELS.includes(value as EffortLevel);
200191
}
201192

193+
/**
194+
* Formats a Claude endpoint for display in the chat response footer.
195+
* Mirrors the Codex CLI's `formatModelDetails` for visual parity across providers.
196+
*/
197+
export function formatClaudeModelDetails(endpoint: IChatEndpoint): string {
198+
return `${endpoint.name}${endpoint.multiplier ? ` • ${endpoint.multiplier}x` : ''}`;
199+
}
200+
201+
/**
202+
* Picks the reasoning effort to use for an endpoint given a requested level.
203+
*/
204+
export function pickReasoningEffort(endpoint: IChatEndpoint | undefined, requestedReasoningEffort: string | undefined): EffortLevel | undefined {
205+
if (!endpoint || !endpoint.supportsReasoningEffort || endpoint.supportsReasoningEffort.length === 0) {
206+
return undefined;
207+
}
208+
if (requestedReasoningEffort && isEffortLevel(requestedReasoningEffort) && endpoint.supportsReasoningEffort.includes(requestedReasoningEffort)) {
209+
return requestedReasoningEffort;
210+
}
211+
if (endpoint.supportsReasoningEffort.length === 1 && isEffortLevel(endpoint.supportsReasoningEffort[0])) {
212+
return endpoint.supportsReasoningEffort[0];
213+
}
214+
return undefined;
215+
}
216+
202217
function buildConfigurationSchema(endpoint: IChatEndpoint): vscode.LanguageModelConfigurationSchema | undefined {
203218
const effortLevels = endpoint.supportsReasoningEffort?.filter(
204219
(level): level is typeof SUPPORTED_EFFORT_LEVELS[number] =>

extensions/copilot/src/extension/chatSessions/vscode-node/chatHistoryBuilder.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -381,8 +381,10 @@ function findModelIdForRequest(
381381
* when we encounter a user message with actual text (a new user request).
382382
*
383383
* @param session The Claude Code session to convert
384+
* @param getModelDetails Optional lookup that returns the display string for a Claude
385+
* model id (as it appears on stored assistant messages).
384386
*/
385-
export function buildChatHistory(session: IClaudeCodeSession): (vscode.ChatRequestTurn2 | vscode.ChatResponseTurn2)[] {
387+
export function buildChatHistory(session: IClaudeCodeSession, getModelDetails?: (modelId: string) => string | undefined): (vscode.ChatRequestTurn2 | vscode.ChatResponseTurn2)[] {
386388
const result: (vscode.ChatRequestTurn2 | vscode.ChatResponseTurn2)[] = [];
387389
const toolContext: ToolContext = {
388390
unprocessedToolCalls: new Map(),
@@ -391,6 +393,16 @@ export function buildChatHistory(session: IClaudeCodeSession): (vscode.ChatReque
391393
let i = 0;
392394
const messages = session.messages;
393395
let pendingResponseParts: (vscode.ChatResponseMarkdownPart | vscode.ChatResponseThinkingProgressPart | vscode.ChatToolInvocationPart)[] = [];
396+
// Tracks the most recent assistant model id observed in the current pending response
397+
// group so we can populate `ChatResponseTurn2.result.details` when finalizing it.
398+
let pendingResponseModelId: string | undefined;
399+
const makeResponseResult = (modelId: string | undefined): vscode.ChatResult => {
400+
if (!modelId || !getModelDetails) {
401+
return {};
402+
}
403+
const details = getModelDetails(modelId);
404+
return details ? { details } : {};
405+
};
394406

395407
// Build a map from parentToolUseId to subagent for quick lookup
396408
const subagentMap = buildSubagentMap(session.subagents);
@@ -437,8 +449,9 @@ export function buildChatHistory(session: IClaudeCodeSession): (vscode.ChatReque
437449
if (commandInfo) {
438450
// Finalize any pending response first
439451
if (pendingResponseParts.length > 0) {
440-
result.push(new vscode.ChatResponseTurn2(pendingResponseParts, {}, ''));
452+
result.push(new vscode.ChatResponseTurn2(pendingResponseParts, makeResponseResult(pendingResponseModelId), ''));
441453
pendingResponseParts = [];
454+
pendingResponseModelId = undefined;
442455
}
443456
// Emit the command as a request turn
444457
result.push(new ChatRequestTurn2(commandInfo.commandName, undefined, [], '', [], undefined, currentMessageId, modelId, undefined));
@@ -456,8 +469,9 @@ export function buildChatHistory(session: IClaudeCodeSession): (vscode.ChatReque
456469
if (requestTurn) {
457470
// Real user message — finalize any pending response first
458471
if (pendingResponseParts.length > 0) {
459-
result.push(new vscode.ChatResponseTurn2(pendingResponseParts, {}, ''));
472+
result.push(new vscode.ChatResponseTurn2(pendingResponseParts, makeResponseResult(pendingResponseModelId), ''));
460473
pendingResponseParts = [];
474+
pendingResponseModelId = undefined;
461475
}
462476
result.push(requestTurn);
463477
}
@@ -471,6 +485,9 @@ export function buildChatHistory(session: IClaudeCodeSession): (vscode.ChatReque
471485
const assistantMessage = messages[i].message as AssistantMessageContent;
472486
if (assistantMessage.model !== SYNTHETIC_MODEL_ID) {
473487
assistantMessages.push(assistantMessage);
488+
if (assistantMessage.model) {
489+
pendingResponseModelId = assistantMessage.model;
490+
}
474491
}
475492
i++;
476493
}
@@ -500,7 +517,7 @@ export function buildChatHistory(session: IClaudeCodeSession): (vscode.ChatReque
500517

501518
// Finalize any remaining pending response
502519
if (pendingResponseParts.length > 0) {
503-
result.push(new vscode.ChatResponseTurn2(pendingResponseParts, {}, ''));
520+
result.push(new vscode.ChatResponseTurn2(pendingResponseParts, makeResponseResult(pendingResponseModelId), ''));
504521
}
505522

506523
return result;

extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ConfigKey, IConfigurationService } from '../../../platform/configuratio
1010
import { INativeEnvService } from '../../../platform/env/common/envService';
1111
import { getGitHubRepoInfoFromContext, IGitService } from '../../../platform/git/common/gitService';
1212
import { ILogService } from '../../../platform/log/common/logService';
13+
import { IChatEndpoint } from '../../../platform/networking/common/networking';
1314
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
1415
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
1516
import { Emitter, Event } from '../../../util/vs/base/common/event';
@@ -22,12 +23,12 @@ import { IInstantiationService } from '../../../util/vs/platform/instantiation/c
2223
import { ClaudeFolderInfo } from '../claude/common/claudeFolderInfo';
2324
import { ClaudeSessionUri } from '../claude/common/claudeSessionUri';
2425
import { ClaudeAgentManager } from '../claude/node/claudeCodeAgent';
25-
import { CLAUDE_REASONING_EFFORT_PROPERTY, IClaudeCodeModels } from '../claude/node/claudeCodeModels';
26+
import { CLAUDE_REASONING_EFFORT_PROPERTY, formatClaudeModelDetails, IClaudeCodeModels, pickReasoningEffort } from '../claude/node/claudeCodeModels';
2627
import { IClaudeCodeSdkService } from '../claude/node/claudeCodeSdkService';
2728
import { parseClaudeModelId } from '../claude/node/claudeModelId';
2829
import { IClaudeSessionStateService } from '../claude/common/claudeSessionStateService';
2930
import { IClaudeCodeSessionService } from '../claude/node/sessionParser/claudeCodeSessionService';
30-
import { IClaudeCodeSessionInfo } from '../claude/node/sessionParser/claudeSessionSchema';
31+
import { IClaudeCodeSessionInfo, IClaudeCodeSession, SYNTHETIC_MODEL_ID } from '../claude/node/sessionParser/claudeSessionSchema';
3132
import { IClaudeSlashCommandService } from '../claude/vscode-node/claudeSlashCommandService';
3233
import { IChatFolderMruService } from '../common/folderRepositoryManager';
3334
import { builtinSlashCommands } from '../common/builtinSlashCommands';
@@ -139,8 +140,13 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco
139140
this.sessionStateService.setPermissionModeForSession(effectiveSessionId, permissionMode);
140141
this.sessionStateService.setFolderInfoForSession(effectiveSessionId, folderInfo);
141142

143+
// Resolve the endpoint once and reuse it for both reasoning effort
144+
// and the response footer details — they otherwise both call
145+
// `resolveEndpoint` (which hits the cached endpoint list, then
146+
// re-filters), which is wasted work and risks divergence.
147+
const endpoint = await this._resolveEndpointForRequest(modelId.toEndpointModelId());
142148
const rawReasoningEffort = request.modelConfiguration?.[CLAUDE_REASONING_EFFORT_PROPERTY];
143-
const reasoningEffort = await this.claudeModels.resolveReasoningEffort(modelId, rawReasoningEffort);
149+
const reasoningEffort = pickReasoningEffort(endpoint, typeof rawReasoningEffort === 'string' ? rawReasoningEffort : undefined);
144150
this.sessionStateService.setReasoningEffortForSession(effectiveSessionId, reasoningEffort);
145151

146152
// Set usage handler to report token usage for context window widget
@@ -156,16 +162,21 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco
156162
// Clear usage handler after request completes
157163
this.sessionStateService.setUsageHandlerForSession(effectiveSessionId, undefined);
158164

159-
return result.errorDetails ? { errorDetails: result.errorDetails } : {};
165+
const details = endpoint ? formatClaudeModelDetails(endpoint) : undefined;
166+
return {
167+
...(details ? { details } : {}),
168+
...(result.errorDetails ? { errorDetails: result.errorDetails } : {}),
169+
};
160170
};
161171
}
162172

163173
// #endregion
164174

165175
async provideChatSessionContent(sessionResource: vscode.Uri, token: vscode.CancellationToken, context?: { readonly inputState: vscode.ChatSessionInputState }): Promise<vscode.ChatSession> {
166176
const existingSession = await this.sessionService.getSession(sessionResource, token);
177+
const detailsByModelId = existingSession ? await this._buildModelDetailsLookup(existingSession, token) : undefined;
167178
const history = existingSession ?
168-
buildChatHistory(existingSession) :
179+
buildChatHistory(existingSession, detailsByModelId ? id => detailsByModelId.get(id) : undefined) :
169180
[];
170181

171182
const options: Record<string, string | vscode.ChatSessionProviderOptionItem> = {};
@@ -188,6 +199,57 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco
188199
options,
189200
};
190201
}
202+
203+
/**
204+
* Resolves a Claude model id to its endpoint. Wraps `resolveEndpoint` in a
205+
* try/catch so transient failures degrade gracefully (return `undefined`)
206+
* instead of breaking the response or session-load path.
207+
*/
208+
private async _resolveEndpointForRequest(modelId: string): Promise<IChatEndpoint | undefined> {
209+
try {
210+
return await this.claudeModels.resolveEndpoint(modelId, undefined);
211+
} catch {
212+
return undefined;
213+
}
214+
}
215+
216+
/**
217+
* Resolves the display string for each unique non-synthetic model id observed in the
218+
* session's assistant messages. Returns `undefined` (not an empty map) when no model
219+
* ids are present, when the caller has cancelled, or when no ids resolve to known
220+
* endpoints — so callers can skip the per-turn details work entirely.
221+
*/
222+
private async _buildModelDetailsLookup(session: IClaudeCodeSession, token: vscode.CancellationToken): Promise<Map<string, string> | undefined> {
223+
if (token.isCancellationRequested) {
224+
return undefined;
225+
}
226+
const modelIds = new Set<string>();
227+
for (const msg of session.messages) {
228+
if (msg.type === 'assistant' && msg.message.role === 'assistant') {
229+
const model = msg.message.model;
230+
if (model && model !== SYNTHETIC_MODEL_ID) {
231+
modelIds.add(model);
232+
}
233+
}
234+
}
235+
if (modelIds.size === 0) {
236+
return undefined;
237+
}
238+
const detailsByModelId = new Map<string, string>();
239+
await Promise.all([...modelIds].map(async modelId => {
240+
if (token.isCancellationRequested) {
241+
return;
242+
}
243+
const endpoint = await this._resolveEndpointForRequest(modelId);
244+
if (endpoint) {
245+
detailsByModelId.set(modelId, formatClaudeModelDetails(endpoint));
246+
}
247+
}));
248+
if (token.isCancellationRequested) {
249+
return undefined;
250+
}
251+
return detailsByModelId.size > 0 ? detailsByModelId : undefined;
252+
}
191253
}
192254

193255
/**

extensions/copilot/src/extension/chatSessions/vscode-node/test/chatHistoryBuilder.spec.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1390,4 +1390,125 @@ describe('buildChatHistory', () => {
13901390
});
13911391

13921392
// #endregion
1393+
1394+
// #region Response Details (model footer)
1395+
1396+
describe('response details via getModelDetails', () => {
1397+
// Returns the raw model id back so we can spot-check exactly which id the
1398+
// builder fed into the lookup for each response turn.
1399+
const echoLookup = (id: string) => `details:${id}`;
1400+
1401+
it('omits details when no lookup is provided (regression)', () => {
1402+
const s = session([
1403+
userMsg('Hello'),
1404+
assistantMsg([{ type: 'text', text: 'Hi' }], 'claude-opus-4-5-20251101'),
1405+
]);
1406+
1407+
const result = buildChatHistory(s);
1408+
1409+
const responseTurn = result[1] as vscode.ChatResponseTurn2;
1410+
expect(responseTurn.result).toEqual({});
1411+
});
1412+
1413+
it('attaches details from the assistant model id to the response turn', () => {
1414+
const s = session([
1415+
userMsg('Hello'),
1416+
assistantMsg([{ type: 'text', text: 'Hi' }], 'claude-opus-4-5-20251101'),
1417+
]);
1418+
1419+
const result = buildChatHistory(s, echoLookup);
1420+
1421+
const responseTurn = result[1] as vscode.ChatResponseTurn2;
1422+
expect(responseTurn.result).toEqual({ details: 'details:claude-opus-4-5-20251101' });
1423+
});
1424+
1425+
it('omits details when the lookup returns undefined', () => {
1426+
const s = session([
1427+
userMsg('Hello'),
1428+
assistantMsg([{ type: 'text', text: 'Hi' }], 'unknown-model-id'),
1429+
]);
1430+
1431+
const result = buildChatHistory(s, () => undefined);
1432+
1433+
const responseTurn = result[1] as vscode.ChatResponseTurn2;
1434+
expect(responseTurn.result).toEqual({});
1435+
});
1436+
1437+
it('attributes per-response model details across model switches', () => {
1438+
const s = session([
1439+
userMsg('First'),
1440+
assistantMsg([{ type: 'text', text: 'A1' }], 'claude-sonnet-4-20250514'),
1441+
userMsg('Second'),
1442+
assistantMsg([{ type: 'text', text: 'A2' }], 'claude-opus-4-5-20251101'),
1443+
]);
1444+
1445+
const result = buildChatHistory(s, echoLookup);
1446+
1447+
const firstResponse = result[1] as vscode.ChatResponseTurn2;
1448+
const secondResponse = result[3] as vscode.ChatResponseTurn2;
1449+
expect(firstResponse.result).toEqual({ details: 'details:claude-sonnet-4-20250514' });
1450+
expect(secondResponse.result).toEqual({ details: 'details:claude-opus-4-5-20251101' });
1451+
});
1452+
1453+
it('uses the last non-synthetic assistant model in a multi-message response group', () => {
1454+
const s = session([
1455+
userMsg('Run'),
1456+
assistantMsg([{ type: 'tool_use', id: 't1', name: 'bash', input: {} }], 'claude-sonnet-4-20250514'),
1457+
toolResult('t1', 'done'),
1458+
// Final assistant message uses a different model — that's the one we attribute.
1459+
assistantMsg([{ type: 'text', text: 'OK' }], 'claude-opus-4-5-20251101'),
1460+
]);
1461+
1462+
const result = buildChatHistory(s, echoLookup);
1463+
1464+
const responseTurn = result[1] as vscode.ChatResponseTurn2;
1465+
expect(responseTurn.result).toEqual({ details: 'details:claude-opus-4-5-20251101' });
1466+
});
1467+
1468+
it('does not bleed model ids across response groups when lookup is undefined for one', () => {
1469+
const s = session([
1470+
userMsg('First'),
1471+
assistantMsg([{ type: 'text', text: 'A1' }], 'claude-sonnet-4-20250514'),
1472+
userMsg('Second'),
1473+
assistantMsg([{ type: 'text', text: 'A2' }], 'unknown-model-id'),
1474+
]);
1475+
1476+
const result = buildChatHistory(s, id => id === 'claude-sonnet-4-20250514' ? 'Sonnet' : undefined);
1477+
1478+
const firstResponse = result[1] as vscode.ChatResponseTurn2;
1479+
const secondResponse = result[3] as vscode.ChatResponseTurn2;
1480+
expect(firstResponse.result).toEqual({ details: 'Sonnet' });
1481+
expect(secondResponse.result).toEqual({});
1482+
});
1483+
1484+
it('ignores synthetic assistant messages when picking the response model id', () => {
1485+
const s = session([
1486+
userMsg('Hello'),
1487+
assistantMsg([{ type: 'text', text: 'Real reply' }], 'claude-sonnet-4-20250514'),
1488+
// A trailing synthetic message (e.g. cancellation marker) must not
1489+
// override the real model id we just observed.
1490+
assistantMsg([{ type: 'text', text: 'No response requested.' }], SYNTHETIC_MODEL_ID),
1491+
]);
1492+
1493+
const result = buildChatHistory(s, echoLookup);
1494+
1495+
const responseTurn = result[1] as vscode.ChatResponseTurn2;
1496+
expect(responseTurn.result).toEqual({ details: 'details:claude-sonnet-4-20250514' });
1497+
});
1498+
1499+
it('attaches details to slash-command response turns', () => {
1500+
const s = session([
1501+
userMsg('<command-name>/compact</command-name><command-message>compact</command-message>'),
1502+
assistantMsg([{ type: 'text', text: 'Compacted.' }], 'claude-sonnet-4-20250514'),
1503+
]);
1504+
1505+
const result = buildChatHistory(s, echoLookup);
1506+
1507+
// [request, response]
1508+
const responseTurn = result[1] as vscode.ChatResponseTurn2;
1509+
expect(responseTurn.result).toEqual({ details: 'details:claude-sonnet-4-20250514' });
1510+
});
1511+
});
1512+
1513+
// #endregion
13931514
});

0 commit comments

Comments
 (0)