mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
feat: add Azure Foundry as dedicated provider
This adds Azure Foundry as a dedicated provider option, addressing the request in issue #10782 to have a dedicated provider that supports: - Full URL configuration (with ability to specify API version) - API key authentication - Model ID configuration The dedicated provider avoids Azure-specific parameter incompatibilities (such as prompt_cache_retention) that cause issues when using Azure Foundry through the generic OpenAI provider. Changes: - Add "azure-foundry" to provider types and schema - Create AzureFoundryHandler API handler - Create AzureFoundry settings UI component - Add useSelectedModel case for azure-foundry - Add translations for Azure Foundry UI - Add comprehensive unit tests
This commit is contained in:
parent
0f08867656
commit
94c2792ccf
11 changed files with 820 additions and 0 deletions
|
|
@ -120,6 +120,7 @@ export const providerNames = [
|
|||
...customProviders,
|
||||
...fauxProviders,
|
||||
"anthropic",
|
||||
"azure-foundry",
|
||||
"bedrock",
|
||||
"baseten",
|
||||
"cerebras",
|
||||
|
|
@ -426,12 +427,19 @@ const basetenSchema = apiModelIdProviderModelSchema.extend({
|
|||
basetenApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
const azureFoundrySchema = baseProviderSettingsSchema.extend({
|
||||
azureFoundryBaseUrl: z.string().optional(),
|
||||
azureFoundryApiKey: z.string().optional(),
|
||||
azureFoundryModelId: z.string().optional(),
|
||||
})
|
||||
|
||||
const defaultSchema = z.object({
|
||||
apiProvider: z.undefined(),
|
||||
})
|
||||
|
||||
export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [
|
||||
anthropicSchema.merge(z.object({ apiProvider: z.literal("anthropic") })),
|
||||
azureFoundrySchema.merge(z.object({ apiProvider: z.literal("azure-foundry") })),
|
||||
claudeCodeSchema.merge(z.object({ apiProvider: z.literal("claude-code") })),
|
||||
openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })),
|
||||
bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })),
|
||||
|
|
@ -474,6 +482,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
|
|||
export const providerSettingsSchema = z.object({
|
||||
apiProvider: providerNamesSchema.optional(),
|
||||
...anthropicSchema.shape,
|
||||
...azureFoundrySchema.shape,
|
||||
...claudeCodeSchema.shape,
|
||||
...openRouterSchema.shape,
|
||||
...bedrockSchema.shape,
|
||||
|
|
@ -531,6 +540,7 @@ export const PROVIDER_SETTINGS_KEYS = providerSettingsSchema.keyof().options
|
|||
|
||||
export const modelIdKeys = [
|
||||
"apiModelId",
|
||||
"azureFoundryModelId",
|
||||
"openRouterModelId",
|
||||
"openAiModelId",
|
||||
"ollamaModelId",
|
||||
|
|
@ -563,6 +573,7 @@ export const isTypicalProvider = (key: unknown): key is TypicalProvider =>
|
|||
|
||||
export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
|
||||
anthropic: "apiModelId",
|
||||
"azure-foundry": "azureFoundryModelId",
|
||||
"claude-code": "apiModelId",
|
||||
openrouter: "openRouterModelId",
|
||||
bedrock: "apiModelId",
|
||||
|
|
@ -640,6 +651,7 @@ export const MODELS_BY_PROVIDER: Record<
|
|||
label: "Anthropic",
|
||||
models: Object.keys(anthropicModels),
|
||||
},
|
||||
"azure-foundry": { id: "azure-foundry", label: "Azure Foundry", models: [] },
|
||||
bedrock: {
|
||||
id: "bedrock",
|
||||
label: "Amazon Bedrock",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { ApiStream } from "./transform/stream"
|
|||
|
||||
import {
|
||||
AnthropicHandler,
|
||||
AzureFoundryHandler,
|
||||
AwsBedrockHandler,
|
||||
CerebrasHandler,
|
||||
OpenRouterHandler,
|
||||
|
|
@ -132,6 +133,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
|
|||
switch (apiProvider) {
|
||||
case "anthropic":
|
||||
return new AnthropicHandler(options)
|
||||
case "azure-foundry":
|
||||
return new AzureFoundryHandler(options)
|
||||
case "claude-code":
|
||||
return new ClaudeCodeHandler(options)
|
||||
case "openrouter":
|
||||
|
|
|
|||
460
src/api/providers/__tests__/azure-foundry.spec.ts
Normal file
460
src/api/providers/__tests__/azure-foundry.spec.ts
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
// npx vitest run api/providers/__tests__/azure-foundry.spec.ts
|
||||
|
||||
import { AzureFoundryHandler } from "../azure-foundry"
|
||||
import { ApiHandlerOptions } from "../../../shared/api"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
|
||||
const mockCreate = vitest.fn()
|
||||
|
||||
vitest.mock("openai", () => {
|
||||
const mockConstructor = vitest.fn()
|
||||
return {
|
||||
__esModule: true,
|
||||
default: mockConstructor.mockImplementation(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: mockCreate.mockImplementation(async (options) => {
|
||||
if (!options.stream) {
|
||||
return {
|
||||
id: "test-completion",
|
||||
choices: [
|
||||
{
|
||||
message: { role: "assistant", content: "Test response", refusal: null },
|
||||
finish_reason: "stop",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "Test response" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
},
|
||||
},
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
describe("AzureFoundryHandler", () => {
|
||||
let handler: AzureFoundryHandler
|
||||
let mockOptions: ApiHandlerOptions
|
||||
|
||||
beforeEach(() => {
|
||||
mockOptions = {
|
||||
azureFoundryBaseUrl:
|
||||
"https://my-endpoint.openai.azure.com/openai/deployments/gpt-5.2-codex/chat/completions?api-version=2024-02-15-preview",
|
||||
azureFoundryApiKey: "test-api-key",
|
||||
azureFoundryModelId: "gpt-5.2-codex",
|
||||
}
|
||||
handler = new AzureFoundryHandler(mockOptions)
|
||||
mockCreate.mockClear()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should initialize with provided options", () => {
|
||||
expect(handler).toBeInstanceOf(AzureFoundryHandler)
|
||||
expect(handler.getModel().id).toBe(mockOptions.azureFoundryModelId)
|
||||
})
|
||||
|
||||
it("should handle undefined base URL gracefully", () => {
|
||||
const handlerWithoutUrl = new AzureFoundryHandler({
|
||||
...mockOptions,
|
||||
azureFoundryBaseUrl: undefined,
|
||||
})
|
||||
expect(handlerWithoutUrl).toBeInstanceOf(AzureFoundryHandler)
|
||||
})
|
||||
|
||||
it("should handle undefined API key gracefully", () => {
|
||||
const handlerWithoutKey = new AzureFoundryHandler({
|
||||
...mockOptions,
|
||||
azureFoundryApiKey: undefined,
|
||||
})
|
||||
expect(handlerWithoutKey).toBeInstanceOf(AzureFoundryHandler)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Hello!",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle streaming responses", async () => {
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0)
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0].text).toBe("Test response")
|
||||
})
|
||||
|
||||
it("should include usage information", async () => {
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunk = chunks.find((chunk) => chunk.type === "usage")
|
||||
expect(usageChunk).toBeDefined()
|
||||
expect(usageChunk?.inputTokens).toBe(10)
|
||||
expect(usageChunk?.outputTokens).toBe(5)
|
||||
})
|
||||
|
||||
it("should NOT include prompt_cache_retention parameter (Azure Foundry incompatibility)", async () => {
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
for await (const _chunk of stream) {
|
||||
// Consume the stream
|
||||
}
|
||||
|
||||
expect(mockCreate).toHaveBeenCalled()
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs).not.toHaveProperty("prompt_cache_retention")
|
||||
})
|
||||
|
||||
it("should include stream_options with include_usage", async () => {
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
for await (const _chunk of stream) {
|
||||
// Consume the stream
|
||||
}
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use provided model ID", async () => {
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
for await (const _chunk of stream) {
|
||||
// Consume the stream
|
||||
}
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "gpt-5.2-codex",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle tool calls in streaming responses", async () => {
|
||||
mockCreate.mockImplementation(async (options) => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_1",
|
||||
function: { name: "test_tool", arguments: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [{ index: 0, function: { arguments: '{"arg":' } }],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [{ index: 0, function: { arguments: '"value"}' } }],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
|
||||
expect(toolCallPartialChunks).toHaveLength(3)
|
||||
expect(toolCallPartialChunks[0]).toEqual({
|
||||
type: "tool_call_partial",
|
||||
index: 0,
|
||||
id: "call_1",
|
||||
name: "test_tool",
|
||||
arguments: "",
|
||||
})
|
||||
|
||||
const toolCallEndChunks = chunks.filter((chunk) => chunk.type === "tool_call_end")
|
||||
expect(toolCallEndChunks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should include max_tokens when includeMaxTokens is true", async () => {
|
||||
const optionsWithMaxTokens: ApiHandlerOptions = {
|
||||
...mockOptions,
|
||||
includeMaxTokens: true,
|
||||
openAiCustomModelInfo: {
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
}
|
||||
const handlerWithMaxTokens = new AzureFoundryHandler(optionsWithMaxTokens)
|
||||
const stream = handlerWithMaxTokens.createMessage(systemPrompt, messages)
|
||||
for await (const _chunk of stream) {
|
||||
// Consume the stream
|
||||
}
|
||||
|
||||
expect(mockCreate).toHaveBeenCalled()
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs.max_completion_tokens).toBe(4096)
|
||||
})
|
||||
|
||||
it("should not include max_tokens when includeMaxTokens is false", async () => {
|
||||
const optionsWithoutMaxTokens: ApiHandlerOptions = {
|
||||
...mockOptions,
|
||||
includeMaxTokens: false,
|
||||
openAiCustomModelInfo: {
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
}
|
||||
const handlerWithoutMaxTokens = new AzureFoundryHandler(optionsWithoutMaxTokens)
|
||||
const stream = handlerWithoutMaxTokens.createMessage(systemPrompt, messages)
|
||||
for await (const _chunk of stream) {
|
||||
// Consume the stream
|
||||
}
|
||||
|
||||
expect(mockCreate).toHaveBeenCalled()
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs.max_completion_tokens).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should use user-configured modelMaxTokens instead of model default", async () => {
|
||||
const optionsWithUserMaxTokens: ApiHandlerOptions = {
|
||||
...mockOptions,
|
||||
includeMaxTokens: true,
|
||||
modelMaxTokens: 32000,
|
||||
openAiCustomModelInfo: {
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
}
|
||||
const handlerWithUserMaxTokens = new AzureFoundryHandler(optionsWithUserMaxTokens)
|
||||
const stream = handlerWithUserMaxTokens.createMessage(systemPrompt, messages)
|
||||
for await (const _chunk of stream) {
|
||||
// Consume the stream
|
||||
}
|
||||
|
||||
expect(mockCreate).toHaveBeenCalled()
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs.max_completion_tokens).toBe(32000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
const testMessages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Hello",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle API errors", async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error("API Error"))
|
||||
|
||||
const stream = handler.createMessage("system prompt", testMessages)
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _chunk of stream) {
|
||||
// Should not reach here
|
||||
}
|
||||
}).rejects.toThrow("API Error")
|
||||
})
|
||||
|
||||
it("should handle rate limiting", async () => {
|
||||
const rateLimitError = new Error("Rate limit exceeded")
|
||||
rateLimitError.name = "Error"
|
||||
;(rateLimitError as any).status = 429
|
||||
mockCreate.mockRejectedValueOnce(rateLimitError)
|
||||
|
||||
const stream = handler.createMessage("system prompt", testMessages)
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _chunk of stream) {
|
||||
// Should not reach here
|
||||
}
|
||||
}).rejects.toThrow("Rate limit exceeded")
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
it("should complete prompt successfully", async () => {
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
expect(result).toBe("Test response")
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
model: mockOptions.azureFoundryModelId,
|
||||
messages: [{ role: "user", content: "Test prompt" }],
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle API errors", async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error("API Error"))
|
||||
await expect(handler.completePrompt("Test prompt")).rejects.toThrow(
|
||||
"Azure Foundry completion error: API Error",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle empty response", async () => {
|
||||
mockCreate.mockImplementationOnce(() => ({
|
||||
choices: [{ message: { content: "" } }],
|
||||
}))
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return model info with sane defaults", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(mockOptions.azureFoundryModelId)
|
||||
expect(model.info).toBeDefined()
|
||||
expect(model.info.contextWindow).toBe(128_000)
|
||||
expect(model.info.supportsImages).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle undefined model ID", () => {
|
||||
const handlerWithoutModel = new AzureFoundryHandler({
|
||||
...mockOptions,
|
||||
azureFoundryModelId: undefined,
|
||||
})
|
||||
const model = handlerWithoutModel.getModel()
|
||||
expect(model.id).toBe("")
|
||||
expect(model.info).toBeDefined()
|
||||
})
|
||||
|
||||
it("should use custom model info when provided", () => {
|
||||
const customModelInfo = {
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8192,
|
||||
supportsPromptCache: true,
|
||||
supportsImages: false,
|
||||
}
|
||||
const handlerWithCustomInfo = new AzureFoundryHandler({
|
||||
...mockOptions,
|
||||
openAiCustomModelInfo: customModelInfo,
|
||||
})
|
||||
const model = handlerWithCustomInfo.getModel()
|
||||
expect(model.info.contextWindow).toBe(200_000)
|
||||
expect(model.info.maxTokens).toBe(8192)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Azure Foundry specific behavior", () => {
|
||||
it("should use full Azure Foundry URL with API version", async () => {
|
||||
// This verifies the handler correctly initializes with Azure Foundry's URL pattern
|
||||
const azureFoundryUrl =
|
||||
"https://my-resource.openai.azure.com/openai/deployments/my-model/chat/completions?api-version=2024-06-01-preview"
|
||||
const handlerWithFullUrl = new AzureFoundryHandler({
|
||||
...mockOptions,
|
||||
azureFoundryBaseUrl: azureFoundryUrl,
|
||||
})
|
||||
|
||||
expect(handlerWithFullUrl).toBeInstanceOf(AzureFoundryHandler)
|
||||
|
||||
// The handler should be able to create messages
|
||||
const stream = handlerWithFullUrl.createMessage("System prompt", [{ role: "user", content: "Test" }])
|
||||
for await (const _chunk of stream) {
|
||||
// Consume the stream
|
||||
}
|
||||
|
||||
// Verify the OpenAI client was called (constructor called with baseURL)
|
||||
expect(vi.mocked(OpenAI)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: azureFoundryUrl,
|
||||
apiKey: mockOptions.azureFoundryApiKey,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should support different API versions in URL", () => {
|
||||
const urls = [
|
||||
"https://my-endpoint.openai.azure.com/openai/deployments/gpt-5.2/chat/completions?api-version=2024-02-15-preview",
|
||||
"https://my-endpoint.openai.azure.com/openai/deployments/gpt-5.2/chat/completions?api-version=2024-06-01-preview",
|
||||
"https://my-endpoint.openai.azure.com/openai/deployments/gpt-5.2/chat/completions",
|
||||
]
|
||||
|
||||
for (const url of urls) {
|
||||
const testHandler = new AzureFoundryHandler({
|
||||
...mockOptions,
|
||||
azureFoundryBaseUrl: url,
|
||||
})
|
||||
expect(testHandler).toBeInstanceOf(AzureFoundryHandler)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
236
src/api/providers/azure-foundry.ts
Normal file
236
src/api/providers/azure-foundry.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
|
||||
import { type ModelInfo, openAiModelInfoSaneDefaults, NATIVE_TOOL_DEFAULTS } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
|
||||
import { DEFAULT_HEADERS } from "./constants"
|
||||
import { BaseProvider } from "./base-provider"
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
import { getApiRequestTimeout } from "./utils/timeout-config"
|
||||
import { handleOpenAIError } from "./utils/openai-error-handler"
|
||||
import { XmlMatcher } from "../../utils/xml-matcher"
|
||||
|
||||
/**
|
||||
* Azure Foundry Handler
|
||||
*
|
||||
* A dedicated provider for Azure AI Foundry that allows users to:
|
||||
* - Provide a full Azure Foundry URL (including API version if desired)
|
||||
* - Use API key authentication
|
||||
* - Avoid Azure-specific parameter incompatibilities (e.g., prompt_cache_retention)
|
||||
*
|
||||
* This handler is based on the OpenAI handler but customized for Azure Foundry's requirements.
|
||||
*/
|
||||
export class AzureFoundryHandler extends BaseProvider implements SingleCompletionHandler {
|
||||
protected options: ApiHandlerOptions
|
||||
protected client: OpenAI
|
||||
private readonly providerName = "Azure Foundry"
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super()
|
||||
this.options = options
|
||||
|
||||
const baseURL = this.options.azureFoundryBaseUrl ?? ""
|
||||
const apiKey = this.options.azureFoundryApiKey ?? "not-provided"
|
||||
|
||||
this.client = new OpenAI({
|
||||
baseURL,
|
||||
apiKey,
|
||||
defaultHeaders: DEFAULT_HEADERS,
|
||||
timeout: getApiRequestTimeout(),
|
||||
})
|
||||
}
|
||||
|
||||
override async *createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const { info: modelInfo, reasoning } = this.getModel()
|
||||
const modelId = this.options.azureFoundryModelId ?? ""
|
||||
|
||||
let systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
}
|
||||
|
||||
const convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)]
|
||||
|
||||
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
|
||||
model: modelId,
|
||||
temperature: this.options.modelTemperature ?? 0,
|
||||
messages: convertedMessages,
|
||||
stream: true as const,
|
||||
stream_options: { include_usage: true },
|
||||
...(reasoning && reasoning),
|
||||
...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
|
||||
...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
|
||||
...(metadata?.toolProtocol === "native" &&
|
||||
metadata.parallelToolCalls === true && {
|
||||
parallel_tool_calls: true,
|
||||
}),
|
||||
}
|
||||
|
||||
// Add max_tokens if needed
|
||||
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
|
||||
|
||||
// Note: We intentionally do NOT add prompt_cache_retention as Azure Foundry doesn't support it
|
||||
// This is the main reason for this dedicated provider
|
||||
|
||||
let stream
|
||||
try {
|
||||
stream = await this.client.chat.completions.create(requestOptions)
|
||||
} catch (error) {
|
||||
throw handleOpenAIError(error, this.providerName)
|
||||
}
|
||||
|
||||
const matcher = new XmlMatcher(
|
||||
"think",
|
||||
(chunk) =>
|
||||
({
|
||||
type: chunk.matched ? "reasoning" : "text",
|
||||
text: chunk.data,
|
||||
}) as const,
|
||||
)
|
||||
|
||||
let lastUsage
|
||||
const activeToolCallIds = new Set<string>()
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta ?? {}
|
||||
const finishReason = chunk.choices?.[0]?.finish_reason
|
||||
|
||||
if (delta.content) {
|
||||
for (const processedChunk of matcher.update(delta.content)) {
|
||||
yield processedChunk
|
||||
}
|
||||
}
|
||||
|
||||
if ("reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: (delta.reasoning_content as string | undefined) || "",
|
||||
}
|
||||
}
|
||||
|
||||
yield* this.processToolCalls(delta, finishReason, activeToolCallIds)
|
||||
|
||||
if (chunk.usage) {
|
||||
lastUsage = chunk.usage
|
||||
}
|
||||
}
|
||||
|
||||
for (const processedChunk of matcher.final()) {
|
||||
yield processedChunk
|
||||
}
|
||||
|
||||
if (lastUsage) {
|
||||
yield this.processUsageMetrics(lastUsage, modelInfo)
|
||||
}
|
||||
}
|
||||
|
||||
protected processUsageMetrics(usage: any, _modelInfo?: ModelInfo): ApiStreamUsageChunk {
|
||||
return {
|
||||
type: "usage",
|
||||
inputTokens: usage?.prompt_tokens || 0,
|
||||
outputTokens: usage?.completion_tokens || 0,
|
||||
cacheWriteTokens: usage?.cache_creation_input_tokens || undefined,
|
||||
cacheReadTokens: usage?.cache_read_input_tokens || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
override getModel() {
|
||||
const id = this.options.azureFoundryModelId ?? ""
|
||||
// Ensure Azure Foundry models default to supporting native tool calling.
|
||||
const info: ModelInfo = {
|
||||
...NATIVE_TOOL_DEFAULTS,
|
||||
...(this.options.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults),
|
||||
}
|
||||
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
|
||||
return { id, info, ...params }
|
||||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
try {
|
||||
const model = this.getModel()
|
||||
const modelInfo = model.info
|
||||
|
||||
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
|
||||
model: model.id,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
}
|
||||
|
||||
// Add max_tokens if needed
|
||||
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await this.client.chat.completions.create(requestOptions)
|
||||
} catch (error) {
|
||||
throw handleOpenAIError(error, this.providerName)
|
||||
}
|
||||
|
||||
return response.choices?.[0]?.message.content || ""
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`${this.providerName} completion error: ${error.message}`)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper generator to process tool calls from a stream chunk.
|
||||
*/
|
||||
private *processToolCalls(
|
||||
delta: OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta | undefined,
|
||||
finishReason: string | null | undefined,
|
||||
activeToolCallIds: Set<string>,
|
||||
): Generator<
|
||||
| { type: "tool_call_partial"; index: number; id?: string; name?: string; arguments?: string }
|
||||
| { type: "tool_call_end"; id: string }
|
||||
> {
|
||||
if (delta?.tool_calls) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
if (toolCall.id) {
|
||||
activeToolCallIds.add(toolCall.id)
|
||||
}
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index: toolCall.index,
|
||||
id: toolCall.id,
|
||||
name: toolCall.function?.name,
|
||||
arguments: toolCall.function?.arguments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit tool_call_end events when finish_reason is "tool_calls"
|
||||
if (finishReason === "tool_calls" && activeToolCallIds.size > 0) {
|
||||
for (const id of activeToolCallIds) {
|
||||
yield { type: "tool_call_end", id }
|
||||
}
|
||||
activeToolCallIds.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds max_completion_tokens to the request body if needed
|
||||
*/
|
||||
protected addMaxTokensIfNeeded(
|
||||
requestOptions:
|
||||
| OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming
|
||||
| OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming,
|
||||
modelInfo: ModelInfo,
|
||||
): void {
|
||||
if (this.options.includeMaxTokens === true) {
|
||||
requestOptions.max_completion_tokens = this.options.modelMaxTokens || modelInfo.maxTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export { AnthropicVertexHandler } from "./anthropic-vertex"
|
||||
export { AnthropicHandler } from "./anthropic"
|
||||
export { AzureFoundryHandler } from "./azure-foundry"
|
||||
export { AwsBedrockHandler } from "./bedrock"
|
||||
export { CerebrasHandler } from "./cerebras"
|
||||
export { ChutesHandler } from "./chutes"
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ import {
|
|||
|
||||
import {
|
||||
Anthropic,
|
||||
AzureFoundry,
|
||||
Baseten,
|
||||
Bedrock,
|
||||
Cerebras,
|
||||
|
|
@ -581,6 +582,14 @@ const ApiOptions = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{selectedProvider === "azure-foundry" && (
|
||||
<AzureFoundry
|
||||
apiConfiguration={apiConfiguration}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
simplifySettings={fromWelcomeView}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedProvider === "claude-code" && (
|
||||
<ClaudeCode
|
||||
apiConfiguration={apiConfiguration}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export const PROVIDERS = [
|
|||
{ value: "openrouter", label: "OpenRouter", proxy: false },
|
||||
{ value: "deepinfra", label: "DeepInfra", proxy: false },
|
||||
{ value: "anthropic", label: "Anthropic", proxy: false },
|
||||
{ value: "azure-foundry", label: "Azure Foundry", proxy: false },
|
||||
{ value: "claude-code", label: "Claude Code", proxy: false },
|
||||
{ value: "cerebras", label: "Cerebras", proxy: false },
|
||||
{ value: "gemini", label: "Google Gemini", proxy: false },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import { useCallback } from "react"
|
||||
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
|
||||
import type { ProviderSettings } from "@roo-code/types"
|
||||
|
||||
import { useAppTranslation } from "@src/i18n/TranslationContext"
|
||||
import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
|
||||
|
||||
import { inputEventTransform } from "../transforms"
|
||||
|
||||
type AzureFoundryProps = {
|
||||
apiConfiguration: ProviderSettings
|
||||
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
|
||||
simplifySettings?: boolean
|
||||
}
|
||||
|
||||
export const AzureFoundry = ({ apiConfiguration, setApiConfigurationField }: AzureFoundryProps) => {
|
||||
const { t } = useAppTranslation()
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
<K extends keyof ProviderSettings, E>(
|
||||
field: K,
|
||||
transform: (event: E) => ProviderSettings[K] = inputEventTransform,
|
||||
) =>
|
||||
(event: E | Event) => {
|
||||
setApiConfigurationField(field, transform(event as E))
|
||||
},
|
||||
[setApiConfigurationField],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.azureFoundryBaseUrl || ""}
|
||||
type="text"
|
||||
onInput={handleInputChange("azureFoundryBaseUrl")}
|
||||
placeholder={t("settings:placeholders.azureFoundryBaseUrl")}
|
||||
className="w-full">
|
||||
<label className="block font-medium mb-1">{t("settings:providers.azureFoundry.baseUrl")}</label>
|
||||
</VSCodeTextField>
|
||||
<div className="text-sm text-vscode-descriptionForeground -mt-2">
|
||||
{t("settings:providers.azureFoundry.baseUrlDescription")}
|
||||
</div>
|
||||
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.azureFoundryApiKey || ""}
|
||||
type="password"
|
||||
onInput={handleInputChange("azureFoundryApiKey")}
|
||||
placeholder={t("settings:placeholders.apiKey")}
|
||||
className="w-full">
|
||||
<label className="block font-medium mb-1">{t("settings:providers.azureFoundry.apiKey")}</label>
|
||||
</VSCodeTextField>
|
||||
<div className="text-sm text-vscode-descriptionForeground -mt-2">
|
||||
{t("settings:providers.apiKeyStorageNotice")}
|
||||
</div>
|
||||
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.azureFoundryModelId || ""}
|
||||
type="text"
|
||||
onInput={handleInputChange("azureFoundryModelId")}
|
||||
placeholder={t("settings:placeholders.azureFoundryModelId")}
|
||||
className="w-full">
|
||||
<label className="block font-medium mb-1">{t("settings:providers.azureFoundry.modelId")}</label>
|
||||
</VSCodeTextField>
|
||||
<div className="text-sm text-vscode-descriptionForeground -mt-2">
|
||||
{t("settings:providers.azureFoundry.modelIdDescription")}
|
||||
</div>
|
||||
|
||||
{!apiConfiguration?.azureFoundryApiKey && (
|
||||
<VSCodeButtonLink href="https://ai.azure.com/" appearance="secondary">
|
||||
{t("settings:providers.azureFoundry.getStarted")}
|
||||
</VSCodeButtonLink>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export { Anthropic } from "./Anthropic"
|
||||
export { AzureFoundry } from "./AzureFoundry"
|
||||
export { Bedrock } from "./Bedrock"
|
||||
export { Cerebras } from "./Cerebras"
|
||||
export { Chutes } from "./Chutes"
|
||||
|
|
|
|||
|
|
@ -387,6 +387,17 @@ function getSelectedModel({
|
|||
const info = openAiCodexModels[id as keyof typeof openAiCodexModels]
|
||||
return { id, info }
|
||||
}
|
||||
case "azure-foundry": {
|
||||
const id = apiConfiguration.azureFoundryModelId ?? ""
|
||||
const customInfo = apiConfiguration?.openAiCustomModelInfo
|
||||
// Only merge native tool call defaults, not prices or other model-specific info
|
||||
const nativeToolDefaults = {
|
||||
supportsNativeTools: openAiModelInfoSaneDefaults.supportsNativeTools,
|
||||
defaultToolProtocol: openAiModelInfoSaneDefaults.defaultToolProtocol,
|
||||
}
|
||||
const info = customInfo ? { ...nativeToolDefaults, ...customInfo } : openAiModelInfoSaneDefaults
|
||||
return { id, info }
|
||||
}
|
||||
case "vercel-ai-gateway": {
|
||||
const id = getValidatedModelId(
|
||||
apiConfiguration.vercelAiGatewayModelId,
|
||||
|
|
|
|||
|
|
@ -461,6 +461,14 @@
|
|||
"learnMore": "Learn more about provider routing"
|
||||
}
|
||||
},
|
||||
"azureFoundry": {
|
||||
"baseUrl": "Azure Foundry Base URL",
|
||||
"baseUrlDescription": "The full URL to your Azure AI Foundry endpoint. You can include an API version parameter in the URL (e.g., https://your-model.models.ai.azure.com/v1)",
|
||||
"apiKey": "Azure Foundry API Key",
|
||||
"modelId": "Model ID",
|
||||
"modelIdDescription": "The model deployment name in Azure AI Foundry",
|
||||
"getStarted": "Get Started with Azure AI Foundry"
|
||||
},
|
||||
"customModel": {
|
||||
"capabilities": "Configure the capabilities and pricing for your custom OpenAI-compatible model. Be careful when specifying the model capabilities, as they can affect how Roo Code performs.",
|
||||
"maxTokens": {
|
||||
|
|
@ -937,6 +945,8 @@
|
|||
"projectId": "Enter Project ID...",
|
||||
"customArn": "Enter ARN (e.g. arn:aws:bedrock:us-east-1:123456789012:foundation-model/my-model)",
|
||||
"baseUrl": "Enter base URL...",
|
||||
"azureFoundryBaseUrl": "e.g. https://your-model.models.ai.azure.com/v1",
|
||||
"azureFoundryModelId": "e.g. gpt-5.2-codex",
|
||||
"modelId": {
|
||||
"lmStudio": "e.g. meta-llama-3.1-8b-instruct",
|
||||
"lmStudioDraft": "e.g. lmstudio-community/llama-3.2-1b-instruct",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue