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:
Roo Code 2026-01-19 09:26:17 +00:00
parent 0f08867656
commit 94c2792ccf
11 changed files with 820 additions and 0 deletions

View file

@ -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",

View file

@ -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":

View 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)
}
})
})
})

View 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
}
}
}

View file

@ -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"

View file

@ -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}

View file

@ -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 },

View file

@ -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>
)}
</>
)
}

View file

@ -1,4 +1,5 @@
export { Anthropic } from "./Anthropic"
export { AzureFoundry } from "./AzureFoundry"
export { Bedrock } from "./Bedrock"
export { Cerebras } from "./Cerebras"
export { Chutes } from "./Chutes"

View file

@ -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,

View file

@ -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",