@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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +40 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
- package/.github/pull_request_template.md +14 -0
- package/.github/workflows/ci.yml +23 -0
- package/CLAUDE.md +72 -0
- package/CODE_OF_CONDUCT.md +48 -0
- package/CONTRIBUTING.md +72 -0
- package/DECISIONS.md +43 -0
- package/README.md +73 -140
- package/README_zh.md +217 -0
- package/SECURITY.md +17 -0
- package/dist/agent/agent.d.ts +5 -0
- package/dist/agent/agent.d.ts.map +1 -1
- package/dist/agent/agent.js +90 -3
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/structured-output.d.ts +33 -0
- package/dist/agent/structured-output.d.ts.map +1 -0
- package/dist/agent/structured-output.js +116 -0
- package/dist/agent/structured-output.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/llm/adapter.d.ts +9 -4
- package/dist/llm/adapter.d.ts.map +1 -1
- package/dist/llm/adapter.js +17 -5
- package/dist/llm/adapter.js.map +1 -1
- package/dist/llm/anthropic.d.ts +1 -1
- package/dist/llm/anthropic.d.ts.map +1 -1
- package/dist/llm/anthropic.js +2 -1
- package/dist/llm/anthropic.js.map +1 -1
- package/dist/llm/copilot.d.ts +92 -0
- package/dist/llm/copilot.d.ts.map +1 -0
- package/dist/llm/copilot.js +426 -0
- package/dist/llm/copilot.js.map +1 -0
- package/dist/llm/openai-common.d.ts +47 -0
- package/dist/llm/openai-common.d.ts.map +1 -0
- package/dist/llm/openai-common.js +209 -0
- package/dist/llm/openai-common.js.map +1 -0
- package/dist/llm/openai.d.ts +1 -1
- package/dist/llm/openai.d.ts.map +1 -1
- package/dist/llm/openai.js +3 -224
- package/dist/llm/openai.js.map +1 -1
- package/dist/orchestrator/orchestrator.d.ts +25 -1
- package/dist/orchestrator/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator/orchestrator.js +130 -37
- package/dist/orchestrator/orchestrator.js.map +1 -1
- package/dist/task/queue.js +1 -1
- package/dist/task/queue.js.map +1 -1
- package/dist/task/task.d.ts +3 -0
- package/dist/task/task.d.ts.map +1 -1
- package/dist/task/task.js +5 -1
- package/dist/task/task.js.map +1 -1
- package/dist/team/messaging.d.ts.map +1 -1
- package/dist/team/messaging.js +2 -1
- package/dist/team/messaging.js.map +1 -1
- package/dist/types.d.ts +31 -3
- package/dist/types.d.ts.map +1 -1
- package/examples/05-copilot-test.ts +49 -0
- package/examples/06-local-model.ts +199 -0
- package/examples/07-fan-out-aggregate.ts +209 -0
- package/examples/08-gemma4-local.ts +203 -0
- package/examples/09-gemma4-auto-orchestration.ts +162 -0
- package/package.json +4 -3
- package/src/agent/agent.ts +115 -6
- package/src/agent/structured-output.ts +126 -0
- package/src/index.ts +2 -1
- package/src/llm/adapter.ts +18 -5
- package/src/llm/anthropic.ts +2 -1
- package/src/llm/copilot.ts +551 -0
- package/src/llm/openai-common.ts +255 -0
- package/src/llm/openai.ts +8 -258
- package/src/orchestrator/orchestrator.ts +164 -38
- package/src/task/queue.ts +1 -1
- package/src/task/task.ts +8 -1
- package/src/team/messaging.ts +3 -1
- package/src/types.ts +31 -2
- package/tests/semaphore.test.ts +57 -0
- package/tests/shared-memory.test.ts +122 -0
- package/tests/structured-output.test.ts +331 -0
- package/tests/task-queue.test.ts +244 -0
- package/tests/task-retry.test.ts +368 -0
- package/tests/task-utils.test.ts +155 -0
- 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
|