diff --git a/src/api/providers/azure.ts b/src/api/providers/azure.ts index 9da21cf4c5..a2b34d4b9e 100644 --- a/src/api/providers/azure.ts +++ b/src/api/providers/azure.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { createAzure } from "@ai-sdk/azure" -import { streamText, generateText, ToolSet } from "ai" +import { streamText, generateText, ToolSet, type ProviderMetadata } from "ai" import { azureOpenAiDefaultApiVersion, azureModels, azureDefaultModelInfo, type ModelInfo } from "@roo-code/types" @@ -41,7 +41,7 @@ export class AzureHandler extends BaseProvider implements SingleCompletionHandle // useDeploymentBasedUrls produces the universally compatible // /deployments/{id}/{path} URL shape. this.provider = createAzure({ - baseURL: options.azureBaseUrl ?? "", + baseURL: options.azureBaseUrl || undefined, apiKey: options.azureApiKey, // Optional — Azure supports managed identity / Entra ID auth apiVersion: options.azureApiVersion ?? azureOpenAiDefaultApiVersion, useDeploymentBasedUrls: true, @@ -89,16 +89,14 @@ export class AzureHandler extends BaseProvider implements SingleCompletionHandle reasoningTokens?: number } }, - providerMetadata?: { - azure?: { - promptCacheHitTokens?: number - promptCacheMissTokens?: number - } - }, + providerMetadata?: ProviderMetadata, ): ApiStreamUsageChunk { // Extract cache metrics from Azure's providerMetadata if available - const cacheReadTokens = providerMetadata?.azure?.promptCacheHitTokens ?? usage.details?.cachedInputTokens - const cacheWriteTokens = providerMetadata?.azure?.promptCacheMissTokens + const azureMeta = providerMetadata?.azure as + | { promptCacheHitTokens?: number; promptCacheMissTokens?: number } + | undefined + const cacheReadTokens = azureMeta?.promptCacheHitTokens ?? usage.details?.cachedInputTokens + const cacheWriteTokens = azureMeta?.promptCacheMissTokens return { type: "usage", @@ -165,7 +163,7 @@ export class AzureHandler extends BaseProvider implements SingleCompletionHandle const usage = await result.usage const providerMetadata = await result.providerMetadata if (usage) { - yield this.processUsageMetrics(usage, providerMetadata as any) + yield this.processUsageMetrics(usage, providerMetadata) } } catch (error) { // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index 5d43432782..80fae19109 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -52,7 +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", label: "Azure AI Foundry", proxy: false }, + { value: "azure", label: "Azure OpenAI", proxy: false }, { value: "cerebras", label: "Cerebras", proxy: false }, { value: "gemini", label: "Google Gemini", proxy: false }, { value: "doubao", label: "Doubao", proxy: false }, diff --git a/webview-ui/src/components/settings/providers/Azure.tsx b/webview-ui/src/components/settings/providers/Azure.tsx index 22918c0ac9..fd86fec078 100644 --- a/webview-ui/src/components/settings/providers/Azure.tsx +++ b/webview-ui/src/components/settings/providers/Azure.tsx @@ -6,6 +6,7 @@ import { type ProviderSettings, azureOpenAiDefaultApiVersion } from "@roo-code/t import { useAppTranslation } from "@src/i18n/TranslationContext" import { inputEventTransform } from "../transforms" +import { parseAzureUrl } from "../utils/parseAzureUrl" type AzureProps = { apiConfiguration: ProviderSettings @@ -27,11 +28,29 @@ export const Azure = ({ apiConfiguration, setApiConfigurationField }: AzureProps [setApiConfigurationField], ) + const handleBaseUrlInput = useCallback( + (event: unknown) => { + const rawValue = inputEventTransform(event) + const parsed = parseAzureUrl(rawValue) + + if (parsed) { + setApiConfigurationField("azureBaseUrl", parsed.baseUrl) + setApiConfigurationField("azureDeploymentName", parsed.deploymentName) + if (parsed.apiVersion) { + setApiConfigurationField("azureApiVersion", parsed.apiVersion) + } + } else { + setApiConfigurationField("azureBaseUrl", rawValue) + } + }, + [setApiConfigurationField], + ) + return ( <> diff --git a/webview-ui/src/components/settings/utils/__tests__/parseAzureUrl.spec.ts b/webview-ui/src/components/settings/utils/__tests__/parseAzureUrl.spec.ts new file mode 100644 index 0000000000..a347cefe0b --- /dev/null +++ b/webview-ui/src/components/settings/utils/__tests__/parseAzureUrl.spec.ts @@ -0,0 +1,115 @@ +import { parseAzureUrl } from "../parseAzureUrl" + +describe("parseAzureUrl", () => { + it("parses a full openai.azure.com URL with api-version", () => { + const result = parseAzureUrl( + "https://my-resource.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-10-21", + ) + expect(result).toEqual({ + baseUrl: "https://my-resource.openai.azure.com/openai", + deploymentName: "gpt-4o", + apiVersion: "2024-10-21", + }) + }) + + it("parses a cognitiveservices.azure.com URL", () => { + const result = parseAzureUrl( + "https://my-deployment.cognitiveservices.azure.com/openai/deployments/gpt-5.2/chat/completions?api-version=2024-05-01-preview", + ) + expect(result).toEqual({ + baseUrl: "https://my-deployment.cognitiveservices.azure.com/openai", + deploymentName: "gpt-5.2", + apiVersion: "2024-05-01-preview", + }) + }) + + it("parses a services.ai.azure.com URL", () => { + const result = parseAzureUrl( + "https://my-resource.services.ai.azure.com/openai/deployments/my-model/responses?api-version=2025-01-01", + ) + expect(result).toEqual({ + baseUrl: "https://my-resource.services.ai.azure.com/openai", + deploymentName: "my-model", + apiVersion: "2025-01-01", + }) + }) + + it("handles URL without api-version query param", () => { + const result = parseAzureUrl("https://my-resource.openai.azure.com/openai/deployments/gpt-4o/chat/completions") + expect(result).toEqual({ + baseUrl: "https://my-resource.openai.azure.com/openai", + deploymentName: "gpt-4o", + }) + }) + + it("handles URL with trailing slash", () => { + const result = parseAzureUrl("https://my-resource.openai.azure.com/openai/deployments/gpt-4o/chat/completions/") + expect(result).toEqual({ + baseUrl: "https://my-resource.openai.azure.com/openai", + deploymentName: "gpt-4o", + }) + }) + + it("handles deployment name with dots", () => { + const result = parseAzureUrl( + "https://my-resource.openai.azure.com/openai/deployments/gpt-4.turbo.2024/chat/completions?api-version=2024-10-21", + ) + expect(result).toEqual({ + baseUrl: "https://my-resource.openai.azure.com/openai", + deploymentName: "gpt-4.turbo.2024", + apiVersion: "2024-10-21", + }) + }) + + it("handles URL with only /openai/deployments/{name} (no trailing path)", () => { + const result = parseAzureUrl( + "https://my-resource.openai.azure.com/openai/deployments/my-deploy?api-version=2024-10-21", + ) + expect(result).toEqual({ + baseUrl: "https://my-resource.openai.azure.com/openai", + deploymentName: "my-deploy", + apiVersion: "2024-10-21", + }) + }) + + it("returns null for a plain base URL (no /deployments/ path)", () => { + const result = parseAzureUrl("https://my-resource.openai.azure.com/openai") + expect(result).toBeNull() + }) + + it("returns null for a non-URL string", () => { + const result = parseAzureUrl("not-a-url") + expect(result).toBeNull() + }) + + it("returns null for an empty string", () => { + const result = parseAzureUrl("") + expect(result).toBeNull() + }) + + it("returns null for a URL without /openai/ prefix", () => { + const result = parseAzureUrl("https://my-resource.openai.azure.com/deployments/gpt-4o/chat/completions") + expect(result).toBeNull() + }) + + it("handles encoded deployment names", () => { + const result = parseAzureUrl( + "https://my-resource.openai.azure.com/openai/deployments/my%20deploy/chat/completions", + ) + expect(result).toEqual({ + baseUrl: "https://my-resource.openai.azure.com/openai", + deploymentName: "my deploy", + }) + }) + + it("handles additional query parameters besides api-version", () => { + const result = parseAzureUrl( + "https://my-resource.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-10-21&extra=param", + ) + expect(result).toEqual({ + baseUrl: "https://my-resource.openai.azure.com/openai", + deploymentName: "gpt-4o", + apiVersion: "2024-10-21", + }) + }) +}) diff --git a/webview-ui/src/components/settings/utils/parseAzureUrl.ts b/webview-ui/src/components/settings/utils/parseAzureUrl.ts new file mode 100644 index 0000000000..427e9a09bd --- /dev/null +++ b/webview-ui/src/components/settings/utils/parseAzureUrl.ts @@ -0,0 +1,38 @@ +export interface ParsedAzureUrl { + /** e.g. "https://my-resource.cognitiveservices.azure.com/openai" */ + baseUrl: string + /** e.g. "gpt-5.2" */ + deploymentName: string + /** e.g. "2024-05-01-preview" */ + apiVersion?: string +} + +/** + * Parses a full Azure OpenAI URL into its components. + * Returns null if the URL doesn't match the expected pattern. + * + * Supported URL formats: + * - https://{resource}.openai.azure.com/openai/deployments/{deployment}/chat/completions?api-version={ver} + * - https://{resource}.cognitiveservices.azure.com/openai/deployments/{deployment}/responses?api-version={ver} + * - https://{resource}.services.ai.azure.com/openai/deployments/{deployment}/{anything}?api-version={ver} + */ +export function parseAzureUrl(input: string): ParsedAzureUrl | null { + let url: URL + try { + url = new URL(input) + } catch { + return null + } + + // Match pathname: /openai/deployments/{name}/... + const match = url.pathname.match(/^(\/openai)\/deployments\/([^/]+)/) + if (!match) { + return null + } + + const baseUrl = `${url.origin}${match[1]}` + const deploymentName = decodeURIComponent(match[2]) + const apiVersion = url.searchParams.get("api-version") ?? undefined + + return { baseUrl, deploymentName, ...(apiVersion !== undefined && { apiVersion }) } +} diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index 5095420a85..34601fe7f5 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -33,7 +33,7 @@ export interface ProviderServiceConfig { export const PROVIDER_SERVICE_CONFIG: Partial> = { anthropic: { serviceName: "Anthropic", serviceUrl: "https://console.anthropic.com" }, azure: { - serviceName: "Azure AI Foundry", + serviceName: "Azure OpenAI", serviceUrl: "https://azure.microsoft.com/en-us/products/ai-foundry/models/openai", }, bedrock: { serviceName: "Amazon Bedrock", serviceUrl: "https://aws.amazon.com/bedrock" }, diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 8ff2169313..c22440e9cf 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -442,14 +442,14 @@ "getBasetenApiKey": "Get Baseten API Key", "cerebrasApiKey": "Cerebras API Key", "getCerebrasApiKey": "Get Cerebras API Key", - "azureBaseUrl": "Base URL", - "azureBaseUrlDescription": "Your Azure OpenAI endpoint URL. Found in the Azure portal under Keys & Endpoint.", + "azureBaseUrl": "Azure Endpoint", + "azureBaseUrlDescription": "Your Azure OpenAI endpoint. You can paste a full deployment URL (e.g., https://myresource.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=...) and all fields below will auto-fill, or enter just the endpoint from the Azure portal (e.g., https://myresource.openai.azure.com/openai).", "azureDeploymentName": "Azure Deployment Name", - "azureDeploymentNameDescription": "The name of your model deployment within the resource.", + "azureDeploymentNameDescription": "The name of your model deployment. This may differ from the model name — it's the name you chose when deploying the model in Azure.", "azureApiKey": "Azure API Key", "getAzureApiKey": "Get Azure OpenAI Access", "azureApiVersion": "Azure API Version", - "azureApiVersionDescription": "The API version to use (e.g., '2024-10-21'). Leave empty for the default.", + "azureApiVersionDescription": "The API version to use. Leave empty to use the default (2025-04-01-preview).", "chutesApiKey": "Chutes API Key", "getChutesApiKey": "Get Chutes API Key", "fireworksApiKey": "Fireworks API Key",