Path: blob/main/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import type Anthropic from '@anthropic-ai/sdk';7import { Iterable } from '../../../../../base/common/iterator.js';8import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';9import { CopilotApiService, type FetchFunction } from '../../../node/shared/copilotApiService.js';10import { NullLogService } from '../../../../log/common/log.js';11import { IProductService } from '../../../../product/common/productService.js';12import product from '../../../../product/common/product.js';1314// #region Test Helpers1516const testProductService: IProductService = { _serviceBrand: undefined, ...product };1718function sseLines(...lines: string[]): Uint8Array {19return new TextEncoder().encode(lines.join('\n') + '\n');20}2122function makeSseBody(chunks: Uint8Array[]): ReadableStream<Uint8Array> {23let index = 0;24return new ReadableStream({25pull(controller) {26if (index < chunks.length) {27controller.enqueue(chunks[index++]);28} else {29controller.close();30}31}32});33}3435const collect = Iterable.asyncToArray;3637function getUrl(input: string | URL | Request): string {38if (typeof input === 'string') {39return input;40}41return input instanceof URL ? input.href : input.url;42}4344function getText(msg: Anthropic.Message): string {45return msg.content46.filter((b): b is Anthropic.TextBlock => b.type === 'text')47.map(b => b.text)48.join('');49}5051function tokenResponse(overrides?: Record<string, unknown>): Response {52return new Response(JSON.stringify({53token: 'copilot-tok-abc',54expires_at: Date.now() / 1000 + 3600,55refresh_in: 1800,56...overrides,57}), { status: 200 });58}5960function anthropicResponse(content: Array<{ type: string; text?: string }>, stopReason = 'end_turn'): Response {61return new Response(JSON.stringify({62id: 'msg_test',63type: 'message',64role: 'assistant',65content,66model: 'claude-sonnet-4-5-20250514',67stop_reason: stopReason,68usage: { input_tokens: 10, output_tokens: 50 },69}), { status: 200, headers: { 'Content-Type': 'application/json' } });70}7172function sseResponse(chunks: Uint8Array[]): Response {73return new Response(makeSseBody(chunks), {74status: 200,75headers: { 'Content-Type': 'text/event-stream' },76});77}7879function modelsResponse(models: object[]): Response {80return new Response(JSON.stringify({ data: models }), {81status: 200,82headers: { 'Content-Type': 'application/json' },83});84}8586function createService(fetchImpl: FetchFunction): CopilotApiService {87return new CopilotApiService(fetchImpl, new NullLogService(), testProductService);88}8990type CapturedRequest = { url: string; init: RequestInit | undefined };9192function routingFetch(93messageResponse: (captured: CapturedRequest) => Response,94tokenOverrides?: Record<string, unknown>,95): { fetch: FetchFunction; captured: () => CapturedRequest } {96let lastCapture: CapturedRequest = { url: '', init: undefined };97const impl: FetchFunction = async (input, init) => {98const url = getUrl(input);99if (url.includes('/token') || url.includes('/copilot_internal')) {100return tokenResponse(tokenOverrides);101}102lastCapture = { url, init };103return messageResponse(lastCapture);104};105return { fetch: impl, captured: () => lastCapture };106}107108const userMsg: Anthropic.MessageParam[] = [{ role: 'user', content: 'hello' }];109const baseRequest = {110model: 'claude-sonnet-4-5',111messages: userMsg,112max_tokens: 8192,113stream: false as const,114};115116function streamService(chunks: Uint8Array[], tokenOverrides?: Record<string, unknown>): CopilotApiService {117const { fetch: fetchFn } = routingFetch(() => sseResponse(chunks), tokenOverrides);118return createService(fetchFn);119}120121// #endregion122123suite('CopilotApiService', () => {124125ensureNoDisposablesAreLeakedInTestSuite();126127// #region Token Minting128129suite('Token Minting', () => {130131test('mints a token on first request', async () => {132let mintCount = 0;133const service = createService(async (input) => {134const url = getUrl(input);135if (url.includes('/copilot_internal')) {136mintCount++;137return tokenResponse();138}139return anthropicResponse([{ type: 'text', text: 'hi' }]);140});141142await service.messages('gh-tok', baseRequest);143assert.strictEqual(mintCount, 1);144});145146test('reuses cached token for consecutive calls with same github token', async () => {147let mintCount = 0;148const service = createService(async (input) => {149const url = getUrl(input);150if (url.includes('/copilot_internal')) {151mintCount++;152return tokenResponse();153}154return anthropicResponse([{ type: 'text', text: 'hi' }]);155});156157await service.messages('gh-tok', baseRequest);158await service.messages('gh-tok', baseRequest);159await service.messages('gh-tok', baseRequest);160assert.strictEqual(mintCount, 1);161});162163test('re-mints when the github token changes', async () => {164let mintCount = 0;165const service = createService(async (input) => {166const url = getUrl(input);167if (url.includes('/copilot_internal')) {168mintCount++;169return tokenResponse();170}171return anthropicResponse([{ type: 'text', text: 'hi' }]);172});173174await service.messages('gh-tok-A', baseRequest);175await service.messages('gh-tok-B', baseRequest);176assert.strictEqual(mintCount, 2);177});178179test('re-mints when the copilot token is within 5 minutes of expiry', async () => {180let mintCount = 0;181const service = createService(async (input) => {182const url = getUrl(input);183if (url.includes('/copilot_internal')) {184mintCount++;185// Both expires_at AND refresh_in must point to a soon-expiring token,186// because cache validity prefers refresh_in over expires_at.187return tokenResponse({ expires_at: Date.now() / 1000 + 120, refresh_in: 0 });188}189return anthropicResponse([{ type: 'text', text: 'hi' }]);190});191192await service.messages('gh-tok', baseRequest);193await service.messages('gh-tok', baseRequest);194assert.strictEqual(mintCount, 2);195});196197test('uses refresh_in (not expires_at) for cache validity to tolerate clock skew', async () => {198// Server says expires_at is in the past (simulating client clock ahead of server),199// but refresh_in is comfortably long. Cache must still be valid.200let mintCount = 0;201const service = createService(async (input) => {202const url = getUrl(input);203if (url.includes('/copilot_internal')) {204mintCount++;205return tokenResponse({ expires_at: Date.now() / 1000 - 999, refresh_in: 1800 });206}207return anthropicResponse([{ type: 'text', text: 'hi' }]);208});209210await service.messages('gh-tok', baseRequest);211await service.messages('gh-tok', baseRequest);212assert.strictEqual(mintCount, 1);213});214215test('invalidates cached token on 401 from messages so the next call re-mints', async () => {216let mintCount = 0;217let messageCallCount = 0;218const service = createService(async (input) => {219const url = getUrl(input);220if (url.includes('/copilot_internal')) {221mintCount++;222return tokenResponse();223}224messageCallCount++;225if (messageCallCount === 1) {226return new Response('unauthorized', { status: 401, statusText: 'Unauthorized' });227}228return anthropicResponse([{ type: 'text', text: 'ok' }]);229});230231await assert.rejects(() => service.messages('gh-tok', baseRequest));232await service.messages('gh-tok', baseRequest);233assert.strictEqual(mintCount, 2);234});235236test('invalidates cached token on 403 from models so the next call re-mints', async () => {237let mintCount = 0;238let modelsCallCount = 0;239const service = createService(async (input) => {240const url = getUrl(input);241if (url.includes('/copilot_internal')) {242mintCount++;243return tokenResponse();244}245modelsCallCount++;246if (modelsCallCount === 1) {247return new Response('forbidden', { status: 403, statusText: 'Forbidden' });248}249return modelsResponse([]);250});251252await assert.rejects(() => service.models('gh-tok'));253await service.models('gh-tok');254assert.strictEqual(mintCount, 2);255});256257test('does not re-mint when copilot token has plenty of time left', async () => {258let mintCount = 0;259const service = createService(async (input) => {260const url = getUrl(input);261if (url.includes('/copilot_internal')) {262mintCount++;263return tokenResponse({ expires_at: Date.now() / 1000 + 7200 });264}265return anthropicResponse([{ type: 'text', text: 'hi' }]);266});267268await service.messages('gh-tok', baseRequest);269await service.messages('gh-tok', baseRequest);270assert.strictEqual(mintCount, 1);271});272273test('uses endpoints.api from the token envelope as the CAPI base', async () => {274const { fetch: fetchFn, captured } = routingFetch(275() => anthropicResponse([{ type: 'text', text: 'ok' }]),276{ endpoints: { api: 'https://custom.copilot.example.com' } },277);278const service = createService(fetchFn);279280await service.messages('gh-tok', baseRequest);281assert.strictEqual(captured().url, 'https://custom.copilot.example.com/v1/messages');282});283284test('falls back to default API base when endpoints.api is missing', async () => {285const { fetch: fetchFn, captured } = routingFetch(286() => anthropicResponse([{ type: 'text', text: 'ok' }]),287);288const service = createService(fetchFn);289290await service.messages('gh-tok', baseRequest);291assert.strictEqual(captured().url, 'https://api.githubcopilot.com/v1/messages');292});293294test('sends the github token as Authorization header to the mint endpoint', async () => {295let capturedAuthHeader: string | undefined;296const service = createService(async (input, init) => {297const url = getUrl(input);298if (url.includes('/copilot_internal')) {299const headers = init?.headers as Record<string, string>;300capturedAuthHeader = headers?.['Authorization'];301return tokenResponse();302}303return anthropicResponse([{ type: 'text', text: 'ok' }]);304});305306await service.messages('my-secret-gh-token', baseRequest);307assert.strictEqual(capturedAuthHeader, 'token my-secret-gh-token');308});309310test('throws on 403 from token mint', async () => {311const service = createService(async () => new Response('{"message":"Not authorized"}', { status: 403, statusText: 'Forbidden' }));312await assert.rejects(313() => service.messages('bad-tok', baseRequest),314(err: Error) => err.message.includes('Copilot token minting failed: 403'),315);316});317318test('throws on 500 from token mint', async () => {319const service = createService(async () => new Response('internal error', { status: 500, statusText: 'Internal Server Error' }));320await assert.rejects(321() => service.messages('gh-tok', baseRequest),322(err: Error) => err.message.includes('Copilot token minting failed: 500'),323);324});325326test('does not double-mint when concurrent requests race on first call', async () => {327let mintCount = 0;328const service = createService(async (input) => {329const url = getUrl(input);330if (url.includes('/copilot_internal')) {331mintCount++;332await new Promise(r => setTimeout(r, 10)); // ensure overlap333return tokenResponse();334}335return anthropicResponse([{ type: 'text', text: 'ok' }]);336});337338await Promise.all([339service.messages('gh-tok', baseRequest),340service.messages('gh-tok', baseRequest),341]);342assert.strictEqual(mintCount, 1);343});344345test('in-flight mint dedup spans concurrent messages + models calls', async () => {346let mintCount = 0;347const service = createService(async (input) => {348const url = getUrl(input);349if (url.includes('/copilot_internal')) {350mintCount++;351await new Promise(r => setTimeout(r, 10));352return tokenResponse();353}354if (url.includes('/models')) {355return modelsResponse([]);356}357return anthropicResponse([{ type: 'text', text: 'ok' }]);358});359360await Promise.all([361service.messages('gh-tok', baseRequest),362service.models('gh-tok'),363]);364assert.strictEqual(mintCount, 1);365});366367test('error from token mint does not include the github token', async () => {368const service = createService(async () => new Response('forbidden', { status: 403, statusText: 'Forbidden' }));369await assert.rejects(370() => service.messages('super-secret-gh-token-xyz', baseRequest),371(err: Error) => !err.message.includes('super-secret-gh-token-xyz'),372);373});374375test('error from CAPI does not include the copilot or github token', async () => {376const service = createService(async (input) => {377const url = getUrl(input);378if (url.includes('/copilot_internal')) {379return tokenResponse({ token: 'super-secret-copilot-token-xyz' });380}381return new Response('rate limited', { status: 429, statusText: 'Too Many Requests' });382});383await assert.rejects(384() => service.messages('super-secret-gh-token-xyz', baseRequest),385(err: Error) => !err.message.includes('super-secret-copilot-token-xyz') && !err.message.includes('super-secret-gh-token-xyz'),386);387});388389test('mints independently for concurrent requests with different github tokens', async () => {390const minted: string[] = [];391const service = createService(async (input, init) => {392const url = getUrl(input);393if (url.includes('/copilot_internal')) {394const auth = (init?.headers as Record<string, string>)?.['Authorization'] ?? '';395minted.push(auth);396await new Promise(r => setTimeout(r, 10)); // ensure overlap397return tokenResponse();398}399return anthropicResponse([{ type: 'text', text: 'ok' }]);400});401402await Promise.all([403service.messages('gh-tok-A', baseRequest),404service.messages('gh-tok-B', baseRequest),405]);406assert.strictEqual(minted.length, 2);407assert.ok(minted.some(h => h.includes('gh-tok-A')));408assert.ok(minted.some(h => h.includes('gh-tok-B')));409});410});411412// #endregion413414// #region Request Format415416suite('Request Format', () => {417418test('sends system as a top-level text-block array', async () => {419const { fetch: fetchFn, captured } = routingFetch(420() => anthropicResponse([{ type: 'text', text: 'ok' }]),421);422const service = createService(fetchFn);423424await service.messages('gh-tok', { ...baseRequest, system: 'You are helpful.' });425const body = JSON.parse(captured().init?.body as string);426427assert.deepStrictEqual(body.system, [{ type: 'text', text: 'You are helpful.' }]);428});429430test('omits system field entirely when not provided', async () => {431const { fetch: fetchFn, captured } = routingFetch(432() => anthropicResponse([{ type: 'text', text: 'ok' }]),433);434const service = createService(fetchFn);435436await service.messages('gh-tok', baseRequest);437const body = JSON.parse(captured().init?.body as string);438439assert.strictEqual(body.system, undefined);440});441442test('sends max_tokens in the body', async () => {443const { fetch: fetchFn, captured } = routingFetch(444() => anthropicResponse([{ type: 'text', text: 'ok' }]),445);446const service = createService(fetchFn);447448await service.messages('gh-tok', { ...baseRequest, max_tokens: 8192 });449const body = JSON.parse(captured().init?.body as string);450451assert.strictEqual(body.max_tokens, 8192);452});453454test('non-streaming sends stream=false in the body', async () => {455const { fetch: fetchFn, captured } = routingFetch(456() => anthropicResponse([{ type: 'text', text: 'ok' }]),457);458const service = createService(fetchFn);459460await service.messages('gh-tok', baseRequest);461const body = JSON.parse(captured().init?.body as string);462463assert.strictEqual(body.stream, false);464});465466test('defaults to non-streaming when stream is omitted', async () => {467const { fetch: fetchFn, captured } = routingFetch(468() => anthropicResponse([{ type: 'text', text: 'ok' }]),469);470const service = createService(fetchFn);471472await service.messages('gh-tok', baseRequest);473const body = JSON.parse(captured().init?.body as string);474475assert.strictEqual(body.stream, false);476});477478test('streaming sends stream=true in the body', async () => {479const { fetch: fetchFn, captured } = routingFetch(480() => sseResponse([sseLines('data: {"type":"message_stop"}')]),481);482const service = createService(fetchFn);483484await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const }));485const body = JSON.parse(captured().init?.body as string);486487assert.strictEqual(body.stream, true);488});489490test('sends correct CAPI headers', async () => {491const { fetch: fetchFn, captured } = routingFetch(492() => anthropicResponse([{ type: 'text', text: 'ok' }]),493);494const service = createService(fetchFn);495496await service.messages('gh-tok', baseRequest);497const headers = captured().init?.headers as Record<string, string>;498499assert.strictEqual(headers['Content-Type'], 'application/json');500assert.strictEqual(headers['Authorization'], 'Bearer copilot-tok-abc');501assert.strictEqual(headers['OpenAI-Intent'], 'conversation');502assert.ok(headers['X-Request-Id'], 'should have a request id');503assert.ok(headers['X-GitHub-Api-Version'], 'CAPIClient should inject API version');504assert.ok(headers['VScode-SessionId'], 'CAPIClient should inject session id');505});506507test('passes messages through as-is', async () => {508const { fetch: fetchFn, captured } = routingFetch(509() => anthropicResponse([{ type: 'text', text: 'ok' }]),510);511const service = createService(fetchFn);512513const messages: Anthropic.MessageParam[] = [514{ role: 'user', content: 'What is 2+2?' },515{ role: 'assistant', content: '4' },516{ role: 'user', content: 'Thanks!' },517];518await service.messages('gh-tok', { ...baseRequest, messages });519const body = JSON.parse(captured().init?.body as string);520521assert.deepStrictEqual(body.messages, messages);522});523524test('sends model in the body', async () => {525const { fetch: fetchFn, captured } = routingFetch(526() => anthropicResponse([{ type: 'text', text: 'ok' }]),527);528const service = createService(fetchFn);529530await service.messages('gh-tok', { ...baseRequest, model: 'claude-opus-4-1-20250805' });531const body = JSON.parse(captured().init?.body as string);532533assert.strictEqual(body.model, 'claude-opus-4-1-20250805');534});535536test('merges caller-provided headers into the request', async () => {537const { fetch: fetchFn, captured } = routingFetch(538() => anthropicResponse([{ type: 'text', text: 'ok' }]),539);540const service = createService(fetchFn);541542await service.messages('gh-tok', baseRequest, {543headers: { 'X-Custom-Trace': 'abc-123', 'X-Session-Id': 'sess-456' },544});545const headers = captured().init?.headers as Record<string, string>;546547assert.strictEqual(headers['X-Custom-Trace'], 'abc-123');548assert.strictEqual(headers['X-Session-Id'], 'sess-456');549assert.strictEqual(headers['Authorization'], 'Bearer copilot-tok-abc', 'standard headers should not be overridden');550});551552test('caller-supplied headers cannot override security-sensitive standard headers', async () => {553// Documented invariant: Authorization, Content-Type, X-Request-Id, OpenAI-Intent554// must always reflect the values the service computes — never the caller's.555const { fetch: fetchFn, captured } = routingFetch(556() => anthropicResponse([{ type: 'text', text: 'ok' }]),557);558const service = createService(fetchFn);559560await service.messages('gh-tok', baseRequest, {561headers: {562'Authorization': 'Bearer attacker-token',563'Content-Type': 'text/plain',564'X-Request-Id': 'attacker-id',565'OpenAI-Intent': 'attacker-intent',566},567});568const headers = captured().init?.headers as Record<string, string>;569570assert.strictEqual(headers['Authorization'], 'Bearer copilot-tok-abc');571assert.strictEqual(headers['Content-Type'], 'application/json');572assert.notStrictEqual(headers['X-Request-Id'], 'attacker-id');573assert.strictEqual(headers['OpenAI-Intent'], 'conversation');574});575});576577// #endregion578579// #region Non-Streaming Responses580581suite('Non-Streaming Responses', () => {582583test('returns text content from a single text block', async () => {584const { fetch: fetchFn } = routingFetch(585() => anthropicResponse([{ type: 'text', text: 'The answer is 42.' }]),586);587const service = createService(fetchFn);588589const result = await service.messages('gh-tok', baseRequest);590assert.strictEqual(getText(result), 'The answer is 42.');591});592593test('concatenates multiple text blocks', async () => {594const { fetch: fetchFn } = routingFetch(595() => anthropicResponse([596{ type: 'text', text: 'First part. ' },597{ type: 'text', text: 'Second part.' },598]),599);600const service = createService(fetchFn);601602const result = await service.messages('gh-tok', baseRequest);603assert.strictEqual(getText(result), 'First part. Second part.');604});605606test('skips non-text content blocks (tool_use, thinking)', async () => {607const { fetch: fetchFn } = routingFetch(608() => anthropicResponse([609{ type: 'thinking', text: 'let me think...' },610{ type: 'text', text: 'the answer' },611{ type: 'tool_use' },612]),613);614const service = createService(fetchFn);615616const result = await service.messages('gh-tok', baseRequest);617assert.strictEqual(getText(result), 'the answer');618});619620test('returns empty string when no text blocks are present', async () => {621const { fetch: fetchFn } = routingFetch(622() => anthropicResponse([{ type: 'tool_use' }]),623);624const service = createService(fetchFn);625626const result = await service.messages('gh-tok', baseRequest);627assert.strictEqual(getText(result), '');628});629630test('returns the stop reason', async () => {631const { fetch: fetchFn } = routingFetch(632() => anthropicResponse([{ type: 'text', text: 'ok' }], 'max_tokens'),633);634const service = createService(fetchFn);635636const result = await service.messages('gh-tok', baseRequest);637assert.strictEqual(result.stop_reason, 'max_tokens');638});639640test('stop_reason is null when missing from server response', async () => {641const { fetch: fetchFn } = routingFetch(() => {642return new Response(JSON.stringify({643content: [{ type: 'text', text: 'ok' }],644}), { status: 200 });645});646const service = createService(fetchFn);647648const result = await service.messages('gh-tok', baseRequest);649assert.strictEqual(result.stop_reason ?? null, null);650});651652test('throws on 429 rate limit', async () => {653const { fetch: fetchFn } = routingFetch(654() => new Response('{"error":"rate_limited"}', { status: 429, statusText: 'Too Many Requests' }),655);656const service = createService(fetchFn);657658await assert.rejects(659() => service.messages('gh-tok', baseRequest),660(err: Error) => err.message.includes('CAPI request failed: 429'),661);662});663664test('throws on 500 server error', async () => {665const { fetch: fetchFn } = routingFetch(666() => new Response('internal server error', { status: 500, statusText: 'Internal Server Error' }),667);668const service = createService(fetchFn);669670await assert.rejects(671() => service.messages('gh-tok', baseRequest),672(err: Error) => err.message.includes('CAPI request failed: 500'),673);674});675});676677// #endregion678679// #region Streaming Responses680681suite('Streaming Responses', () => {682683function collectTextDeltas(events: Anthropic.MessageStreamEvent[]): string[] {684return events685.filter((e): e is Anthropic.RawContentBlockDeltaEvent =>686e.type === 'content_block_delta' && e.delta.type === 'text_delta')687.map(e => (e.delta as Anthropic.TextDelta).text);688}689690test('yields text deltas from content_block_delta events', async () => {691const service = streamService([692sseLines(693'event: content_block_delta',694'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}',695'',696'event: content_block_delta',697'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}',698),699sseLines(700'event: message_stop',701'data: {"type":"message_stop"}',702),703]);704705const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const }));706assert.deepStrictEqual(collectTextDeltas(events), ['Hello', ' world']);707});708709test('handles data split across multiple network chunks', async () => {710const encoder = new TextEncoder();711const service = streamService([712encoder.encode('event: content_block_delta\ndata: {"type":"content_bl'),713encoder.encode('ock_delta","index":0,"delta":{"type":"text_delta","text":"split"}}\n'),714sseLines(715'event: message_stop',716'data: {"type":"message_stop"}',717),718]);719720const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const }));721assert.deepStrictEqual(collectTextDeltas(events), ['split']);722});723724test('handles a data line split right at the newline boundary', async () => {725const encoder = new TextEncoder();726const service = streamService([727encoder.encode('data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"chunk1"}}'),728encoder.encode('\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"chunk2"}}\n'),729sseLines('data: {"type":"message_stop"}'),730]);731732const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const }));733assert.deepStrictEqual(collectTextDeltas(events), ['chunk1', 'chunk2']);734});735736test('skips event: lines, comment lines, and blank lines', async () => {737const service = streamService([738sseLines(739': keep-alive comment',740'event: content_block_delta',741'',742'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}',743'',744'event: message_stop',745'data: {"type":"message_stop"}',746),747]);748749const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const }));750assert.deepStrictEqual(collectTextDeltas(events), ['ok']);751});752753test('handles many small deltas', async () => {754const deltas = Array.from({ length: 100 }, (_, i) =>755`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"w${i}"}}`756);757const service = streamService([758sseLines(...deltas),759sseLines('data: {"type":"message_stop"}'),760]);761762const texts = collectTextDeltas(await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })));763assert.strictEqual(texts.length, 100);764assert.strictEqual(texts[0], 'w0');765assert.strictEqual(texts[99], 'w99');766});767768test('throws on error event with message', async () => {769const service = streamService([770sseLines(771'event: error',772'data: {"type":"error","error":{"message":"overloaded"}}',773),774]);775776await assert.rejects(777() => collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })),778(err: Error) => err.message === 'overloaded',779);780});781782test('throws on error event without message', async () => {783const service = streamService([784sseLines(785'event: error',786'data: {"type":"error","error":{}}',787),788]);789790await assert.rejects(791() => collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })),792(err: Error) => err.message === 'Unknown streaming error',793);794});795796test('throws on non-200 CAPI response', async () => {797const { fetch: fetchFn } = routingFetch(798() => new Response('overloaded', { status: 529, statusText: 'Overloaded' }),799);800const service = createService(fetchFn);801802await assert.rejects(803() => collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })),804(err: Error) => err.message.includes('CAPI request failed: 529'),805);806});807808test('throws when response has no body', async () => {809const { fetch: fetchFn } = routingFetch(810() => new Response(null, { status: 200 }),811);812const service = createService(fetchFn);813814await assert.rejects(815() => collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })),816(err: Error) => err.message.includes('no body'),817);818});819820test('survives malformed JSON in the stream (skips the line)', async () => {821const service = streamService([822sseLines(823'data: not-valid-json',824'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}',825'data: {"type":"message_stop"}',826),827]);828829const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const }));830assert.deepStrictEqual(collectTextDeltas(events), ['ok']);831});832});833834// #endregion835836// #region Raw Event Stream (messages())837838suite('Raw Event Stream (messages())', () => {839840test('yields all six protocol event types in order', async () => {841const service = streamService([842sseLines(843'data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-5","stop_reason":null,"usage":{"input_tokens":1,"output_tokens":1}}}',844'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',845'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}',846'data: {"type":"content_block_stop","index":0}',847'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}',848'data: {"type":"message_stop"}',849),850]);851852const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const }));853assert.deepStrictEqual(events.map(e => e.type), [854'message_start',855'content_block_start',856'content_block_delta',857'content_block_stop',858'message_delta',859'message_stop',860]);861});862863test('message_stop is the last yielded event', async () => {864const service = streamService([865sseLines(866'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"a"}}',867'data: {"type":"message_stop"}',868),869]);870871const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const }));872assert.strictEqual(events.length, 2);873assert.strictEqual(events[events.length - 1].type, 'message_stop');874});875876test('stops after message_stop even if extra SSE data follows', async () => {877const service = streamService([878sseLines(879'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"a"}}',880'data: {"type":"message_stop"}',881'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"SHOULD_NOT_APPEAR"}}',882),883]);884885const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const }));886const texts = events887.filter((e): e is Anthropic.RawContentBlockDeltaEvent => e.type === 'content_block_delta')888.map(e => e.delta.type === 'text_delta' ? e.delta.text : '');889assert.deepStrictEqual(texts, ['a']);890});891892test('yields thinking_delta events (not filtered by messages())', async () => {893const service = streamService([894sseLines(895'data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hmm"}}',896'data: {"type":"message_stop"}',897),898]);899900const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const }));901const delta = events.find((e): e is Anthropic.RawContentBlockDeltaEvent => e.type === 'content_block_delta');902assert.ok(delta);903assert.strictEqual(delta.delta.type, 'thinking_delta');904});905906test('yields input_json_delta events', async () => {907const service = streamService([908sseLines(909'data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\\"k\\":1}"}}',910'data: {"type":"message_stop"}',911),912]);913914const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const }));915const delta = events.find((e): e is Anthropic.RawContentBlockDeltaEvent => e.type === 'content_block_delta');916assert.ok(delta);917assert.strictEqual(delta.delta.type, 'input_json_delta');918});919920test('yields message_delta with stop_reason payload', async () => {921const service = streamService([922sseLines(923'data: {"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"output_tokens":7}}',924'data: {"type":"message_stop"}',925),926]);927928const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const }));929const msgDelta = events.find((e): e is Anthropic.RawMessageDeltaEvent => e.type === 'message_delta');930assert.ok(msgDelta);931assert.strictEqual(msgDelta.delta.stop_reason, 'max_tokens');932});933934test('tool_use block events round-trip through messages()', async () => {935const service = streamService([936sseLines(937'data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tu_1","name":"read_file","input":{}}}',938'data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\\"path\\":"}}',939'data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\\"/tmp/x\\"}"}}',940'data: {"type":"content_block_stop","index":0}',941'data: {"type":"message_stop"}',942),943]);944945const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const }));946const blockStart = events.find((e): e is Anthropic.RawContentBlockStartEvent => e.type === 'content_block_start');947assert.ok(blockStart, 'expected content_block_start event');948assert.strictEqual(blockStart.content_block.type, 'tool_use');949assert.strictEqual((blockStart.content_block as Anthropic.ToolUseBlock).name, 'read_file');950951const jsonDeltas = events.filter(952(e): e is Anthropic.RawContentBlockDeltaEvent =>953e.type === 'content_block_delta' && e.delta.type === 'input_json_delta',954);955assert.strictEqual(jsonDeltas.length, 2);956assert.strictEqual(events[events.length - 1].type, 'message_stop');957});958});959960// #endregion961962// #region countTokens963964suite('countTokens', () => {965966test('throws "countTokens not supported by CAPI"', async () => {967const service = createService(async () => new Response('{}', { status: 200 }));968await assert.rejects(969() => service.countTokens('gh-tok', { model: 'claude-sonnet-4-5', messages: [{ role: 'user', content: 'hi' }] }),970(err: Error) => err.message.includes('countTokens not supported by CAPI'),971);972});973974test('does not mint a token before throwing', async () => {975let mintCount = 0;976const service = createService(async (input) => {977const url = getUrl(input);978if (url.includes('/copilot_internal')) {979mintCount++;980return tokenResponse();981}982return new Response('{}', { status: 200 });983});984985await assert.rejects(986() => service.countTokens('gh-tok', { model: 'claude-sonnet-4-5', messages: [{ role: 'user', content: 'hi' }] }),987);988assert.strictEqual(mintCount, 0);989});990});991992// #endregion993994// #region Streaming + Non-Streaming Shared Behavior995996suite('Shared Behavior', () => {997998test('streaming and non-streaming hit the same /v1/messages endpoint', async () => {999const urls: string[] = [];1000const service = createService(async (input) => {1001const url = getUrl(input);1002if (url.includes('/copilot_internal')) {1003return tokenResponse();1004}1005urls.push(url);1006if (urls.length === 1) {1007return anthropicResponse([{ type: 'text', text: 'ok' }]);1008}1009return sseResponse([sseLines('data: {"type":"message_stop"}')]);1010});10111012await service.messages('gh-tok', baseRequest);1013await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const }));10141015assert.strictEqual(urls.length, 2);1016assert.ok(urls[0].endsWith('/v1/messages'));1017assert.ok(urls[1].endsWith('/v1/messages'));1018});10191020test('both modes share the same cached copilot token', async () => {1021let mintCount = 0;1022const service = createService(async (input) => {1023const url = getUrl(input);1024if (url.includes('/copilot_internal')) {1025mintCount++;1026return tokenResponse();1027}1028return anthropicResponse([{ type: 'text', text: 'ok' }]);1029});10301031await service.messages('gh-tok', baseRequest);1032await service.messages('gh-tok', baseRequest);1033assert.strictEqual(mintCount, 1);1034});1035});10361037// #endregion10381039// #region Cancellation10401041suite('Cancellation', () => {10421043test('forwards AbortSignal to fetch for messages', async () => {1044const controller = new AbortController();1045let capturedSignal: AbortSignal | undefined;1046const service = createService(async (input, init) => {1047const url = getUrl(input);1048if (url.includes('/copilot_internal')) {1049return tokenResponse();1050}1051capturedSignal = init?.signal as AbortSignal;1052return anthropicResponse([{ type: 'text', text: 'ok' }]);1053});10541055await service.messages('gh-tok', baseRequest, { signal: controller.signal });1056assert.strictEqual(capturedSignal, controller.signal);1057});10581059test('forwards AbortSignal to fetch for models', async () => {1060const controller = new AbortController();1061let capturedSignal: AbortSignal | undefined;1062const service = createService(async (input, init) => {1063const url = getUrl(input);1064if (url.includes('/copilot_internal')) {1065return tokenResponse();1066}1067capturedSignal = init?.signal as AbortSignal;1068return modelsResponse([]);1069});10701071await service.models('gh-tok', { signal: controller.signal });1072assert.strictEqual(capturedSignal, controller.signal);1073});10741075test('does not forward AbortSignal to the shared token mint fetch', async () => {1076const controller = new AbortController();1077let mintSignal: AbortSignal | undefined;1078const service = createService(async (input, init) => {1079const url = getUrl(input);1080if (url.includes('/copilot_internal')) {1081mintSignal = init?.signal as AbortSignal;1082return tokenResponse();1083}1084return anthropicResponse([{ type: 'text', text: 'ok' }]);1085});10861087await service.messages('gh-tok', baseRequest, { signal: controller.signal });1088assert.strictEqual(mintSignal, undefined);1089});10901091test('cancels the underlying SSE stream when the consumer breaks early', async () => {1092let cancelled = false;1093const body = new ReadableStream<Uint8Array>({1094pull(controller) {1095controller.enqueue(sseLines(1096'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}',1097));1098},1099cancel() {1100cancelled = true;1101},1102});1103const service = createService(async (input) => {1104const url = getUrl(input);1105if (url.includes('/copilot_internal')) {1106return tokenResponse();1107}1108return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });1109});11101111const iter = service.messages('gh-tok', { ...baseRequest, stream: true });1112for await (const _ of iter) {1113break; // abandon after first chunk1114}1115assert.strictEqual(cancelled, true);1116});11171118test('cancels the underlying SSE stream after message_stop terminates the generator', async () => {1119let cancelled = false;1120const body = new ReadableStream<Uint8Array>({1121start(controller) {1122controller.enqueue(sseLines(1123'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}',1124'data: {"type":"message_stop"}',1125));1126// Server is still alive — connection must be released by the client1127// even though the producer hasn't closed yet.1128},1129cancel() {1130cancelled = true;1131},1132});1133const service = createService(async (input) => {1134const url = getUrl(input);1135if (url.includes('/copilot_internal')) {1136return tokenResponse();1137}1138return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });1139});11401141await collect(service.messages('gh-tok', { ...baseRequest, stream: true }));1142assert.strictEqual(cancelled, true);1143});11441145test('cancels the underlying SSE stream when the generator throws', async () => {1146let cancelled = false;1147const body = new ReadableStream<Uint8Array>({1148start(controller) {1149controller.enqueue(sseLines(1150'data: {"type":"error","error":{"message":"boom"}}',1151));1152},1153cancel() {1154cancelled = true;1155},1156});1157const service = createService(async (input) => {1158const url = getUrl(input);1159if (url.includes('/copilot_internal')) {1160return tokenResponse();1161}1162return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });1163});11641165await assert.rejects(() => collect(service.messages('gh-tok', { ...baseRequest, stream: true })));1166assert.strictEqual(cancelled, true);1167});1168});11691170// #endregion11711172// #region Models11731174suite('Models', () => {11751176test('returns models from the data array', async () => {1177const fakeModels = [1178{ id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', vendor: 'anthropic', supported_endpoints: ['chat/messages'] },1179{ id: 'claude-opus-4', name: 'Claude Opus 4', vendor: 'anthropic', supported_endpoints: ['chat/messages'] },1180];1181const service = createService(async (input) => {1182const url = getUrl(input);1183if (url.includes('/copilot_internal')) {1184return tokenResponse();1185}1186return modelsResponse(fakeModels);1187});11881189const result = await service.models('gh-tok');1190assert.deepStrictEqual(result, fakeModels);1191});11921193test('returns empty array when data is missing', async () => {1194const service = createService(async (input) => {1195const url = getUrl(input);1196if (url.includes('/copilot_internal')) {1197return tokenResponse();1198}1199return new Response(JSON.stringify({}), { status: 200 });1200});12011202const result = await service.models('gh-tok');1203assert.deepStrictEqual(result, []);1204});12051206test('sends Bearer token in Authorization header', async () => {1207let capturedAuthHeader: string | undefined;1208const service = createService(async (input, init) => {1209const url = getUrl(input);1210if (url.includes('/copilot_internal')) {1211return tokenResponse();1212}1213capturedAuthHeader = (init?.headers as Record<string, string>)?.['Authorization'];1214return modelsResponse([]);1215});12161217await service.models('gh-tok');1218assert.strictEqual(capturedAuthHeader, 'Bearer copilot-tok-abc');1219});12201221test('throws on non-200 response', async () => {1222const service = createService(async (input) => {1223const url = getUrl(input);1224if (url.includes('/copilot_internal')) {1225return tokenResponse();1226}1227return new Response('forbidden', { status: 403, statusText: 'Forbidden' });1228});12291230await assert.rejects(1231() => service.models('gh-tok'),1232(err: Error) => err.message.includes('CAPI models request failed: 403'),1233);1234});12351236test('reuses cached token across messages and models calls', async () => {1237let mintCount = 0;1238const service = createService(async (input) => {1239const url = getUrl(input);1240if (url.includes('/copilot_internal')) {1241mintCount++;1242return tokenResponse();1243}1244if (url.includes('/models')) {1245return modelsResponse([]);1246}1247return anthropicResponse([{ type: 'text', text: 'ok' }]);1248});12491250await service.messages('gh-tok', baseRequest);1251await service.models('gh-tok');1252assert.strictEqual(mintCount, 1);1253});12541255test('routes to the models endpoint URL', async () => {1256const { fetch: fetchFn, captured } = routingFetch(() => modelsResponse([]));1257const service = createService(fetchFn);12581259await service.models('gh-tok');1260assert.ok(captured().url.includes('/models'), `expected models URL, got: ${captured().url}`);1261});12621263test('caller-supplied headers cannot override Authorization in models()', async () => {1264let capturedHeaders: Record<string, string> | undefined;1265const service = createService(async (input, init) => {1266const url = getUrl(input);1267if (url.includes('/copilot_internal')) {1268return tokenResponse();1269}1270capturedHeaders = init?.headers as Record<string, string>;1271return modelsResponse([]);1272});12731274await service.models('gh-tok', {1275headers: { 'Authorization': 'Bearer attacker-token' },1276});1277assert.strictEqual(capturedHeaders?.['Authorization'], 'Bearer copilot-tok-abc');1278});1279});12801281// #endregion1282});128312841285