diff --git a/src/models.ts b/src/models.ts index 42ffe7d..c1c01d8 100644 --- a/src/models.ts +++ b/src/models.ts @@ -133,6 +133,8 @@ const BUILTIN_ALIASES: Record = { 'cursor-auto': 'claude-sonnet-4-5', 'cursor-agent-auto': 'claude-sonnet-4-5', 'copilot-auto': 'claude-sonnet-4-5', + 'copilot-openai-auto': 'gpt-5.3-codex', + 'copilot-anthropic-auto': 'claude-sonnet-4-5', 'kiro-auto': 'claude-sonnet-4-5', 'cline-auto': 'claude-sonnet-4-5', 'openclaw-auto': 'claude-sonnet-4-5', diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index 4f9f9b9..9e5f771 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -13,8 +13,16 @@ const modelDisplayNames: Record = { 'gpt-4.1': 'GPT-4.1', 'gpt-4o-mini': 'GPT-4o Mini', 'gpt-4o': 'GPT-4o', + 'gpt-5.4': 'GPT-5.4', + 'gpt-5.3-codex': 'GPT-5.3 Codex', 'gpt-5-mini': 'GPT-5 Mini', 'gpt-5': 'GPT-5', + 'claude-opus-4-7': 'Opus 4.7', + 'claude-opus-4-6': 'Opus 4.6', + 'claude-opus-4-5': 'Opus 4.5', + 'claude-opus-4-1': 'Opus 4.1', + 'claude-opus-4': 'Opus 4', + 'claude-sonnet-4-6': 'Sonnet 4.6', 'claude-sonnet-4-5': 'Sonnet 4.5', 'claude-sonnet-4': 'Sonnet 4', 'claude-3-7-sonnet': 'Sonnet 3.7', @@ -45,6 +53,8 @@ const toolNameMap: Record = { } const CHARS_PER_TOKEN = 4 +const COPILOT_OPENAI_AUTO = 'copilot-openai-auto' +const COPILOT_ANTHROPIC_AUTO = 'copilot-anthropic-auto' const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length) @@ -143,15 +153,35 @@ type TranscriptEvent = | { type: 'assistant.message'; timestamp?: string; data: { messageId: string; content?: string; reasoningText?: string; toolRequests?: TranscriptToolRequest[]; outputTokens?: number } } | { type: string; timestamp?: string; data: Record } +const transcriptToolCallModelHints: Array<{ prefix: string; model: string }> = [ + // Anthropic tool-call ID variants observed in Copilot transcript logs. + { prefix: 'toolu_bdrk_', model: COPILOT_ANTHROPIC_AUTO }, + { prefix: 'toolu_vrtx_', model: COPILOT_ANTHROPIC_AUTO }, + { prefix: 'tooluse_', model: COPILOT_ANTHROPIC_AUTO }, + // OpenAI tool-call IDs. + { prefix: 'call_', model: COPILOT_OPENAI_AUTO }, +] + function inferModelFromToolCallIds(events: TranscriptEvent[]): string { + const modelCounts = new Map() + for (const e of events) { if (e.type !== 'assistant.message') continue const msg = e as { data: { toolRequests?: TranscriptToolRequest[] } } for (const t of msg.data.toolRequests ?? []) { - if (t.toolCallId?.startsWith('toolu_bdrk_')) return 'claude-sonnet-4-5' - if (t.toolCallId?.startsWith('call_')) return 'gpt-4.1' + const toolCallId = t.toolCallId ?? '' + for (const hint of transcriptToolCallModelHints) { + if (!toolCallId.startsWith(hint.prefix)) continue + modelCounts.set(hint.model, (modelCounts.get(hint.model) ?? 0) + 1) + break + } } } + + if (modelCounts.size > 0) { + return [...modelCounts.entries()].sort((a, b) => b[1] - a[1])[0]![0] + } + return 'copilot-auto' } @@ -375,6 +405,8 @@ export function createCopilotProvider(sessionStateDir?: string, workspaceStorage modelDisplayName(model: string): string { if (model === 'copilot-auto') return 'Copilot (auto)' + if (model === COPILOT_OPENAI_AUTO) return 'Copilot (OpenAI auto)' + if (model === COPILOT_ANTHROPIC_AUTO) return 'Copilot (Anthropic auto)' for (const [key, name] of modelDisplayEntries) { if (model === key || model.startsWith(key + '-')) return name } diff --git a/tests/providers/copilot.test.ts b/tests/providers/copilot.test.ts index 273ef42..f1bc8fa 100644 --- a/tests/providers/copilot.test.ts +++ b/tests/providers/copilot.test.ts @@ -37,6 +37,30 @@ function assistantMessage(opts: { messageId: string; outputTokens: number; tools }) } +function transcriptSessionStart(sessionId: string) { + return JSON.stringify({ type: 'session.start', data: { sessionId, producer: 'copilot-agent' } }) +} + +function transcriptUserMessage(content: string) { + return JSON.stringify({ type: 'user.message', data: { content, attachments: [] } }) +} + +function transcriptAssistantMessage(opts: { messageId: string; content?: string; reasoningText?: string; toolCallIds?: string[] }) { + return JSON.stringify({ + type: 'assistant.message', + data: { + messageId: opts.messageId, + content: opts.content ?? '', + reasoningText: opts.reasoningText ?? '', + toolRequests: (opts.toolCallIds ?? []).map((id, i) => ({ + toolCallId: id, + name: i === 0 ? 'read_file' : 'run_in_terminal', + type: 'function', + })), + }, + }) +} + describe('copilot provider - JSONL parsing', () => { beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'copilot-test-')) @@ -155,10 +179,76 @@ describe('copilot provider - JSONL parsing', () => { for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) expect(calls).toHaveLength(1) - expect(calls[0]!.messageId).toBeUndefined() expect(calls[0]!.outputTokens).toBe(80) expect(calls[0]!.model).toBe('gpt-4.1') }) + + it('infers OpenAI auto bucket for transcript toolCallId prefix call_', async () => { + const eventsPath = await createSessionDir('sess-tr-call', [ + transcriptSessionStart('sess-tr-call'), + transcriptUserMessage('check model inference'), + transcriptAssistantMessage({ + messageId: 'msg-1', + content: 'done', + toolCallIds: ['call_abc123'], + }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('copilot-openai-auto') + }) + + it('infers Anthropic auto bucket for transcript toolCallId prefixes tooluse_/toolu_vrtx_', async () => { + const eventsPath = await createSessionDir('sess-tr-claude', [ + transcriptSessionStart('sess-tr-claude'), + transcriptUserMessage('check model inference'), + transcriptAssistantMessage({ + messageId: 'msg-1', + content: 'done', + toolCallIds: ['tooluse_XY', 'toolu_vrtx_01ABC'], + }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('copilot-anthropic-auto') + }) + + it('chooses the dominant inferred transcript model when prefixes are mixed', async () => { + const eventsPath = await createSessionDir('sess-tr-mixed', [ + transcriptSessionStart('sess-tr-mixed'), + transcriptUserMessage('mixed'), + transcriptAssistantMessage({ + messageId: 'msg-1', + content: 'one', + toolCallIds: ['toolu_bdrk_123'], + }), + transcriptAssistantMessage({ + messageId: 'msg-2', + content: 'two', + toolCallIds: ['call_1'], + }), + transcriptAssistantMessage({ + messageId: 'msg-3', + content: 'three', + toolCallIds: ['call_2'], + }), + ]) + + const source = { path: eventsPath, project: 'test', provider: 'copilot' } + const calls: ParsedProviderCall[] = [] + for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(3) + expect(calls.every(c => c.model === 'copilot-openai-auto')).toBe(true) + }) }) describe('copilot provider - discoverSessions', () => { @@ -257,6 +347,8 @@ describe('copilot provider - metadata', () => { expect(copilot.modelDisplayName('gpt-5-mini')).toBe('GPT-5 Mini') expect(copilot.modelDisplayName('o3')).toBe('o3') expect(copilot.modelDisplayName('o4-mini')).toBe('o4-mini') + expect(copilot.modelDisplayName('copilot-openai-auto')).toBe('Copilot (OpenAI auto)') + expect(copilot.modelDisplayName('copilot-anthropic-auto')).toBe('Copilot (Anthropic auto)') expect(copilot.modelDisplayName('unknown-model-xyz')).toBe('unknown-model-xyz') })