- 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:
parent
075ea3dbc9
commit
99d9d7f52e
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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` | 已验证 |
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue