From 99d9d7f52e3c128967f9e89f12fe94b90e0b1330 Mon Sep 17 00:00:00 2001 From: Claire <168231064+Klarline@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:28:30 -0700 Subject: [PATCH] feat: add Azure OpenAI LLMAdapter (#24) (#143) - New AzureOpenAIAdapter using AzureOpenAI client from openai SDK - Registered 'azure-openai' in SupportedProvider and createAdapter() - model field is primary deployment name; AZURE_OPENAI_DEPLOYMENT as fallback - Default api-version: 2024-10-21 - Example in examples/providers/azure-openai.ts - 14 tests covering chat, stream, tool_use, deployment fallback, error path - Updated README.md, README_zh.md, examples/README.md, src/cli/oma.ts --- README.md | 2 + README_zh.md | 2 + examples/README.md | 1 + examples/providers/azure-openai.ts | 179 ++++++++++++++ package-lock.json | 3 + src/cli/oma.ts | 2 + src/llm/adapter.ts | 25 +- src/llm/azure-openai.ts | 313 +++++++++++++++++++++++ tests/azure-openai-adapter.test.ts | 383 +++++++++++++++++++++++++++++ 9 files changed, 901 insertions(+), 9 deletions(-) create mode 100644 examples/providers/azure-openai.ts create mode 100644 src/llm/azure-openai.ts create mode 100644 tests/azure-openai-adapter.test.ts diff --git a/README.md b/README.md index 0af6d0f..9b2e555 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ npm install @jackchen_me/open-multi-agent Set the API key for your provider. Local models via Ollama require no API key. See [`providers/ollama`](examples/providers/ollama.ts). - `ANTHROPIC_API_KEY` +- `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_VERSION`, `AZURE_OPENAI_DEPLOYMENT` (for Azure OpenAI; deployment is optional fallback when `model` is blank) - `OPENAI_API_KEY` - `GEMINI_API_KEY` - `XAI_API_KEY` (for Grok) @@ -365,6 +366,7 @@ Pairs well with `compressToolResults` and `maxToolOutputChars` above. |----------|--------|---------|--------| | Anthropic (Claude) | `provider: 'anthropic'` | `ANTHROPIC_API_KEY` | Verified | | OpenAI (GPT) | `provider: 'openai'` | `OPENAI_API_KEY` | Verified | +| Azure OpenAI | `provider: 'azure-openai'` | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT` (+ optional `AZURE_OPENAI_API_VERSION`, `AZURE_OPENAI_DEPLOYMENT`) | Verified | | Grok (xAI) | `provider: 'grok'` | `XAI_API_KEY` | Verified | | MiniMax (global) | `provider: 'minimax'` | `MINIMAX_API_KEY` | Verified | | MiniMax (China) | `provider: 'minimax'` + `MINIMAX_BASE_URL` | `MINIMAX_API_KEY` | Verified | diff --git a/README_zh.md b/README_zh.md index 27ce2bc..96580a1 100644 --- a/README_zh.md +++ b/README_zh.md @@ -65,6 +65,7 @@ npm install @jackchen_me/open-multi-agent 根据用的 provider 设对应 API key。通过 Ollama 跑本地模型不用 key,见 [`providers/ollama`](examples/providers/ollama.ts)。 - `ANTHROPIC_API_KEY` +- `AZURE_OPENAI_API_KEY`、`AZURE_OPENAI_ENDPOINT`、`AZURE_OPENAI_API_VERSION`、`AZURE_OPENAI_DEPLOYMENT`(Azure OpenAI;当 `model` 为空时可用 deployment 环境变量兜底) - `OPENAI_API_KEY` - `GEMINI_API_KEY` - `XAI_API_KEY`(Grok) @@ -360,6 +361,7 @@ const agent: AgentConfig = { |----------|------|----------|------| | Anthropic (Claude) | `provider: 'anthropic'` | `ANTHROPIC_API_KEY` | 已验证 | | OpenAI (GPT) | `provider: 'openai'` | `OPENAI_API_KEY` | 已验证 | +| Azure OpenAI | `provider: 'azure-openai'` | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`(可选:`AZURE_OPENAI_API_VERSION`、`AZURE_OPENAI_DEPLOYMENT`) | 已验证 | | Grok (xAI) | `provider: 'grok'` | `XAI_API_KEY` | 已验证 | | MiniMax(全球) | `provider: 'minimax'` | `MINIMAX_API_KEY` | 已验证 | | MiniMax(国内) | `provider: 'minimax'` + `MINIMAX_BASE_URL` | `MINIMAX_API_KEY` | 已验证 | diff --git a/examples/README.md b/examples/README.md index a2cf8ba..6147662 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,6 +26,7 @@ One example per supported provider. All follow the same three-agent (architect / | [`providers/ollama`](providers/ollama.ts) | Ollama (local) + Claude | `ANTHROPIC_API_KEY` | | [`providers/gemma4-local`](providers/gemma4-local.ts) | Gemma 4 via Ollama (100% local) | — | | [`providers/copilot`](providers/copilot.ts) | GitHub Copilot (GPT-4o + Claude) | `GITHUB_TOKEN` | +| [`providers/azure-openai`](providers/azure-openai.ts) | Azure OpenAI | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT` (+ optional `AZURE_OPENAI_API_VERSION`, `AZURE_OPENAI_DEPLOYMENT`) | | [`providers/grok`](providers/grok.ts) | xAI Grok | `XAI_API_KEY` | | [`providers/gemini`](providers/gemini.ts) | Google Gemini | `GEMINI_API_KEY` | | [`providers/minimax`](providers/minimax.ts) | MiniMax M2.7 | `MINIMAX_API_KEY` | diff --git a/examples/providers/azure-openai.ts b/examples/providers/azure-openai.ts new file mode 100644 index 0000000..7c67496 --- /dev/null +++ b/examples/providers/azure-openai.ts @@ -0,0 +1,179 @@ +/** + * Multi-Agent Team Collaboration with Azure OpenAI + * + * Three specialized agents (architect, developer, reviewer) collaborate via `runTeam()` + * to build a minimal Express.js REST API. Every agent uses Azure-hosted OpenAI models. + * + * Run: + * npx tsx examples/providers/azure-openai.ts + * + * Prerequisites: + * AZURE_OPENAI_API_KEY — Your Azure OpenAI API key (required) + * AZURE_OPENAI_ENDPOINT — Your Azure endpoint URL (required) + * Example: https://my-resource.openai.azure.com + * AZURE_OPENAI_API_VERSION — API version (optional, defaults to 2024-10-21) + * AZURE_OPENAI_DEPLOYMENT — Deployment name fallback when model is blank (optional) + * + * Important Note on Model Field: + * The 'model' field in agent configs should contain your Azure DEPLOYMENT NAME, + * not the underlying model name. For example, if you deployed GPT-4 with the + * deployment name "my-gpt4-prod", use `model: 'my-gpt4-prod'` in the agent config. + * + * You can find your deployment names in the Azure Portal under: + * Azure OpenAI → Your Resource → Model deployments + * + * Example Setup: + * If you have these Azure deployments: + * - "gpt-4" (your GPT-4 deployment) + * - "gpt-35-turbo" (your GPT-3.5 Turbo deployment) + * + * Then use those exact names in the model field below. + */ + +import { OpenMultiAgent } from '../../src/index.js' +import type { AgentConfig, OrchestratorEvent } from '../../src/types.js' + +// --------------------------------------------------------------------------- +// Agent definitions (using Azure OpenAI deployments) +// --------------------------------------------------------------------------- + +/** + * IMPORTANT: Replace 'gpt-4' and 'gpt-35-turbo' below with YOUR actual + * Azure deployment names. These are just examples. + */ + +const architect: AgentConfig = { + name: 'architect', + model: 'gpt-4', // Replace with your Azure GPT-4 deployment name + provider: 'azure-openai', + systemPrompt: `You are a software architect with deep experience in Node.js and REST API design. +Your job is to design clear, production-quality API contracts and file/directory structures. +Output concise plans in markdown — no unnecessary prose.`, + tools: ['bash', 'file_write'], + maxTurns: 5, + temperature: 0.2, +} + +const developer: AgentConfig = { + name: 'developer', + model: 'gpt-4', // Replace with your Azure GPT-4 or GPT-3.5 deployment name + provider: 'azure-openai', + systemPrompt: `You are a TypeScript/Node.js developer. You implement what the architect specifies. +Write clean, runnable code with proper error handling. Use the tools to write files and run tests.`, + tools: ['bash', 'file_read', 'file_write', 'file_edit'], + maxTurns: 12, + temperature: 0.1, +} + +const reviewer: AgentConfig = { + name: 'reviewer', + model: 'gpt-4', // Replace with your Azure GPT-4 deployment name + provider: 'azure-openai', + systemPrompt: `You are a senior code reviewer. Review code for correctness, security, and clarity. +Provide a structured review with: LGTM items, suggestions, and any blocking issues. +Read files using the tools before reviewing.`, + tools: ['bash', 'file_read', 'grep'], + maxTurns: 5, + temperature: 0.3, +} + +// --------------------------------------------------------------------------- +// Progress tracking +// --------------------------------------------------------------------------- +const startTimes = new Map() + +function handleProgress(event: OrchestratorEvent): void { + const ts = new Date().toISOString().slice(11, 23) // HH:MM:SS.mmm + switch (event.type) { + case 'agent_start': + startTimes.set(event.agent ?? '', Date.now()) + console.log(`[${ts}] AGENT START → ${event.agent}`) + break + case 'agent_complete': { + const elapsed = Date.now() - (startTimes.get(event.agent ?? '') ?? Date.now()) + console.log(`[${ts}] AGENT DONE ← ${event.agent} (${elapsed}ms)`) + break + } + case 'task_start': + console.log(`[${ts}] TASK START ↓ ${event.task}`) + break + case 'task_complete': + console.log(`[${ts}] TASK DONE ↑ ${event.task}`) + break + case 'message': + console.log(`[${ts}] MESSAGE • ${event.agent} → (team)`) + break + case 'error': + console.error(`[${ts}] ERROR ✗ agent=${event.agent} task=${event.task}`) + if (event.data instanceof Error) console.error(` ${event.data.message}`) + break + } +} + +// --------------------------------------------------------------------------- +// Orchestrate +// --------------------------------------------------------------------------- +const orchestrator = new OpenMultiAgent({ + defaultModel: 'gpt-4', // Replace with your default Azure deployment name + defaultProvider: 'azure-openai', + maxConcurrency: 1, // sequential for readable output + onProgress: handleProgress, +}) + +const team = orchestrator.createTeam('api-team', { + name: 'api-team', + agents: [architect, developer, reviewer], + sharedMemory: true, + maxConcurrency: 1, +}) + +console.log(`Team "${team.name}" created with agents: ${team.getAgents().map(a => a.name).join(', ')}`) +console.log('\nStarting team run...\n') +console.log('='.repeat(60)) + +const goal = `Create a minimal Express.js REST API in /tmp/express-api/ with: +- GET /health → { status: "ok" } +- GET /users → returns a hardcoded array of 2 user objects +- POST /users → accepts { name, email } body, logs it, returns 201 +- Proper error handling middleware +- The server should listen on port 3001 +- Include a package.json with the required dependencies` + +const result = await orchestrator.runTeam(team, goal) + +console.log('\n' + '='.repeat(60)) + +// --------------------------------------------------------------------------- +// Results +// --------------------------------------------------------------------------- +console.log('\nTeam run complete.') +console.log(`Success: ${result.success}`) +console.log(`Total tokens — input: ${result.totalTokenUsage.input_tokens}, output: ${result.totalTokenUsage.output_tokens}`) + +console.log('\nPer-agent results:') +for (const [agentName, agentResult] of result.agentResults) { + const status = agentResult.success ? 'OK' : 'FAILED' + const tools = agentResult.toolCalls.length + console.log(` ${agentName.padEnd(12)} [${status}] tool_calls=${tools}`) + if (!agentResult.success) { + console.log(` Error: ${agentResult.output.slice(0, 120)}`) + } +} + +// Sample outputs +const developerResult = result.agentResults.get('developer') +if (developerResult?.success) { + console.log('\nDeveloper output (last 600 chars):') + console.log('─'.repeat(60)) + const out = developerResult.output + console.log(out.length > 600 ? '...' + out.slice(-600) : out) + console.log('─'.repeat(60)) +} + +const reviewerResult = result.agentResults.get('reviewer') +if (reviewerResult?.success) { + console.log('\nReviewer output:') + console.log('─'.repeat(60)) + console.log(reviewerResult.output) + console.log('─'.repeat(60)) +} diff --git a/package-lock.json b/package-lock.json index b9180e3..f6c979c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "openai": "^4.73.0", "zod": "^3.23.0" }, + "bin": { + "oma": "dist/cli/oma.js" + }, "devDependencies": { "@google/genai": "^1.48.0", "@modelcontextprotocol/sdk": "^1.18.0", diff --git a/src/cli/oma.ts b/src/cli/oma.ts index d73760f..04d7fdf 100644 --- a/src/cli/oma.ts +++ b/src/cli/oma.ts @@ -49,6 +49,7 @@ const PROVIDER_REFERENCE: ReadonlyArray<{ notes?: string }> = [ { id: 'anthropic', apiKeyEnv: ['ANTHROPIC_API_KEY'], baseUrlSupported: true }, + { id: 'azure-openai', apiKeyEnv: ['AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_ENDPOINT', 'AZURE_OPENAI_DEPLOYMENT'], baseUrlSupported: true, notes: 'Azure OpenAI requires endpoint URL (e.g., https://my-resource.openai.azure.com) and API key. Optional: AZURE_OPENAI_API_VERSION (defaults to 2024-10-21). Prefer setting deployment on agent.model; AZURE_OPENAI_DEPLOYMENT is a fallback when model is blank.' }, { id: 'openai', apiKeyEnv: ['OPENAI_API_KEY'], baseUrlSupported: true, notes: 'Set baseURL for Ollama / vLLM / LM Studio; apiKey may be a placeholder.' }, { id: 'gemini', apiKeyEnv: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'], baseUrlSupported: false }, { id: 'grok', apiKeyEnv: ['XAI_API_KEY'], baseUrlSupported: true }, @@ -262,6 +263,7 @@ function help(): string { const DEFAULT_MODEL_HINT: Record = { anthropic: 'claude-opus-4-6', + 'azure-openai': 'gpt-4', openai: 'gpt-4o', gemini: 'gemini-2.0-flash', grok: 'grok-2-latest', diff --git a/src/llm/adapter.ts b/src/llm/adapter.ts index 75426ef..3a94eb3 100644 --- a/src/llm/adapter.ts +++ b/src/llm/adapter.ts @@ -38,21 +38,22 @@ import type { LLMAdapter } from '../types.js' * Additional providers can be integrated by implementing {@link LLMAdapter} * directly and bypassing this factory. */ -export type SupportedProvider = 'anthropic' | 'copilot' | 'deepseek' | 'grok' | 'minimax' | 'openai' | 'gemini' +export type SupportedProvider = 'anthropic' | 'azure-openai' | 'copilot' | 'deepseek' | 'grok' | 'minimax' | 'openai' | 'gemini' /** * Instantiate the appropriate {@link LLMAdapter} for the given provider. * * API keys fall back to the standard environment variables when not supplied * explicitly: - * - `anthropic` → `ANTHROPIC_API_KEY` - * - `openai` → `OPENAI_API_KEY` - * - `gemini` → `GEMINI_API_KEY` / `GOOGLE_API_KEY` - * - `grok` → `XAI_API_KEY` - * - `minimax` → `MINIMAX_API_KEY` - * - `deepseek` → `DEEPSEEK_API_KEY` - * - `copilot` → `GITHUB_COPILOT_TOKEN` / `GITHUB_TOKEN`, or interactive - * OAuth2 device flow if neither is set + * - `anthropic` → `ANTHROPIC_API_KEY` + * - `azure-openai` → `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_VERSION`, `AZURE_OPENAI_DEPLOYMENT` + * - `openai` → `OPENAI_API_KEY` + * - `gemini` → `GEMINI_API_KEY` / `GOOGLE_API_KEY` + * - `grok` → `XAI_API_KEY` + * - `minimax` → `MINIMAX_API_KEY` + * - `deepseek` → `DEEPSEEK_API_KEY` + * - `copilot` → `GITHUB_COPILOT_TOKEN` / `GITHUB_TOKEN`, or interactive + * OAuth2 device flow if neither is set * * Adapters are imported lazily so that projects using only one provider * are not forced to install the SDK for the other. @@ -99,6 +100,12 @@ export async function createAdapter( const { DeepSeekAdapter } = await import('./deepseek.js') return new DeepSeekAdapter(apiKey, baseURL) } + case 'azure-openai': { + // For azure-openai, the `baseURL` parameter serves as the Azure endpoint URL. + // To override the API version, set AZURE_OPENAI_API_VERSION env var. + const { AzureOpenAIAdapter } = await import('./azure-openai.js') + return new AzureOpenAIAdapter(apiKey, baseURL) + } default: { // The `never` cast here makes TypeScript enforce exhaustiveness. const _exhaustive: never = provider diff --git a/src/llm/azure-openai.ts b/src/llm/azure-openai.ts new file mode 100644 index 0000000..321bdc3 --- /dev/null +++ b/src/llm/azure-openai.ts @@ -0,0 +1,313 @@ +/** + * @fileoverview Azure OpenAI adapter implementing {@link LLMAdapter}. + * + * Azure OpenAI uses regional deployment endpoints and API versioning that differ + * from standard OpenAI: + * + * - Endpoint: `https://{resource-name}.openai.azure.com` + * - API version: Query parameter (e.g., `?api-version=2024-10-21`) + * - Model/Deployment: Users deploy models with custom names; the `model` field + * in agent config should contain the Azure deployment name, not the underlying + * model name (e.g., `model: 'my-gpt4-deployment'`) + * + * The OpenAI SDK provides an `AzureOpenAI` client class that handles these + * Azure-specific requirements. This adapter uses that client while reusing all + * message conversion logic from `openai-common.ts`. + * + * Environment variable resolution order: + * 1. Constructor arguments + * 2. `AZURE_OPENAI_API_KEY` environment variable + * 3. `AZURE_OPENAI_ENDPOINT` environment variable + * 4. `AZURE_OPENAI_API_VERSION` environment variable (defaults to '2024-10-21') + * 5. `AZURE_OPENAI_DEPLOYMENT` as an optional fallback when `model` is blank + * + * Note: Azure introduced a next-generation v1 API (August 2025) that uses the standard + * OpenAI() client with baseURL set to `{endpoint}/openai/v1/` and requires no api-version. + * That path is not yet supported by this adapter. To use it, pass `provider: 'openai'` + * with `baseURL: 'https://{resource}.openai.azure.com/openai/v1/'` in your agent config. + * + * @example + * ```ts + * import { AzureOpenAIAdapter } from './azure-openai.js' + * + * const adapter = new AzureOpenAIAdapter() + * const response = await adapter.chat(messages, { + * model: 'my-gpt4-deployment', // Azure deployment name, not 'gpt-4' + * maxTokens: 1024, + * }) + * ``` + */ + +import { AzureOpenAI } from 'openai' +import type { + ChatCompletionChunk, +} from 'openai/resources/chat/completions/index.js' + +import type { + ContentBlock, + LLMAdapter, + LLMChatOptions, + LLMMessage, + LLMResponse, + LLMStreamOptions, + StreamEvent, + TextBlock, + ToolUseBlock, +} from '../types.js' + +import { + toOpenAITool, + fromOpenAICompletion, + normalizeFinishReason, + buildOpenAIMessageList, +} from './openai-common.js' +import { extractToolCallsFromText } from '../tool/text-tool-extractor.js' + +// --------------------------------------------------------------------------- +// Adapter implementation +// --------------------------------------------------------------------------- + +const DEFAULT_AZURE_OPENAI_API_VERSION = '2024-10-21' + +function resolveAzureDeploymentName(model: string): string { + const explicitModel = model.trim() + if (explicitModel.length > 0) return explicitModel + + const fallbackDeployment = process.env['AZURE_OPENAI_DEPLOYMENT']?.trim() + if (fallbackDeployment !== undefined && fallbackDeployment.length > 0) { + return fallbackDeployment + } + + throw new Error( + 'Azure OpenAI deployment is required. Set agent model to your deployment name, or set AZURE_OPENAI_DEPLOYMENT.', + ) +} + +/** + * LLM adapter backed by Azure OpenAI Chat Completions API. + * + * Thread-safe — a single instance may be shared across concurrent agent runs. + */ +export class AzureOpenAIAdapter implements LLMAdapter { + readonly name: string = 'azure-openai' + + readonly #client: AzureOpenAI + + /** + * @param apiKey - Azure OpenAI API key (falls back to AZURE_OPENAI_API_KEY env var) + * @param endpoint - Azure endpoint URL (falls back to AZURE_OPENAI_ENDPOINT env var) + * @param apiVersion - API version string (falls back to AZURE_OPENAI_API_VERSION, defaults to '2024-10-21') + */ + constructor(apiKey?: string, endpoint?: string, apiVersion?: string) { + this.#client = new AzureOpenAI({ + apiKey: apiKey ?? process.env['AZURE_OPENAI_API_KEY'], + endpoint: endpoint ?? process.env['AZURE_OPENAI_ENDPOINT'], + apiVersion: apiVersion ?? process.env['AZURE_OPENAI_API_VERSION'] ?? DEFAULT_AZURE_OPENAI_API_VERSION, + }) + } + + // ------------------------------------------------------------------------- + // chat() + // ------------------------------------------------------------------------- + + /** + * Send a synchronous (non-streaming) chat request and return the complete + * {@link LLMResponse}. + * + * Throws an `AzureOpenAI.APIError` on non-2xx responses. Callers should catch and + * handle these (e.g. rate limits, context length exceeded, deployment not found). + */ + async chat(messages: LLMMessage[], options: LLMChatOptions): Promise { + const deploymentName = resolveAzureDeploymentName(options.model) + const openAIMessages = buildOpenAIMessageList(messages, options.systemPrompt) + + const completion = await this.#client.chat.completions.create( + { + model: deploymentName, + messages: openAIMessages, + max_tokens: options.maxTokens, + temperature: options.temperature, + tools: options.tools ? options.tools.map(toOpenAITool) : undefined, + stream: false, + }, + { + signal: options.abortSignal, + }, + ) + + const toolNames = options.tools?.map(t => t.name) + return fromOpenAICompletion(completion, toolNames) + } + + // ------------------------------------------------------------------------- + // stream() + // ------------------------------------------------------------------------- + + /** + * Send a streaming chat request and yield {@link StreamEvent}s incrementally. + * + * Sequence guarantees match {@link OpenAIAdapter.stream}: + * - Zero or more `text` events + * - Zero or more `tool_use` events (emitted once per tool call, after + * arguments have been fully assembled) + * - Exactly one terminal event: `done` or `error` + */ + async *stream( + messages: LLMMessage[], + options: LLMStreamOptions, + ): AsyncIterable { + const deploymentName = resolveAzureDeploymentName(options.model) + const openAIMessages = buildOpenAIMessageList(messages, options.systemPrompt) + + // We request usage in the final chunk so we can include it in the `done` event. + const streamResponse = await this.#client.chat.completions.create( + { + model: deploymentName, + messages: openAIMessages, + max_tokens: options.maxTokens, + temperature: options.temperature, + tools: options.tools ? options.tools.map(toOpenAITool) : undefined, + stream: true, + stream_options: { include_usage: true }, + }, + { + signal: options.abortSignal, + }, + ) + + // Accumulate state across chunks. + let completionId = '' + let completionModel = '' + let finalFinishReason: string = 'stop' + let inputTokens = 0 + let outputTokens = 0 + + // tool_calls are streamed piecemeal; key = tool call index + const toolCallBuffers = new Map< + number, + { id: string; name: string; argsJson: string } + >() + + // Full text accumulator for the `done` response. + let fullText = '' + + try { + for await (const chunk of streamResponse) { + completionId = chunk.id + completionModel = chunk.model + + // Usage is only populated in the final chunk when stream_options.include_usage is set. + if (chunk.usage !== null && chunk.usage !== undefined) { + inputTokens = chunk.usage.prompt_tokens + outputTokens = chunk.usage.completion_tokens + } + + const choice: ChatCompletionChunk.Choice | undefined = chunk.choices[0] + if (choice === undefined) continue + + const delta = choice.delta + + // --- text delta --- + if (delta.content !== null && delta.content !== undefined) { + fullText += delta.content + const textEvent: StreamEvent = { type: 'text', data: delta.content } + yield textEvent + } + + // --- tool call delta --- + for (const toolCallDelta of delta.tool_calls ?? []) { + const idx = toolCallDelta.index + + if (!toolCallBuffers.has(idx)) { + toolCallBuffers.set(idx, { + id: toolCallDelta.id ?? '', + name: toolCallDelta.function?.name ?? '', + argsJson: '', + }) + } + + const buf = toolCallBuffers.get(idx) + // buf is guaranteed to exist: we just set it above. + if (buf !== undefined) { + if (toolCallDelta.id) buf.id = toolCallDelta.id + if (toolCallDelta.function?.name) buf.name = toolCallDelta.function.name + if (toolCallDelta.function?.arguments) { + buf.argsJson += toolCallDelta.function.arguments + } + } + } + + if (choice.finish_reason !== null && choice.finish_reason !== undefined) { + finalFinishReason = choice.finish_reason + } + } + + // Emit accumulated tool_use events after the stream ends. + const finalToolUseBlocks: ToolUseBlock[] = [] + for (const buf of toolCallBuffers.values()) { + let parsedInput: Record = {} + try { + const parsed: unknown = JSON.parse(buf.argsJson) + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + parsedInput = parsed as Record + } + } catch { + // Malformed JSON — surface as empty object. + } + + const toolUseBlock: ToolUseBlock = { + type: 'tool_use', + id: buf.id, + name: buf.name, + input: parsedInput, + } + finalToolUseBlocks.push(toolUseBlock) + const toolUseEvent: StreamEvent = { type: 'tool_use', data: toolUseBlock } + yield toolUseEvent + } + + // Build the complete content array for the done response. + const doneContent: ContentBlock[] = [] + if (fullText.length > 0) { + const textBlock: TextBlock = { type: 'text', text: fullText } + doneContent.push(textBlock) + } + doneContent.push(...finalToolUseBlocks) + + // Fallback: extract tool calls from text when streaming produced no + // native tool_calls (same logic as fromOpenAICompletion). + if (finalToolUseBlocks.length === 0 && fullText.length > 0 && options.tools) { + const toolNames = options.tools.map(t => t.name) + const extracted = extractToolCallsFromText(fullText, toolNames) + if (extracted.length > 0) { + doneContent.push(...extracted) + for (const block of extracted) { + yield { type: 'tool_use', data: block } satisfies StreamEvent + } + } + } + + const hasToolUseBlocks = doneContent.some(b => b.type === 'tool_use') + const resolvedStopReason = hasToolUseBlocks && finalFinishReason === 'stop' + ? 'tool_use' + : normalizeFinishReason(finalFinishReason) + + const finalResponse: LLMResponse = { + id: completionId, + content: doneContent, + model: completionModel, + stop_reason: resolvedStopReason, + usage: { input_tokens: inputTokens, output_tokens: outputTokens }, + } + + const doneEvent: StreamEvent = { type: 'done', data: finalResponse } + yield doneEvent + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + const errorEvent: StreamEvent = { type: 'error', data: error } + yield errorEvent + } + } +} + + diff --git a/tests/azure-openai-adapter.test.ts b/tests/azure-openai-adapter.test.ts new file mode 100644 index 0000000..cd2f181 --- /dev/null +++ b/tests/azure-openai-adapter.test.ts @@ -0,0 +1,383 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { chatOpts, collectEvents, textMsg, toolDef } from './helpers/llm-fixtures.js' +import type { LLMResponse, ToolUseBlock } from '../src/types.js' + +// --------------------------------------------------------------------------- +// Mock AzureOpenAI constructor (must be hoisted for Vitest) +// --------------------------------------------------------------------------- +const AzureOpenAIMock = vi.hoisted(() => vi.fn()) +const createCompletionMock = vi.hoisted(() => vi.fn()) + +vi.mock('openai', () => ({ + AzureOpenAI: AzureOpenAIMock, +})) + +import { AzureOpenAIAdapter } from '../src/llm/azure-openai.js' +import { createAdapter } from '../src/llm/adapter.js' + +function makeCompletion(overrides: Record = {}) { + return { + id: 'chatcmpl-123', + model: 'gpt-4o', + choices: [{ + index: 0, + message: { + role: 'assistant', + content: 'Hello', + tool_calls: undefined, + }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + ...overrides, + } +} + +async function* makeChunks(chunks: Array>) { + for (const chunk of chunks) yield chunk +} + +function textChunk(text: string, finish_reason: string | null = null, usage: Record | null = null) { + return { + id: 'chatcmpl-123', + model: 'gpt-4o', + choices: [{ + index: 0, + delta: { content: text }, + finish_reason, + }], + usage, + } +} + +function toolCallChunk( + index: number, + id: string | undefined, + name: string | undefined, + args: string, + finish_reason: string | null = null, +) { + return { + id: 'chatcmpl-123', + model: 'gpt-4o', + choices: [{ + index: 0, + delta: { + tool_calls: [{ + index, + id, + function: { + name, + arguments: args, + }, + }], + }, + finish_reason, + }], + usage: null, + } +} + +// --------------------------------------------------------------------------- +// AzureOpenAIAdapter tests +// --------------------------------------------------------------------------- + +describe('AzureOpenAIAdapter', () => { + beforeEach(() => { + AzureOpenAIMock.mockClear() + createCompletionMock.mockReset() + AzureOpenAIMock.mockImplementation(() => ({ + chat: { + completions: { + create: createCompletionMock, + }, + }, + })) + }) + + it('has name "azure-openai"', () => { + const adapter = new AzureOpenAIAdapter() + expect(adapter.name).toBe('azure-openai') + }) + + it('uses AZURE_OPENAI_API_KEY by default', () => { + const originalKey = process.env['AZURE_OPENAI_API_KEY'] + const originalEndpoint = process.env['AZURE_OPENAI_ENDPOINT'] + process.env['AZURE_OPENAI_API_KEY'] = 'azure-test-key-123' + process.env['AZURE_OPENAI_ENDPOINT'] = 'https://test.openai.azure.com' + + try { + new AzureOpenAIAdapter() + expect(AzureOpenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'azure-test-key-123', + endpoint: 'https://test.openai.azure.com', + }) + ) + } finally { + if (originalKey === undefined) { + delete process.env['AZURE_OPENAI_API_KEY'] + } else { + process.env['AZURE_OPENAI_API_KEY'] = originalKey + } + if (originalEndpoint === undefined) { + delete process.env['AZURE_OPENAI_ENDPOINT'] + } else { + process.env['AZURE_OPENAI_ENDPOINT'] = originalEndpoint + } + } + }) + + it('uses AZURE_OPENAI_ENDPOINT by default', () => { + const originalEndpoint = process.env['AZURE_OPENAI_ENDPOINT'] + process.env['AZURE_OPENAI_ENDPOINT'] = 'https://my-resource.openai.azure.com' + + try { + new AzureOpenAIAdapter('some-key') + expect(AzureOpenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'some-key', + endpoint: 'https://my-resource.openai.azure.com', + }) + ) + } finally { + if (originalEndpoint === undefined) { + delete process.env['AZURE_OPENAI_ENDPOINT'] + } else { + process.env['AZURE_OPENAI_ENDPOINT'] = originalEndpoint + } + } + }) + + it('uses default API version when not set', () => { + new AzureOpenAIAdapter('some-key', 'https://test.openai.azure.com') + expect(AzureOpenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'some-key', + endpoint: 'https://test.openai.azure.com', + apiVersion: '2024-10-21', + }) + ) + }) + + it('uses AZURE_OPENAI_API_VERSION env var when set', () => { + const originalVersion = process.env['AZURE_OPENAI_API_VERSION'] + process.env['AZURE_OPENAI_API_VERSION'] = '2024-03-01-preview' + + try { + new AzureOpenAIAdapter('some-key', 'https://test.openai.azure.com') + expect(AzureOpenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'some-key', + endpoint: 'https://test.openai.azure.com', + apiVersion: '2024-03-01-preview', + }) + ) + } finally { + if (originalVersion === undefined) { + delete process.env['AZURE_OPENAI_API_VERSION'] + } else { + process.env['AZURE_OPENAI_API_VERSION'] = originalVersion + } + } + }) + + it('allows overriding apiKey, endpoint, and apiVersion', () => { + new AzureOpenAIAdapter( + 'custom-key', + 'https://custom.openai.azure.com', + '2024-04-01-preview' + ) + expect(AzureOpenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'custom-key', + endpoint: 'https://custom.openai.azure.com', + apiVersion: '2024-04-01-preview', + }) + ) + }) + + it('createAdapter("azure-openai") returns AzureOpenAIAdapter instance', async () => { + const adapter = await createAdapter('azure-openai') + expect(adapter).toBeInstanceOf(AzureOpenAIAdapter) + }) + + it('chat() calls SDK with expected parameters', async () => { + createCompletionMock.mockResolvedValue(makeCompletion()) + const adapter = new AzureOpenAIAdapter('k', 'https://test.openai.azure.com') + const tool = toolDef('search', 'Search') + + const result = await adapter.chat( + [textMsg('user', 'Hi')], + chatOpts({ + model: 'my-deployment', + tools: [tool], + temperature: 0.3, + }), + ) + + const callArgs = createCompletionMock.mock.calls[0][0] + expect(callArgs).toMatchObject({ + model: 'my-deployment', + stream: false, + max_tokens: 1024, + temperature: 0.3, + }) + expect(callArgs.tools[0]).toEqual({ + type: 'function', + function: { + name: 'search', + description: 'Search', + parameters: tool.inputSchema, + }, + }) + expect(result).toEqual({ + id: 'chatcmpl-123', + content: [{ type: 'text', text: 'Hello' }], + model: 'gpt-4o', + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 5 }, + }) + }) + + it('chat() maps native tool_calls to tool_use blocks', async () => { + createCompletionMock.mockResolvedValue(makeCompletion({ + choices: [{ + index: 0, + message: { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_1', + type: 'function', + function: { name: 'search', arguments: '{"q":"test"}' }, + }], + }, + finish_reason: 'tool_calls', + }], + })) + const adapter = new AzureOpenAIAdapter('k', 'https://test.openai.azure.com') + + const result = await adapter.chat( + [textMsg('user', 'Hi')], + chatOpts({ model: 'my-deployment', tools: [toolDef('search')] }), + ) + + expect(result.content[0]).toEqual({ + type: 'tool_use', + id: 'call_1', + name: 'search', + input: { q: 'test' }, + }) + expect(result.stop_reason).toBe('tool_use') + }) + + it('chat() uses AZURE_OPENAI_DEPLOYMENT when model is blank', async () => { + const originalDeployment = process.env['AZURE_OPENAI_DEPLOYMENT'] + process.env['AZURE_OPENAI_DEPLOYMENT'] = 'env-deployment' + createCompletionMock.mockResolvedValue({ + id: 'cmpl-1', + model: 'gpt-4', + choices: [ + { + finish_reason: 'stop', + message: { content: 'ok' }, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }) + + try { + const adapter = new AzureOpenAIAdapter('k', 'https://test.openai.azure.com') + await adapter.chat([], { model: ' ' }) + + expect(createCompletionMock).toHaveBeenCalledWith( + expect.objectContaining({ model: 'env-deployment', stream: false }), + expect.any(Object), + ) + } finally { + if (originalDeployment === undefined) { + delete process.env['AZURE_OPENAI_DEPLOYMENT'] + } else { + process.env['AZURE_OPENAI_DEPLOYMENT'] = originalDeployment + } + } + }) + + it('chat() throws when both model and AZURE_OPENAI_DEPLOYMENT are blank', async () => { + const originalDeployment = process.env['AZURE_OPENAI_DEPLOYMENT'] + delete process.env['AZURE_OPENAI_DEPLOYMENT'] + const adapter = new AzureOpenAIAdapter('k', 'https://test.openai.azure.com') + + try { + await expect(adapter.chat([], { model: ' ' })).rejects.toThrow( + 'Azure OpenAI deployment is required', + ) + expect(createCompletionMock).not.toHaveBeenCalled() + } finally { + if (originalDeployment !== undefined) { + process.env['AZURE_OPENAI_DEPLOYMENT'] = originalDeployment + } + } + }) + + it('stream() sends stream options and emits done usage', async () => { + createCompletionMock.mockResolvedValue(makeChunks([ + textChunk('Hi', 'stop'), + { id: 'chatcmpl-123', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 10, completion_tokens: 2 } }, + ])) + const adapter = new AzureOpenAIAdapter('k', 'https://test.openai.azure.com') + + const events = await collectEvents( + adapter.stream([textMsg('user', 'Hi')], chatOpts({ model: 'my-deployment' })), + ) + + const callArgs = createCompletionMock.mock.calls[0][0] + expect(callArgs.stream).toBe(true) + expect(callArgs.stream_options).toEqual({ include_usage: true }) + + const done = events.find(e => e.type === 'done') + const response = done?.data as LLMResponse + expect(response.usage).toEqual({ input_tokens: 10, output_tokens: 2 }) + expect(response.model).toBe('gpt-4o') + }) + + it('stream() accumulates tool call deltas and emits tool_use', async () => { + createCompletionMock.mockResolvedValue(makeChunks([ + toolCallChunk(0, 'call_1', 'search', '{"q":'), + toolCallChunk(0, undefined, undefined, '"test"}', 'tool_calls'), + { id: 'chatcmpl-123', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 10, completion_tokens: 5 } }, + ])) + const adapter = new AzureOpenAIAdapter('k', 'https://test.openai.azure.com') + + const events = await collectEvents( + adapter.stream([textMsg('user', 'Hi')], chatOpts({ model: 'my-deployment' })), + ) + + const toolEvents = events.filter(e => e.type === 'tool_use') + expect(toolEvents).toHaveLength(1) + expect(toolEvents[0]?.data as ToolUseBlock).toEqual({ + type: 'tool_use', + id: 'call_1', + name: 'search', + input: { q: 'test' }, + }) + }) + + it('stream() yields error event when iterator throws', async () => { + createCompletionMock.mockResolvedValue( + (async function* () { + throw new Error('Stream exploded') + })(), + ) + const adapter = new AzureOpenAIAdapter('k', 'https://test.openai.azure.com') + + const events = await collectEvents( + adapter.stream([textMsg('user', 'Hi')], chatOpts({ model: 'my-deployment' })), + ) + + const errorEvents = events.filter(e => e.type === 'error') + expect(errorEvents).toHaveLength(1) + expect((errorEvents[0]?.data as Error).message).toBe('Stream exploded') + }) +})