@jackchen_me/open-multi-agent 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +40 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
  3. package/.github/pull_request_template.md +14 -0
  4. package/.github/workflows/ci.yml +23 -0
  5. package/CLAUDE.md +80 -0
  6. package/CODE_OF_CONDUCT.md +48 -0
  7. package/CONTRIBUTING.md +72 -0
  8. package/DECISIONS.md +43 -0
  9. package/README.md +144 -144
  10. package/README_zh.md +277 -0
  11. package/SECURITY.md +17 -0
  12. package/dist/agent/agent.d.ts +20 -1
  13. package/dist/agent/agent.d.ts.map +1 -1
  14. package/dist/agent/agent.js +233 -12
  15. package/dist/agent/agent.js.map +1 -1
  16. package/dist/agent/loop-detector.d.ts +39 -0
  17. package/dist/agent/loop-detector.d.ts.map +1 -0
  18. package/dist/agent/loop-detector.js +122 -0
  19. package/dist/agent/loop-detector.js.map +1 -0
  20. package/dist/agent/pool.d.ts +2 -1
  21. package/dist/agent/pool.d.ts.map +1 -1
  22. package/dist/agent/pool.js +4 -2
  23. package/dist/agent/pool.js.map +1 -1
  24. package/dist/agent/runner.d.ts +23 -1
  25. package/dist/agent/runner.d.ts.map +1 -1
  26. package/dist/agent/runner.js +113 -12
  27. package/dist/agent/runner.js.map +1 -1
  28. package/dist/agent/structured-output.d.ts +33 -0
  29. package/dist/agent/structured-output.d.ts.map +1 -0
  30. package/dist/agent/structured-output.js +116 -0
  31. package/dist/agent/structured-output.js.map +1 -0
  32. package/dist/index.d.ts +5 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +4 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/llm/adapter.d.ts +12 -4
  37. package/dist/llm/adapter.d.ts.map +1 -1
  38. package/dist/llm/adapter.js +28 -5
  39. package/dist/llm/adapter.js.map +1 -1
  40. package/dist/llm/anthropic.d.ts +1 -1
  41. package/dist/llm/anthropic.d.ts.map +1 -1
  42. package/dist/llm/anthropic.js +2 -1
  43. package/dist/llm/anthropic.js.map +1 -1
  44. package/dist/llm/copilot.d.ts +92 -0
  45. package/dist/llm/copilot.d.ts.map +1 -0
  46. package/dist/llm/copilot.js +427 -0
  47. package/dist/llm/copilot.js.map +1 -0
  48. package/dist/llm/gemini.d.ts +65 -0
  49. package/dist/llm/gemini.d.ts.map +1 -0
  50. package/dist/llm/gemini.js +317 -0
  51. package/dist/llm/gemini.js.map +1 -0
  52. package/dist/llm/grok.d.ts +21 -0
  53. package/dist/llm/grok.d.ts.map +1 -0
  54. package/dist/llm/grok.js +24 -0
  55. package/dist/llm/grok.js.map +1 -0
  56. package/dist/llm/openai-common.d.ts +54 -0
  57. package/dist/llm/openai-common.d.ts.map +1 -0
  58. package/dist/llm/openai-common.js +242 -0
  59. package/dist/llm/openai-common.js.map +1 -0
  60. package/dist/llm/openai.d.ts +2 -2
  61. package/dist/llm/openai.d.ts.map +1 -1
  62. package/dist/llm/openai.js +23 -226
  63. package/dist/llm/openai.js.map +1 -1
  64. package/dist/orchestrator/orchestrator.d.ts +25 -1
  65. package/dist/orchestrator/orchestrator.d.ts.map +1 -1
  66. package/dist/orchestrator/orchestrator.js +214 -41
  67. package/dist/orchestrator/orchestrator.js.map +1 -1
  68. package/dist/task/queue.d.ts +31 -2
  69. package/dist/task/queue.d.ts.map +1 -1
  70. package/dist/task/queue.js +70 -3
  71. package/dist/task/queue.js.map +1 -1
  72. package/dist/task/task.d.ts +3 -0
  73. package/dist/task/task.d.ts.map +1 -1
  74. package/dist/task/task.js +5 -1
  75. package/dist/task/task.js.map +1 -1
  76. package/dist/team/messaging.d.ts.map +1 -1
  77. package/dist/team/messaging.js +2 -1
  78. package/dist/team/messaging.js.map +1 -1
  79. package/dist/tool/text-tool-extractor.d.ts +32 -0
  80. package/dist/tool/text-tool-extractor.d.ts.map +1 -0
  81. package/dist/tool/text-tool-extractor.js +187 -0
  82. package/dist/tool/text-tool-extractor.js.map +1 -0
  83. package/dist/types.d.ts +167 -7
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/utils/trace.d.ts +12 -0
  86. package/dist/utils/trace.d.ts.map +1 -0
  87. package/dist/utils/trace.js +30 -0
  88. package/dist/utils/trace.js.map +1 -0
  89. package/examples/05-copilot-test.ts +49 -0
  90. package/examples/06-local-model.ts +200 -0
  91. package/examples/07-fan-out-aggregate.ts +209 -0
  92. package/examples/08-gemma4-local.ts +192 -0
  93. package/examples/09-structured-output.ts +73 -0
  94. package/examples/10-task-retry.ts +132 -0
  95. package/examples/11-trace-observability.ts +133 -0
  96. package/examples/12-grok.ts +154 -0
  97. package/examples/13-gemini.ts +48 -0
  98. package/package.json +14 -3
  99. package/src/agent/agent.ts +273 -15
  100. package/src/agent/loop-detector.ts +137 -0
  101. package/src/agent/pool.ts +9 -2
  102. package/src/agent/runner.ts +148 -19
  103. package/src/agent/structured-output.ts +126 -0
  104. package/src/index.ts +17 -1
  105. package/src/llm/adapter.ts +29 -5
  106. package/src/llm/anthropic.ts +2 -1
  107. package/src/llm/copilot.ts +552 -0
  108. package/src/llm/gemini.ts +378 -0
  109. package/src/llm/grok.ts +29 -0
  110. package/src/llm/openai-common.ts +294 -0
  111. package/src/llm/openai.ts +31 -261
  112. package/src/orchestrator/orchestrator.ts +260 -40
  113. package/src/task/queue.ts +74 -4
  114. package/src/task/task.ts +8 -1
  115. package/src/team/messaging.ts +3 -1
  116. package/src/tool/text-tool-extractor.ts +219 -0
  117. package/src/types.ts +186 -6
  118. package/src/utils/trace.ts +34 -0
  119. package/tests/agent-hooks.test.ts +473 -0
  120. package/tests/agent-pool.test.ts +212 -0
  121. package/tests/approval.test.ts +464 -0
  122. package/tests/built-in-tools.test.ts +393 -0
  123. package/tests/gemini-adapter.test.ts +97 -0
  124. package/tests/grok-adapter.test.ts +74 -0
  125. package/tests/llm-adapters.test.ts +357 -0
  126. package/tests/loop-detection.test.ts +456 -0
  127. package/tests/openai-fallback.test.ts +159 -0
  128. package/tests/orchestrator.test.ts +281 -0
  129. package/tests/scheduler.test.ts +221 -0
  130. package/tests/semaphore.test.ts +57 -0
  131. package/tests/shared-memory.test.ts +122 -0
  132. package/tests/structured-output.test.ts +331 -0
  133. package/tests/task-queue.test.ts +244 -0
  134. package/tests/task-retry.test.ts +368 -0
  135. package/tests/task-utils.test.ts +155 -0
  136. package/tests/team-messaging.test.ts +329 -0
  137. package/tests/text-tool-extractor.test.ts +170 -0
  138. package/tests/tool-executor.test.ts +193 -0
  139. package/tests/trace.test.ts +453 -0
  140. package/vitest.config.ts +9 -0
@@ -0,0 +1,552 @@
1
+ /**
2
+ * @fileoverview GitHub Copilot adapter implementing {@link LLMAdapter}.
3
+ *
4
+ * Uses the OpenAI-compatible Copilot Chat Completions endpoint at
5
+ * `https://api.githubcopilot.com`. Authentication requires a GitHub token
6
+ * which is exchanged for a short-lived Copilot session token via the
7
+ * internal token endpoint.
8
+ *
9
+ * API key resolution order:
10
+ * 1. `apiKey` constructor argument
11
+ * 2. `GITHUB_COPILOT_TOKEN` environment variable
12
+ * 3. `GITHUB_TOKEN` environment variable
13
+ * 4. Interactive OAuth2 device flow (prompts the user to sign in)
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import { CopilotAdapter } from './copilot.js'
18
+ *
19
+ * const adapter = new CopilotAdapter() // uses GITHUB_COPILOT_TOKEN, falling back to GITHUB_TOKEN
20
+ * const response = await adapter.chat(messages, {
21
+ * model: 'claude-sonnet-4',
22
+ * maxTokens: 4096,
23
+ * })
24
+ * ```
25
+ */
26
+
27
+ import OpenAI from 'openai'
28
+ import type {
29
+ ChatCompletionChunk,
30
+ } from 'openai/resources/chat/completions/index.js'
31
+
32
+ import type {
33
+ ContentBlock,
34
+ LLMAdapter,
35
+ LLMChatOptions,
36
+ LLMMessage,
37
+ LLMResponse,
38
+ LLMStreamOptions,
39
+ LLMToolDef,
40
+ StreamEvent,
41
+ TextBlock,
42
+ ToolUseBlock,
43
+ } from '../types.js'
44
+
45
+ import {
46
+ toOpenAITool,
47
+ fromOpenAICompletion,
48
+ normalizeFinishReason,
49
+ buildOpenAIMessageList,
50
+ } from './openai-common.js'
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Copilot auth — OAuth2 device flow + token exchange
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const COPILOT_TOKEN_URL = 'https://api.github.com/copilot_internal/v2/token'
57
+ const DEVICE_CODE_URL = 'https://github.com/login/device/code'
58
+ const POLL_URL = 'https://github.com/login/oauth/access_token'
59
+ const COPILOT_CLIENT_ID = 'Iv1.b507a08c87ecfe98'
60
+
61
+ const COPILOT_HEADERS: Record<string, string> = {
62
+ 'Copilot-Integration-Id': 'vscode-chat',
63
+ 'Editor-Version': 'vscode/1.100.0',
64
+ 'Editor-Plugin-Version': 'copilot-chat/0.42.2',
65
+ }
66
+
67
+ interface CopilotTokenResponse {
68
+ token: string
69
+ expires_at: number
70
+ }
71
+
72
+ interface DeviceCodeResponse {
73
+ device_code: string
74
+ user_code: string
75
+ verification_uri: string
76
+ interval: number
77
+ expires_in: number
78
+ }
79
+
80
+ interface PollResponse {
81
+ access_token?: string
82
+ error?: string
83
+ error_description?: string
84
+ }
85
+
86
+ /**
87
+ * Callback invoked when the OAuth2 device flow needs the user to authorize.
88
+ * Receives the verification URI and user code. If not provided, defaults to
89
+ * printing them to stdout.
90
+ */
91
+ export type DeviceCodeCallback = (verificationUri: string, userCode: string) => void
92
+
93
+ const defaultDeviceCodeCallback: DeviceCodeCallback = (uri, code) => {
94
+ console.log(`\n┌─────────────────────────────────────────────┐`)
95
+ console.log(`│ GitHub Copilot — Sign in │`)
96
+ console.log(`│ │`)
97
+ console.log(`│ Open: ${uri.padEnd(35)}│`)
98
+ console.log(`│ Code: ${code.padEnd(35)}│`)
99
+ console.log(`└─────────────────────────────────────────────┘\n`)
100
+ }
101
+
102
+ /**
103
+ * Start the GitHub OAuth2 device code flow with the Copilot client ID.
104
+ *
105
+ * Calls `onDeviceCode` with the verification URI and user code, then polls
106
+ * until the user completes authorization. Returns a GitHub OAuth token
107
+ * scoped for Copilot access.
108
+ */
109
+ async function deviceCodeLogin(onDeviceCode: DeviceCodeCallback): Promise<string> {
110
+ // Step 1: Request a device code
111
+ const codeRes = await fetch(DEVICE_CODE_URL, {
112
+ method: 'POST',
113
+ headers: {
114
+ Accept: 'application/json',
115
+ 'Content-Type': 'application/x-www-form-urlencoded',
116
+ },
117
+ body: new URLSearchParams({ client_id: COPILOT_CLIENT_ID, scope: 'copilot' }),
118
+ })
119
+
120
+ if (!codeRes.ok) {
121
+ const body = await codeRes.text().catch(() => '')
122
+ throw new Error(`Device code request failed (${codeRes.status}): ${body}`)
123
+ }
124
+
125
+ const codeData = (await codeRes.json()) as DeviceCodeResponse
126
+
127
+ // Step 2: Prompt the user via callback
128
+ onDeviceCode(codeData.verification_uri, codeData.user_code)
129
+
130
+ // Step 3: Poll for the user to complete auth
131
+ const interval = (codeData.interval || 5) * 1000
132
+ const deadline = Date.now() + codeData.expires_in * 1000
133
+
134
+ while (Date.now() < deadline) {
135
+ await new Promise((resolve) => setTimeout(resolve, interval))
136
+
137
+ const pollRes = await fetch(POLL_URL, {
138
+ method: 'POST',
139
+ headers: {
140
+ Accept: 'application/json',
141
+ 'Content-Type': 'application/x-www-form-urlencoded',
142
+ },
143
+ body: new URLSearchParams({
144
+ client_id: COPILOT_CLIENT_ID,
145
+ device_code: codeData.device_code,
146
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
147
+ }),
148
+ })
149
+
150
+ const pollData = (await pollRes.json()) as PollResponse
151
+
152
+ if (pollData.access_token) {
153
+ console.log('✓ Authenticated with GitHub Copilot\n')
154
+ return pollData.access_token
155
+ }
156
+
157
+ if (pollData.error === 'authorization_pending') continue
158
+ if (pollData.error === 'slow_down') {
159
+ await new Promise((resolve) => setTimeout(resolve, 5000))
160
+ continue
161
+ }
162
+
163
+ throw new Error(
164
+ `OAuth device flow failed: ${pollData.error} — ${pollData.error_description ?? ''}`,
165
+ )
166
+ }
167
+
168
+ throw new Error('Device code expired. Please try again.')
169
+ }
170
+
171
+ /**
172
+ * Exchange a GitHub OAuth token (from the Copilot device flow) for a
173
+ * short-lived Copilot session token.
174
+ *
175
+ * Note: the token exchange endpoint does NOT require the Copilot-specific
176
+ * headers (Editor-Version etc.) — only the chat completions endpoint does.
177
+ */
178
+ async function fetchCopilotToken(githubToken: string): Promise<CopilotTokenResponse> {
179
+ const res = await fetch(COPILOT_TOKEN_URL, {
180
+ method: 'GET',
181
+ headers: {
182
+ Authorization: `token ${githubToken}`,
183
+ Accept: 'application/json',
184
+ 'User-Agent': 'GitHubCopilotChat/0.28.0',
185
+ },
186
+ })
187
+
188
+ if (!res.ok) {
189
+ const body = await res.text().catch(() => '')
190
+ throw new Error(
191
+ `Copilot token exchange failed (${res.status}): ${body || res.statusText}`,
192
+ )
193
+ }
194
+
195
+ return (await res.json()) as CopilotTokenResponse
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Adapter implementation
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /** Options for the {@link CopilotAdapter} constructor. */
203
+ export interface CopilotAdapterOptions {
204
+ /** GitHub OAuth token already scoped for Copilot. Falls back to env vars. */
205
+ apiKey?: string
206
+ /**
207
+ * Callback invoked when the OAuth2 device flow needs user action.
208
+ * Defaults to printing the verification URI and user code to stdout.
209
+ */
210
+ onDeviceCode?: DeviceCodeCallback
211
+ }
212
+
213
+ /**
214
+ * LLM adapter backed by the GitHub Copilot Chat Completions API.
215
+ *
216
+ * Authentication options (tried in order):
217
+ * 1. `apiKey` constructor arg — a GitHub OAuth token already scoped for Copilot
218
+ * 2. `GITHUB_COPILOT_TOKEN` env var
219
+ * 3. `GITHUB_TOKEN` env var
220
+ * 4. Interactive OAuth2 device flow
221
+ *
222
+ * The GitHub token is exchanged for a short-lived Copilot session token, which
223
+ * is cached and auto-refreshed.
224
+ *
225
+ * Thread-safe — a single instance may be shared across concurrent agent runs.
226
+ * Concurrent token refreshes are serialised via an internal mutex.
227
+ */
228
+ export class CopilotAdapter implements LLMAdapter {
229
+ readonly name = 'copilot'
230
+
231
+ #githubToken: string | null
232
+ #cachedToken: string | null = null
233
+ #tokenExpiresAt = 0
234
+ #refreshPromise: Promise<string> | null = null
235
+ readonly #onDeviceCode: DeviceCodeCallback
236
+
237
+ constructor(apiKeyOrOptions?: string | CopilotAdapterOptions) {
238
+ const opts = typeof apiKeyOrOptions === 'string'
239
+ ? { apiKey: apiKeyOrOptions }
240
+ : apiKeyOrOptions ?? {}
241
+
242
+ this.#githubToken = opts.apiKey
243
+ ?? process.env['GITHUB_COPILOT_TOKEN']
244
+ ?? process.env['GITHUB_TOKEN']
245
+ ?? null
246
+ this.#onDeviceCode = opts.onDeviceCode ?? defaultDeviceCodeCallback
247
+ }
248
+
249
+ /**
250
+ * Return a valid Copilot session token, refreshing if necessary.
251
+ * If no GitHub token is available, triggers the interactive device flow.
252
+ * Concurrent calls share a single in-flight refresh to avoid races.
253
+ */
254
+ async #getSessionToken(): Promise<string> {
255
+ const now = Math.floor(Date.now() / 1000)
256
+ if (this.#cachedToken && this.#tokenExpiresAt - 60 > now) {
257
+ return this.#cachedToken
258
+ }
259
+
260
+ // If another call is already refreshing, piggyback on that promise
261
+ if (this.#refreshPromise) {
262
+ return this.#refreshPromise
263
+ }
264
+
265
+ this.#refreshPromise = this.#doRefresh()
266
+ try {
267
+ return await this.#refreshPromise
268
+ } finally {
269
+ this.#refreshPromise = null
270
+ }
271
+ }
272
+
273
+ async #doRefresh(): Promise<string> {
274
+ if (!this.#githubToken) {
275
+ this.#githubToken = await deviceCodeLogin(this.#onDeviceCode)
276
+ }
277
+
278
+ const resp = await fetchCopilotToken(this.#githubToken)
279
+ this.#cachedToken = resp.token
280
+ this.#tokenExpiresAt = resp.expires_at
281
+ return resp.token
282
+ }
283
+
284
+ /** Build a short-lived OpenAI client pointed at the Copilot endpoint. */
285
+ async #createClient(): Promise<OpenAI> {
286
+ const sessionToken = await this.#getSessionToken()
287
+ return new OpenAI({
288
+ apiKey: sessionToken,
289
+ baseURL: 'https://api.githubcopilot.com',
290
+ defaultHeaders: COPILOT_HEADERS,
291
+ })
292
+ }
293
+
294
+ // -------------------------------------------------------------------------
295
+ // chat()
296
+ // -------------------------------------------------------------------------
297
+
298
+ async chat(messages: LLMMessage[], options: LLMChatOptions): Promise<LLMResponse> {
299
+ const client = await this.#createClient()
300
+ const openAIMessages = buildOpenAIMessageList(messages, options.systemPrompt)
301
+
302
+ const completion = await client.chat.completions.create(
303
+ {
304
+ model: options.model,
305
+ messages: openAIMessages,
306
+ max_tokens: options.maxTokens,
307
+ temperature: options.temperature,
308
+ tools: options.tools ? options.tools.map(toOpenAITool) : undefined,
309
+ stream: false,
310
+ },
311
+ {
312
+ signal: options.abortSignal,
313
+ },
314
+ )
315
+
316
+ const toolNames = options.tools?.map(t => t.name)
317
+ return fromOpenAICompletion(completion, toolNames)
318
+ }
319
+
320
+ // -------------------------------------------------------------------------
321
+ // stream()
322
+ // -------------------------------------------------------------------------
323
+
324
+ async *stream(
325
+ messages: LLMMessage[],
326
+ options: LLMStreamOptions,
327
+ ): AsyncIterable<StreamEvent> {
328
+ const client = await this.#createClient()
329
+ const openAIMessages = buildOpenAIMessageList(messages, options.systemPrompt)
330
+
331
+ const streamResponse = await client.chat.completions.create(
332
+ {
333
+ model: options.model,
334
+ messages: openAIMessages,
335
+ max_tokens: options.maxTokens,
336
+ temperature: options.temperature,
337
+ tools: options.tools ? options.tools.map(toOpenAITool) : undefined,
338
+ stream: true,
339
+ stream_options: { include_usage: true },
340
+ },
341
+ {
342
+ signal: options.abortSignal,
343
+ },
344
+ )
345
+
346
+ let completionId = ''
347
+ let completionModel = ''
348
+ let finalFinishReason: string = 'stop'
349
+ let inputTokens = 0
350
+ let outputTokens = 0
351
+ const toolCallBuffers = new Map<
352
+ number,
353
+ { id: string; name: string; argsJson: string }
354
+ >()
355
+ let fullText = ''
356
+
357
+ try {
358
+ for await (const chunk of streamResponse) {
359
+ completionId = chunk.id
360
+ completionModel = chunk.model
361
+
362
+ if (chunk.usage !== null && chunk.usage !== undefined) {
363
+ inputTokens = chunk.usage.prompt_tokens
364
+ outputTokens = chunk.usage.completion_tokens
365
+ }
366
+
367
+ const choice: ChatCompletionChunk.Choice | undefined = chunk.choices[0]
368
+ if (choice === undefined) continue
369
+
370
+ const delta = choice.delta
371
+
372
+ if (delta.content !== null && delta.content !== undefined) {
373
+ fullText += delta.content
374
+ const textEvent: StreamEvent = { type: 'text', data: delta.content }
375
+ yield textEvent
376
+ }
377
+
378
+ for (const toolCallDelta of delta.tool_calls ?? []) {
379
+ const idx = toolCallDelta.index
380
+
381
+ if (!toolCallBuffers.has(idx)) {
382
+ toolCallBuffers.set(idx, {
383
+ id: toolCallDelta.id ?? '',
384
+ name: toolCallDelta.function?.name ?? '',
385
+ argsJson: '',
386
+ })
387
+ }
388
+
389
+ const buf = toolCallBuffers.get(idx)
390
+ if (buf !== undefined) {
391
+ if (toolCallDelta.id) buf.id = toolCallDelta.id
392
+ if (toolCallDelta.function?.name) buf.name = toolCallDelta.function.name
393
+ if (toolCallDelta.function?.arguments) {
394
+ buf.argsJson += toolCallDelta.function.arguments
395
+ }
396
+ }
397
+ }
398
+
399
+ if (choice.finish_reason !== null && choice.finish_reason !== undefined) {
400
+ finalFinishReason = choice.finish_reason
401
+ }
402
+ }
403
+
404
+ const finalToolUseBlocks: ToolUseBlock[] = []
405
+ for (const buf of toolCallBuffers.values()) {
406
+ let parsedInput: Record<string, unknown> = {}
407
+ try {
408
+ const parsed: unknown = JSON.parse(buf.argsJson)
409
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
410
+ parsedInput = parsed as Record<string, unknown>
411
+ }
412
+ } catch {
413
+ // Malformed JSON — surface as empty object.
414
+ }
415
+
416
+ const toolUseBlock: ToolUseBlock = {
417
+ type: 'tool_use',
418
+ id: buf.id,
419
+ name: buf.name,
420
+ input: parsedInput,
421
+ }
422
+ finalToolUseBlocks.push(toolUseBlock)
423
+ const toolUseEvent: StreamEvent = { type: 'tool_use', data: toolUseBlock }
424
+ yield toolUseEvent
425
+ }
426
+
427
+ const doneContent: ContentBlock[] = []
428
+ if (fullText.length > 0) {
429
+ const textBlock: TextBlock = { type: 'text', text: fullText }
430
+ doneContent.push(textBlock)
431
+ }
432
+ doneContent.push(...finalToolUseBlocks)
433
+
434
+ const finalResponse: LLMResponse = {
435
+ id: completionId,
436
+ content: doneContent,
437
+ model: completionModel,
438
+ stop_reason: normalizeFinishReason(finalFinishReason),
439
+ usage: { input_tokens: inputTokens, output_tokens: outputTokens },
440
+ }
441
+
442
+ const doneEvent: StreamEvent = { type: 'done', data: finalResponse }
443
+ yield doneEvent
444
+ } catch (err) {
445
+ const error = err instanceof Error ? err : new Error(String(err))
446
+ const errorEvent: StreamEvent = { type: 'error', data: error }
447
+ yield errorEvent
448
+ }
449
+ }
450
+ }
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // Premium request multipliers
454
+ // ---------------------------------------------------------------------------
455
+
456
+ /**
457
+ * Model metadata used for display names, context windows, and premium request
458
+ * multiplier lookup.
459
+ */
460
+ export interface CopilotModelInfo {
461
+ readonly id: string
462
+ readonly name: string
463
+ readonly contextWindow: number
464
+ }
465
+
466
+ /**
467
+ * Return the premium-request multiplier for a Copilot model.
468
+ *
469
+ * Copilot doesn't charge per-token — instead each request costs
470
+ * `multiplier × 1 premium request` from the user's monthly allowance.
471
+ * A multiplier of 0 means the model is included at no premium cost.
472
+ *
473
+ * Based on https://docs.github.com/en/copilot/reference/ai-models/supported-models#model-multipliers
474
+ */
475
+ export function getCopilotMultiplier(modelId: string): number {
476
+ const id = modelId.toLowerCase()
477
+
478
+ // 0x — included models
479
+ if (id.includes('gpt-4.1')) return 0
480
+ if (id.includes('gpt-4o')) return 0
481
+ if (id.includes('gpt-5-mini') || id.includes('gpt-5 mini')) return 0
482
+ if (id.includes('raptor')) return 0
483
+ if (id.includes('goldeneye')) return 0
484
+
485
+ // 0.25x
486
+ if (id.includes('grok')) return 0.25
487
+
488
+ // 0.33x
489
+ if (id.includes('claude-haiku')) return 0.33
490
+ if (id.includes('gemini-3-flash') || id.includes('gemini-3.0-flash')) return 0.33
491
+ if (id.includes('gpt-5.1-codex-mini')) return 0.33
492
+ if (id.includes('gpt-5.4-mini') || id.includes('gpt-5.4 mini')) return 0.33
493
+
494
+ // 1x — standard premium
495
+ if (id.includes('claude-sonnet')) return 1
496
+ if (id.includes('gemini-2.5-pro')) return 1
497
+ if (id.includes('gemini-3-pro') || id.includes('gemini-3.0-pro')) return 1
498
+ if (id.includes('gemini-3.1-pro')) return 1
499
+ if (id.includes('gpt-5.1')) return 1
500
+ if (id.includes('gpt-5.2')) return 1
501
+ if (id.includes('gpt-5.3')) return 1
502
+ if (id.includes('gpt-5.4')) return 1
503
+
504
+ // 30x — fast opus
505
+ if (id.includes('claude-opus') && id.includes('fast')) return 30
506
+
507
+ // 3x — opus
508
+ if (id.includes('claude-opus')) return 3
509
+
510
+ return 1
511
+ }
512
+
513
+ /**
514
+ * Human-readable string describing the premium-request cost for a model.
515
+ *
516
+ * Examples: `"included (0×)"`, `"1× premium request"`, `"0.33× premium request"`
517
+ */
518
+ export function formatCopilotMultiplier(multiplier: number): string {
519
+ if (multiplier === 0) return 'included (0×)'
520
+ if (Number.isInteger(multiplier)) return `${multiplier}× premium request`
521
+ return `${multiplier}× premium request`
522
+ }
523
+
524
+ /** Known model metadata for Copilot-available models. */
525
+ export const COPILOT_MODELS: readonly CopilotModelInfo[] = [
526
+ { id: 'gpt-4.1', name: 'GPT-4.1', contextWindow: 128_000 },
527
+ { id: 'gpt-4o', name: 'GPT-4o', contextWindow: 128_000 },
528
+ { id: 'gpt-5-mini', name: 'GPT-5 mini', contextWindow: 200_000 },
529
+ { id: 'gpt-5.1', name: 'GPT-5.1', contextWindow: 200_000 },
530
+ { id: 'gpt-5.1-codex', name: 'GPT-5.1-Codex', contextWindow: 200_000 },
531
+ { id: 'gpt-5.1-codex-mini', name: 'GPT-5.1-Codex-Mini', contextWindow: 200_000 },
532
+ { id: 'gpt-5.1-codex-max', name: 'GPT-5.1-Codex-Max', contextWindow: 200_000 },
533
+ { id: 'gpt-5.2', name: 'GPT-5.2', contextWindow: 200_000 },
534
+ { id: 'gpt-5.2-codex', name: 'GPT-5.2-Codex', contextWindow: 200_000 },
535
+ { id: 'gpt-5.3-codex', name: 'GPT-5.3-Codex', contextWindow: 200_000 },
536
+ { id: 'gpt-5.4', name: 'GPT-5.4', contextWindow: 200_000 },
537
+ { id: 'gpt-5.4-mini', name: 'GPT-5.4 mini', contextWindow: 200_000 },
538
+ { id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5', contextWindow: 200_000 },
539
+ { id: 'claude-opus-4.5', name: 'Claude Opus 4.5', contextWindow: 200_000 },
540
+ { id: 'claude-opus-4.6', name: 'Claude Opus 4.6', contextWindow: 200_000 },
541
+ { id: 'claude-opus-4.6-fast', name: 'Claude Opus 4.6 (fast)', contextWindow: 200_000 },
542
+ { id: 'claude-sonnet-4', name: 'Claude Sonnet 4', contextWindow: 200_000 },
543
+ { id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5', contextWindow: 200_000 },
544
+ { id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6', contextWindow: 200_000 },
545
+ { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', contextWindow: 1_000_000 },
546
+ { id: 'gemini-3-flash', name: 'Gemini 3 Flash', contextWindow: 1_000_000 },
547
+ { id: 'gemini-3-pro', name: 'Gemini 3 Pro', contextWindow: 1_000_000 },
548
+ { id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', contextWindow: 1_000_000 },
549
+ { id: 'grok-code-fast-1', name: 'Grok Code Fast 1', contextWindow: 128_000 },
550
+ { id: 'raptor-mini', name: 'Raptor mini', contextWindow: 128_000 },
551
+ { id: 'goldeneye', name: 'Goldeneye', contextWindow: 128_000 },
552
+ ] as const