406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { textMsg, chatOpts, toolDef, collectEvents } from './helpers/llm-fixtures.js'
|
|
import type { LLMResponse, StreamEvent, ToolUseBlock } from '../src/types.js'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock OpenAI SDK (Copilot uses it under the hood)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mockCreate = vi.hoisted(() => vi.fn())
|
|
const OpenAIMock = vi.hoisted(() =>
|
|
vi.fn(() => ({
|
|
chat: { completions: { create: mockCreate } },
|
|
})),
|
|
)
|
|
|
|
vi.mock('openai', () => ({
|
|
default: OpenAIMock,
|
|
OpenAI: OpenAIMock,
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock global fetch for token management
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const originalFetch = globalThis.fetch
|
|
|
|
function mockFetchForToken(sessionToken = 'cop_session_abc', expiresAt?: number) {
|
|
const exp = expiresAt ?? Math.floor(Date.now() / 1000) + 3600
|
|
return vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ token: sessionToken, expires_at: exp }),
|
|
text: () => Promise.resolve(''),
|
|
})
|
|
}
|
|
|
|
import { CopilotAdapter, getCopilotMultiplier, formatCopilotMultiplier } from '../src/llm/copilot.js'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeCompletion(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 'chatcmpl-cop',
|
|
model: 'claude-sonnet-4',
|
|
choices: [{
|
|
index: 0,
|
|
message: { role: 'assistant', content: 'Hello from Copilot', tool_calls: undefined },
|
|
finish_reason: 'stop',
|
|
}],
|
|
usage: { prompt_tokens: 8, completion_tokens: 4 },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
async function* makeChunks(chunks: Array<Record<string, unknown>>) {
|
|
for (const chunk of chunks) yield chunk
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('CopilotAdapter', () => {
|
|
let savedEnv: Record<string, string | undefined>
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
savedEnv = {
|
|
GITHUB_COPILOT_TOKEN: process.env['GITHUB_COPILOT_TOKEN'],
|
|
GITHUB_TOKEN: process.env['GITHUB_TOKEN'],
|
|
}
|
|
delete process.env['GITHUB_COPILOT_TOKEN']
|
|
delete process.env['GITHUB_TOKEN']
|
|
})
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch
|
|
for (const [key, val] of Object.entries(savedEnv)) {
|
|
if (val === undefined) delete process.env[key]
|
|
else process.env[key] = val
|
|
}
|
|
})
|
|
|
|
// =========================================================================
|
|
// Constructor & token resolution
|
|
// =========================================================================
|
|
|
|
describe('constructor', () => {
|
|
it('accepts string apiKey as first argument', () => {
|
|
const adapter = new CopilotAdapter('gh_token_123')
|
|
expect(adapter.name).toBe('copilot')
|
|
})
|
|
|
|
it('accepts options object with apiKey', () => {
|
|
const adapter = new CopilotAdapter({ apiKey: 'gh_token_456' })
|
|
expect(adapter.name).toBe('copilot')
|
|
})
|
|
|
|
it('falls back to GITHUB_COPILOT_TOKEN env var', () => {
|
|
process.env['GITHUB_COPILOT_TOKEN'] = 'env_copilot_token'
|
|
const adapter = new CopilotAdapter()
|
|
expect(adapter.name).toBe('copilot')
|
|
})
|
|
|
|
it('falls back to GITHUB_TOKEN env var', () => {
|
|
process.env['GITHUB_TOKEN'] = 'env_gh_token'
|
|
const adapter = new CopilotAdapter()
|
|
expect(adapter.name).toBe('copilot')
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Token management
|
|
// =========================================================================
|
|
|
|
describe('token management', () => {
|
|
it('exchanges GitHub token for Copilot session token', async () => {
|
|
const fetchMock = mockFetchForToken('session_xyz')
|
|
globalThis.fetch = fetchMock
|
|
const adapter = new CopilotAdapter('gh_token')
|
|
mockCreate.mockResolvedValue(makeCompletion())
|
|
|
|
await adapter.chat([textMsg('user', 'Hi')], chatOpts())
|
|
|
|
// fetch was called to exchange token
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://api.github.com/copilot_internal/v2/token',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'token gh_token',
|
|
}),
|
|
}),
|
|
)
|
|
|
|
// OpenAI client was created with session token
|
|
expect(OpenAIMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
apiKey: 'session_xyz',
|
|
baseURL: 'https://api.githubcopilot.com',
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('caches session token and reuses on second call', async () => {
|
|
const fetchMock = mockFetchForToken()
|
|
globalThis.fetch = fetchMock
|
|
const adapter = new CopilotAdapter('gh_token')
|
|
mockCreate.mockResolvedValue(makeCompletion())
|
|
|
|
await adapter.chat([textMsg('user', 'Hi')], chatOpts())
|
|
await adapter.chat([textMsg('user', 'Hi again')], chatOpts())
|
|
|
|
// fetch should only be called once (cached)
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('refreshes token when near expiry (within 60s)', async () => {
|
|
const nowSec = Math.floor(Date.now() / 1000)
|
|
// First call: token expires in 30 seconds (within 60s grace)
|
|
let callCount = 0
|
|
globalThis.fetch = vi.fn().mockImplementation(() => {
|
|
callCount++
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
token: `session_${callCount}`,
|
|
expires_at: callCount === 1 ? nowSec + 30 : nowSec + 3600,
|
|
}),
|
|
text: () => Promise.resolve(''),
|
|
})
|
|
})
|
|
|
|
const adapter = new CopilotAdapter('gh_token')
|
|
mockCreate.mockResolvedValue(makeCompletion())
|
|
|
|
await adapter.chat([textMsg('user', 'Hi')], chatOpts())
|
|
// Token is within 60s of expiry, should refresh
|
|
await adapter.chat([textMsg('user', 'Hi again')], chatOpts())
|
|
|
|
expect(callCount).toBe(2)
|
|
})
|
|
|
|
it('concurrent requests share a single refresh promise', async () => {
|
|
let resolveToken: ((v: unknown) => void) | undefined
|
|
const slowFetch = vi.fn().mockImplementation(() => {
|
|
return new Promise((resolve) => {
|
|
resolveToken = resolve
|
|
})
|
|
})
|
|
globalThis.fetch = slowFetch
|
|
|
|
const adapter = new CopilotAdapter('gh_token')
|
|
mockCreate.mockResolvedValue(makeCompletion())
|
|
|
|
// Fire two concurrent requests
|
|
const p1 = adapter.chat([textMsg('user', 'A')], chatOpts())
|
|
const p2 = adapter.chat([textMsg('user', 'B')], chatOpts())
|
|
|
|
// Resolve the single in-flight fetch
|
|
resolveToken!({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
token: 'shared_session',
|
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
}),
|
|
text: () => Promise.resolve(''),
|
|
})
|
|
|
|
await Promise.all([p1, p2])
|
|
|
|
// fetch was called only once (mutex prevented double refresh)
|
|
expect(slowFetch).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('throws on failed token exchange', async () => {
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 401,
|
|
text: () => Promise.resolve('Unauthorized'),
|
|
statusText: 'Unauthorized',
|
|
})
|
|
|
|
const adapter = new CopilotAdapter('bad_token')
|
|
mockCreate.mockResolvedValue(makeCompletion())
|
|
|
|
await expect(
|
|
adapter.chat([textMsg('user', 'Hi')], chatOpts()),
|
|
).rejects.toThrow('Copilot token exchange failed')
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// chat()
|
|
// =========================================================================
|
|
|
|
describe('chat()', () => {
|
|
let adapter: CopilotAdapter
|
|
|
|
beforeEach(() => {
|
|
globalThis.fetch = mockFetchForToken()
|
|
adapter = new CopilotAdapter('gh_token')
|
|
})
|
|
|
|
it('creates OpenAI client with Copilot-specific headers and baseURL', async () => {
|
|
mockCreate.mockResolvedValue(makeCompletion())
|
|
|
|
await adapter.chat([textMsg('user', 'Hi')], chatOpts())
|
|
|
|
expect(OpenAIMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
baseURL: 'https://api.githubcopilot.com',
|
|
defaultHeaders: expect.objectContaining({
|
|
'Copilot-Integration-Id': 'vscode-chat',
|
|
'Editor-Version': 'vscode/1.100.0',
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('returns LLMResponse from completion', async () => {
|
|
mockCreate.mockResolvedValue(makeCompletion())
|
|
|
|
const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())
|
|
|
|
expect(result).toEqual({
|
|
id: 'chatcmpl-cop',
|
|
content: [{ type: 'text', text: 'Hello from Copilot' }],
|
|
model: 'claude-sonnet-4',
|
|
stop_reason: 'end_turn',
|
|
usage: { input_tokens: 8, output_tokens: 4 },
|
|
})
|
|
})
|
|
|
|
it('passes tools and temperature through', async () => {
|
|
mockCreate.mockResolvedValue(makeCompletion())
|
|
const tool = toolDef('search')
|
|
|
|
await adapter.chat(
|
|
[textMsg('user', 'Hi')],
|
|
chatOpts({ tools: [tool], temperature: 0.5 }),
|
|
)
|
|
|
|
const callArgs = mockCreate.mock.calls[0][0]
|
|
expect(callArgs.tools[0].function.name).toBe('search')
|
|
expect(callArgs.temperature).toBe(0.5)
|
|
expect(callArgs.stream).toBe(false)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// stream()
|
|
// =========================================================================
|
|
|
|
describe('stream()', () => {
|
|
let adapter: CopilotAdapter
|
|
|
|
beforeEach(() => {
|
|
globalThis.fetch = mockFetchForToken()
|
|
adapter = new CopilotAdapter('gh_token')
|
|
})
|
|
|
|
it('yields text and done events', async () => {
|
|
mockCreate.mockResolvedValue(makeChunks([
|
|
{ id: 'c1', model: 'gpt-4o', choices: [{ index: 0, delta: { content: 'Hi' }, finish_reason: null }], usage: null },
|
|
{ id: 'c1', model: 'gpt-4o', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: null },
|
|
{ id: 'c1', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 5, completion_tokens: 2 } },
|
|
]))
|
|
|
|
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
|
|
|
|
expect(events.filter(e => e.type === 'text')).toEqual([
|
|
{ type: 'text', data: 'Hi' },
|
|
])
|
|
const done = events.find(e => e.type === 'done')
|
|
expect((done!.data as LLMResponse).usage).toEqual({ input_tokens: 5, output_tokens: 2 })
|
|
})
|
|
|
|
it('yields tool_use events from streamed tool calls', async () => {
|
|
mockCreate.mockResolvedValue(makeChunks([
|
|
{
|
|
id: 'c1', model: 'gpt-4o',
|
|
choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'search', arguments: '{"q":"x"}' } }] }, finish_reason: null }],
|
|
usage: null,
|
|
},
|
|
{ id: 'c1', model: 'gpt-4o', choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], usage: null },
|
|
{ id: 'c1', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 5, completion_tokens: 3 } },
|
|
]))
|
|
|
|
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
|
|
|
|
const toolEvents = events.filter(e => e.type === 'tool_use')
|
|
expect(toolEvents).toHaveLength(1)
|
|
expect((toolEvents[0].data as ToolUseBlock).name).toBe('search')
|
|
})
|
|
|
|
it('yields error event on failure', async () => {
|
|
mockCreate.mockResolvedValue(
|
|
(async function* () { throw new Error('Copilot down') })(),
|
|
)
|
|
|
|
const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
|
|
|
|
expect(events.filter(e => e.type === 'error')).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// getCopilotMultiplier()
|
|
// =========================================================================
|
|
|
|
describe('getCopilotMultiplier()', () => {
|
|
it('returns 0 for included models', () => {
|
|
expect(getCopilotMultiplier('gpt-4.1')).toBe(0)
|
|
expect(getCopilotMultiplier('gpt-4o')).toBe(0)
|
|
expect(getCopilotMultiplier('gpt-5-mini')).toBe(0)
|
|
})
|
|
|
|
it('returns 0.25 for grok models', () => {
|
|
expect(getCopilotMultiplier('grok-code-fast-1')).toBe(0.25)
|
|
})
|
|
|
|
it('returns 0.33 for haiku, gemini-3-flash, etc.', () => {
|
|
expect(getCopilotMultiplier('claude-haiku-4.5')).toBe(0.33)
|
|
expect(getCopilotMultiplier('gemini-3-flash')).toBe(0.33)
|
|
})
|
|
|
|
it('returns 1 for sonnet, gemini-pro, gpt-5.x', () => {
|
|
expect(getCopilotMultiplier('claude-sonnet-4')).toBe(1)
|
|
expect(getCopilotMultiplier('gemini-2.5-pro')).toBe(1)
|
|
expect(getCopilotMultiplier('gpt-5.1')).toBe(1)
|
|
})
|
|
|
|
it('returns 3 for claude-opus (non-fast)', () => {
|
|
expect(getCopilotMultiplier('claude-opus-4.5')).toBe(3)
|
|
})
|
|
|
|
it('returns 30 for claude-opus fast', () => {
|
|
expect(getCopilotMultiplier('claude-opus-4.6-fast')).toBe(30)
|
|
})
|
|
|
|
it('returns 1 for unknown models', () => {
|
|
expect(getCopilotMultiplier('some-new-model')).toBe(1)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// formatCopilotMultiplier()
|
|
// =========================================================================
|
|
|
|
describe('formatCopilotMultiplier()', () => {
|
|
it('returns "included (0\u00d7)" for 0', () => {
|
|
expect(formatCopilotMultiplier(0)).toBe('included (0\u00d7)')
|
|
})
|
|
|
|
it('returns "1\u00d7 premium request" for 1', () => {
|
|
expect(formatCopilotMultiplier(1)).toBe('1\u00d7 premium request')
|
|
})
|
|
|
|
it('returns "0.33\u00d7 premium request" for 0.33', () => {
|
|
expect(formatCopilotMultiplier(0.33)).toBe('0.33\u00d7 premium request')
|
|
})
|
|
})
|
|
})
|