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:
Hannes Rudolph 2026-02-06 19:44:02 -07:00
parent b7278aece7
commit d7be2b9b33
7 changed files with 612 additions and 1 deletions

View 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)
}
})
})

View file

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

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

View file

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

View file

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

View file

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

View file

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