diff --git a/packages/types/src/__tests__/azure-models.test.ts b/packages/types/src/__tests__/azure-models.test.ts new file mode 100644 index 0000000000..ac6e1ea393 --- /dev/null +++ b/packages/types/src/__tests__/azure-models.test.ts @@ -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) + } + }) +}) diff --git a/packages/types/src/__tests__/provider-settings.test.ts b/packages/types/src/__tests__/provider-settings.test.ts index fc7bee2268..0eaaf6fdb8 100644 --- a/packages/types/src/__tests__/provider-settings.test.ts +++ b/packages/types/src/__tests__/provider-settings.test.ts @@ -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") diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c231b43e53..df33f846d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/api/providers/__tests__/azure.spec.ts b/src/api/providers/__tests__/azure.spec.ts index 3d1171a8e2..9196cb795e 100644 --- a/src/api/providers/__tests__/azure.spec.ts +++ b/src/api/providers/__tests__/azure.spec.ts @@ -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)["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)["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() + }) + }) }) diff --git a/src/shared/__tests__/checkExistApiConfig.spec.ts b/src/shared/__tests__/checkExistApiConfig.spec.ts index 55dae005f2..502c9dba69 100644 --- a/src/shared/__tests__/checkExistApiConfig.spec.ts +++ b/src/shared/__tests__/checkExistApiConfig.spec.ts @@ -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) + }) +}) diff --git a/webview-ui/src/components/settings/utils/__tests__/parseAzureUrl.spec.ts b/webview-ui/src/components/settings/utils/__tests__/parseAzureUrl.spec.ts index b90501eed1..1c6059a2a3 100644 --- a/webview-ui/src/components/settings/utils/__tests__/parseAzureUrl.spec.ts +++ b/webview-ui/src/components/settings/utils/__tests__/parseAzureUrl.spec.ts @@ -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", + }) + }) + }) }) diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index 09239b649c..213c1cdb07 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -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() + }) +})