From 3fb3b1f83f6ad340f92ee99dbdc4ad81e96411c6 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 21 Nov 2025 08:40:23 +0000 Subject: [PATCH] fix: enable Azure Anthropic models with OpenAI Compatible provider - Add detection for Azure Anthropic endpoints (services.ai.azure.com/anthropic/) - Use x-api-key header instead of Authorization Bearer for Azure Anthropic - Exclude Anthropic endpoints from Azure AI Inference path appending - Add comprehensive tests for Azure Anthropic integration Fixes #9467 --- .../__tests__/openai-azure-anthropic.spec.ts | 292 ++++++++++++++++++ src/api/providers/openai.ts | 50 ++- 2 files changed, 334 insertions(+), 8 deletions(-) create mode 100644 src/api/providers/__tests__/openai-azure-anthropic.spec.ts diff --git a/src/api/providers/__tests__/openai-azure-anthropic.spec.ts b/src/api/providers/__tests__/openai-azure-anthropic.spec.ts new file mode 100644 index 0000000000..5c4ee73399 --- /dev/null +++ b/src/api/providers/__tests__/openai-azure-anthropic.spec.ts @@ -0,0 +1,292 @@ +// npx vitest run api/providers/__tests__/openai-azure-anthropic.spec.ts + +import { describe, it, expect, beforeEach, vi } from "vitest" +import { OpenAiHandler } from "../openai" +import { ApiHandlerOptions } from "../../../shared/api" +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +const mockCreate = vi.fn() +const mockConstructor = vi.fn() + +vitest.mock("openai", () => { + const MockOpenAI = vi.fn().mockImplementation(function (this: any, config: any) { + mockConstructor(config) + this.chat = { + completions: { + create: mockCreate, + }, + } + return this + }) + + return { + default: MockOpenAI, + AzureOpenAI: MockOpenAI, + } +}) + +describe("OpenAiHandler - Azure Anthropic Integration", () => { + let handler: OpenAiHandler + let azureAnthropicOptions: ApiHandlerOptions + + beforeEach(() => { + vi.clearAllMocks() + azureAnthropicOptions = { + openAiModelId: "claude-sonnet-4-5", + openAiBaseUrl: "https://my-resource.services.ai.azure.com/anthropic/v1/messages", + openAiApiKey: "test-azure-anthropic-key", + } + }) + + describe("Azure Anthropic endpoint detection", () => { + it("should correctly identify Azure Anthropic endpoints", () => { + handler = new OpenAiHandler(azureAnthropicOptions) + + // Check that the constructor was called with x-api-key header + expect(mockConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: azureAnthropicOptions.openAiBaseUrl, + apiKey: "not-provided", + defaultHeaders: expect.objectContaining({ + "x-api-key": "test-azure-anthropic-key", + }), + }), + ) + + // Verify Authorization header is not present + const headers = mockConstructor.mock.calls[0][0].defaultHeaders + expect(headers).not.toHaveProperty("Authorization") + }) + + it("should use x-api-key header for Azure Anthropic instead of Authorization", () => { + handler = new OpenAiHandler(azureAnthropicOptions) + + const callArgs = mockConstructor.mock.calls[0][0] + expect(callArgs.defaultHeaders["x-api-key"]).toBe("test-azure-anthropic-key") + expect(callArgs.apiKey).toBe("not-provided") + }) + + it("should not treat Azure Anthropic as Azure AI Inference", () => { + handler = new OpenAiHandler(azureAnthropicOptions) + + // Azure Anthropic should not have the api-version query parameter + // which is specific to Azure AI Inference + const callArgs = mockConstructor.mock.calls[0][0] + expect(callArgs.defaultQuery).toBeUndefined() + }) + + it("should handle multiple Azure Anthropic URL patterns", () => { + const anthropicUrls = [ + "https://resource1.services.ai.azure.com/anthropic/v1/messages", + "https://my-company-resource.services.ai.azure.com/anthropic/v1/messages", + "https://test.services.ai.azure.com/anthropic/v1/complete", + ] + + anthropicUrls.forEach((url) => { + vi.clearAllMocks() + const options = { + ...azureAnthropicOptions, + openAiBaseUrl: url, + } + handler = new OpenAiHandler(options) + + const callArgs = mockConstructor.mock.calls[0][0] + expect(callArgs.defaultHeaders["x-api-key"]).toBe("test-azure-anthropic-key") + expect(callArgs.apiKey).toBe("not-provided") + }) + }) + }) + + describe("Azure AI Inference exclusion of Anthropic", () => { + it("should treat non-Anthropic Azure endpoints as AI Inference", () => { + const nonAnthropicOptions: ApiHandlerOptions = { + openAiModelId: "deepseek-chat", + openAiBaseUrl: "https://my-deepseek.services.ai.azure.com/v1/chat/completions", + openAiApiKey: "test-key", + } + + handler = new OpenAiHandler(nonAnthropicOptions) + + const callArgs = mockConstructor.mock.calls[0][0] + // Should have api-version for AI Inference + expect(callArgs.defaultQuery).toEqual({ "api-version": "2024-05-01-preview" }) + // Should use regular apiKey, not x-api-key + expect(callArgs.apiKey).toBe("test-key") + expect(callArgs.defaultHeaders["x-api-key"]).toBeUndefined() + }) + + it("should not treat regular OpenAI endpoints as Azure", () => { + const regularOptions: ApiHandlerOptions = { + openAiModelId: "gpt-4", + openAiBaseUrl: "https://api.openai.com/v1", + openAiApiKey: "test-openai-key", + } + + handler = new OpenAiHandler(regularOptions) + + const callArgs = mockConstructor.mock.calls[0][0] + expect(callArgs.defaultQuery).toBeUndefined() + expect(callArgs.apiKey).toBe("test-openai-key") + expect(callArgs.defaultHeaders["x-api-key"]).toBeUndefined() + }) + }) + + describe("Azure Anthropic streaming mode", () => { + it("should not append OPENAI_AZURE_AI_INFERENCE_PATH for Azure Anthropic", async () => { + mockCreate.mockReturnValue({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Hello" }, finish_reason: null }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + } + }, + }) + + handler = new OpenAiHandler({ + ...azureAnthropicOptions, + openAiStreamingEnabled: true, + }) + + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] + + const stream = handler.createMessage(systemPrompt, messages) + const results = [] + for await (const chunk of stream) { + results.push(chunk) + } + + // Verify that create was called without the path option + expect(mockCreate).toHaveBeenCalledWith( + expect.any(Object), + {}, // Empty options object, no path + ) + }) + }) + + describe("Azure Anthropic non-streaming mode", () => { + it("should not append OPENAI_AZURE_AI_INFERENCE_PATH for Azure Anthropic", async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: "Response" } }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + }) + + handler = new OpenAiHandler({ + ...azureAnthropicOptions, + openAiStreamingEnabled: false, + }) + + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] + + const stream = handler.createMessage(systemPrompt, messages) + const results = [] + for await (const chunk of stream) { + results.push(chunk) + } + + // Verify that create was called without the path option + expect(mockCreate).toHaveBeenCalledWith( + expect.any(Object), + {}, // Empty options object, no path + ) + }) + }) + + describe("Azure Anthropic completePrompt", () => { + it("should not append path for Azure Anthropic in completePrompt", async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: "Completed response" } }], + }) + + handler = new OpenAiHandler(azureAnthropicOptions) + const result = await handler.completePrompt("Test prompt") + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "claude-sonnet-4-5", + messages: [{ role: "user", content: "Test prompt" }], + }), + {}, // No path option + ) + + expect(result).toBe("Completed response") + }) + }) + + describe("Azure Anthropic with custom headers", () => { + it("should preserve custom headers while adding x-api-key", () => { + const optionsWithHeaders: ApiHandlerOptions = { + ...azureAnthropicOptions, + openAiHeaders: { + "Custom-Header": "custom-value", + "Another-Header": "another-value", + }, + } + + handler = new OpenAiHandler(optionsWithHeaders) + + const callArgs = mockConstructor.mock.calls[0][0] + expect(callArgs.defaultHeaders["x-api-key"]).toBe("test-azure-anthropic-key") + expect(callArgs.defaultHeaders["Custom-Header"]).toBe("custom-value") + expect(callArgs.defaultHeaders["Another-Header"]).toBe("another-value") + expect(callArgs.defaultHeaders["Authorization"]).toBeUndefined() + }) + + it("should override Authorization header if present in custom headers", () => { + const optionsWithAuth: ApiHandlerOptions = { + ...azureAnthropicOptions, + openAiHeaders: { + Authorization: "Bearer should-be-removed", + }, + } + + handler = new OpenAiHandler(optionsWithAuth) + + const callArgs = mockConstructor.mock.calls[0][0] + expect(callArgs.defaultHeaders["x-api-key"]).toBe("test-azure-anthropic-key") + expect(callArgs.defaultHeaders["Authorization"]).toBeUndefined() + }) + }) + + describe("Edge cases", () => { + it("should handle URLs with trailing slashes", () => { + const optionsWithTrailingSlash: ApiHandlerOptions = { + ...azureAnthropicOptions, + openAiBaseUrl: "https://my-resource.services.ai.azure.com/anthropic/v1/messages/", + } + + handler = new OpenAiHandler(optionsWithTrailingSlash) + + const callArgs = mockConstructor.mock.calls[0][0] + expect(callArgs.defaultHeaders["x-api-key"]).toBe("test-azure-anthropic-key") + }) + + it("should handle URLs with query parameters", () => { + const optionsWithQuery: ApiHandlerOptions = { + ...azureAnthropicOptions, + openAiBaseUrl: "https://my-resource.services.ai.azure.com/anthropic/v1/messages?api-version=2024-05-01", + } + + handler = new OpenAiHandler(optionsWithQuery) + + const callArgs = mockConstructor.mock.calls[0][0] + expect(callArgs.defaultHeaders["x-api-key"]).toBe("test-azure-anthropic-key") + }) + + it("should not treat non-Azure Anthropic URLs as Azure Anthropic", () => { + const nonAzureAnthropicOptions: ApiHandlerOptions = { + openAiModelId: "claude-3-opus", + openAiBaseUrl: "https://api.anthropic.com/v1/messages", + openAiApiKey: "test-key", + } + + handler = new OpenAiHandler(nonAzureAnthropicOptions) + + const callArgs = mockConstructor.mock.calls[0][0] + expect(callArgs.apiKey).toBe("test-key") + expect(callArgs.defaultHeaders["x-api-key"]).toBeUndefined() + }) + }) +}) diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 79d65e82e2..d91d03507e 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -41,17 +41,33 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const baseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1" const apiKey = this.options.openAiApiKey ?? "not-provided" const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) + const isAzureAnthropic = this._isAzureAnthropic(this.options.openAiBaseUrl) const urlHost = this._getUrlHost(this.options.openAiBaseUrl) const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || options.openAiUseAzure - const headers = { + const headers: Record = { ...DEFAULT_HEADERS, ...(this.options.openAiHeaders || {}), } + // For Azure Anthropic, use x-api-key header instead of Authorization Bearer + if (isAzureAnthropic) { + headers["x-api-key"] = apiKey + // Remove Authorization header if it exists + delete headers["Authorization"] + } + const timeout = getApiRequestTimeout() - if (isAzureAiInference) { + if (isAzureAnthropic) { + // Azure Anthropic uses standard OpenAI client but with special headers + this.client = new OpenAI({ + baseURL, + apiKey: "not-provided", // API key is passed via x-api-key header + defaultHeaders: headers, + timeout, + }) + } else if (isAzureAiInference) { // Azure AI Inference Service (e.g., for DeepSeek) uses a different path structure this.client = new OpenAI({ baseURL, @@ -91,6 +107,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const enabledR1Format = this.options.openAiR1FormatEnabled ?? false const enabledLegacyFormat = this.options.openAiLegacyFormat ?? false const isAzureAiInference = this._isAzureAiInference(modelUrl) + const isAzureAnthropic = this._isAzureAnthropic(modelUrl) const deepseekReasoner = modelId.includes("deepseek-reasoner") || enabledR1Format const ark = modelUrl.includes(".volces.com") @@ -175,7 +192,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl try { stream = await this.client.chat.completions.create( requestOptions, - isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, + // Don't append path for Azure Anthropic - it already has the correct path + isAzureAiInference && !isAzureAnthropic ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, ) } catch (error) { throw handleOpenAIError(error, this.providerName) @@ -270,9 +288,12 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl let response try { + const methodIsAzureAiInference = this._isAzureAiInference(modelUrl) + const methodIsAzureAnthropic = this._isAzureAnthropic(modelUrl) response = await this.client.chat.completions.create( requestOptions, - this._isAzureAiInference(modelUrl) ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, + // Don't append path for Azure Anthropic + methodIsAzureAiInference && !methodIsAzureAnthropic ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, ) } catch (error) { throw handleOpenAIError(error, this.providerName) @@ -322,6 +343,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl async completePrompt(prompt: string): Promise { try { const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) + const isAzureAnthropic = this._isAzureAnthropic(this.options.openAiBaseUrl) const model = this.getModel() const modelInfo = model.info @@ -337,7 +359,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl try { response = await this.client.chat.completions.create( requestOptions, - isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, + // Don't append path for Azure Anthropic + isAzureAiInference && !isAzureAnthropic ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, ) } catch (error) { throw handleOpenAIError(error, this.providerName) @@ -361,6 +384,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ): ApiStream { const modelInfo = this.getModel().info const methodIsAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) + const methodIsAzureAnthropic = this._isAzureAnthropic(this.options.openAiBaseUrl) if (this.options.openAiStreamingEnabled ?? true) { const isGrokXAI = this._isGrokXAI(this.options.openAiBaseUrl) @@ -391,7 +415,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl try { stream = await this.client.chat.completions.create( requestOptions, - methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, + // Don't append path for Azure Anthropic + methodIsAzureAiInference && !methodIsAzureAnthropic ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, ) } catch (error) { throw handleOpenAIError(error, this.providerName) @@ -423,7 +448,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl try { response = await this.client.chat.completions.create( requestOptions, - methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, + // Don't append path for Azure Anthropic + methodIsAzureAiInference && !methodIsAzureAnthropic ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, ) } catch (error) { throw handleOpenAIError(error, this.providerName) @@ -521,9 +547,17 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl return urlHost.includes("x.ai") } + private _isAzureAnthropic(baseUrl?: string): boolean { + // Check if the URL is for Azure Anthropic + // Pattern: https://{resource}-resource.services.ai.azure.com/anthropic/v1/messages + if (!baseUrl) return false + return baseUrl.includes(".services.ai.azure.com") && baseUrl.includes("/anthropic/") + } + private _isAzureAiInference(baseUrl?: string): boolean { const urlHost = this._getUrlHost(baseUrl) - return urlHost.endsWith(".services.ai.azure.com") + // Exclude Anthropic endpoints from Azure AI Inference detection + return urlHost.endsWith(".services.ai.azure.com") && !this._isAzureAnthropic(baseUrl) } /**