From 94c2792ccf856f05334a9fb76cc2400d0bae723b Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 19 Jan 2026 09:26:17 +0000 Subject: [PATCH] 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 --- packages/types/src/provider-settings.ts | 12 + src/api/index.ts | 3 + .../providers/__tests__/azure-foundry.spec.ts | 460 ++++++++++++++++++ src/api/providers/azure-foundry.ts | 236 +++++++++ src/api/providers/index.ts | 1 + .../src/components/settings/ApiOptions.tsx | 9 + .../src/components/settings/constants.ts | 1 + .../settings/providers/AzureFoundry.tsx | 76 +++ .../components/settings/providers/index.ts | 1 + .../components/ui/hooks/useSelectedModel.ts | 11 + webview-ui/src/i18n/locales/en/settings.json | 10 + 11 files changed, 820 insertions(+) create mode 100644 src/api/providers/__tests__/azure-foundry.spec.ts create mode 100644 src/api/providers/azure-foundry.ts create mode 100644 webview-ui/src/components/settings/providers/AzureFoundry.tsx diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 457252e7fe6..2420acabc29 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -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 = { 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", diff --git a/src/api/index.ts b/src/api/index.ts index 4dfe1e2ecb4..d50105a481a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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": diff --git a/src/api/providers/__tests__/azure-foundry.spec.ts b/src/api/providers/__tests__/azure-foundry.spec.ts new file mode 100644 index 00000000000..66e9c97ecb7 --- /dev/null +++ b/src/api/providers/__tests__/azure-foundry.spec.ts @@ -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) + } + }) + }) +}) diff --git a/src/api/providers/azure-foundry.ts b/src/api/providers/azure-foundry.ts new file mode 100644 index 00000000000..657417023f6 --- /dev/null +++ b/src/api/providers/azure-foundry.ts @@ -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() + + 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 { + 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, + ): 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 + } + } +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 1e0ae50c9d2..e8c3f4cce22 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -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" diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index d10e4cb3dc5..ca0dfc09be3 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -66,6 +66,7 @@ import { import { Anthropic, + AzureFoundry, Baseten, Bedrock, Cerebras, @@ -581,6 +582,14 @@ const ApiOptions = ({ /> )} + {selectedProvider === "azure-foundry" && ( + + )} + {selectedProvider === "claude-code" && ( void + simplifySettings?: boolean +} + +export const AzureFoundry = ({ apiConfiguration, setApiConfigurationField }: AzureFoundryProps) => { + const { t } = useAppTranslation() + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ProviderSettings[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + return ( + <> + + + +
+ {t("settings:providers.azureFoundry.baseUrlDescription")} +
+ + + + +
+ {t("settings:providers.apiKeyStorageNotice")} +
+ + + + +
+ {t("settings:providers.azureFoundry.modelIdDescription")} +
+ + {!apiConfiguration?.azureFoundryApiKey && ( + + {t("settings:providers.azureFoundry.getStarted")} + + )} + + ) +} diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index e28cc257706..7eed5009f3d 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -1,4 +1,5 @@ export { Anthropic } from "./Anthropic" +export { AzureFoundry } from "./AzureFoundry" export { Bedrock } from "./Bedrock" export { Cerebras } from "./Cerebras" export { Chutes } from "./Chutes" diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 5788d38d912..f8ea4491f59 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -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, diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index fc64ad18510..6f079fb7650 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -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",