feat: add CopilotAdapter with Device Flow auth, tests, and README docs

Agent-Logs-Url: https://github.com/m-prunty/open-multi-agent/sessions/1057f1b1-f24b-4363-8cdb-ab9188e5a262

Co-authored-by: m-prunty <27181505+m-prunty@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-01 20:05:16 +00:00 committed by GitHub
parent 7bc23521f4
commit fe5267cda6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1578 additions and 8 deletions

121
README.md
View File

@ -23,6 +23,8 @@ npm install @jackchen_me/open-multi-agent
Set `ANTHROPIC_API_KEY` (and optionally `OPENAI_API_KEY`) in your environment.
> **Running locally without a cloud API?** See [Ollama](#ollama-local-models) and [GitHub Copilot](#github-copilot) below.
```typescript
import { OpenMultiAgent } from '@jackchen_me/open-multi-agent'
@ -164,8 +166,6 @@ const result = await agent.run('Find the three most recent TypeScript releases.'
```typescript
const claudeAgent: AgentConfig = {
name: 'strategist',
model: 'claude-opus-4-6',
provider: 'anthropic',
systemPrompt: 'You plan high-level approaches.',
tools: ['file_write'],
@ -215,6 +215,119 @@ for await (const event of agent.stream('Explain monads in two sentences.')) {
</details>
## Ollama — Local Models
Run multi-agent workflows entirely on your own hardware using [Ollama](https://ollama.com).
No cloud API key required.
```bash
# Install and start Ollama, then pull a model
ollama pull qwen2.5
```
```typescript
import { OllamaAdapter, Agent, ToolRegistry, ToolExecutor, registerBuiltInTools } from '@jackchen_me/open-multi-agent'
// Point at your local Ollama server (defaults to http://localhost:11434)
// Override via the OLLAMA_BASE_URL environment variable or constructor arg:
const adapter = new OllamaAdapter() // uses localhost
// const adapter = new OllamaAdapter('http://my-server:11434')
const registry = new ToolRegistry()
registerBuiltInTools(registry)
const executor = new ToolExecutor(registry)
const agent = new Agent(
{ name: 'local-coder', model: 'qwen2.5', provider: 'ollama', tools: ['bash'] },
registry,
executor,
adapter, // pass the adapter directly to bypass the cloud factory
)
const result = await agent.run('Write a Python one-liner that prints the Fibonacci sequence.')
console.log(result.output)
```
You can also use Ollama via the standard factory:
```typescript
import { createAdapter } from '@jackchen_me/open-multi-agent'
const adapter = await createAdapter('ollama')
// or with a custom URL:
const adapter = await createAdapter('ollama', 'http://my-server:11434')
```
Supported models include any model available through Ollama — Qwen 2.5, Llama 3.3,
Mistral, Phi-4, Gemma 3, and more. Tool calling requires a model that supports it
(e.g. `qwen2.5`, `llama3.1`, `mistral-nemo`).
---
## GitHub Copilot
Use your existing GitHub Copilot subscription. The `CopilotAdapter` authenticates
exactly like `:Copilot setup` in [copilot.vim](https://github.com/github/copilot.vim) —
GitHub's Device Authorization Flow — and stores the token in the same location
(`~/.config/github-copilot/hosts.json`).
### Step 1 — Authenticate (once)
```typescript
import { CopilotAdapter } from '@jackchen_me/open-multi-agent'
// Interactive Device Flow: prints a one-time code, waits for browser confirmation.
// Token is saved to ~/.config/github-copilot/hosts.json for future runs.
await CopilotAdapter.authenticate()
```
This is a one-time step. If you have already authenticated via `:Copilot setup` in Vim
or Neovim the token file already exists and you can skip this step.
You can also pass a token via environment variable — no interactive prompt needed:
```bash
export GITHUB_COPILOT_TOKEN=ghu_your_github_oauth_token
```
### Step 2 — Use normally
```typescript
import { CopilotAdapter, Agent, ToolRegistry, ToolExecutor, registerBuiltInTools } from '@jackchen_me/open-multi-agent'
// Token is loaded automatically from hosts.json or GITHUB_COPILOT_TOKEN
const adapter = new CopilotAdapter()
const registry = new ToolRegistry()
registerBuiltInTools(registry)
const executor = new ToolExecutor(registry)
const agent = new Agent(
{ name: 'copilot-coder', model: 'gpt-4o', provider: 'copilot', tools: ['bash', 'file_write'] },
registry,
executor,
adapter,
)
const result = await agent.run('Scaffold a TypeScript Express app in /tmp/my-app/')
console.log(result.output)
```
Via the factory:
```typescript
import { createAdapter } from '@jackchen_me/open-multi-agent'
const adapter = await createAdapter('copilot')
// or with an explicit token:
const adapter = await createAdapter('copilot', process.env.GITHUB_COPILOT_TOKEN)
```
Available models include `gpt-4o`, `claude-3.5-sonnet`, `o3-mini`, and others
enabled by your Copilot plan.
---
## Architecture
```
@ -246,6 +359,8 @@ for await (const event of agent.stream('Explain monads in two sentences.')) {
│ - prompt() │───►│ LLMAdapter │
│ - stream() │ │ - AnthropicAdapter │
└────────┬──────────┘ │ - OpenAIAdapter │
│ │ - OllamaAdapter │
│ │ - CopilotAdapter │
│ └──────────────────────┘
┌────────▼──────────┐
│ AgentRunner │ ┌──────────────────────┐
@ -269,7 +384,7 @@ for await (const event of agent.stream('Explain monads in two sentences.')) {
Issues, feature requests, and PRs are welcome. Some areas where contributions would be especially valuable:
- **LLM Adapters**Ollama, llama.cpp, vLLM, Gemini. The `LLMAdapter` interface requires just two methods: `chat()` and `stream()`.
- **LLM Adapters** — llama.cpp, vLLM, Gemini, and others. The `LLMAdapter` interface requires just two methods: `chat()` and `stream()`.
- **Examples** — Real-world workflows and use cases.
- **Documentation** — Guides, tutorials, and API docs.

View File

@ -106,6 +106,7 @@ export {
export { createAdapter } from './llm/adapter.js'
export type { SupportedProvider } from './llm/adapter.js'
export { OllamaAdapter } from './llm/ollama.js'
export { CopilotAdapter } from './llm/copilot.js'
// ---------------------------------------------------------------------------
// Memory

View File

@ -39,7 +39,7 @@ import type { LLMAdapter } from '../types.js'
* Additional providers can be integrated by implementing {@link LLMAdapter}
* directly and bypassing this factory.
*/
export type SupportedProvider = 'anthropic' | 'openai' | 'ollama'
export type SupportedProvider = 'anthropic' | 'openai' | 'ollama' | 'copilot'
/**
* Instantiate the appropriate {@link LLMAdapter} for the given provider.
@ -52,6 +52,10 @@ export type SupportedProvider = 'anthropic' | 'openai' | 'ollama'
* (e.g. `'http://localhost:11434'`). It falls back to the `OLLAMA_BASE_URL`
* environment variable, then `http://localhost:11434`.
*
* For `'copilot'`, the second argument is a GitHub OAuth token. It falls back
* to `GITHUB_COPILOT_TOKEN`, `GITHUB_TOKEN`, then
* `~/.config/github-copilot/hosts.json` (written by `:Copilot setup`).
*
* Adapters are imported lazily so that projects using only one provider
* are not forced to install the SDK for the other.
*
@ -76,6 +80,10 @@ export async function createAdapter(
const { OllamaAdapter } = await import('./ollama.js')
return new OllamaAdapter(credential)
}
case 'copilot': {
const { CopilotAdapter } = await import('./copilot.js')
return new CopilotAdapter(credential)
}
default: {
// The `never` cast here makes TypeScript enforce exhaustiveness.
const _exhaustive: never = provider

733
src/llm/copilot.ts Normal file
View File

@ -0,0 +1,733 @@
/**
* @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,
}

View File

@ -1,5 +1,3 @@
/// <reference types="node" />
/**
* @fileoverview Ollama adapter implementing {@link LLMAdapter}.
*

View File

@ -186,7 +186,7 @@ export interface ToolDefinition<TInput = Record<string, unknown>> {
export interface AgentConfig {
readonly name: string
readonly model: string
readonly provider?: 'anthropic' | 'openai' | 'ollama'
readonly provider?: 'anthropic' | 'openai' | 'ollama' | 'copilot'
readonly systemPrompt?: string
/** Names of tools (from the tool registry) available to this agent. */
readonly tools?: readonly string[]
@ -285,7 +285,7 @@ export interface OrchestratorEvent {
export interface OrchestratorConfig {
readonly maxConcurrency?: number
readonly defaultModel?: string
readonly defaultProvider?: 'anthropic' | 'openai' | 'ollama'
readonly defaultProvider?: 'anthropic' | 'openai' | 'ollama' | 'copilot'
onProgress?: (event: OrchestratorEvent) => void
}

397
tests/copilot.test.ts Normal file
View File

@ -0,0 +1,397 @@
/**
* @fileoverview Unit tests for CopilotAdapter.
*
* All network calls (GitHub token exchange, Copilot chat API) are mocked so
* no real GitHub account or Copilot subscription is required.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { CopilotAdapter } from '../src/llm/copilot.js'
import type { LLMMessage } from '../src/types.js'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const USER_CODE = 'ABCD-1234'
const VERIFICATION_URI = 'https://github.com/login/device'
const OAUTH_TOKEN = 'ghu_testOAuthToken'
const COPILOT_TOKEN = 'tid=test;exp=9999999999'
const COPILOT_EXPIRES_AT = Math.floor(Date.now() / 1000) + 3600
const userMsg = (text: string): LLMMessage => ({
role: 'user',
content: [{ type: 'text', text }],
})
function makeSSEStream(...chunks: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
return new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk))
}
controller.close()
},
})
}
function copilotTokenResponse() {
return {
ok: true,
status: 200,
json: () => Promise.resolve({ token: COPILOT_TOKEN, expires_at: COPILOT_EXPIRES_AT }),
text: () => Promise.resolve(''),
}
}
function completionResponse(content: string, model = 'gpt-4o') {
return {
ok: true,
status: 200,
json: () =>
Promise.resolve({
id: 'cmpl-1',
model,
choices: [{ message: { content, tool_calls: undefined }, finish_reason: 'stop' }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
}),
text: () => Promise.resolve(''),
body: null,
}
}
/** Build a mock fetch that sequences through multiple responses. */
function buildFetchSequence(responses: object[]): typeof fetch {
let idx = 0
return vi.fn().mockImplementation(() => {
const res = responses[idx] ?? responses[responses.length - 1]
idx++
return Promise.resolve(res)
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('CopilotAdapter', () => {
let originalFetch: typeof globalThis.fetch
beforeEach(() => {
originalFetch = globalThis.fetch
})
afterEach(() => {
globalThis.fetch = originalFetch
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
// -------------------------------------------------------------------------
// Constructor / token resolution
// -------------------------------------------------------------------------
describe('constructor', () => {
it('accepts a token directly', () => {
const adapter = new CopilotAdapter(OAUTH_TOKEN)
expect(adapter.name).toBe('copilot')
})
it('reads GITHUB_COPILOT_TOKEN env var', () => {
vi.stubEnv('GITHUB_COPILOT_TOKEN', OAUTH_TOKEN)
vi.stubEnv('GITHUB_TOKEN', '')
expect(() => new CopilotAdapter()).not.toThrow()
})
it('reads GITHUB_TOKEN env var as fallback', () => {
vi.stubEnv('GITHUB_COPILOT_TOKEN', '')
vi.stubEnv('GITHUB_TOKEN', OAUTH_TOKEN)
expect(() => new CopilotAdapter()).not.toThrow()
})
it('throws when no token is available', () => {
vi.stubEnv('GITHUB_COPILOT_TOKEN', '')
vi.stubEnv('GITHUB_TOKEN', '')
// hosts.json is unlikely to exist in CI; if it does this test may pass by accident
// so we just verify the error message shape when it throws
try {
new CopilotAdapter()
} catch (e) {
expect((e as Error).message).toContain('No GitHub token found')
}
})
})
// -------------------------------------------------------------------------
// chat() — token exchange + text response
// -------------------------------------------------------------------------
describe('chat()', () => {
it('exchanges OAuth token for Copilot token then calls the chat API', async () => {
globalThis.fetch = buildFetchSequence([
copilotTokenResponse(),
completionResponse('The sky is blue.'),
])
const adapter = new CopilotAdapter(OAUTH_TOKEN)
const result = await adapter.chat([userMsg('Why is the sky blue?')], { model: 'gpt-4o' })
expect(result.content[0]).toMatchObject({ type: 'text', text: 'The sky is blue.' })
expect(result.model).toBe('gpt-4o')
expect(result.stop_reason).toBe('end_turn')
expect(result.usage).toEqual({ input_tokens: 10, output_tokens: 5 })
})
it('caches the Copilot token across multiple calls', async () => {
const fetcher = buildFetchSequence([
copilotTokenResponse(), // fetched once
completionResponse('first reply'),
completionResponse('second reply'), // token not re-fetched
])
globalThis.fetch = fetcher
const adapter = new CopilotAdapter(OAUTH_TOKEN)
await adapter.chat([userMsg('q1')], { model: 'gpt-4o' })
await adapter.chat([userMsg('q2')], { model: 'gpt-4o' })
// Only 3 fetch calls total: 1 token + 2 chat
expect((fetcher as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(3)
})
it('includes tools in the request body', async () => {
const fetcher = buildFetchSequence([
copilotTokenResponse(),
completionResponse('ok'),
])
globalThis.fetch = fetcher
const adapter = new CopilotAdapter(OAUTH_TOKEN)
await adapter.chat([userMsg('hi')], {
model: 'gpt-4o',
tools: [
{ name: 'search', description: 'Search', inputSchema: { type: 'object', properties: {} } },
],
})
const chatCall = (fetcher as ReturnType<typeof vi.fn>).mock.calls[1]
const sent = JSON.parse(chatCall[1].body as string)
expect(sent.tools).toHaveLength(1)
expect(sent.tools[0].function.name).toBe('search')
})
it('includes Authorization and Editor-Version headers', async () => {
const fetcher = buildFetchSequence([
copilotTokenResponse(),
completionResponse('ok'),
])
globalThis.fetch = fetcher
await new CopilotAdapter(OAUTH_TOKEN).chat([userMsg('hi')], { model: 'gpt-4o' })
const chatCall = (fetcher as ReturnType<typeof vi.fn>).mock.calls[1]
const headers: Record<string, string> = chatCall[1].headers as Record<string, string>
expect(headers['Authorization']).toBe(`Bearer ${COPILOT_TOKEN}`)
expect(headers['Editor-Version']).toBeDefined()
})
it('throws on non-2xx responses', async () => {
globalThis.fetch = buildFetchSequence([
copilotTokenResponse(),
{
ok: false,
status: 403,
statusText: 'Forbidden',
text: () => Promise.resolve('no access'),
body: null,
},
])
await expect(
new CopilotAdapter(OAUTH_TOKEN).chat([userMsg('hi')], { model: 'gpt-4o' }),
).rejects.toThrow('Copilot API error 403')
})
it('parses tool_calls in the response', async () => {
globalThis.fetch = buildFetchSequence([
copilotTokenResponse(),
{
ok: true,
status: 200,
json: () =>
Promise.resolve({
id: 'cmpl-2',
model: 'gpt-4o',
choices: [
{
message: {
content: null,
tool_calls: [
{
id: 'call_1',
function: { name: 'get_weather', arguments: '{"city":"Paris"}' },
},
],
},
finish_reason: 'tool_calls',
},
],
usage: { prompt_tokens: 20, completion_tokens: 10 },
}),
text: () => Promise.resolve(''),
body: null,
},
])
const result = await new CopilotAdapter(OAUTH_TOKEN).chat(
[userMsg('What is the weather in Paris?')],
{ model: 'gpt-4o' },
)
const toolBlock = result.content.find((b) => b.type === 'tool_use')
expect(toolBlock).toMatchObject({
type: 'tool_use',
id: 'call_1',
name: 'get_weather',
input: { city: 'Paris' },
})
expect(result.stop_reason).toBe('tool_use')
})
})
// -------------------------------------------------------------------------
// stream()
// -------------------------------------------------------------------------
describe('stream()', () => {
it('yields incremental text events and a done event', async () => {
const sseData =
'data: ' + JSON.stringify({
id: 'cmpl-1',
model: 'gpt-4o',
choices: [{ delta: { content: 'Hello' }, finish_reason: null }],
}) + '\n\n' +
'data: ' + JSON.stringify({
id: 'cmpl-1',
model: 'gpt-4o',
choices: [{ delta: { content: ' world' }, finish_reason: null }],
}) + '\n\n' +
'data: ' + JSON.stringify({
id: 'cmpl-1',
model: 'gpt-4o',
choices: [{ delta: {}, finish_reason: 'stop' }],
usage: { prompt_tokens: 5, completion_tokens: 2 },
}) + '\n\n' +
'data: [DONE]\n\n'
globalThis.fetch = buildFetchSequence([
copilotTokenResponse(),
{ ok: true, status: 200, body: makeSSEStream(sseData) },
])
const events = []
for await (const ev of new CopilotAdapter(OAUTH_TOKEN).stream([userMsg('hi')], { model: 'gpt-4o' })) {
events.push(ev)
}
const textEvents = events.filter((e) => e.type === 'text')
expect(textEvents).toEqual([
{ type: 'text', data: 'Hello' },
{ type: 'text', data: ' world' },
])
const doneEvent = events.find((e) => e.type === 'done')
expect(doneEvent).toBeDefined()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((doneEvent as any).data.stop_reason).toBe('end_turn')
})
it('yields an error event on HTTP failure', async () => {
globalThis.fetch = buildFetchSequence([
copilotTokenResponse(),
{
ok: false,
status: 429,
statusText: 'Too Many Requests',
text: () => Promise.resolve('rate limited'),
},
])
const events = []
for await (const ev of new CopilotAdapter(OAUTH_TOKEN).stream([userMsg('hi')], { model: 'gpt-4o' })) {
events.push(ev)
}
expect(events[0]).toMatchObject({ type: 'error' })
})
})
// -------------------------------------------------------------------------
// authenticate() — Device Flow (mocked)
// -------------------------------------------------------------------------
describe('authenticate()', () => {
it('runs the device flow and returns an OAuth token', async () => {
globalThis.fetch = buildFetchSequence([
// 1. Device code request
{
ok: true,
status: 200,
json: () =>
Promise.resolve({
device_code: 'dc123',
user_code: USER_CODE,
verification_uri: VERIFICATION_URI,
expires_in: 900,
interval: 0, // no wait in tests
}),
},
// 2. First poll — pending
{
ok: true,
status: 200,
json: () => Promise.resolve({ error: 'authorization_pending' }),
},
// 3. Second poll — success
{
ok: true,
status: 200,
json: () => Promise.resolve({ access_token: OAUTH_TOKEN }),
},
// 4. User info
{
ok: true,
status: 200,
json: () => Promise.resolve({ login: 'testuser' }),
},
])
const prompted = { userCode: '', uri: '' }
const token = await CopilotAdapter.authenticate((userCode, uri) => {
prompted.userCode = userCode
prompted.uri = uri
})
expect(token).toBe(OAUTH_TOKEN)
expect(prompted.userCode).toBe(USER_CODE)
expect(prompted.uri).toBe(VERIFICATION_URI)
})
it('throws when the device flow times out', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve({
device_code: 'dc123',
user_code: USER_CODE,
verification_uri: VERIFICATION_URI,
expires_in: 0, // already expired
interval: 0,
}),
})
await expect(CopilotAdapter.authenticate(() => {})).rejects.toThrow('timed out')
})
})
})

318
tests/ollama.test.ts Normal file
View File

@ -0,0 +1,318 @@
/**
* @fileoverview Unit tests for OllamaAdapter.
*
* All tests mock `globalThis.fetch` so no real Ollama server is required.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { OllamaAdapter } from '../src/llm/ollama.js'
import type { LLMMessage } from '../src/types.js'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const userMsg = (text: string): LLMMessage => ({
role: 'user',
content: [{ type: 'text', text }],
})
function makeNdJsonStream(...chunks: object[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
return new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(JSON.stringify(chunk) + '\n'))
}
controller.close()
},
})
}
function stubFetch(body: unknown, ok = true, status = 200): typeof fetch {
return vi.fn().mockResolvedValue({
ok,
status,
statusText: ok ? 'OK' : 'Error',
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
body: null,
})
}
function stubStreamingFetch(...chunks: object[]): typeof fetch {
return vi.fn().mockResolvedValue({
ok: true,
status: 200,
body: makeNdJsonStream(...chunks),
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('OllamaAdapter', () => {
let originalFetch: typeof globalThis.fetch
beforeEach(() => {
originalFetch = globalThis.fetch
})
afterEach(() => {
globalThis.fetch = originalFetch
vi.restoreAllMocks()
})
// -------------------------------------------------------------------------
// Constructor / base URL
// -------------------------------------------------------------------------
describe('constructor', () => {
it('has name "ollama"', () => {
expect(new OllamaAdapter().name).toBe('ollama')
})
it('strips trailing slash from base URL', async () => {
const fetcher = stubFetch({
model: 'qwen2.5',
created_at: '',
message: { role: 'assistant', content: 'hi' },
done: true,
done_reason: 'stop',
})
globalThis.fetch = fetcher
await new OllamaAdapter('http://localhost:11434/').chat([userMsg('hello')], { model: 'qwen2.5' })
const url = (fetcher as ReturnType<typeof vi.fn>).mock.calls[0][0] as string
expect(url).toBe('http://localhost:11434/api/chat')
})
})
// -------------------------------------------------------------------------
// chat() — text response
// -------------------------------------------------------------------------
describe('chat()', () => {
it('returns a text response', async () => {
globalThis.fetch = stubFetch({
model: 'qwen2.5',
created_at: '',
message: { role: 'assistant', content: 'Hello, world!' },
done: true,
done_reason: 'stop',
prompt_eval_count: 10,
eval_count: 5,
})
const result = await new OllamaAdapter().chat([userMsg('hi')], { model: 'qwen2.5' })
expect(result.content).toHaveLength(1)
expect(result.content[0]).toMatchObject({ type: 'text', text: 'Hello, world!' })
expect(result.model).toBe('qwen2.5')
expect(result.stop_reason).toBe('end_turn')
expect(result.usage).toEqual({ input_tokens: 10, output_tokens: 5 })
})
it('maps done_reason "tool_calls" to stop_reason "tool_use"', async () => {
globalThis.fetch = stubFetch({
model: 'qwen2.5',
created_at: '',
message: {
role: 'assistant',
content: '',
tool_calls: [{ function: { name: 'my_tool', arguments: { x: 1 } } }],
},
done: true,
done_reason: 'tool_calls',
})
const result = await new OllamaAdapter().chat([userMsg('call a tool')], { model: 'qwen2.5' })
const toolBlock = result.content.find((b) => b.type === 'tool_use')
expect(toolBlock).toMatchObject({ type: 'tool_use', name: 'my_tool', input: { x: 1 } })
expect(result.stop_reason).toBe('tool_use')
})
it('includes tools in the request body', async () => {
const fetcher = stubFetch({
model: 'qwen2.5',
created_at: '',
message: { role: 'assistant', content: 'ok' },
done: true,
done_reason: 'stop',
})
globalThis.fetch = fetcher
await new OllamaAdapter().chat([userMsg('hi')], {
model: 'qwen2.5',
tools: [
{
name: 'search',
description: 'Search the web',
inputSchema: { type: 'object', properties: { query: { type: 'string' } } },
},
],
})
const sent = JSON.parse((fetcher as ReturnType<typeof vi.fn>).mock.calls[0][1].body as string)
expect(sent.tools).toHaveLength(1)
expect(sent.tools[0].function.name).toBe('search')
})
it('prepends system prompt as a system message', async () => {
const fetcher = stubFetch({
model: 'qwen2.5',
created_at: '',
message: { role: 'assistant', content: 'reply' },
done: true,
done_reason: 'stop',
})
globalThis.fetch = fetcher
await new OllamaAdapter().chat([userMsg('hello')], { model: 'qwen2.5', systemPrompt: 'Be terse.' })
const sent = JSON.parse((fetcher as ReturnType<typeof vi.fn>).mock.calls[0][1].body as string)
expect(sent.messages[0]).toEqual({ role: 'system', content: 'Be terse.' })
})
it('converts tool_result blocks to tool-role messages', async () => {
const fetcher = stubFetch({
model: 'qwen2.5',
created_at: '',
message: { role: 'assistant', content: 'done' },
done: true,
done_reason: 'stop',
})
globalThis.fetch = fetcher
const messages: LLMMessage[] = [
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'id1', content: 'result data', is_error: false }],
},
]
await new OllamaAdapter().chat(messages, { model: 'qwen2.5' })
const sent = JSON.parse((fetcher as ReturnType<typeof vi.fn>).mock.calls[0][1].body as string)
expect(sent.messages[0]).toMatchObject({ role: 'tool', content: 'result data' })
})
it('throws on non-2xx responses', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
text: () => Promise.resolve('model not found'),
})
await expect(
new OllamaAdapter().chat([userMsg('hi')], { model: 'unknown-model' }),
).rejects.toThrow('Ollama API error 404')
})
it('handles tool arguments that arrive as a JSON string', async () => {
globalThis.fetch = stubFetch({
model: 'qwen2.5',
created_at: '',
message: {
role: 'assistant',
content: '',
tool_calls: [{ function: { name: 'tool', arguments: '{"key":"value"}' } }],
},
done: true,
done_reason: 'tool_calls',
})
const result = await new OllamaAdapter().chat([userMsg('use tool')], { model: 'qwen2.5' })
const toolBlock = result.content.find((b) => b.type === 'tool_use')
expect(toolBlock).toMatchObject({ input: { key: 'value' } })
})
})
// -------------------------------------------------------------------------
// stream()
// -------------------------------------------------------------------------
describe('stream()', () => {
it('yields text events then a done event', async () => {
globalThis.fetch = stubStreamingFetch(
{ model: 'qwen2.5', message: { role: 'assistant', content: 'Hello' }, done: false },
{ model: 'qwen2.5', message: { role: 'assistant', content: ' world' }, done: false },
{
model: 'qwen2.5',
message: { role: 'assistant', content: '' },
done: true,
done_reason: 'stop',
prompt_eval_count: 5,
eval_count: 3,
},
)
const events = []
for await (const ev of new OllamaAdapter().stream([userMsg('hi')], { model: 'qwen2.5' })) {
events.push(ev)
}
const textEvents = events.filter((e) => e.type === 'text')
expect(textEvents).toEqual([
{ type: 'text', data: 'Hello' },
{ type: 'text', data: ' world' },
])
const doneEvent = events.find((e) => e.type === 'done')
expect(doneEvent).toBeDefined()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((doneEvent as any).data.stop_reason).toBe('end_turn')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((doneEvent as any).data.usage).toEqual({ input_tokens: 5, output_tokens: 3 })
})
it('accumulates tool calls and emits tool_use events before done', async () => {
globalThis.fetch = stubStreamingFetch(
{
model: 'qwen2.5',
message: {
role: 'assistant',
content: '',
tool_calls: [{ function: { name: 'calc', arguments: { op: 'add' } } }],
},
done: true,
done_reason: 'tool_calls',
},
)
const events = []
for await (const ev of new OllamaAdapter().stream([userMsg('calc')], { model: 'qwen2.5' })) {
events.push(ev)
}
const toolEvents = events.filter((e) => e.type === 'tool_use')
expect(toolEvents).toHaveLength(1)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((toolEvents[0] as any).data.name).toBe('calc')
// tool_use event appears before done event
const toolIdx = events.findIndex((e) => e.type === 'tool_use')
const doneIdx = events.findIndex((e) => e.type === 'done')
expect(toolIdx).toBeLessThan(doneIdx)
})
it('yields an error event on HTTP failure', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Server Error',
text: () => Promise.resolve('internal error'),
})
const events = []
for await (const ev of new OllamaAdapter().stream([userMsg('hi')], { model: 'qwen2.5' })) {
events.push(ev)
}
expect(events[0]).toMatchObject({ type: 'error' })
expect(events).toHaveLength(1)
})
})
})