feat: add Azure AI Foundry support to Anthropic provider

- Add anthropicUseAzureFoundry and anthropicAzureDeploymentName settings
- Allow overriding model ID with Azure deployment name
- Maintain model capabilities while using custom deployment names
- Add comprehensive tests for Azure Foundry functionality

Fixes #9940
This commit is contained in:
Roo Code 2025-12-09 10:57:42 +00:00
parent c103a4a639
commit 49028af5e4
3 changed files with 278 additions and 2 deletions

View file

@ -199,6 +199,8 @@ const anthropicSchema = apiModelIdProviderModelSchema.extend({
anthropicBaseUrl: z.string().optional(),
anthropicUseAuthToken: z.boolean().optional(),
anthropicBeta1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window.
anthropicUseAzureFoundry: z.boolean().optional(), // Enable Azure AI Foundry mode for Anthropic-compatible endpoints.
anthropicAzureDeploymentName: z.string().optional(), // Override model ID with Azure deployment name.
})
const claudeCodeSchema = apiModelIdProviderModelSchema.extend({

View file

@ -0,0 +1,257 @@
// npx vitest run src/api/providers/__tests__/anthropic-azure-foundry.spec.ts
import { AnthropicHandler } from "../anthropic"
import { ApiHandlerOptions } from "../../../shared/api"
const mockCreate = vitest.fn()
vitest.mock("@anthropic-ai/sdk", () => {
const mockAnthropicConstructor = vitest.fn().mockImplementation(() => ({
messages: {
create: mockCreate.mockImplementation(async (options) => {
if (!options.stream) {
return {
id: "test-completion",
content: [{ type: "text", text: "Test response" }],
role: "assistant",
model: options.model,
usage: {
input_tokens: 10,
output_tokens: 5,
},
}
}
return {
async *[Symbol.asyncIterator]() {
yield {
type: "message_start",
message: {
usage: {
input_tokens: 100,
output_tokens: 50,
},
},
}
yield {
type: "content_block_start",
index: 0,
content_block: {
type: "text",
text: "Hello from Azure",
},
}
yield {
type: "content_block_delta",
delta: {
type: "text_delta",
text: " Foundry",
},
}
},
}
}),
},
}))
return {
Anthropic: mockAnthropicConstructor,
}
})
// Import after mock
import { Anthropic } from "@anthropic-ai/sdk"
const mockAnthropicConstructor = vitest.mocked(Anthropic)
describe("AnthropicHandler - Azure Foundry Support", () => {
let handler: AnthropicHandler
let mockOptions: ApiHandlerOptions
beforeEach(() => {
vitest.clearAllMocks()
})
describe("Azure Foundry mode", () => {
it("should use deployment name as model ID when Azure Foundry mode is enabled", () => {
mockOptions = {
apiKey: "test-api-key",
apiModelId: "claude-opus-4-5-20251101",
anthropicBaseUrl: "https://my-resource.services.ai.azure.com/anthropic",
anthropicUseAzureFoundry: true,
anthropicAzureDeploymentName: "claude-opus-4-5",
anthropicUseAuthToken: true,
}
handler = new AnthropicHandler(mockOptions)
const model = handler.getModel()
expect(model.id).toBe("claude-opus-4-5")
// Should still have the correct model info for capabilities
expect(model.info).toBeDefined()
expect(model.info.maxTokens).toBe(32000) // Raw model info value
expect(model.info.contextWindow).toBe(200000)
})
it("should use authToken when Azure Foundry mode is enabled with custom base URL", () => {
mockOptions = {
apiKey: "test-azure-key",
apiModelId: "claude-sonnet-4-5",
anthropicBaseUrl: "https://my-resource.services.ai.azure.com/anthropic",
anthropicUseAzureFoundry: true,
anthropicAzureDeploymentName: "my-sonnet-deployment",
anthropicUseAuthToken: true,
}
handler = new AnthropicHandler(mockOptions)
expect(mockAnthropicConstructor).toHaveBeenCalledWith({
baseURL: "https://my-resource.services.ai.azure.com/anthropic",
authToken: "test-azure-key",
})
})
it("should fall back to selected model when no deployment name is provided", () => {
mockOptions = {
apiKey: "test-api-key",
apiModelId: "claude-opus-4-5-20251101",
anthropicBaseUrl: "https://my-resource.services.ai.azure.com/anthropic",
anthropicUseAzureFoundry: true,
// No deployment name provided
anthropicUseAuthToken: true,
}
handler = new AnthropicHandler(mockOptions)
const model = handler.getModel()
// Should use the original model ID
expect(model.id).toBe("claude-opus-4-5-20251101")
})
it("should not use deployment name when Azure Foundry mode is disabled", () => {
mockOptions = {
apiKey: "test-api-key",
apiModelId: "claude-opus-4-5-20251101",
anthropicBaseUrl: "https://my-resource.services.ai.azure.com/anthropic",
anthropicUseAzureFoundry: false,
anthropicAzureDeploymentName: "claude-opus-4-5",
anthropicUseAuthToken: true,
}
handler = new AnthropicHandler(mockOptions)
const model = handler.getModel()
// Should use the original model ID, not the deployment name
expect(model.id).toBe("claude-opus-4-5-20251101")
})
it("should send correct model name in API requests with Azure Foundry", async () => {
mockOptions = {
apiKey: "test-azure-key",
apiModelId: "claude-opus-4-5-20251101",
anthropicBaseUrl: "https://my-resource.services.ai.azure.com/anthropic",
anthropicUseAzureFoundry: true,
anthropicAzureDeploymentName: "my-custom-deployment",
anthropicUseAuthToken: true,
}
handler = new AnthropicHandler(mockOptions)
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "Hello",
},
]
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Verify the API was called with the deployment name as the model
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.model).toBe("my-custom-deployment")
// Verify we got the expected response
const textChunks = chunks.filter((chunk) => chunk.type === "text")
expect(textChunks).toHaveLength(2)
expect(textChunks[0].text).toBe("Hello from Azure")
expect(textChunks[1].text).toBe(" Foundry")
})
it("should work with different Claude models in Azure Foundry", () => {
// Test with Claude Sonnet 4.5
mockOptions = {
apiKey: "test-api-key",
apiModelId: "claude-sonnet-4-5",
anthropicBaseUrl: "https://my-resource.services.ai.azure.com/anthropic",
anthropicUseAzureFoundry: true,
anthropicAzureDeploymentName: "sonnet-deployment",
anthropicUseAuthToken: true,
}
handler = new AnthropicHandler(mockOptions)
let model = handler.getModel()
expect(model.id).toBe("sonnet-deployment")
expect(model.info.maxTokens).toBe(64000) // Raw model info value for Sonnet 4.5
// Test with Claude Haiku 4.5
mockOptions = {
apiKey: "test-api-key",
apiModelId: "claude-haiku-4-5-20251001",
anthropicBaseUrl: "https://my-resource.services.ai.azure.com/anthropic",
anthropicUseAzureFoundry: true,
anthropicAzureDeploymentName: "haiku-deployment",
anthropicUseAuthToken: true,
}
handler = new AnthropicHandler(mockOptions)
model = handler.getModel()
expect(model.id).toBe("haiku-deployment")
expect(model.info.maxTokens).toBe(64000) // Raw model info value for Haiku 4.5
})
it("should handle completePrompt with Azure Foundry deployment name", async () => {
mockOptions = {
apiKey: "test-azure-key",
apiModelId: "claude-opus-4-5-20251101",
anthropicBaseUrl: "https://my-resource.services.ai.azure.com/anthropic",
anthropicUseAzureFoundry: true,
anthropicAzureDeploymentName: "my-opus-deployment",
anthropicUseAuthToken: true,
}
handler = new AnthropicHandler(mockOptions)
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test response")
expect(mockCreate).toHaveBeenCalledWith({
model: "my-opus-deployment",
messages: [{ role: "user", content: "Test prompt" }],
max_tokens: 8192,
temperature: 0,
thinking: undefined,
stream: false,
})
})
})
describe("Azure Foundry with 1M context", () => {
it("should support 1M context beta with Azure Foundry for Claude Sonnet 4.5", () => {
mockOptions = {
apiKey: "test-api-key",
apiModelId: "claude-sonnet-4-5",
anthropicBaseUrl: "https://my-resource.services.ai.azure.com/anthropic",
anthropicUseAzureFoundry: true,
anthropicAzureDeploymentName: "sonnet-1m-deployment",
anthropicBeta1MContext: true,
anthropicUseAuthToken: true,
}
handler = new AnthropicHandler(mockOptions)
const model = handler.getModel()
expect(model.id).toBe("sonnet-1m-deployment")
// Should have 1M context window
expect(model.info.contextWindow).toBe(1000000)
expect(model.info.inputPrice).toBe(6.0)
expect(model.info.outputPrice).toBe(22.5)
})
})
})

View file

@ -336,10 +336,27 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
// reasoning model and that reasoning is required to be enabled.
// The actual model ID honored by Anthropic's API does not have this
// suffix.
const baseId = id === "claude-3-7-sonnet-20250219:thinking" ? "claude-3-7-sonnet-20250219" : id
const betas = id === "claude-3-7-sonnet-20250219:thinking" ? ["output-128k-2025-02-19"] : undefined
// If Azure Foundry mode is enabled and a deployment name is provided, use it as the model ID
if (this.options.anthropicUseAzureFoundry && this.options.anthropicAzureDeploymentName) {
// Override the model ID with the Azure deployment name
// Keep the model info from the selected model for pricing/capabilities
const deploymentName = this.options.anthropicAzureDeploymentName
return {
id: deploymentName,
info,
betas,
...params,
}
}
return {
id: id === "claude-3-7-sonnet-20250219:thinking" ? "claude-3-7-sonnet-20250219" : id,
id: baseId,
info,
betas: id === "claude-3-7-sonnet-20250219:thinking" ? ["output-128k-2025-02-19"] : undefined,
betas,
...params,
}
}