734 lines
24 KiB
TypeScript
734 lines
24 KiB
TypeScript
/**
|
|
* @fileoverview GitHub Copilot adapter implementing {@link LLMAdapter}.
|
|
*
|
|
* ## Authentication
|
|
*
|
|
* GitHub Copilot requires a GitHub OAuth token. Resolution order:
|
|
* 1. `token` constructor argument
|
|
* 2. `GITHUB_COPILOT_TOKEN` environment variable
|
|
* 3. `GITHUB_TOKEN` environment variable
|
|
* 4. `~/.config/github-copilot/hosts.json` (written by `:Copilot setup` / `gh auth login`)
|
|
*
|
|
* If no token is found, the constructor throws. Run the interactive Device
|
|
* Authorization Flow with {@link CopilotAdapter.authenticate} — this mirrors
|
|
* exactly what `:Copilot setup` does in copilot.vim: it prints a one-time
|
|
* code, opens GitHub in the browser, polls for confirmation, then saves the
|
|
* token to `~/.config/github-copilot/hosts.json`.
|
|
*
|
|
* ## Internal token exchange
|
|
*
|
|
* Each GitHub OAuth token is exchanged on-demand for a short-lived Copilot
|
|
* API bearer token via `GET https://api.github.com/copilot_internal/v2/token`.
|
|
* This token is cached in memory and auto-refreshed 60 seconds before it
|
|
* expires, so callers never need to manage it.
|
|
*
|
|
* ## Wire format
|
|
*
|
|
* The Copilot Chat API (`https://api.githubcopilot.com/chat/completions`) is
|
|
* OpenAI-compatible. Message conversion reuses the same rules as
|
|
* {@link OpenAIAdapter}.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Authenticate once (writes to ~/.config/github-copilot/hosts.json)
|
|
* await CopilotAdapter.authenticate()
|
|
*
|
|
* // Then use normally — token is read from hosts.json automatically
|
|
* const adapter = new CopilotAdapter()
|
|
* const response = await adapter.chat(messages, { model: 'gpt-4o' })
|
|
* ```
|
|
*/
|
|
|
|
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
import { homedir } from 'node:os'
|
|
import { join, dirname } from 'node:path'
|
|
|
|
import type {
|
|
ContentBlock,
|
|
LLMAdapter,
|
|
LLMChatOptions,
|
|
LLMMessage,
|
|
LLMResponse,
|
|
LLMStreamOptions,
|
|
LLMToolDef,
|
|
StreamEvent,
|
|
TextBlock,
|
|
ToolUseBlock,
|
|
} from '../types.js'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** OAuth App client ID used by the VS Code Copilot extension (public). */
|
|
const GITHUB_CLIENT_ID = 'Iv1.b507a08c87ecfe98'
|
|
|
|
const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code'
|
|
const GITHUB_ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token'
|
|
const COPILOT_TOKEN_URL = 'https://api.github.com/copilot_internal/v2/token'
|
|
const COPILOT_CHAT_URL = 'https://api.githubcopilot.com/chat/completions'
|
|
|
|
/** Editor headers expected by the Copilot API. */
|
|
const EDITOR_HEADERS = {
|
|
'Editor-Version': 'vscode/1.95.0',
|
|
'Editor-Plugin-Version': 'copilot/1.0',
|
|
'Copilot-Integration-Id': 'vscode-chat',
|
|
'User-Agent': 'open-multi-agent',
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Token file helpers (mirrors copilot.vim's hosts.json location)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface HostsJson {
|
|
[host: string]: {
|
|
oauth_token?: string
|
|
user?: string
|
|
}
|
|
}
|
|
|
|
/** Return the path to the GitHub Copilot hosts file. */
|
|
function hostsFilePath(): string {
|
|
const xdgConfig = process.env['XDG_CONFIG_HOME']
|
|
const base = xdgConfig ?? join(homedir(), '.config')
|
|
return join(base, 'github-copilot', 'hosts.json')
|
|
}
|
|
|
|
/** Read the stored GitHub OAuth token from the copilot.vim hosts file. */
|
|
function readStoredToken(): string | undefined {
|
|
try {
|
|
const raw = readFileSync(hostsFilePath(), 'utf8')
|
|
const data: unknown = JSON.parse(raw)
|
|
if (data !== null && typeof data === 'object') {
|
|
const hosts = data as HostsJson
|
|
const entry = hosts['github.com']
|
|
if (entry?.oauth_token) return entry.oauth_token
|
|
}
|
|
} catch {
|
|
// File not found or malformed — not an error.
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/** Persist an OAuth token to the copilot.vim hosts file. */
|
|
function writeStoredToken(token: string, user: string): void {
|
|
const filePath = hostsFilePath()
|
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
|
|
let existing: HostsJson = {}
|
|
try {
|
|
const raw = readFileSync(filePath, 'utf8')
|
|
const parsed: unknown = JSON.parse(raw)
|
|
if (parsed !== null && typeof parsed === 'object') {
|
|
existing = parsed as HostsJson
|
|
}
|
|
} catch {
|
|
// File does not exist yet — start fresh.
|
|
}
|
|
|
|
existing['github.com'] = { oauth_token: token, user }
|
|
writeFileSync(filePath, JSON.stringify(existing, null, 2) + '\n', 'utf8')
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Copilot token exchange
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface CopilotTokenResponse {
|
|
token: string
|
|
expires_at: number
|
|
}
|
|
|
|
async function fetchCopilotToken(githubToken: string): Promise<CopilotTokenResponse> {
|
|
const res = await fetch(COPILOT_TOKEN_URL, {
|
|
headers: {
|
|
Authorization: `token ${githubToken}`,
|
|
...EDITOR_HEADERS,
|
|
},
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => res.statusText)
|
|
throw new Error(`Copilot token exchange failed (${res.status}): ${body}`)
|
|
}
|
|
|
|
return (await res.json()) as CopilotTokenResponse
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Device Authorization Flow (mirrors :Copilot setup in copilot.vim)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface DeviceCodeResponse {
|
|
device_code: string
|
|
user_code: string
|
|
verification_uri: string
|
|
expires_in: number
|
|
interval: number
|
|
}
|
|
|
|
interface AccessTokenResponse {
|
|
access_token?: string
|
|
error?: string
|
|
error_description?: string
|
|
}
|
|
|
|
interface GitHubUser {
|
|
login: string
|
|
}
|
|
|
|
/**
|
|
* Run the GitHub Device Authorization Flow and return the OAuth access token.
|
|
*
|
|
* This is the same flow that `:Copilot setup` in copilot.vim performs:
|
|
* 1. Request a device code
|
|
* 2. Display the user code and open (or print) the verification URL
|
|
* 3. Poll until the user authorises the app
|
|
* 4. Return the OAuth token
|
|
*/
|
|
async function runDeviceFlow(
|
|
onPrompt: (userCode: string, verificationUri: string) => void,
|
|
): Promise<string> {
|
|
// Step 1 — request device + user code
|
|
const dcRes = await fetch(GITHUB_DEVICE_CODE_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
body: `client_id=${GITHUB_CLIENT_ID}&scope=read:user`,
|
|
})
|
|
|
|
if (!dcRes.ok) {
|
|
throw new Error(`Device code request failed: ${dcRes.statusText}`)
|
|
}
|
|
|
|
const dc: DeviceCodeResponse = (await dcRes.json()) as DeviceCodeResponse
|
|
|
|
// Step 2 — prompt the user
|
|
onPrompt(dc.user_code, dc.verification_uri)
|
|
|
|
// Step 3 — poll for the access token
|
|
const intervalMs = (dc.interval ?? 5) * 1000
|
|
const deadline = Date.now() + dc.expires_in * 1000
|
|
|
|
while (Date.now() < deadline) {
|
|
await new Promise<void>((r) => setTimeout(r, intervalMs))
|
|
|
|
const tokenRes = await fetch(GITHUB_ACCESS_TOKEN_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
body:
|
|
`client_id=${GITHUB_CLIENT_ID}` +
|
|
`&device_code=${dc.device_code}` +
|
|
`&grant_type=urn:ietf:params:oauth:grant-type:device_code`,
|
|
})
|
|
|
|
const body: AccessTokenResponse = (await tokenRes.json()) as AccessTokenResponse
|
|
|
|
if (body.access_token) return body.access_token
|
|
|
|
// Keep polling on authorization_pending / slow_down; throw on all other errors.
|
|
if (body.error && body.error !== 'authorization_pending' && body.error !== 'slow_down') {
|
|
throw new Error(`GitHub OAuth error: ${body.error_description ?? body.error}`)
|
|
}
|
|
}
|
|
|
|
throw new Error('Device authorization flow timed out')
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OpenAI-compatible message conversion (Copilot uses the same wire format)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function toOpenAITool(tool: LLMToolDef): Record<string, unknown> {
|
|
return {
|
|
type: 'function',
|
|
function: {
|
|
name: tool.name,
|
|
description: tool.description,
|
|
parameters: tool.inputSchema as Record<string, unknown>,
|
|
},
|
|
}
|
|
}
|
|
|
|
function buildOpenAIMessages(
|
|
messages: LLMMessage[],
|
|
systemPrompt: string | undefined,
|
|
): Record<string, unknown>[] {
|
|
const result: Record<string, unknown>[] = []
|
|
|
|
if (systemPrompt !== undefined && systemPrompt.length > 0) {
|
|
result.push({ role: 'system', content: systemPrompt })
|
|
}
|
|
|
|
for (const msg of messages) {
|
|
if (msg.role === 'assistant') {
|
|
result.push(assistantToOpenAI(msg))
|
|
} else {
|
|
const toolResults = msg.content.filter((b) => b.type === 'tool_result')
|
|
const others = msg.content.filter((b) => b.type !== 'tool_result')
|
|
|
|
if (others.length > 0) {
|
|
if (others.length === 1 && others[0]?.type === 'text') {
|
|
result.push({ role: 'user', content: (others[0] as TextBlock).text })
|
|
} else {
|
|
const parts = others
|
|
.filter((b): b is TextBlock => b.type === 'text')
|
|
.map((b) => ({ type: 'text', text: b.text }))
|
|
result.push({ role: 'user', content: parts })
|
|
}
|
|
}
|
|
|
|
for (const block of toolResults) {
|
|
if (block.type === 'tool_result') {
|
|
result.push({ role: 'tool', tool_call_id: block.tool_use_id, content: block.content })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
function assistantToOpenAI(msg: LLMMessage): Record<string, unknown> {
|
|
const toolCalls: Record<string, unknown>[] = []
|
|
const texts: string[] = []
|
|
|
|
for (const b of msg.content) {
|
|
if (b.type === 'tool_use') {
|
|
toolCalls.push({
|
|
id: b.id,
|
|
type: 'function',
|
|
function: { name: b.name, arguments: JSON.stringify(b.input) },
|
|
})
|
|
} else if (b.type === 'text') {
|
|
texts.push(b.text)
|
|
}
|
|
}
|
|
|
|
const out: Record<string, unknown> = {
|
|
role: 'assistant',
|
|
content: texts.join('') || null,
|
|
}
|
|
if (toolCalls.length > 0) out['tool_calls'] = toolCalls
|
|
return out
|
|
}
|
|
|
|
function normalizeFinishReason(reason: string | null | undefined): string {
|
|
switch (reason) {
|
|
case 'stop': return 'end_turn'
|
|
case 'tool_calls': return 'tool_use'
|
|
case 'length': return 'max_tokens'
|
|
default: return reason ?? 'end_turn'
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Response conversion (OpenAI → framework)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface OpenAIChoice {
|
|
message?: {
|
|
content?: string | null
|
|
tool_calls?: Array<{
|
|
id: string
|
|
function: { name: string; arguments: string }
|
|
}>
|
|
}
|
|
delta?: {
|
|
content?: string | null
|
|
tool_calls?: Array<{
|
|
index: number
|
|
id?: string
|
|
function?: { name?: string; arguments?: string }
|
|
}>
|
|
}
|
|
finish_reason?: string | null
|
|
}
|
|
|
|
interface OpenAICompletion {
|
|
id: string
|
|
model: string
|
|
choices: OpenAIChoice[]
|
|
usage?: { prompt_tokens?: number; completion_tokens?: number }
|
|
}
|
|
|
|
function fromOpenAICompletion(completion: OpenAICompletion): LLMResponse {
|
|
const choice = completion.choices[0]
|
|
if (choice === undefined) throw new Error('Copilot returned a completion with no choices')
|
|
|
|
const content: ContentBlock[] = []
|
|
const message = choice.message ?? {}
|
|
|
|
if (message.content) {
|
|
content.push({ type: 'text', text: message.content } satisfies TextBlock)
|
|
}
|
|
|
|
for (const tc of message.tool_calls ?? []) {
|
|
let input: Record<string, unknown> = {}
|
|
try {
|
|
const parsed: unknown = JSON.parse(tc.function.arguments)
|
|
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
input = parsed as Record<string, unknown>
|
|
}
|
|
} catch { /* malformed — surface as empty object */ }
|
|
|
|
content.push({
|
|
type: 'tool_use',
|
|
id: tc.id,
|
|
name: tc.function.name,
|
|
input,
|
|
} satisfies ToolUseBlock)
|
|
}
|
|
|
|
return {
|
|
id: completion.id,
|
|
content,
|
|
model: completion.model,
|
|
stop_reason: normalizeFinishReason(choice.finish_reason),
|
|
usage: {
|
|
input_tokens: completion.usage?.prompt_tokens ?? 0,
|
|
output_tokens: completion.usage?.completion_tokens ?? 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Adapter implementation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* LLM adapter backed by the GitHub Copilot Chat API.
|
|
*
|
|
* The Copilot Chat API is OpenAI-compatible, supporting the same models
|
|
* available in GitHub Copilot (e.g. `gpt-4o`, `claude-3.5-sonnet`, `o3-mini`).
|
|
*
|
|
* Call the static {@link CopilotAdapter.authenticate} method once to run the
|
|
* GitHub Device Authorization Flow and persist the token — identical to
|
|
* `:Copilot setup` in copilot.vim.
|
|
*
|
|
* Thread-safe — a single instance may be shared across concurrent agent runs.
|
|
*/
|
|
export class CopilotAdapter implements LLMAdapter {
|
|
readonly name = 'copilot'
|
|
|
|
readonly #githubToken: string
|
|
|
|
/** Short-lived Copilot API bearer token (auto-refreshed). */
|
|
#copilotToken: string | null = null
|
|
/** Unix timestamp (seconds) at which the cached token expires. */
|
|
#copilotTokenExpiry = 0
|
|
|
|
/**
|
|
* @param token - GitHub OAuth token. Falls back to `GITHUB_COPILOT_TOKEN`,
|
|
* `GITHUB_TOKEN`, then `~/.config/github-copilot/hosts.json`.
|
|
* @throws {Error} When no token can be resolved. Run
|
|
* {@link CopilotAdapter.authenticate} first.
|
|
*/
|
|
constructor(token?: string) {
|
|
const resolved =
|
|
token ??
|
|
process.env['GITHUB_COPILOT_TOKEN'] ??
|
|
process.env['GITHUB_TOKEN'] ??
|
|
readStoredToken()
|
|
|
|
if (!resolved) {
|
|
throw new Error(
|
|
'CopilotAdapter: No GitHub token found. ' +
|
|
'Run CopilotAdapter.authenticate() or set GITHUB_COPILOT_TOKEN.',
|
|
)
|
|
}
|
|
|
|
this.#githubToken = resolved
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Static: Device Authorization Flow (mirrors :Copilot setup)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Authenticate with GitHub using the Device Authorization Flow — the same
|
|
* flow that `:Copilot setup` in copilot.vim runs.
|
|
*
|
|
* Prints a one-time code and a URL. After the user authorises the app the
|
|
* OAuth token is saved to `~/.config/github-copilot/hosts.json` so that
|
|
* future `new CopilotAdapter()` calls find it automatically.
|
|
*
|
|
* @param onPrompt - Called with the user code and verification URL so the
|
|
* caller can display / open them. Defaults to printing to stdout.
|
|
* @returns The GitHub OAuth token.
|
|
*/
|
|
static async authenticate(
|
|
onPrompt?: (userCode: string, verificationUri: string) => void,
|
|
): Promise<string> {
|
|
const prompt =
|
|
onPrompt ??
|
|
((userCode, uri) => {
|
|
process.stdout.write(
|
|
`\nFirst copy your one-time code: ${userCode}\n` +
|
|
`Then visit: ${uri}\n` +
|
|
`Waiting for authorisation…\n`,
|
|
)
|
|
})
|
|
|
|
const oauthToken = await runDeviceFlow(prompt)
|
|
|
|
// Resolve the authenticated username and persist the token.
|
|
let user = 'unknown'
|
|
try {
|
|
const res = await fetch('https://api.github.com/user', {
|
|
headers: { Authorization: `token ${oauthToken}`, ...EDITOR_HEADERS },
|
|
})
|
|
if (res.ok) {
|
|
const data: unknown = await res.json()
|
|
if (data !== null && typeof data === 'object') {
|
|
user = (data as GitHubUser).login ?? user
|
|
}
|
|
}
|
|
} catch { /* best-effort */ }
|
|
|
|
writeStoredToken(oauthToken, user)
|
|
process.stdout.write(`\nCopilot: Authenticated as GitHub user ${user}\n`)
|
|
|
|
return oauthToken
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Internal: Copilot API token (short-lived bearer)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Return a valid Copilot API bearer token, refreshing if needed.
|
|
*
|
|
* The token is cached in memory for its lifetime (typically 30 min) and
|
|
* refreshed 60 seconds before expiry.
|
|
*/
|
|
async #getCopilotToken(): Promise<string> {
|
|
const nowSeconds = Date.now() / 1000
|
|
if (this.#copilotToken !== null && this.#copilotTokenExpiry > nowSeconds + 60) {
|
|
return this.#copilotToken
|
|
}
|
|
|
|
const data = await fetchCopilotToken(this.#githubToken)
|
|
this.#copilotToken = data.token
|
|
this.#copilotTokenExpiry = data.expires_at
|
|
return this.#copilotToken
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// chat()
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Send a synchronous (non-streaming) chat request and return the complete
|
|
* {@link LLMResponse}.
|
|
*
|
|
* Throws on non-2xx responses. Callers should handle rate-limit errors
|
|
* (HTTP 429) and quota errors (HTTP 403).
|
|
*/
|
|
async chat(messages: LLMMessage[], options: LLMChatOptions): Promise<LLMResponse> {
|
|
const copilotToken = await this.#getCopilotToken()
|
|
const openAIMessages = buildOpenAIMessages(messages, options.systemPrompt)
|
|
|
|
const body: Record<string, unknown> = {
|
|
model: options.model,
|
|
messages: openAIMessages,
|
|
stream: false,
|
|
}
|
|
if (options.tools) body['tools'] = options.tools.map(toOpenAITool)
|
|
if (options.maxTokens !== undefined) body['max_tokens'] = options.maxTokens
|
|
if (options.temperature !== undefined) body['temperature'] = options.temperature
|
|
|
|
const res = await fetch(COPILOT_CHAT_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${copilotToken}`,
|
|
'Content-Type': 'application/json',
|
|
...EDITOR_HEADERS,
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: options.abortSignal,
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => res.statusText)
|
|
throw new Error(`Copilot API error ${res.status}: ${text}`)
|
|
}
|
|
|
|
const completion: OpenAICompletion = (await res.json()) as OpenAICompletion
|
|
return fromOpenAICompletion(completion)
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// stream()
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Send a streaming chat request and yield {@link StreamEvent}s incrementally.
|
|
*
|
|
* Sequence guarantees (matching other adapters):
|
|
* - Zero or more `text` events (incremental deltas)
|
|
* - Zero or more `tool_use` events (emitted once per tool call, after stream ends)
|
|
* - Exactly one terminal event: `done` or `error`
|
|
*/
|
|
async *stream(
|
|
messages: LLMMessage[],
|
|
options: LLMStreamOptions,
|
|
): AsyncIterable<StreamEvent> {
|
|
try {
|
|
const copilotToken = await this.#getCopilotToken()
|
|
const openAIMessages = buildOpenAIMessages(messages, options.systemPrompt)
|
|
|
|
const body: Record<string, unknown> = {
|
|
model: options.model,
|
|
messages: openAIMessages,
|
|
stream: true,
|
|
stream_options: { include_usage: true },
|
|
}
|
|
if (options.tools) body['tools'] = options.tools.map(toOpenAITool)
|
|
if (options.maxTokens !== undefined) body['max_tokens'] = options.maxTokens
|
|
if (options.temperature !== undefined) body['temperature'] = options.temperature
|
|
|
|
const res = await fetch(COPILOT_CHAT_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${copilotToken}`,
|
|
'Content-Type': 'application/json',
|
|
...EDITOR_HEADERS,
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: options.abortSignal,
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => res.statusText)
|
|
throw new Error(`Copilot API error ${res.status}: ${text}`)
|
|
}
|
|
|
|
if (res.body === null) throw new Error('Copilot streaming response has no body')
|
|
|
|
// Accumulate state across SSE chunks.
|
|
let completionId = ''
|
|
let completionModel = options.model
|
|
let finalFinishReason: string | null = 'stop'
|
|
let inputTokens = 0
|
|
let outputTokens = 0
|
|
let fullText = ''
|
|
|
|
const toolCallBuffers = new Map<
|
|
number,
|
|
{ id: string; name: string; argsJson: string }
|
|
>()
|
|
|
|
const reader = res.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let lineBuffer = ''
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
lineBuffer += decoder.decode(value, { stream: true })
|
|
const lines = lineBuffer.split('\n')
|
|
lineBuffer = lines.pop() ?? ''
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim()
|
|
if (!trimmed.startsWith('data: ')) continue
|
|
const data = trimmed.slice(6)
|
|
if (data === '[DONE]') continue
|
|
|
|
let chunk: OpenAICompletion
|
|
try {
|
|
chunk = JSON.parse(data) as OpenAICompletion
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
completionId = chunk.id || completionId
|
|
completionModel = chunk.model || completionModel
|
|
|
|
if (chunk.usage) {
|
|
inputTokens = chunk.usage.prompt_tokens ?? inputTokens
|
|
outputTokens = chunk.usage.completion_tokens ?? outputTokens
|
|
}
|
|
|
|
const choice: OpenAIChoice | undefined = chunk.choices[0]
|
|
if (choice === undefined) continue
|
|
|
|
const delta = choice.delta ?? {}
|
|
|
|
if (delta.content) {
|
|
fullText += delta.content
|
|
yield { type: 'text', data: delta.content } satisfies StreamEvent
|
|
}
|
|
|
|
for (const tc of delta.tool_calls ?? []) {
|
|
const idx = tc.index
|
|
if (!toolCallBuffers.has(idx)) {
|
|
toolCallBuffers.set(idx, { id: tc.id ?? '', name: tc.function?.name ?? '', argsJson: '' })
|
|
}
|
|
const buf = toolCallBuffers.get(idx)
|
|
if (buf !== undefined) {
|
|
if (tc.id) buf.id = tc.id
|
|
if (tc.function?.name) buf.name = tc.function.name
|
|
if (tc.function?.arguments) buf.argsJson += tc.function.arguments
|
|
}
|
|
}
|
|
|
|
if (choice.finish_reason !== null && choice.finish_reason !== undefined) {
|
|
finalFinishReason = choice.finish_reason
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
reader.releaseLock()
|
|
}
|
|
|
|
// Emit accumulated tool_use events.
|
|
const finalToolUseBlocks: ToolUseBlock[] = []
|
|
for (const buf of toolCallBuffers.values()) {
|
|
let input: Record<string, unknown> = {}
|
|
try {
|
|
const parsed: unknown = JSON.parse(buf.argsJson)
|
|
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
input = parsed as Record<string, unknown>
|
|
}
|
|
} catch { /* malformed — empty object */ }
|
|
|
|
const block: ToolUseBlock = { type: 'tool_use', id: buf.id, name: buf.name, input }
|
|
finalToolUseBlocks.push(block)
|
|
yield { type: 'tool_use', data: block } satisfies StreamEvent
|
|
}
|
|
|
|
const doneContent: ContentBlock[] = []
|
|
if (fullText.length > 0) doneContent.push({ type: 'text', text: fullText })
|
|
doneContent.push(...finalToolUseBlocks)
|
|
|
|
const finalResponse: LLMResponse = {
|
|
id: completionId,
|
|
content: doneContent,
|
|
model: completionModel,
|
|
stop_reason: normalizeFinishReason(finalFinishReason),
|
|
usage: { input_tokens: inputTokens, output_tokens: outputTokens },
|
|
}
|
|
|
|
yield { type: 'done', data: finalResponse } satisfies StreamEvent
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err : new Error(String(err))
|
|
yield { type: 'error', data: error } satisfies StreamEvent
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-export types that consumers of this module commonly need.
|
|
export type {
|
|
ContentBlock,
|
|
LLMAdapter,
|
|
LLMChatOptions,
|
|
LLMMessage,
|
|
LLMResponse,
|
|
LLMStreamOptions,
|
|
LLMToolDef,
|
|
StreamEvent,
|
|
}
|