diff --git a/packages/ai/README.md b/packages/ai/README.md index 7e2b40d3..b41b7790 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1020,7 +1020,7 @@ In Node.js environments, you can set environment variables to avoid passing API | Provider | Environment Variable(s) | |----------|------------------------| | OpenAI | `OPENAI_API_KEY` | -| Azure OpenAI | `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_BASE_URL` or `AZURE_OPENAI_RESOURCE_NAME` (optional `AZURE_OPENAI_API_VERSION`, `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` like `model=deployment,model2=deployment2`) | +| Azure OpenAI | `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_BASE_URL` (e.g. `https://{resource}.openai.azure.com`) or `AZURE_OPENAI_RESOURCE_NAME`. Supports `*.openai.azure.com` and `*.cognitiveservices.azure.com`; root endpoints auto-normalize to `/openai/v1`. Optional: `AZURE_OPENAI_API_VERSION` (default `v1`), `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`. | | Anthropic | `ANTHROPIC_API_KEY` or `ANTHROPIC_OAUTH_TOKEN` | | DeepSeek | `DEEPSEEK_API_KEY` | | Google | `GEMINI_API_KEY` | @@ -1222,7 +1222,7 @@ const response = await complete(model, { **OpenAI Codex**: Requires a ChatGPT Plus or Pro subscription. Provides access to GPT-5.x Codex models with extended context windows and reasoning capabilities. The library automatically handles session-based prompt caching when `sessionId` is provided in stream options. You can set `transport` in stream options to `"sse"`, `"websocket"`, or `"auto"` for Codex Responses transport selection. When using WebSocket with a `sessionId`, connections are reused per session and expire after 5 minutes of inactivity. -**Azure OpenAI (Responses)**: Uses the Responses API only. Set `AZURE_OPENAI_API_KEY` and either `AZURE_OPENAI_BASE_URL` or `AZURE_OPENAI_RESOURCE_NAME`. Use `AZURE_OPENAI_API_VERSION` (defaults to `v1`) to override the API version if needed. Deployment names are treated as model IDs by default, override with `azureDeploymentName` or `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` using comma-separated `model-id=deployment` pairs (for example `gpt-4o-mini=my-deployment,gpt-4o=prod`). Legacy deployment-based URLs are intentionally unsupported. +**Azure OpenAI (Responses)**: Uses the Responses API only. Set `AZURE_OPENAI_API_KEY` and either `AZURE_OPENAI_BASE_URL` or `AZURE_OPENAI_RESOURCE_NAME`. `AZURE_OPENAI_BASE_URL` supports both `https://.openai.azure.com` and `https://.cognitiveservices.azure.com`; root endpoints are normalized to `.../openai/v1` automatically. Use `AZURE_OPENAI_API_VERSION` (defaults to `v1`) to override the API version if needed. Deployment names are treated as model IDs by default, override with `azureDeploymentName` or `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` using comma-separated `model-id=deployment` pairs (for example `gpt-4o-mini=my-deployment,gpt-4o=prod`). Legacy deployment-based URLs are intentionally unsupported. **GitHub Copilot**: If you get "The requested model is not supported" error, enable the model manually in VS Code: open Copilot Chat, click the model selector, select the model (warning icon), and click "Enable". diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 155001b2..587b75cc 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -148,7 +148,26 @@ export const streamSimpleAzureOpenAIResponses: StreamFunction<"azure-openai-resp }; function normalizeAzureBaseUrl(baseUrl: string): string { - return baseUrl.replace(/\/+$/, ""); + const trimmed = baseUrl.trim().replace(/\/+$/, ""); + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error(`Invalid Azure OpenAI base URL: ${baseUrl}`); + } + + const isAzureHost = + url.hostname.endsWith(".openai.azure.com") || url.hostname.endsWith(".cognitiveservices.azure.com"); + const normalizedPath = url.pathname.replace(/\/+$/, ""); + + // Ensure Azure hosts have /openai/v1 as base path so the AzureOpenAI SDK + // can append /deployments//... and ?api-version=v1 correctly. + if (isAzureHost && (normalizedPath === "" || normalizedPath === "/" || normalizedPath === "/openai")) { + url.pathname = "/openai/v1"; + url.search = ""; + } + + return url.toString().replace(/\/+$/, ""); } function buildDefaultBaseUrl(resourceName: string): string { diff --git a/packages/ai/test/azure-openai-base-url.test.ts b/packages/ai/test/azure-openai-base-url.test.ts new file mode 100644 index 00000000..3a8e9c19 --- /dev/null +++ b/packages/ai/test/azure-openai-base-url.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getModel } from "../src/models.js"; +import { streamAzureOpenAIResponses } from "../src/providers/azure-openai-responses.js"; +import type { Context } from "../src/types.js"; + +interface CapturedAzureClientOptions { + apiKey: string; + apiVersion: string; + dangerouslyAllowBrowser: boolean; + defaultHeaders?: Record; + baseURL: string; +} + +const azureMock = vi.hoisted(() => ({ + constructorCalls: [] as CapturedAzureClientOptions[], +})); + +vi.mock("openai", () => { + class AzureOpenAI { + responses = { + create: () => { + throw new Error("mock create"); + }, + }; + + constructor(config: CapturedAzureClientOptions) { + azureMock.constructorCalls.push(config); + } + } + + return { AzureOpenAI }; +}); + +const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], +}; + +const originalAzureOpenAIBaseUrl = process.env.AZURE_OPENAI_BASE_URL; +const originalAzureOpenAIResourceName = process.env.AZURE_OPENAI_RESOURCE_NAME; +const originalAzureOpenAIApiVersion = process.env.AZURE_OPENAI_API_VERSION; +const originalAzureOpenAIApiKey = process.env.AZURE_OPENAI_API_KEY; + +beforeEach(() => { + azureMock.constructorCalls.length = 0; + delete process.env.AZURE_OPENAI_BASE_URL; + delete process.env.AZURE_OPENAI_RESOURCE_NAME; + delete process.env.AZURE_OPENAI_API_VERSION; + delete process.env.AZURE_OPENAI_API_KEY; +}); + +afterEach(() => { + if (originalAzureOpenAIBaseUrl === undefined) { + delete process.env.AZURE_OPENAI_BASE_URL; + } else { + process.env.AZURE_OPENAI_BASE_URL = originalAzureOpenAIBaseUrl; + } + + if (originalAzureOpenAIResourceName === undefined) { + delete process.env.AZURE_OPENAI_RESOURCE_NAME; + } else { + process.env.AZURE_OPENAI_RESOURCE_NAME = originalAzureOpenAIResourceName; + } + + if (originalAzureOpenAIApiVersion === undefined) { + delete process.env.AZURE_OPENAI_API_VERSION; + } else { + process.env.AZURE_OPENAI_API_VERSION = originalAzureOpenAIApiVersion; + } + + if (originalAzureOpenAIApiKey === undefined) { + delete process.env.AZURE_OPENAI_API_KEY; + } else { + process.env.AZURE_OPENAI_API_KEY = originalAzureOpenAIApiKey; + } +}); + +async function captureClientBaseUrl(baseUrl: string): Promise { + process.env.AZURE_OPENAI_BASE_URL = baseUrl; + const model = getModel("azure-openai-responses", "gpt-4o-mini"); + await streamAzureOpenAIResponses(model, context, { apiKey: "test-api-key" }).result(); + expect(azureMock.constructorCalls).toHaveLength(1); + return azureMock.constructorCalls[0].baseURL; +} + +describe("azure-openai-responses base URL normalization", () => { + it("normalizes Cognitive Services root endpoints to /openai/v1", async () => { + const baseURL = await captureClientBaseUrl("https://marc-quicktests-resource.cognitiveservices.azure.com"); + expect(baseURL).toBe("https://marc-quicktests-resource.cognitiveservices.azure.com/openai/v1"); + }); + + it("normalizes Azure OpenAI root endpoints to /openai/v1", async () => { + const baseURL = await captureClientBaseUrl("https://my-resource.openai.azure.com"); + expect(baseURL).toBe("https://my-resource.openai.azure.com/openai/v1"); + }); + + it("normalizes /openai to /openai/v1", async () => { + const baseURL = await captureClientBaseUrl("https://my-resource.cognitiveservices.azure.com/openai"); + expect(baseURL).toBe("https://my-resource.cognitiveservices.azure.com/openai/v1"); + }); + + it("preserves /openai/v1 endpoints", async () => { + const baseURL = await captureClientBaseUrl("https://my-resource.cognitiveservices.azure.com/openai/v1"); + expect(baseURL).toBe("https://my-resource.cognitiveservices.azure.com/openai/v1"); + }); + + it("preserves explicit non-Azure proxy paths", async () => { + const baseURL = await captureClientBaseUrl("https://my-proxy.example.com/v1"); + expect(baseURL).toBe("https://my-proxy.example.com/v1"); + }); + + it("strips query params when normalizing Azure host URLs", async () => { + const baseURL = await captureClientBaseUrl("https://my-resource.openai.azure.com/openai?api-version=2024-12-01"); + expect(baseURL).toBe("https://my-resource.openai.azure.com/openai/v1"); + }); + + it("preserves query params on non-Azure proxy URLs", async () => { + const baseURL = await captureClientBaseUrl("https://my-proxy.example.com/v1?custom=true"); + expect(baseURL).toBe("https://my-proxy.example.com/v1?custom=true"); + }); + + it("throws on invalid URLs", async () => { + process.env.AZURE_OPENAI_BASE_URL = "not-a-url"; + const model = getModel("azure-openai-responses", "gpt-4o-mini"); + const result = await streamAzureOpenAIResponses(model, context, { apiKey: "test-api-key" }).result(); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("Invalid Azure OpenAI base URL"); + }); + + it("builds correct default URL from AZURE_OPENAI_RESOURCE_NAME", async () => { + process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource"; + const model = getModel("azure-openai-responses", "gpt-4o-mini"); + await streamAzureOpenAIResponses(model, context, { apiKey: "test-api-key" }).result(); + expect(azureMock.constructorCalls).toHaveLength(1); + expect(azureMock.constructorCalls[0].baseURL).toBe("https://my-resource.openai.azure.com/openai/v1"); + }); +}); diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 3ec446e3..89b651bc 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -119,6 +119,8 @@ OAuth credentials are also stored here after `/login` and managed automatically. ```bash export AZURE_OPENAI_API_KEY=... export AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com +# also supported: https://your-resource.cognitiveservices.azure.com +# root endpoints are auto-normalized to /openai/v1 # or use resource name instead of base URL export AZURE_OPENAI_RESOURCE_NAME=your-resource diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index b1c84d8c..ae334801 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -299,7 +299,7 @@ ${chalk.bold("Environment Variables:")} ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key) OPENAI_API_KEY - OpenAI GPT API key AZURE_OPENAI_API_KEY - Azure OpenAI API key - AZURE_OPENAI_BASE_URL - Azure OpenAI base URL (https://{resource}.openai.azure.com/openai/v1) + AZURE_OPENAI_BASE_URL - Azure OpenAI/Cognitive Services base URL (e.g. https://{resource}.openai.azure.com) AZURE_OPENAI_RESOURCE_NAME - Azure OpenAI resource name (alternative to base URL) AZURE_OPENAI_API_VERSION - Azure OpenAI API version (default: v1) AZURE_OPENAI_DEPLOYMENT_NAME_MAP - Azure OpenAI model=deployment map (comma-separated)