@jackchen_me/open-multi-agent 0.1.0 → 0.2.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 (84) 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 +72 -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 +73 -140
  10. package/README_zh.md +217 -0
  11. package/SECURITY.md +17 -0
  12. package/dist/agent/agent.d.ts +5 -0
  13. package/dist/agent/agent.d.ts.map +1 -1
  14. package/dist/agent/agent.js +90 -3
  15. package/dist/agent/agent.js.map +1 -1
  16. package/dist/agent/structured-output.d.ts +33 -0
  17. package/dist/agent/structured-output.d.ts.map +1 -0
  18. package/dist/agent/structured-output.js +116 -0
  19. package/dist/agent/structured-output.js.map +1 -0
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/llm/adapter.d.ts +9 -4
  25. package/dist/llm/adapter.d.ts.map +1 -1
  26. package/dist/llm/adapter.js +17 -5
  27. package/dist/llm/adapter.js.map +1 -1
  28. package/dist/llm/anthropic.d.ts +1 -1
  29. package/dist/llm/anthropic.d.ts.map +1 -1
  30. package/dist/llm/anthropic.js +2 -1
  31. package/dist/llm/anthropic.js.map +1 -1
  32. package/dist/llm/copilot.d.ts +92 -0
  33. package/dist/llm/copilot.d.ts.map +1 -0
  34. package/dist/llm/copilot.js +426 -0
  35. package/dist/llm/copilot.js.map +1 -0
  36. package/dist/llm/openai-common.d.ts +47 -0
  37. package/dist/llm/openai-common.d.ts.map +1 -0
  38. package/dist/llm/openai-common.js +209 -0
  39. package/dist/llm/openai-common.js.map +1 -0
  40. package/dist/llm/openai.d.ts +1 -1
  41. package/dist/llm/openai.d.ts.map +1 -1
  42. package/dist/llm/openai.js +3 -224
  43. package/dist/llm/openai.js.map +1 -1
  44. package/dist/orchestrator/orchestrator.d.ts +25 -1
  45. package/dist/orchestrator/orchestrator.d.ts.map +1 -1
  46. package/dist/orchestrator/orchestrator.js +130 -37
  47. package/dist/orchestrator/orchestrator.js.map +1 -1
  48. package/dist/task/queue.js +1 -1
  49. package/dist/task/queue.js.map +1 -1
  50. package/dist/task/task.d.ts +3 -0
  51. package/dist/task/task.d.ts.map +1 -1
  52. package/dist/task/task.js +5 -1
  53. package/dist/task/task.js.map +1 -1
  54. package/dist/team/messaging.d.ts.map +1 -1
  55. package/dist/team/messaging.js +2 -1
  56. package/dist/team/messaging.js.map +1 -1
  57. package/dist/types.d.ts +31 -3
  58. package/dist/types.d.ts.map +1 -1
  59. package/examples/05-copilot-test.ts +49 -0
  60. package/examples/06-local-model.ts +199 -0
  61. package/examples/07-fan-out-aggregate.ts +209 -0
  62. package/examples/08-gemma4-local.ts +203 -0
  63. package/examples/09-gemma4-auto-orchestration.ts +162 -0
  64. package/package.json +4 -3
  65. package/src/agent/agent.ts +115 -6
  66. package/src/agent/structured-output.ts +126 -0
  67. package/src/index.ts +2 -1
  68. package/src/llm/adapter.ts +18 -5
  69. package/src/llm/anthropic.ts +2 -1
  70. package/src/llm/copilot.ts +551 -0
  71. package/src/llm/openai-common.ts +255 -0
  72. package/src/llm/openai.ts +8 -258
  73. package/src/orchestrator/orchestrator.ts +164 -38
  74. package/src/task/queue.ts +1 -1
  75. package/src/task/task.ts +8 -1
  76. package/src/team/messaging.ts +3 -1
  77. package/src/types.ts +31 -2
  78. package/tests/semaphore.test.ts +57 -0
  79. package/tests/shared-memory.test.ts +122 -0
  80. package/tests/structured-output.test.ts +331 -0
  81. package/tests/task-queue.test.ts +244 -0
  82. package/tests/task-retry.test.ts +368 -0
  83. package/tests/task-utils.test.ts +155 -0
  84. package/tests/tool-executor.test.ts +193 -0
@@ -0,0 +1,551 @@
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
+ return fromOpenAICompletion(completion)
317
+ }
318
+
319
+ // -------------------------------------------------------------------------
320
+ // stream()
321
+ // -------------------------------------------------------------------------
322
+
323
+ async *stream(
324
+ messages: LLMMessage[],
325
+ options: LLMStreamOptions,
326
+ ): AsyncIterable<StreamEvent> {
327
+ const client = await this.#createClient()
328
+ const openAIMessages = buildOpenAIMessageList(messages, options.systemPrompt)
329
+
330
+ const streamResponse = await client.chat.completions.create(
331
+ {
332
+ model: options.model,
333
+ messages: openAIMessages,
334
+ max_tokens: options.maxTokens,
335
+ temperature: options.temperature,
336
+ tools: options.tools ? options.tools.map(toOpenAITool) : undefined,
337
+ stream: true,
338
+ stream_options: { include_usage: true },
339
+ },
340
+ {
341
+ signal: options.abortSignal,
342
+ },
343
+ )
344
+
345
+ let completionId = ''
346
+ let completionModel = ''
347
+ let finalFinishReason: string = 'stop'
348
+ let inputTokens = 0
349
+ let outputTokens = 0
350
+ const toolCallBuffers = new Map<
351
+ number,
352
+ { id: string; name: string; argsJson: string }
353
+ >()
354
+ let fullText = ''
355
+
356
+ try {
357
+ for await (const chunk of streamResponse) {
358
+ completionId = chunk.id
359
+ completionModel = chunk.model
360
+
361
+ if (chunk.usage !== null && chunk.usage !== undefined) {
362
+ inputTokens = chunk.usage.prompt_tokens
363
+ outputTokens = chunk.usage.completion_tokens
364
+ }
365
+
366
+ const choice: ChatCompletionChunk.Choice | undefined = chunk.choices[0]
367
+ if (choice === undefined) continue
368
+
369
+ const delta = choice.delta
370
+
371
+ if (delta.content !== null && delta.content !== undefined) {
372
+ fullText += delta.content
373
+ const textEvent: StreamEvent = { type: 'text', data: delta.content }
374
+ yield textEvent
375
+ }
376
+
377
+ for (const toolCallDelta of delta.tool_calls ?? []) {
378
+ const idx = toolCallDelta.index
379
+
380
+ if (!toolCallBuffers.has(idx)) {
381
+ toolCallBuffers.set(idx, {
382
+ id: toolCallDelta.id ?? '',
383
+ name: toolCallDelta.function?.name ?? '',
384
+ argsJson: '',
385
+ })
386
+ }
387
+
388
+ const buf = toolCallBuffers.get(idx)
389
+ if (buf !== undefined) {
390
+ if (toolCallDelta.id) buf.id = toolCallDelta.id
391
+ if (toolCallDelta.function?.name) buf.name = toolCallDelta.function.name
392
+ if (toolCallDelta.function?.arguments) {
393
+ buf.argsJson += toolCallDelta.function.arguments
394
+ }
395
+ }
396
+ }
397
+
398
+ if (choice.finish_reason !== null && choice.finish_reason !== undefined) {
399
+ finalFinishReason = choice.finish_reason
400
+ }
401
+ }
402
+
403
+ const finalToolUseBlocks: ToolUseBlock[] = []
404
+ for (const buf of toolCallBuffers.values()) {
405
+ let parsedInput: Record<string, unknown> = {}
406
+ try {
407
+ const parsed: unknown = JSON.parse(buf.argsJson)
408
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
409
+ parsedInput = parsed as Record<string, unknown>
410
+ }
411
+ } catch {
412
+ // Malformed JSON — surface as empty object.
413
+ }
414
+
415
+ const toolUseBlock: ToolUseBlock = {
416
+ type: 'tool_use',
417
+ id: buf.id,
418
+ name: buf.name,
419
+ input: parsedInput,
420
+ }
421
+ finalToolUseBlocks.push(toolUseBlock)
422
+ const toolUseEvent: StreamEvent = { type: 'tool_use', data: toolUseBlock }
423
+ yield toolUseEvent
424
+ }
425
+
426
+ const doneContent: ContentBlock[] = []
427
+ if (fullText.length > 0) {
428
+ const textBlock: TextBlock = { type: 'text', text: fullText }
429
+ doneContent.push(textBlock)
430
+ }
431
+ doneContent.push(...finalToolUseBlocks)
432
+
433
+ const finalResponse: LLMResponse = {
434
+ id: completionId,
435
+ content: doneContent,
436
+ model: completionModel,
437
+ stop_reason: normalizeFinishReason(finalFinishReason),
438
+ usage: { input_tokens: inputTokens, output_tokens: outputTokens },
439
+ }
440
+
441
+ const doneEvent: StreamEvent = { type: 'done', data: finalResponse }
442
+ yield doneEvent
443
+ } catch (err) {
444
+ const error = err instanceof Error ? err : new Error(String(err))
445
+ const errorEvent: StreamEvent = { type: 'error', data: error }
446
+ yield errorEvent
447
+ }
448
+ }
449
+ }
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // Premium request multipliers
453
+ // ---------------------------------------------------------------------------
454
+
455
+ /**
456
+ * Model metadata used for display names, context windows, and premium request
457
+ * multiplier lookup.
458
+ */
459
+ export interface CopilotModelInfo {
460
+ readonly id: string
461
+ readonly name: string
462
+ readonly contextWindow: number
463
+ }
464
+
465
+ /**
466
+ * Return the premium-request multiplier for a Copilot model.
467
+ *
468
+ * Copilot doesn't charge per-token — instead each request costs
469
+ * `multiplier × 1 premium request` from the user's monthly allowance.
470
+ * A multiplier of 0 means the model is included at no premium cost.
471
+ *
472
+ * Based on https://docs.github.com/en/copilot/reference/ai-models/supported-models#model-multipliers
473
+ */
474
+ export function getCopilotMultiplier(modelId: string): number {
475
+ const id = modelId.toLowerCase()
476
+
477
+ // 0x — included models
478
+ if (id.includes('gpt-4.1')) return 0
479
+ if (id.includes('gpt-4o')) return 0
480
+ if (id.includes('gpt-5-mini') || id.includes('gpt-5 mini')) return 0
481
+ if (id.includes('raptor')) return 0
482
+ if (id.includes('goldeneye')) return 0
483
+
484
+ // 0.25x
485
+ if (id.includes('grok')) return 0.25
486
+
487
+ // 0.33x
488
+ if (id.includes('claude-haiku')) return 0.33
489
+ if (id.includes('gemini-3-flash') || id.includes('gemini-3.0-flash')) return 0.33
490
+ if (id.includes('gpt-5.1-codex-mini')) return 0.33
491
+ if (id.includes('gpt-5.4-mini') || id.includes('gpt-5.4 mini')) return 0.33
492
+
493
+ // 1x — standard premium
494
+ if (id.includes('claude-sonnet')) return 1
495
+ if (id.includes('gemini-2.5-pro')) return 1
496
+ if (id.includes('gemini-3-pro') || id.includes('gemini-3.0-pro')) return 1
497
+ if (id.includes('gemini-3.1-pro')) return 1
498
+ if (id.includes('gpt-5.1')) return 1
499
+ if (id.includes('gpt-5.2')) return 1
500
+ if (id.includes('gpt-5.3')) return 1
501
+ if (id.includes('gpt-5.4')) return 1
502
+
503
+ // 30x — fast opus
504
+ if (id.includes('claude-opus') && id.includes('fast')) return 30
505
+
506
+ // 3x — opus
507
+ if (id.includes('claude-opus')) return 3
508
+
509
+ return 1
510
+ }
511
+
512
+ /**
513
+ * Human-readable string describing the premium-request cost for a model.
514
+ *
515
+ * Examples: `"included (0×)"`, `"1× premium request"`, `"0.33× premium request"`
516
+ */
517
+ export function formatCopilotMultiplier(multiplier: number): string {
518
+ if (multiplier === 0) return 'included (0×)'
519
+ if (Number.isInteger(multiplier)) return `${multiplier}× premium request`
520
+ return `${multiplier}× premium request`
521
+ }
522
+
523
+ /** Known model metadata for Copilot-available models. */
524
+ export const COPILOT_MODELS: readonly CopilotModelInfo[] = [
525
+ { id: 'gpt-4.1', name: 'GPT-4.1', contextWindow: 128_000 },
526
+ { id: 'gpt-4o', name: 'GPT-4o', contextWindow: 128_000 },
527
+ { id: 'gpt-5-mini', name: 'GPT-5 mini', contextWindow: 200_000 },
528
+ { id: 'gpt-5.1', name: 'GPT-5.1', contextWindow: 200_000 },
529
+ { id: 'gpt-5.1-codex', name: 'GPT-5.1-Codex', contextWindow: 200_000 },
530
+ { id: 'gpt-5.1-codex-mini', name: 'GPT-5.1-Codex-Mini', contextWindow: 200_000 },
531
+ { id: 'gpt-5.1-codex-max', name: 'GPT-5.1-Codex-Max', contextWindow: 200_000 },
532
+ { id: 'gpt-5.2', name: 'GPT-5.2', contextWindow: 200_000 },
533
+ { id: 'gpt-5.2-codex', name: 'GPT-5.2-Codex', contextWindow: 200_000 },
534
+ { id: 'gpt-5.3-codex', name: 'GPT-5.3-Codex', contextWindow: 200_000 },
535
+ { id: 'gpt-5.4', name: 'GPT-5.4', contextWindow: 200_000 },
536
+ { id: 'gpt-5.4-mini', name: 'GPT-5.4 mini', contextWindow: 200_000 },
537
+ { id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5', contextWindow: 200_000 },
538
+ { id: 'claude-opus-4.5', name: 'Claude Opus 4.5', contextWindow: 200_000 },
539
+ { id: 'claude-opus-4.6', name: 'Claude Opus 4.6', contextWindow: 200_000 },
540
+ { id: 'claude-opus-4.6-fast', name: 'Claude Opus 4.6 (fast)', contextWindow: 200_000 },
541
+ { id: 'claude-sonnet-4', name: 'Claude Sonnet 4', contextWindow: 200_000 },
542
+ { id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5', contextWindow: 200_000 },
543
+ { id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6', contextWindow: 200_000 },
544
+ { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', contextWindow: 1_000_000 },
545
+ { id: 'gemini-3-flash', name: 'Gemini 3 Flash', contextWindow: 1_000_000 },
546
+ { id: 'gemini-3-pro', name: 'Gemini 3 Pro', contextWindow: 1_000_000 },
547
+ { id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', contextWindow: 1_000_000 },
548
+ { id: 'grok-code-fast-1', name: 'Grok Code Fast 1', contextWindow: 128_000 },
549
+ { id: 'raptor-mini', name: 'Raptor mini', contextWindow: 128_000 },
550
+ { id: 'goldeneye', name: 'Goldeneye', contextWindow: 128_000 },
551
+ ] as const