mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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
This commit is contained in:
parent
038f830bf6
commit
3fb3b1f83f
2 changed files with 334 additions and 8 deletions
292
src/api/providers/__tests__/openai-azure-anthropic.spec.ts
Normal file
292
src/api/providers/__tests__/openai-azure-anthropic.spec.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, string> = {
|
||||
...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<string> {
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue