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
This commit is contained in:
Claire 2026-04-20 23:28:30 -07:00 committed by GitHub
parent 075ea3dbc9
commit 99d9d7f52e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 901 additions and 9 deletions

View File

@ -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 |

View File

@ -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` | 已验证 |

View File

@ -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` |

View File

@ -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<string, number>()
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))
}

3
package-lock.json generated
View File

@ -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",

View File

@ -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<SupportedProvider, string> = {
anthropic: 'claude-opus-4-6',
'azure-openai': 'gpt-4',
openai: 'gpt-4o',
gemini: 'gemini-2.0-flash',
grok: 'grok-2-latest',

View File

@ -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

313
src/llm/azure-openai.ts Normal file
View File

@ -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<LLMResponse> {
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<StreamEvent> {
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<string, unknown> = {}
try {
const parsed: unknown = JSON.parse(buf.argsJson)
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
parsedInput = parsed as Record<string, unknown>
}
} 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
}
}
}

View File

@ -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<string, unknown> = {}) {
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<Record<string, unknown>>) {
for (const chunk of chunks) yield chunk
}
function textChunk(text: string, finish_reason: string | null = null, usage: Record<string, number> | 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')
})
})