mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
test: add 35 Azure provider verification tests
- Handler: createAzure constructor args, dual-ID getModel lookup, processUsageMetrics, getMaxOutputTokens, reasoning events, tool-call ignoring, completePrompt error propagation, provider metadata key mismatch - Validation: 4 Azure branches in validate.spec.ts - Config: 4 Azure cases in checkExistApiConfig.spec.ts - Types: 6 model definition correctness tests, 3 Zod schema round-trips - URL parser: 3 additional edge cases Total: 105 tests across 6 suites (was 70)
This commit is contained in:
parent
b7278aece7
commit
d7be2b9b33
7 changed files with 612 additions and 1 deletions
58
packages/types/src/__tests__/azure-models.test.ts
Normal file
58
packages/types/src/__tests__/azure-models.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { ModelInfo } from "../model.js"
|
||||
import { azureModels, azureDefaultModelId, azureDefaultModelInfo } from "../providers/azure.js"
|
||||
|
||||
// Object.entries loses the per-key literal types from `as const satisfies`,
|
||||
// so we cast each value back to ModelInfo to access optional properties.
|
||||
const modelEntries = Object.entries(azureModels) as [string, ModelInfo][]
|
||||
|
||||
describe("Azure model definitions", () => {
|
||||
it("all models have required ModelInfo fields with valid values", () => {
|
||||
for (const [id, info] of modelEntries) {
|
||||
expect(info.maxTokens, `${id} maxTokens`).toBeGreaterThan(0)
|
||||
expect(info.contextWindow, `${id} contextWindow`).toBeGreaterThan(0)
|
||||
expect(typeof info.supportsImages, `${id} supportsImages`).toBe("boolean")
|
||||
expect(typeof info.supportsPromptCache, `${id} supportsPromptCache`).toBe("boolean")
|
||||
expect(info.inputPrice, `${id} inputPrice`).toBeGreaterThanOrEqual(0)
|
||||
expect(info.outputPrice, `${id} outputPrice`).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
|
||||
it("default model ID exists in model map", () => {
|
||||
expect(azureModels[azureDefaultModelId]).toBeDefined()
|
||||
})
|
||||
|
||||
it("default model info matches the default model ID entry", () => {
|
||||
expect(azureDefaultModelInfo).toBe(azureModels[azureDefaultModelId])
|
||||
})
|
||||
|
||||
it("models with supportsReasoningEffort have a valid reasoningEffort default", () => {
|
||||
for (const [id, info] of modelEntries) {
|
||||
if (Array.isArray(info.supportsReasoningEffort)) {
|
||||
expect(info.reasoningEffort, `${id} missing reasoningEffort default`).toBeDefined()
|
||||
expect(
|
||||
info.supportsReasoningEffort,
|
||||
`${id} reasoningEffort not in supportsReasoningEffort array`,
|
||||
).toContain(info.reasoningEffort)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("models claiming prompt cache support have cacheReadsPrice defined", () => {
|
||||
for (const [id, info] of modelEntries) {
|
||||
if (info.supportsPromptCache) {
|
||||
// Azure models with cache support define cacheReadsPrice but not
|
||||
// cacheWritesPrice — Azure does not charge separately for cache writes.
|
||||
expect(info.cacheReadsPrice, `${id} supports cache but missing cacheReadsPrice`).toBeDefined()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("maxTokens never exceeds contextWindow for any model", () => {
|
||||
for (const [id, info] of modelEntries) {
|
||||
expect(
|
||||
info.maxTokens,
|
||||
`${id} maxTokens (${info.maxTokens}) exceeds contextWindow (${info.contextWindow})`,
|
||||
).toBeLessThanOrEqual(info.contextWindow)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { getApiProtocol } from "../provider-settings.js"
|
||||
import { getApiProtocol, providerSettingsSchemaDiscriminated } from "../provider-settings.js"
|
||||
|
||||
describe("getApiProtocol", () => {
|
||||
describe("Anthropic-style providers", () => {
|
||||
|
|
@ -62,6 +62,37 @@ describe("getApiProtocol", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("azure provider settings", () => {
|
||||
it("accepts valid Azure config with all fields", () => {
|
||||
const result = providerSettingsSchemaDiscriminated.safeParse({
|
||||
apiProvider: "azure",
|
||||
azureApiKey: "test-key-123",
|
||||
azureBaseUrl: "https://my-resource.openai.azure.com/openai",
|
||||
azureDeploymentName: "gpt-5.2",
|
||||
azureApiVersion: "2024-10-21",
|
||||
apiModelId: "gpt-5.2",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("accepts Azure config without optional azureApiKey (managed identity)", () => {
|
||||
const result = providerSettingsSchemaDiscriminated.safeParse({
|
||||
apiProvider: "azure",
|
||||
azureBaseUrl: "https://my-resource.openai.azure.com/openai",
|
||||
azureDeploymentName: "gpt-4o",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects Azure config with invalid field types", () => {
|
||||
const result = providerSettingsSchemaDiscriminated.safeParse({
|
||||
apiProvider: "azure",
|
||||
azureApiKey: 12345,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should return 'openai' when provider is undefined", () => {
|
||||
expect(getApiProtocol(undefined)).toBe("openai")
|
||||
|
|
|
|||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
|
|
@ -749,6 +749,9 @@ importers:
|
|||
'@ai-sdk/amazon-bedrock':
|
||||
specifier: ^4.0.50
|
||||
version: 4.0.50(zod@3.25.76)
|
||||
'@ai-sdk/azure':
|
||||
specifier: ^3.0.26
|
||||
version: 3.0.27(zod@3.25.76)
|
||||
'@ai-sdk/baseten':
|
||||
specifier: ^1.0.31
|
||||
version: 1.0.31(zod@3.25.76)
|
||||
|
|
@ -1438,6 +1441,12 @@ packages:
|
|||
peerDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/azure@3.0.27':
|
||||
resolution: {integrity: sha512-UEaWnOlMRYErQoZsFNxuF0shQ/XhsFcVFm6LbA1RMOBsfWkBtKCTaz+dDsbB8dzGRCa2knZR2thtdYwwSKRyQA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/baseten@1.0.31':
|
||||
resolution: {integrity: sha512-tGbV96WBb5nnfyUYFrPyBxrhw53YlKSJbMC+rH3HhQlUaIs8+m/Bm4M0isrek9owIIf4MmmSDZ5VZL08zz7eFQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -1528,6 +1537,12 @@ packages:
|
|||
peerDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/openai@3.0.26':
|
||||
resolution: {integrity: sha512-W/hiwxIfG29IO0Fob1HwWpFssMsNrxWoX8A7DwNGOtKArDBmJNuGzQeU/k0Fnh8WyvZEnfxkjO4oXkSXfVBayg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/provider-utils@3.0.20':
|
||||
resolution: {integrity: sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -11128,6 +11143,13 @@ snapshots:
|
|||
'@ai-sdk/provider-utils': 4.0.13(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/azure@3.0.27(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/openai': 3.0.26(zod@3.25.76)
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@ai-sdk/provider-utils': 4.0.14(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/baseten@1.0.31(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/openai-compatible': 2.0.28(zod@3.25.76)
|
||||
|
|
@ -11228,6 +11250,12 @@ snapshots:
|
|||
'@ai-sdk/provider-utils': 4.0.14(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/openai@3.0.26(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@ai-sdk/provider-utils': 4.0.14(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/provider-utils@3.0.20(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.1
|
||||
|
|
|
|||
|
|
@ -23,8 +23,11 @@ vi.mock("@ai-sdk/azure", () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
import { createAzure } from "@ai-sdk/azure"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { azureDefaultModelInfo, azureModels, type ModelInfo } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
import { AzureHandler } from "../azure"
|
||||
|
|
@ -374,4 +377,400 @@ describe("AzureHandler", () => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createAzure constructor args", () => {
|
||||
it("should pass correct configuration to createAzure", () => {
|
||||
const handler = new AzureHandler({
|
||||
azureApiKey: "test-key",
|
||||
azureBaseUrl: "https://myresource.openai.azure.com/openai",
|
||||
azureApiVersion: "2025-04-01-preview",
|
||||
azureDeploymentName: "my-deployment",
|
||||
apiModelId: "gpt-4o",
|
||||
})
|
||||
// Force model creation which triggers provider usage
|
||||
handler.getModel()
|
||||
expect(createAzure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://myresource.openai.azure.com/openai",
|
||||
apiKey: "test-key",
|
||||
apiVersion: "2025-04-01-preview",
|
||||
useDeploymentBasedUrls: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel - model lookup", () => {
|
||||
it("should use apiModelId for capability lookup when different from deploymentName", () => {
|
||||
const handler = new AzureHandler({
|
||||
azureDeploymentName: "my-custom-deployment",
|
||||
apiModelId: "gpt-4o",
|
||||
azureBaseUrl: "https://x.openai.azure.com/openai",
|
||||
})
|
||||
const model = handler.getModel()
|
||||
// Model ID used for API calls should be deployment name
|
||||
expect(model.id).toBe("my-custom-deployment")
|
||||
// Model info should come from gpt-4o lookup
|
||||
expect(model.info.contextWindow).toBe(128_000) // gpt-4o's context window
|
||||
expect(model.info.maxTokens).toBe(16_384) // gpt-4o's maxTokens
|
||||
})
|
||||
|
||||
it("should fall back to deployment name for capability lookup when apiModelId not in catalog", () => {
|
||||
const handler = new AzureHandler({
|
||||
azureDeploymentName: "gpt-4o",
|
||||
apiModelId: "some-unknown-model",
|
||||
azureBaseUrl: "https://x.openai.azure.com/openai",
|
||||
})
|
||||
const model = handler.getModel()
|
||||
// Should fall back to looking up "gpt-4o" (the deployment name) in the catalog
|
||||
expect(model.info.contextWindow).toBe(128_000)
|
||||
expect(model.info.maxTokens).toBe(16_384)
|
||||
})
|
||||
|
||||
it("should fall back to azureDefaultModelInfo when both IDs are unrecognized", () => {
|
||||
const handler = new AzureHandler({
|
||||
azureDeploymentName: "totally-custom-name",
|
||||
apiModelId: "also-not-in-catalog",
|
||||
azureBaseUrl: "https://x.openai.azure.com/openai",
|
||||
})
|
||||
const model = handler.getModel()
|
||||
// Should use default model info (gpt-5.2)
|
||||
expect(model.info).toBeDefined()
|
||||
expect(model.info.contextWindow).toBe(azureDefaultModelInfo.contextWindow)
|
||||
expect(model.info.maxTokens).toBe(azureDefaultModelInfo.maxTokens)
|
||||
})
|
||||
})
|
||||
|
||||
describe("processUsageMetrics", () => {
|
||||
class TestAzureHandler extends AzureHandler {
|
||||
public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
|
||||
return this.processUsageMetrics(usage, providerMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
it("should correctly process usage metrics including cache information from providerMetadata", () => {
|
||||
const testHandler = new TestAzureHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
}
|
||||
|
||||
// Azure provides cache metrics via providerMetadata
|
||||
const providerMetadata = {
|
||||
azure: {
|
||||
promptCacheHitTokens: 20,
|
||||
promptCacheMissTokens: 80,
|
||||
},
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage, providerMetadata)
|
||||
|
||||
expect(result.type).toBe("usage")
|
||||
expect(result.inputTokens).toBe(100)
|
||||
expect(result.outputTokens).toBe(50)
|
||||
expect(result.cacheWriteTokens).toBe(80) // promptCacheMissTokens
|
||||
expect(result.cacheReadTokens).toBe(20) // promptCacheHitTokens
|
||||
})
|
||||
|
||||
it("should handle usage with details.cachedInputTokens when providerMetadata is not available", () => {
|
||||
const testHandler = new TestAzureHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
details: {
|
||||
cachedInputTokens: 25,
|
||||
reasoningTokens: 30,
|
||||
},
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage)
|
||||
|
||||
expect(result.type).toBe("usage")
|
||||
expect(result.inputTokens).toBe(100)
|
||||
expect(result.outputTokens).toBe(50)
|
||||
expect(result.cacheReadTokens).toBe(25) // from details.cachedInputTokens
|
||||
expect(result.cacheWriteTokens).toBeUndefined()
|
||||
expect(result.reasoningTokens).toBe(30)
|
||||
})
|
||||
|
||||
it("should handle missing cache metrics gracefully", () => {
|
||||
const testHandler = new TestAzureHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
// No details or providerMetadata
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage)
|
||||
|
||||
expect(result.type).toBe("usage")
|
||||
expect(result.inputTokens).toBe(100)
|
||||
expect(result.outputTokens).toBe(50)
|
||||
expect(result.cacheWriteTokens).toBeUndefined()
|
||||
expect(result.cacheReadTokens).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMaxOutputTokens", () => {
|
||||
class TestAzureHandler extends AzureHandler {
|
||||
public testGetMaxOutputTokens() {
|
||||
return this.getMaxOutputTokens()
|
||||
}
|
||||
}
|
||||
|
||||
it("should return modelMaxTokens from options when set and > 0", () => {
|
||||
const customMaxTokens = 5000
|
||||
const testHandler = new TestAzureHandler({
|
||||
...mockOptions,
|
||||
modelMaxTokens: customMaxTokens,
|
||||
})
|
||||
|
||||
const result = testHandler.testGetMaxOutputTokens()
|
||||
expect(result).toBe(customMaxTokens)
|
||||
})
|
||||
|
||||
it("should fall back to info.maxTokens when modelMaxTokens not set", () => {
|
||||
const testHandler = new TestAzureHandler(mockOptions)
|
||||
const result = testHandler.testGetMaxOutputTokens()
|
||||
|
||||
// Default handler uses gpt-4o deployment which has maxTokens of 16_384
|
||||
expect(result).toBe((azureModels as Record<string, ModelInfo>)["gpt-4o"].maxTokens)
|
||||
})
|
||||
|
||||
it("should fall back to info.maxTokens when modelMaxTokens is 0", () => {
|
||||
const testHandler = new TestAzureHandler({
|
||||
...mockOptions,
|
||||
modelMaxTokens: 0,
|
||||
})
|
||||
|
||||
const result = testHandler.testGetMaxOutputTokens()
|
||||
|
||||
// 0 is falsy so || falls through to info.maxTokens (gpt-4o = 16_384)
|
||||
expect(result).toBe((azureModels as Record<string, ModelInfo>)["gpt-4o"].maxTokens)
|
||||
})
|
||||
})
|
||||
|
||||
describe("reasoning events", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: "Hello!" }],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle reasoning content in stream", async () => {
|
||||
// Azure models like o3, o4-mini, gpt-5 can emit reasoning events
|
||||
async function* mockFullStream() {
|
||||
yield { type: "reasoning", text: "Let me think about this..." }
|
||||
yield { type: "reasoning", text: " I'll analyze step by step." }
|
||||
yield { type: "text-delta", text: "Here is the answer." }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 20,
|
||||
outputTokens: 10,
|
||||
details: {
|
||||
reasoningTokens: 15,
|
||||
},
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({
|
||||
azure: {
|
||||
promptCacheHitTokens: 5,
|
||||
promptCacheMissTokens: 15,
|
||||
},
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Should have reasoning chunks
|
||||
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
|
||||
expect(reasoningChunks).toHaveLength(2)
|
||||
expect(reasoningChunks[0].text).toBe("Let me think about this...")
|
||||
expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.")
|
||||
|
||||
// Should also have text chunks
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0].text).toBe("Here is the answer.")
|
||||
})
|
||||
|
||||
it("should include reasoning tokens in usage metrics", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "reasoning", text: "Thinking..." }
|
||||
yield { type: "text-delta", text: "Answer" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 20,
|
||||
outputTokens: 10,
|
||||
details: {
|
||||
reasoningTokens: 25,
|
||||
},
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks).toHaveLength(1)
|
||||
expect(usageChunks[0].reasoningTokens).toBe(25)
|
||||
expect(usageChunks[0].inputTokens).toBe(20)
|
||||
expect(usageChunks[0].outputTokens).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool-call event handling", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: "Use a tool" }],
|
||||
},
|
||||
]
|
||||
|
||||
it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
|
||||
// tool-call events are intentionally ignored because tool-input-start/delta/end
|
||||
// already provide complete tool call information. Emitting tool-call would cause
|
||||
// duplicate tools in the UI for AI SDK providers.
|
||||
async function* mockFullStream() {
|
||||
yield {
|
||||
type: "tool-call",
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "read_file",
|
||||
input: { path: "test.ts" },
|
||||
}
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages, {
|
||||
taskId: "test-task",
|
||||
tools: [
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read a file",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" } },
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// tool-call events are ignored, so no tool_call chunks should be emitted
|
||||
const toolCallChunks = chunks.filter((c) => c.type === "tool_call")
|
||||
expect(toolCallChunks).toHaveLength(0)
|
||||
|
||||
// Also verify no tool_call_start from tool-call events (only tool-input-start produces these)
|
||||
const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start")
|
||||
expect(toolCallStartChunks).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt error handling", () => {
|
||||
it("should propagate errors from completePrompt without handleAiSdkError wrapping", async () => {
|
||||
// completePrompt does NOT wrap errors with handleAiSdkError unlike createMessage
|
||||
const rawError = new Error("API request failed")
|
||||
mockGenerateText.mockRejectedValue(rawError)
|
||||
|
||||
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("API request failed")
|
||||
// Verify it's the same raw error (not wrapped by handleAiSdkError)
|
||||
await expect(handler.completePrompt("Test prompt")).rejects.toBe(rawError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("provider metadata key verification", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: "Hello!" }],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle missing azure provider metadata key gracefully", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
// providerMetadata uses wrong key ("openai" instead of "azure")
|
||||
const mockProviderMetadata = Promise.resolve({
|
||||
openai: {
|
||||
promptCacheHitTokens: 2,
|
||||
promptCacheMissTokens: 8,
|
||||
},
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Should not crash — cache tokens should be undefined when azure key is missing
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks).toHaveLength(1)
|
||||
expect(usageChunks[0].inputTokens).toBe(10)
|
||||
expect(usageChunks[0].outputTokens).toBe(5)
|
||||
expect(usageChunks[0].cacheReadTokens).toBeUndefined()
|
||||
expect(usageChunks[0].cacheWriteTokens).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -88,3 +88,36 @@ describe("checkExistKey", () => {
|
|||
expect(checkExistKey(config)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("azure", () => {
|
||||
it("should return true when azureBaseUrl is set", () => {
|
||||
const config: ProviderSettings = {
|
||||
apiProvider: "azure",
|
||||
azureBaseUrl: "https://my-resource.openai.azure.com",
|
||||
}
|
||||
expect(checkExistKey(config)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true when azureDeploymentName is set", () => {
|
||||
const config: ProviderSettings = {
|
||||
apiProvider: "azure",
|
||||
azureDeploymentName: "my-deployment",
|
||||
}
|
||||
expect(checkExistKey(config)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true when azureApiKey is set", () => {
|
||||
const config: ProviderSettings = {
|
||||
apiProvider: "azure",
|
||||
azureApiKey: "my-api-key",
|
||||
}
|
||||
expect(checkExistKey(config)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when no Azure fields are set", () => {
|
||||
const config: ProviderSettings = {
|
||||
apiProvider: "azure",
|
||||
}
|
||||
expect(checkExistKey(config)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -106,4 +106,24 @@ describe("parseAzureUrl", () => {
|
|||
deploymentName: "gpt-4o",
|
||||
})
|
||||
})
|
||||
|
||||
describe("additional edge cases", () => {
|
||||
it("should handle URL with port number", () => {
|
||||
const result = parseAzureUrl("https://localhost:8080/openai/deployments/test/chat/completions")
|
||||
expect(result).toEqual({ baseUrl: "https://localhost:8080/openai", deploymentName: "test" })
|
||||
})
|
||||
|
||||
it("should return null for URL with /openai but no /deployments/ segment", () => {
|
||||
const result = parseAzureUrl("https://myresource.openai.azure.com/openai/models")
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle URL with deep subdomain", () => {
|
||||
const result = parseAzureUrl("https://dept.team.openai.azure.com/openai/deployments/gpt4/chat")
|
||||
expect(result).toEqual({
|
||||
baseUrl: "https://dept.team.openai.azure.com/openai",
|
||||
deploymentName: "gpt4",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -267,3 +267,45 @@ describe("validateBedrockArn", () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("azure validation", () => {
|
||||
it("should return no errors when all Azure fields are empty (fresh provider selection)", () => {
|
||||
const config: ProviderSettings = {
|
||||
apiProvider: "azure",
|
||||
}
|
||||
|
||||
const result = validateApiConfigurationExcludingModelErrors(config)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should require azureBaseUrl when other Azure fields are set", () => {
|
||||
const config: ProviderSettings = {
|
||||
apiProvider: "azure",
|
||||
azureDeploymentName: "my-deployment",
|
||||
}
|
||||
|
||||
const result = validateApiConfigurationExcludingModelErrors(config)
|
||||
expect(result).toBe("settings:validation.azureBaseUrl")
|
||||
})
|
||||
|
||||
it("should require azureDeploymentName when azureBaseUrl is set", () => {
|
||||
const config: ProviderSettings = {
|
||||
apiProvider: "azure",
|
||||
azureBaseUrl: "https://my-resource.openai.azure.com",
|
||||
}
|
||||
|
||||
const result = validateApiConfigurationExcludingModelErrors(config)
|
||||
expect(result).toBe("settings:validation.azureDeploymentName")
|
||||
})
|
||||
|
||||
it("should accept valid config without azureApiKey (managed identity)", () => {
|
||||
const config: ProviderSettings = {
|
||||
apiProvider: "azure",
|
||||
azureBaseUrl: "https://my-resource.openai.azure.com",
|
||||
azureDeploymentName: "my-deployment",
|
||||
}
|
||||
|
||||
const result = validateApiConfigurationExcludingModelErrors(config)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue