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:
parent
7bc23521f4
commit
fe5267cda6
121
README.md
121
README.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
/// <reference types="node" />
|
||||
|
||||
/**
|
||||
* @fileoverview Ollama adapter implementing {@link LLMAdapter}.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue