From a37306d437c528c355357bc9123524de2bf67267 Mon Sep 17 00:00:00 2001 From: Justin Santer <61017794+jsanter27@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:46:48 -0400 Subject: [PATCH] feat(ai): support Azure Foundry Chat Completions deployments (#9714) * feat(ai): support Azure Foundry Chat Completions deployments The Azure provider only implemented the Responses API, so Foundry deployments that speak Chat Completions could not be used. On Chat Completions the deepseek/deepseek-v4-pro model sends `thinking` and `prompt_cache_key`, both of which Azure rejects with 400. The provider now dispatches openai-completions alongside azure-openai-responses. A stream wrapper resolves the Azure endpoint onto the model first, since the shared openai-completions implementation reads it off the model. The request-shape differences are catalog compat rather than api code: reasoning_effort instead of DeepSeek's thinking field, no prompt cache parameters, reasoning_content kept on assistant turns so the prefix Foundry cached stays byte-identical, and effort clamped to the low/medium/high the deployment accepts. The provider is renamed from azure-openai-responses to azure, since it now serves more than the Responses API. The azure-openai-responses api id is unchanged. Breaking: auth.json and models.json entries keyed on azure-openai-responses must move to azure, and existing sessions see a different provider on their next turn. closes #9645 * feat(ai): apply the Azure deployment name map to Chat Completions The Responses path already sent the deployment name from AZURE_OPENAI_DEPLOYMENT_NAME_MAP. The Chat Completions path sent the catalog model id, so any deployment not named after the model 404'd. The Azure provider now swaps the deployment name into the request via onPayload before dispatch, then runs the caller's own onPayload on it. model.id stays the catalog id. The map helpers move to azure-openai-config.ts so the provider can share them without importing the SDK. * docs(ai,coding-agent): add changelog entries for the Azure provider rename Breaking: the azure-openai-responses provider is now azure. Users rename the key in auth.json, models.json, and settings.json; SDK users switch to getModel("azure", ...) and providers/azure. Also note Azure Foundry Chat Completions support (DeepSeek V4 Pro). --------- Co-authored-by: David Brailovsky --- packages/ai/CHANGELOG.md | 8 + packages/ai/scripts/generate-models.ts | 45 ++- packages/ai/src/api/azure-openai-config.ts | 107 +++++++ packages/ai/src/api/azure-openai-responses.ts | 114 +------ packages/ai/src/env-api-keys.ts | 2 +- packages/ai/src/models.generated.ts | 14 +- packages/ai/src/providers/all.ts | 4 +- .../azure-openai-responses.models.ts | 14 - .../src/providers/azure-openai-responses.ts | 14 - packages/ai/src/providers/azure.models.ts | 14 + packages/ai/src/providers/azure.ts | 55 ++++ packages/ai/src/types.ts | 2 +- packages/ai/test/abort.test.ts | 2 +- .../ai/test/azure-openai-base-url.test.ts | 22 +- .../ai/test/azure-openai-completions.test.ts | 280 ++++++++++++++++++ ...-openai-responses-reasoning-replay.test.ts | 4 +- .../ai/test/azure-openai-tool-choice.test.ts | 2 +- packages/ai/test/context-overflow.test.ts | 2 +- .../ai/test/cross-provider-handoff.test.ts | 4 +- packages/ai/test/empty.test.ts | 2 +- packages/ai/test/image-tool-result.test.ts | 2 +- .../test/openai-responses-namespace.test.ts | 2 +- ...penai-responses-tool-result-images.test.ts | 2 +- packages/ai/test/responseid.test.ts | 2 +- packages/ai/test/stream.test.ts | 2 +- packages/ai/test/supports-xhigh.test.ts | 2 +- packages/ai/test/tokens.test.ts | 2 +- .../ai/test/tool-call-without-result.test.ts | 2 +- packages/ai/test/total-tokens.test.ts | 2 +- packages/ai/test/unicode-surrogate.test.ts | 2 +- packages/coding-agent/CHANGELOG.md | 8 + .../coding-agent/src/core/model-resolver.ts | 2 +- .../coding-agent/test/model-resolver.test.ts | 6 +- 33 files changed, 559 insertions(+), 188 deletions(-) create mode 100644 packages/ai/src/api/azure-openai-config.ts delete mode 100644 packages/ai/src/providers/azure-openai-responses.models.ts delete mode 100644 packages/ai/src/providers/azure-openai-responses.ts create mode 100644 packages/ai/src/providers/azure.models.ts create mode 100644 packages/ai/src/providers/azure.ts create mode 100644 packages/ai/test/azure-openai-completions.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6fa861a3c..bf0136763 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Breaking Changes + +- Renamed the Azure provider from `azure-openai-responses` to `azure`, since it now serves Chat Completions as well as the Responses API. Use `getModel("azure", ...)`, and import `azureProvider` and `AZURE_MODELS` from `@earendil-works/pi-ai/providers/azure` instead of `azureOpenAIResponsesProvider` and `AZURE_OPENAI_RESPONSES_MODELS` from `providers/azure-openai-responses`. The `azure-openai-responses` api id and the `AZURE_OPENAI_*` environment variables are unchanged ([#9714](https://github.com/earendil-works/pi/pull/9714) by [@jsanter27](https://github.com/jsanter27)) + +### Added + +- Added Chat Completions support to the Azure provider for Foundry deployments, with DeepSeek V4 Pro in the built-in catalog. Other Foundry models can be added under the `azure` provider with `api: "openai-completions"`, and `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` and `azureDeploymentName` apply to both APIs ([#9645](https://github.com/earendil-works/pi/issues/9645), [#9714](https://github.com/earendil-works/pi/pull/9714) by [@jsanter27](https://github.com/jsanter27)) + ### Fixed - Fixed OAuth credentials being invalidated when a request or model refresh was cancelled or superseded during a token refresh: a token refresh that has started now completes and persists the rotated refresh token diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 39ae3745c..8deeb1014 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -290,6 +290,15 @@ const DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP = { ...DEEPSEEK_V4_THINKING_LEVEL_MAP, low: "low", } as const; +// Azure Foundry rejects DeepSeek's own max effort. +const AZURE_DEEPSEEK_V4_THINKING_LEVEL_MAP = { + minimal: null, + low: "low", + medium: "medium", + high: "high", + xhigh: null, + max: null, +} as const; // Verified against Fireworks Messages raw_output on 2026-09-10 (#9323). // Fall back to verified support when models.dev omits effort metadata; this is // not an allowlist. Any Fireworks Messages model advertising effort uses adaptive thinking. @@ -870,7 +879,7 @@ function applyStrictToolCompatMetadata(model: Model): void { const OPENAI_GRAMMAR_TOOL_PROVIDERS = new Set([ "openai", "openai-codex", - "azure-openai-responses", + "azure", "github-copilot", "opencode", "cloudflare-ai-gateway", @@ -1121,10 +1130,12 @@ function applyThinkingLevelMetadata(model: Model): void { model, model.provider === "openrouter" ? { ...DEEPSEEK_V4_THINKING_LEVEL_MAP, xhigh: "xhigh", max: null } - : (model.provider === "deepseek" || model.provider === "opencode" || model.provider === "opencode-go") && - model.id.includes("deepseek-v4-flash") - ? DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP - : DEEPSEEK_V4_THINKING_LEVEL_MAP, + : model.provider === "azure" + ? AZURE_DEEPSEEK_V4_THINKING_LEVEL_MAP + : (model.provider === "deepseek" || model.provider === "opencode" || model.provider === "opencode-go") && + model.id.includes("deepseek-v4-flash") + ? DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP + : DEEPSEEK_V4_THINKING_LEVEL_MAP, ); } if (model.provider === "groq" && model.id === "qwen/qwen3.6-27b") { @@ -3427,7 +3438,7 @@ async function generateModels() { .map((model) => ({ ...model, api: "azure-openai-responses", - provider: "azure-openai-responses", + provider: "azure", baseUrl: "", cost: { input: model.cost.input, @@ -3439,6 +3450,28 @@ async function generateModels() { })); allModels.push(...azureOpenAiModels); + // Azure resells DeepSeek at its own rates. US data zone, checked 2026-09-16. + // https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/ + const AZURE_DEEPSEEK_V4_PRO_COST: ModelCost = { input: 1.925, output: 3.828, cacheRead: 0.165, cacheWrite: 0 }; + // Azure 400s on DeepSeek's `thinking` field and on every prompt cache parameter, discards a + // `developer` system message unbilled once reasoning_effort is set, and honours mid-convo ones (#9645). + const azureDeepSeekModels: Model[] = allModels + .filter((model) => model.provider === "deepseek" && model.id === "deepseek-v4-pro") + .map((model) => ({ + ...model, + provider: "azure", + baseUrl: "", + cost: AZURE_DEEPSEEK_V4_PRO_COST, + compat: { + ...(model.compat as OpenAICompletionsCompat), + supportsDeveloperRole: false, + supportsMidConvoSystemMessages: true, + thinkingFormat: "openai", + supportsLongCacheRetention: false, + }, + })); + allModels.push(...azureDeepSeekModels); + for (const model of allModels) { applyOpenAICompletionsCompatMetadata(model); applyAnthropicMessagesCompatMetadata(model); diff --git a/packages/ai/src/api/azure-openai-config.ts b/packages/ai/src/api/azure-openai-config.ts new file mode 100644 index 000000000..cb7368f06 --- /dev/null +++ b/packages/ai/src/api/azure-openai-config.ts @@ -0,0 +1,107 @@ +import type { Api, Model, StreamOptions } from "../types.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; + +const DEFAULT_AZURE_API_VERSION = "v1"; + +/** Azure models ship without a baseUrl: one resource per user, resolved per request. */ +export interface AzureEndpointOptions extends StreamOptions { + azureApiVersion?: string; + azureResourceName?: string; + azureBaseUrl?: string; + azureDeploymentName?: string; +} + +function parseDeploymentNameMap(value: string | undefined): Map { + const map = new Map(); + if (!value) return map; + for (const entry of value.split(",")) { + const trimmed = entry.trim(); + if (!trimmed) continue; + const [modelId, deploymentName] = trimmed.split("=", 2); + if (!modelId || !deploymentName) continue; + map.set(modelId.trim(), deploymentName.trim()); + } + return map; +} + +export function resolveDeploymentName(model: Pick, "id">, options?: AzureEndpointOptions): string { + if (options?.azureDeploymentName) { + return options.azureDeploymentName; + } + const mappedDeployment = parseDeploymentNameMap( + getProviderEnvValue("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", options?.env), + ).get(model.id); + return mappedDeployment || model.id; +} + +function normalizeAzureBaseUrl(baseUrl: string): string { + 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") || + url.hostname.endsWith(".ai.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" || + normalizedPath === "/openai/v1/responses") + ) { + url.pathname = "/openai/v1"; + url.search = ""; + } + + return url.toString().replace(/\/+$/, ""); +} + +function buildDefaultBaseUrl(resourceName: string): string { + return `https://${resourceName}.openai.azure.com/openai/v1`; +} + +export function resolveAzureBaseUrl(model: Pick, "baseUrl">, options?: AzureEndpointOptions): string { + const baseUrl = + options?.azureBaseUrl?.trim() || getProviderEnvValue("AZURE_OPENAI_BASE_URL", options?.env)?.trim() || undefined; + const resourceName = options?.azureResourceName || getProviderEnvValue("AZURE_OPENAI_RESOURCE_NAME", options?.env); + + let resolvedBaseUrl = baseUrl; + + if (!resolvedBaseUrl && resourceName) { + resolvedBaseUrl = buildDefaultBaseUrl(resourceName); + } + + if (!resolvedBaseUrl && model.baseUrl) { + resolvedBaseUrl = model.baseUrl; + } + + if (!resolvedBaseUrl) { + throw new Error( + "Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or pass azureBaseUrl, azureResourceName, or model.baseUrl.", + ); + } + + return normalizeAzureBaseUrl(resolvedBaseUrl); +} + +export function resolveAzureConfig( + model: Pick, "baseUrl">, + options?: AzureEndpointOptions, +): { baseUrl: string; apiVersion: string } { + return { + baseUrl: resolveAzureBaseUrl(model, options), + apiVersion: + options?.azureApiVersion || + getProviderEnvValue("AZURE_OPENAI_API_VERSION", options?.env) || + DEFAULT_AZURE_API_VERSION, + }; +} diff --git a/packages/ai/src/api/azure-openai-responses.ts b/packages/ai/src/api/azure-openai-responses.ts index 614965fbd..9b07bc9da 100644 --- a/packages/ai/src/api/azure-openai-responses.ts +++ b/packages/ai/src/api/azure-openai-responses.ts @@ -1,68 +1,32 @@ import { AzureOpenAI } from "openai"; import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; import { clampThinkingLevel } from "../models.ts"; -import type { - Api, - AssistantMessage, - Model, - SimpleStreamOptions, - StreamFunction, - StreamOptions, - TranscriptContext, -} from "../types.ts"; +import type { Api, AssistantMessage, Model, SimpleStreamOptions, StreamFunction, TranscriptContext } from "../types.ts"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { getPiUserAgent } from "../utils/pi-user-agent.ts"; -import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { getDeclaredTools, resolveTranscript, resolveTranscriptTools } from "../utils/transcript.ts"; +import { type AzureEndpointOptions, resolveAzureConfig, resolveDeploymentName } from "./azure-openai-config.ts"; import { createGrammarToolInputProperties } from "./constrained-sampling.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts"; import { buildBaseOptions, resolveSamplingParams } from "./simple-options.ts"; -const DEFAULT_AZURE_API_VERSION = "v1"; -const AZURE_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode", "azure-openai-responses"]); +const AZURE_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode", "azure"]); // OpenAI Responses rejects max_output_tokens below 16: https://github.com/earendil-works/pi/issues/6265 const OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16; -function parseDeploymentNameMap(value: string | undefined): Map { - const map = new Map(); - if (!value) return map; - for (const entry of value.split(",")) { - const trimmed = entry.trim(); - if (!trimmed) continue; - const [modelId, deploymentName] = trimmed.split("=", 2); - if (!modelId || !deploymentName) continue; - map.set(modelId.trim(), deploymentName.trim()); - } - return map; -} - -function resolveDeploymentName(model: Model<"azure-openai-responses">, options?: AzureOpenAIResponsesOptions): string { - if (options?.azureDeploymentName) { - return options.azureDeploymentName; - } - const mappedDeployment = parseDeploymentNameMap( - getProviderEnvValue("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", options?.env), - ).get(model.id); - return mappedDeployment || model.id; -} - function formatAzureOpenAIError(error: unknown): string { return formatProviderError(normalizeProviderError(error), "Azure OpenAI API error"); } // Azure OpenAI Responses-specific options -export interface AzureOpenAIResponsesOptions extends StreamOptions { +export interface AzureOpenAIResponsesOptions extends AzureEndpointOptions { reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; toolChoice?: ResponseCreateParamsStreaming["tool_choice"]; reasoningSummary?: "auto" | "detailed" | "concise" | null; - azureApiVersion?: string; - azureResourceName?: string; - azureBaseUrl?: string; - azureDeploymentName?: string; } /** @@ -188,76 +152,6 @@ export const streamSimple: StreamFunction<"azure-openai-responses", SimpleStream } satisfies AzureOpenAIResponsesOptions); }; -function normalizeAzureBaseUrl(baseUrl: string): string { - 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") || - url.hostname.endsWith(".ai.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" || - normalizedPath === "/openai/v1/responses") - ) { - url.pathname = "/openai/v1"; - url.search = ""; - } - - return url.toString().replace(/\/+$/, ""); -} - -function buildDefaultBaseUrl(resourceName: string): string { - return `https://${resourceName}.openai.azure.com/openai/v1`; -} - -function resolveAzureConfig( - model: Model<"azure-openai-responses">, - options?: AzureOpenAIResponsesOptions, -): { baseUrl: string; apiVersion: string } { - const apiVersion = - options?.azureApiVersion || - getProviderEnvValue("AZURE_OPENAI_API_VERSION", options?.env) || - DEFAULT_AZURE_API_VERSION; - - const baseUrl = - options?.azureBaseUrl?.trim() || getProviderEnvValue("AZURE_OPENAI_BASE_URL", options?.env)?.trim() || undefined; - const resourceName = options?.azureResourceName || getProviderEnvValue("AZURE_OPENAI_RESOURCE_NAME", options?.env); - - let resolvedBaseUrl = baseUrl; - - if (!resolvedBaseUrl && resourceName) { - resolvedBaseUrl = buildDefaultBaseUrl(resourceName); - } - - if (!resolvedBaseUrl && model.baseUrl) { - resolvedBaseUrl = model.baseUrl; - } - - if (!resolvedBaseUrl) { - throw new Error( - "Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or pass azureBaseUrl, azureResourceName, or model.baseUrl.", - ); - } - - return { - baseUrl: normalizeAzureBaseUrl(resolvedBaseUrl), - apiVersion, - }; -} - function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) { const headers = { "User-Agent": getPiUserAgent(), ...model.headers }; diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 467ed0772..560dce8a4 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -87,7 +87,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { "qwen-token-plan-cn": "QWEN_TOKEN_PLAN_CN_API_KEY", "qwen-token-plan-individual": "QWEN_TOKEN_PLAN_API_KEY", openai: "OPENAI_API_KEY", - "azure-openai-responses": "AZURE_OPENAI_API_KEY", + azure: "AZURE_OPENAI_API_KEY", nvidia: "NVIDIA_API_KEY", deepseek: "DEEPSEEK_API_KEY", google: "GEMINI_API_KEY", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 25ee9e6a0..0890c4cee 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -4,7 +4,7 @@ import { AMAZON_BEDROCK_CLASSIFIER_MODELS, AMAZON_BEDROCK_IMAGE_MODELS, AMAZON_BEDROCK_MODELS } from "./providers/amazon-bedrock.models.ts"; import { ANT_LING_CLASSIFIER_MODELS, ANT_LING_IMAGE_MODELS, ANT_LING_MODELS } from "./providers/ant-ling.models.ts"; import { ANTHROPIC_CLASSIFIER_MODELS, ANTHROPIC_IMAGE_MODELS, ANTHROPIC_MODELS } from "./providers/anthropic.models.ts"; -import { AZURE_OPENAI_RESPONSES_CLASSIFIER_MODELS, AZURE_OPENAI_RESPONSES_IMAGE_MODELS, AZURE_OPENAI_RESPONSES_MODELS } from "./providers/azure-openai-responses.models.ts"; +import { AZURE_CLASSIFIER_MODELS, AZURE_IMAGE_MODELS, AZURE_MODELS } from "./providers/azure.models.ts"; import { BASETEN_CLASSIFIER_MODELS, BASETEN_IMAGE_MODELS, BASETEN_MODELS } from "./providers/baseten.models.ts"; import { CEREBRAS_CLASSIFIER_MODELS, CEREBRAS_IMAGE_MODELS, CEREBRAS_MODELS } from "./providers/cerebras.models.ts"; import { CLOUDFLARE_AI_GATEWAY_CLASSIFIER_MODELS, CLOUDFLARE_AI_GATEWAY_IMAGE_MODELS, CLOUDFLARE_AI_GATEWAY_MODELS } from "./providers/cloudflare-ai-gateway.models.ts"; @@ -48,7 +48,7 @@ export const MODELS: { readonly "amazon-bedrock": typeof AMAZON_BEDROCK_MODELS; readonly "ant-ling": typeof ANT_LING_MODELS; readonly "anthropic": typeof ANTHROPIC_MODELS; - readonly "azure-openai-responses": typeof AZURE_OPENAI_RESPONSES_MODELS; + readonly "azure": typeof AZURE_MODELS; readonly "baseten": typeof BASETEN_MODELS; readonly "cerebras": typeof CEREBRAS_MODELS; readonly "cloudflare-ai-gateway": typeof CLOUDFLARE_AI_GATEWAY_MODELS; @@ -91,7 +91,7 @@ export const MODELS: { "amazon-bedrock": AMAZON_BEDROCK_MODELS, "ant-ling": ANT_LING_MODELS, "anthropic": ANTHROPIC_MODELS, - "azure-openai-responses": AZURE_OPENAI_RESPONSES_MODELS, + "azure": AZURE_MODELS, "baseten": BASETEN_MODELS, "cerebras": CEREBRAS_MODELS, "cloudflare-ai-gateway": CLOUDFLARE_AI_GATEWAY_MODELS, @@ -136,7 +136,7 @@ export const IMAGE_MODELS: { readonly "amazon-bedrock": typeof AMAZON_BEDROCK_IMAGE_MODELS; readonly "ant-ling": typeof ANT_LING_IMAGE_MODELS; readonly "anthropic": typeof ANTHROPIC_IMAGE_MODELS; - readonly "azure-openai-responses": typeof AZURE_OPENAI_RESPONSES_IMAGE_MODELS; + readonly "azure": typeof AZURE_IMAGE_MODELS; readonly "baseten": typeof BASETEN_IMAGE_MODELS; readonly "cerebras": typeof CEREBRAS_IMAGE_MODELS; readonly "cloudflare-ai-gateway": typeof CLOUDFLARE_AI_GATEWAY_IMAGE_MODELS; @@ -179,7 +179,7 @@ export const IMAGE_MODELS: { "amazon-bedrock": AMAZON_BEDROCK_IMAGE_MODELS, "ant-ling": ANT_LING_IMAGE_MODELS, "anthropic": ANTHROPIC_IMAGE_MODELS, - "azure-openai-responses": AZURE_OPENAI_RESPONSES_IMAGE_MODELS, + "azure": AZURE_IMAGE_MODELS, "baseten": BASETEN_IMAGE_MODELS, "cerebras": CEREBRAS_IMAGE_MODELS, "cloudflare-ai-gateway": CLOUDFLARE_AI_GATEWAY_IMAGE_MODELS, @@ -224,7 +224,7 @@ export const CLASSIFIER_MODELS: { readonly "amazon-bedrock": typeof AMAZON_BEDROCK_CLASSIFIER_MODELS; readonly "ant-ling": typeof ANT_LING_CLASSIFIER_MODELS; readonly "anthropic": typeof ANTHROPIC_CLASSIFIER_MODELS; - readonly "azure-openai-responses": typeof AZURE_OPENAI_RESPONSES_CLASSIFIER_MODELS; + readonly "azure": typeof AZURE_CLASSIFIER_MODELS; readonly "baseten": typeof BASETEN_CLASSIFIER_MODELS; readonly "cerebras": typeof CEREBRAS_CLASSIFIER_MODELS; readonly "cloudflare-ai-gateway": typeof CLOUDFLARE_AI_GATEWAY_CLASSIFIER_MODELS; @@ -267,7 +267,7 @@ export const CLASSIFIER_MODELS: { "amazon-bedrock": AMAZON_BEDROCK_CLASSIFIER_MODELS, "ant-ling": ANT_LING_CLASSIFIER_MODELS, "anthropic": ANTHROPIC_CLASSIFIER_MODELS, - "azure-openai-responses": AZURE_OPENAI_RESPONSES_CLASSIFIER_MODELS, + "azure": AZURE_CLASSIFIER_MODELS, "baseten": BASETEN_CLASSIFIER_MODELS, "cerebras": CEREBRAS_CLASSIFIER_MODELS, "cloudflare-ai-gateway": CLOUDFLARE_AI_GATEWAY_CLASSIFIER_MODELS, diff --git a/packages/ai/src/providers/all.ts b/packages/ai/src/providers/all.ts index 62e949440..297db37c9 100644 --- a/packages/ai/src/providers/all.ts +++ b/packages/ai/src/providers/all.ts @@ -4,7 +4,7 @@ import type { AnyModel, Api, ClassifierApi, ClassifierModel, ImageApi, ImageMode import { amazonBedrockProvider } from "./amazon-bedrock.ts"; import { antLingProvider } from "./ant-ling.ts"; import { anthropicProvider } from "./anthropic.ts"; -import { azureOpenAIResponsesProvider } from "./azure-openai-responses.ts"; +import { azureProvider } from "./azure.ts"; import { basetenProvider } from "./baseten.ts"; import { cerebrasProvider } from "./cerebras.ts"; import { cloudflareAIGatewayProvider } from "./cloudflare-ai-gateway.ts"; @@ -138,7 +138,7 @@ export function builtinProviders(): Provider[] { amazonBedrockProvider(), antLingProvider(), anthropicProvider(), - azureOpenAIResponsesProvider(), + azureProvider(), basetenProvider(), cerebrasProvider(), cloudflareAIGatewayProvider(), diff --git a/packages/ai/src/providers/azure-openai-responses.models.ts b/packages/ai/src/providers/azure-openai-responses.models.ts deleted file mode 100644 index 66d85c3c7..000000000 --- a/packages/ai/src/providers/azure-openai-responses.models.ts +++ /dev/null @@ -1,14 +0,0 @@ -// This file is auto-generated by scripts/generate-models.ts -// Do not edit manually - run 'npm run generate-models' to update - -import values from "./data/azure-openai-responses.json" with { type: "json" }; -import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts"; - -export const AZURE_OPENAI_RESPONSES_MODELS: ChatModelCatalog = - flattenChatModelCatalog("azure-openai-responses", values); - -export const AZURE_OPENAI_RESPONSES_IMAGE_MODELS: ImageModelCatalog = - flattenImageModelCatalog("azure-openai-responses", values); - -export const AZURE_OPENAI_RESPONSES_CLASSIFIER_MODELS: ClassifierModelCatalog = - flattenClassifierModelCatalog("azure-openai-responses", values); diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts deleted file mode 100644 index 78351deae..000000000 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { azureOpenAIResponsesApi } from "../api/azure-openai-responses.lazy.ts"; -import { envApiKeyAuth } from "../auth/helpers.ts"; -import { createProvider, type Provider } from "../models.ts"; -import { AZURE_OPENAI_RESPONSES_MODELS } from "./azure-openai-responses.models.ts"; - -export function azureOpenAIResponsesProvider(): Provider<"azure-openai-responses"> { - return createProvider({ - id: "azure-openai-responses", - name: "Azure OpenAI", - auth: { apiKey: envApiKeyAuth("Azure OpenAI API key", ["AZURE_OPENAI_API_KEY"]) }, - models: Object.values(AZURE_OPENAI_RESPONSES_MODELS), - api: azureOpenAIResponsesApi(), - }); -} diff --git a/packages/ai/src/providers/azure.models.ts b/packages/ai/src/providers/azure.models.ts new file mode 100644 index 000000000..f86701758 --- /dev/null +++ b/packages/ai/src/providers/azure.models.ts @@ -0,0 +1,14 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/azure.json" with { type: "json" }; +import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts"; + +export const AZURE_MODELS: ChatModelCatalog = + flattenChatModelCatalog("azure", values); + +export const AZURE_IMAGE_MODELS: ImageModelCatalog = + flattenImageModelCatalog("azure", values); + +export const AZURE_CLASSIFIER_MODELS: ClassifierModelCatalog = + flattenClassifierModelCatalog("azure", values); diff --git a/packages/ai/src/providers/azure.ts b/packages/ai/src/providers/azure.ts new file mode 100644 index 000000000..c2b9c1bd6 --- /dev/null +++ b/packages/ai/src/providers/azure.ts @@ -0,0 +1,55 @@ +import { resolveAzureBaseUrl, resolveDeploymentName } from "../api/azure-openai-config.ts"; +import { azureOpenAIResponsesApi } from "../api/azure-openai-responses.lazy.ts"; +import { lazyStream } from "../api/lazy.ts"; +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import type { Api, Model, ProviderStreams, StreamOptions } from "../types.ts"; +import { AZURE_MODELS } from "./azure.models.ts"; + +function resolveAzureModel(model: Model, options: StreamOptions | undefined): Model { + return { ...model, baseUrl: resolveAzureBaseUrl(model, options) }; +} + +/** Send the deployment name as the request's model, keeping `model.id` as the catalog id. */ +function withDeploymentName(model: Model, options: T | undefined): T | undefined { + const deploymentName = resolveDeploymentName(model, options); + if (deploymentName === model.id) return options; + return { + ...options, + onPayload: async (payload, payloadModel) => { + const params = { ...(payload as object), model: deploymentName }; + return (await options?.onPayload?.(params, payloadModel)) ?? params; + }, + } as T; +} + +/** + * Resolve the Azure endpoint and deployment before dispatch, inside `lazyStream` so an + * unconfigured endpoint errors on the stream instead of throwing out of `stream()`. + */ +function azureStreams(streams: ProviderStreams): ProviderStreams { + return { + stream: (model, context, options) => + lazyStream(model, async () => + streams.stream(resolveAzureModel(model, options), context, withDeploymentName(model, options)), + ), + streamSimple: (model, context, options) => + lazyStream(model, async () => + streams.streamSimple(resolveAzureModel(model, options), context, withDeploymentName(model, options)), + ), + }; +} + +export function azureProvider(): Provider<"azure-openai-responses" | "openai-completions"> { + return createProvider({ + id: "azure", + name: "Azure", + auth: { apiKey: envApiKeyAuth("Azure OpenAI API key", ["AZURE_OPENAI_API_KEY"]) }, + models: Object.values(AZURE_MODELS), + api: { + "azure-openai-responses": azureOpenAIResponsesApi(), + "openai-completions": azureStreams(openAICompletionsApi()), + }, + }); +} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 374f2274a..7469f3038 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -43,7 +43,7 @@ export type KnownProvider = | "google" | "google-vertex" | "openai" - | "azure-openai-responses" + | "azure" | "openai-codex" | "radius" | "typesafe" diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index 7e2b9f97b..fb3ab7e10 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -140,7 +140,7 @@ describe("AI Providers Abort Tests", () => { }); describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider Abort", () => { - const llm = getModel("azure-openai-responses", "gpt-4o-mini"); + const llm = getModel("azure", "gpt-4o-mini"); const azureDeploymentName = resolveAzureDeploymentName(llm.id); const azureOptions = azureDeploymentName ? { azureDeploymentName } : {}; diff --git a/packages/ai/test/azure-openai-base-url.test.ts b/packages/ai/test/azure-openai-base-url.test.ts index 9ac8ce4be..c3f8ae7eb 100644 --- a/packages/ai/test/azure-openai-base-url.test.ts +++ b/packages/ai/test/azure-openai-base-url.test.ts @@ -101,14 +101,14 @@ afterEach(() => { async function captureClientBaseUrl(baseUrl: string): Promise { process.env.AZURE_OPENAI_BASE_URL = baseUrl; - const model = getModel("azure-openai-responses", "gpt-4o-mini"); + const model = getModel("azure", "gpt-4o-mini"); await streamAzureOpenAIResponses(model, normalizeContext(context), { apiKey: "test-api-key" }).result(); expect(azureMock.constructorCalls).toHaveLength(1); return azureMock.constructorCalls[0].baseURL; } async function captureClientHeaders(headers?: Record): Promise> { - const model = getModel("azure-openai-responses", "gpt-4o-mini"); + const model = getModel("azure", "gpt-4o-mini"); await streamAzureOpenAIResponses(model, normalizeContext(context), { apiKey: "test-api-key", azureBaseUrl: "https://my-resource.openai.azure.com", @@ -118,7 +118,7 @@ async function captureClientHeaders(headers?: Record): Promise { +describe("azure 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"); @@ -166,7 +166,7 @@ describe("azure-openai-responses base URL normalization", () => { 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 model = getModel("azure", "gpt-4o-mini"); const result = await streamAzureOpenAIResponses(model, normalizeContext(context), { apiKey: "test-api-key", }).result(); @@ -175,7 +175,7 @@ describe("azure-openai-responses base URL normalization", () => { }); it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => { - const model = getModel("azure-openai-responses", "gpt-4o-mini"); + const model = getModel("azure", "gpt-4o-mini"); await streamAzureOpenAIResponses(model, normalizeContext(context), { apiKey: "test-api-key", azureBaseUrl: "https://my-resource.openai.azure.com", @@ -186,7 +186,7 @@ describe("azure-openai-responses base URL normalization", () => { }); it("disables server-side response storage", async () => { - const model = getModel("azure-openai-responses", "gpt-4o-mini"); + const model = getModel("azure", "gpt-4o-mini"); await streamAzureOpenAIResponses(model, normalizeContext(context), { apiKey: "test-api-key", azureBaseUrl: "https://my-resource.openai.azure.com", @@ -196,7 +196,7 @@ describe("azure-openai-responses base URL normalization", () => { }); it("honors supportsStrictMode: false", async () => { - const baseModel = getModel("azure-openai-responses", "gpt-4o-mini"); + const baseModel = getModel("azure", "gpt-4o-mini"); const model: Model<"azure-openai-responses"> = { ...baseModel, compat: { ...baseModel.compat, supportsStrictMode: false }, @@ -223,14 +223,14 @@ describe("azure-openai-responses base URL normalization", () => { 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"); + const model = getModel("azure", "gpt-4o-mini"); await streamAzureOpenAIResponses(model, normalizeContext(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"); }); }); -describe("azure-openai-responses provider stream events", () => { +describe("azure provider stream events", () => { it("forwards parsed events in order before normalizing the response", async () => { azureMock.streamEvents = [ { type: "response.created", sequence_number: 0, response: { id: "resp_azure" } } as ResponseStreamEvent, @@ -240,7 +240,7 @@ describe("azure-openai-responses provider stream events", () => { response: { id: "resp_azure", status: "completed" }, } as ResponseStreamEvent, ]; - const model = getModel("azure-openai-responses", "gpt-4o-mini"); + const model = getModel("azure", "gpt-4o-mini"); const received: unknown[] = []; const eventModels: Model[] = []; const result = await streamAzureOpenAIResponses(model, normalizeContext(context), { @@ -262,7 +262,7 @@ describe("azure-openai-responses provider stream events", () => { }); }); -describe("azure-openai-responses user agent", () => { +describe("azure user agent", () => { it("uses pi's User-Agent by default", async () => { expect((await captureClientHeaders())["User-Agent"]).toBe(PI_USER_AGENT); }); diff --git a/packages/ai/test/azure-openai-completions.test.ts b/packages/ai/test/azure-openai-completions.test.ts new file mode 100644 index 000000000..5c7869c10 --- /dev/null +++ b/packages/ai/test/azure-openai-completions.test.ts @@ -0,0 +1,280 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getModel, normalizeContext } from "../src/compat.ts"; +import { azureProvider } from "../src/providers/azure.ts"; +import type { AssistantMessage, Context } from "../src/types.ts"; + +interface FakeOpenAIClientOptions { + apiKey: string; + baseURL: string; +} + +interface CapturedCompletionsPayload { + model?: string; + messages?: Array<{ role: string; content?: unknown; reasoning_content?: string }>; + reasoning_effort?: string; + thinking?: unknown; + prompt_cache_key?: string; + prompt_cache_retention?: string; +} + +const mockState = vi.hoisted(() => ({ + lastParams: undefined as CapturedCompletionsPayload | undefined, + lastClientOptions: undefined as FakeOpenAIClientOptions | undefined, + dispatchedTo: undefined as "chat.completions" | "responses" | undefined, +})); + +vi.mock("openai", () => { + class FakeOpenAI { + chat = { + completions: { + create: (params: CapturedCompletionsPayload) => { + mockState.lastParams = params; + mockState.dispatchedTo = "chat.completions"; + const stream = { + async *[Symbol.asyncIterator]() { + yield { + choices: [{ delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: 1, + completion_tokens: 1, + prompt_tokens_details: { cached_tokens: 0 }, + }, + }; + }, + }; + const promise = Promise.resolve(stream) as Promise & { + withResponse: () => Promise<{ + data: typeof stream; + response: { status: number; headers: Headers }; + }>; + }; + promise.withResponse = async () => ({ + data: stream, + response: { status: 200, headers: new Headers() }, + }); + return promise; + }, + }, + }; + + constructor(options: FakeOpenAIClientOptions) { + mockState.lastClientOptions = options; + } + } + + class FakeAzureOpenAI { + responses = { + create: () => { + mockState.dispatchedTo = "responses"; + throw new Error("responses reached"); + }, + }; + } + + return { default: FakeOpenAI, AzureOpenAI: FakeAzureOpenAI }; +}); + +const azure = azureProvider(); + +const originalBaseUrl = process.env.AZURE_OPENAI_BASE_URL; +const originalCacheRetention = process.env.PI_CACHE_RETENTION; +const originalDeploymentMap = process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP; + +beforeEach(() => { + mockState.lastParams = undefined; + mockState.lastClientOptions = undefined; + mockState.dispatchedTo = undefined; + delete process.env.PI_CACHE_RETENTION; + delete process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP; + process.env.AZURE_OPENAI_BASE_URL = "https://my-resource.services.ai.azure.com"; +}); + +afterEach(() => { + if (originalBaseUrl === undefined) delete process.env.AZURE_OPENAI_BASE_URL; + else process.env.AZURE_OPENAI_BASE_URL = originalBaseUrl; + if (originalCacheRetention === undefined) delete process.env.PI_CACHE_RETENTION; + else process.env.PI_CACHE_RETENTION = originalCacheRetention; + if (originalDeploymentMap === undefined) delete process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP; + else process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP = originalDeploymentMap; +}); + +const context = normalizeContext({ + systemPrompt: "sys", + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], +}); + +function deepSeekModel() { + return getModel("azure", "deepseek-v4-pro"); +} + +// Regression for #9645: Azure Foundry rejects DeepSeek's thinking field and every prompt cache parameter here. +describe("azure deepseek-v4-pro over Chat Completions", () => { + it("turns thinking on with reasoning_effort instead of DeepSeek's thinking field", async () => { + await azure.streamSimple(deepSeekModel(), context, { apiKey: "test-key", reasoning: "high" }).result(); + + expect(mockState.lastParams?.reasoning_effort).toBe("high"); + expect(mockState.lastParams?.thinking).toBeUndefined(); + }); + + it("clamps thinking levels the deployment does not accept", async () => { + await azure.streamSimple(deepSeekModel(), context, { apiKey: "test-key", reasoning: "max" }).result(); + + expect(mockState.lastParams?.reasoning_effort).toBe("high"); + }); + + it("sends no reasoning_effort when no thinking level is requested", async () => { + await azure.streamSimple(deepSeekModel(), context, { apiKey: "test-key" }).result(); + + expect(mockState.lastParams?.reasoning_effort).toBeUndefined(); + expect(mockState.lastParams?.thinking).toBeUndefined(); + }); + + it("omits prompt cache parameters when long retention comes from PI_CACHE_RETENTION", async () => { + process.env.PI_CACHE_RETENTION = "long"; + + await azure.stream(deepSeekModel(), context, { apiKey: "test-key", sessionId: "session-env" }).result(); + + expect(mockState.lastParams?.prompt_cache_key).toBeUndefined(); + expect(mockState.lastParams?.prompt_cache_retention).toBeUndefined(); + }); + + // The deployment discards a `developer` system message once reasoning_effort is set, without + // billing it, so the system prompt has to go out under the system role. + it("sends the system prompt under the system role", async () => { + await azure.stream(deepSeekModel(), context, { apiKey: "test-key", reasoningEffort: "low" }).result(); + + expect(mockState.lastParams?.messages?.[0]).toMatchObject({ role: "system", content: "sys" }); + }); + + // The deployment honours system messages sent mid-conversation, so pi must not collapse them. + it("keeps mid-conversation system messages in place", async () => { + const resumed = normalizeContext({ + systemPrompt: "first", + messages: [ + { role: "user", content: "hi", timestamp: Date.now() }, + { role: "system", content: "second", timestamp: Date.now() }, + { role: "user", content: "again", timestamp: Date.now() }, + ], + }); + + await azure.stream(deepSeekModel(), resumed, { apiKey: "test-key" }).result(); + + expect(mockState.lastParams?.messages?.map((message) => message.role)).toEqual([ + "system", + "user", + "system", + "user", + ]); + }); + + it("omits prompt cache parameters even when long retention is requested", async () => { + await azure + .stream(deepSeekModel(), context, { apiKey: "test-key", cacheRetention: "long", sessionId: "session-1" }) + .result(); + + expect(mockState.lastParams?.prompt_cache_key).toBeUndefined(); + expect(mockState.lastParams?.prompt_cache_retention).toBeUndefined(); + }); + + it("replays reasoning_content on assistant turns so the cached prefix is unchanged", async () => { + const assistant: AssistantMessage = { + role: "assistant", + content: [ + { type: "thinking", thinking: "internal reasoning", thinkingSignature: "reasoning_content" }, + { type: "text", text: "answer" }, + ], + provider: "azure", + api: "openai-completions", + model: "deepseek-v4-pro", + timestamp: Date.now(), + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + }; + const resumed: Context = { + systemPrompt: "sys", + messages: [ + { role: "user", content: "first", timestamp: Date.now() }, + assistant, + { role: "user", content: "second", timestamp: Date.now() }, + ], + }; + + await azure.stream(deepSeekModel(), normalizeContext(resumed), { apiKey: "test-key" }).result(); + + const assistantMessage = mockState.lastParams?.messages?.find((message) => message.role === "assistant"); + expect(assistantMessage?.reasoning_content).toBe("internal reasoning"); + }); +}); + +describe("azure Chat Completions endpoint resolution", () => { + it("normalizes the Azure endpoint the completions client is built with", async () => { + await azure.stream(deepSeekModel(), context, { apiKey: "test-key" }).result(); + + expect(mockState.lastClientOptions?.baseURL).toBe("https://my-resource.services.ai.azure.com/openai/v1"); + }); + + it("surfaces an unconfigured endpoint as an error event rather than throwing out of stream()", async () => { + delete process.env.AZURE_OPENAI_BASE_URL; + + const result = await azure.stream(deepSeekModel(), context, { apiKey: "test-key" }).result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("Azure OpenAI base URL is required"); + }); + + // The id is persisted on the assistant message and read back by name, so it has to stay a catalog id. + it("sends the model id as the request model", async () => { + const result = await azure.stream(deepSeekModel(), context, { apiKey: "test-key" }).result(); + + expect(mockState.lastParams?.model).toBe("deepseek-v4-pro"); + expect(result.model).toBe("deepseek-v4-pro"); + }); + + it("sends the mapped deployment name while keeping the catalog id on the message", async () => { + process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP = "deepseek-v4-pro=my-deepseek"; + + const result = await azure.streamSimple(deepSeekModel(), context, { apiKey: "test-key" }).result(); + + expect(mockState.lastParams?.model).toBe("my-deepseek"); + expect(result.model).toBe("deepseek-v4-pro"); + }); + + it("passes the deployment name through a caller's onPayload", async () => { + process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP = "deepseek-v4-pro=my-deepseek"; + let seenModel: unknown; + + await azure + .stream(deepSeekModel(), context, { + apiKey: "test-key", + onPayload: (payload) => { + seenModel = (payload as CapturedCompletionsPayload).model; + return { ...(payload as object), temperature: 0.1 }; + }, + }) + .result(); + + expect(seenModel).toBe("my-deepseek"); + expect(mockState.lastParams).toMatchObject({ model: "my-deepseek", temperature: 0.1 }); + }); +}); + +describe("azure api map", () => { + it("still routes Responses models to the Responses api", async () => { + await azure.stream(getModel("azure", "gpt-4o-mini"), context, { apiKey: "test-key" }).result(); + + expect(mockState.dispatchedTo).toBe("responses"); + }); + + it("routes openai-completions models to chat completions", async () => { + await azure.stream(deepSeekModel(), context, { apiKey: "test-key" }).result(); + + expect(mockState.dispatchedTo).toBe("chat.completions"); + }); +}); diff --git a/packages/ai/test/azure-openai-responses-reasoning-replay.test.ts b/packages/ai/test/azure-openai-responses-reasoning-replay.test.ts index c0fb71fe3..202269062 100644 --- a/packages/ai/test/azure-openai-responses-reasoning-replay.test.ts +++ b/packages/ai/test/azure-openai-responses-reasoning-replay.test.ts @@ -10,7 +10,7 @@ function createModel(): Model<"azure-openai-responses"> { id: "gpt-5-mini", name: "GPT-5 Mini", api: "azure-openai-responses", - provider: "azure-openai-responses", + provider: "azure", baseUrl: "https://example.invalid", reasoning: true, input: ["text"], @@ -75,7 +75,7 @@ function getReplayedReasoning(model: Model<"azure-openai-responses">, assistant: { role: "user", content: "follow-up", timestamp: Date.now() }, ], }); - const input = convertResponsesMessages(model, context, new Set(["azure-openai-responses"])); + const input = convertResponsesMessages(model, context, new Set(["azure"])); return input.find((item) => item.type === "reasoning"); } diff --git a/packages/ai/test/azure-openai-tool-choice.test.ts b/packages/ai/test/azure-openai-tool-choice.test.ts index eee91e4de..1c8f78114 100644 --- a/packages/ai/test/azure-openai-tool-choice.test.ts +++ b/packages/ai/test/azure-openai-tool-choice.test.ts @@ -8,7 +8,7 @@ const model: Model<"azure-openai-responses"> = { id: "test-deployment", name: "Test Deployment", api: "azure-openai-responses", - provider: "azure-openai-responses", + provider: "azure", baseUrl: "http://127.0.0.1:9/openai/v1", reasoning: false, input: ["text"], diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 15d860435..721d69533 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -187,7 +187,7 @@ describe("Context overflow error handling", () => { describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses", () => { it("gpt-4o-mini - should detect overflow via isContextOverflow", async () => { - const model = getModel("azure-openai-responses", "gpt-4o-mini"); + const model = getModel("azure", "gpt-4o-mini"); const result = await testContextOverflow(model, process.env.AZURE_OPENAI_API_KEY!); logResult(result); diff --git a/packages/ai/test/cross-provider-handoff.test.ts b/packages/ai/test/cross-provider-handoff.test.ts index 93dc93f01..4f250becf 100644 --- a/packages/ai/test/cross-provider-handoff.test.ts +++ b/packages/ai/test/cross-provider-handoff.test.ts @@ -64,7 +64,7 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [ apiOverride: "openai-completions", }, { provider: "openai", model: "gpt-5-mini", label: "openai-responses-gpt-5-mini" }, - { provider: "azure-openai-responses", model: "gpt-4o-mini", label: "azure-openai-responses-gpt-4o-mini" }, + { provider: "azure", model: "gpt-4o-mini", label: "azure-gpt-4o-mini" }, // OpenAI Codex { provider: "openai-codex", model: "gpt-5.5", label: "openai-codex-gpt-5.5" }, // GitHub Copilot @@ -177,7 +177,7 @@ async function getApiKey(provider: string): Promise { * Synchronous check for API key availability (env vars only, for skipIf) */ function hasApiKey(pair: ProviderModelPair): boolean { - if (pair.provider === "azure-openai-responses") { + if (pair.provider === "azure") { return hasAzureOpenAICredentials(); } if (pair.provider === "cloudflare-workers-ai") { diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index a7a9c5f45..e8a684238 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -205,7 +205,7 @@ describe("AI Providers Empty Message Tests", () => { }); describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider Empty Messages", () => { - const llm = getModel("azure-openai-responses", "gpt-4o-mini"); + const llm = getModel("azure", "gpt-4o-mini"); const azureDeploymentName = resolveAzureDeploymentName(llm.id); const azureOptions = azureDeploymentName ? { azureDeploymentName } : {}; diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 76ca2374b..2f7897f85 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -249,7 +249,7 @@ describe("Tool Results with Images", () => { }); describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider (gpt-4o-mini)", () => { - const llm = getModel("azure-openai-responses", "gpt-4o-mini"); + const llm = getModel("azure", "gpt-4o-mini"); const azureDeploymentName = resolveAzureDeploymentName(llm.id); const azureOptions = azureDeploymentName ? { azureDeploymentName } : {}; diff --git a/packages/ai/test/openai-responses-namespace.test.ts b/packages/ai/test/openai-responses-namespace.test.ts index e3d2cbb58..2e431221f 100644 --- a/packages/ai/test/openai-responses-namespace.test.ts +++ b/packages/ai/test/openai-responses-namespace.test.ts @@ -195,7 +195,7 @@ describe("OpenAI Responses tool-call namespaces", () => { ); const targetModels: Model[] = [ { ...model, id: "gpt-5.2", name: "GPT-5.2" }, - { ...model, provider: "azure-openai-responses" }, + { ...model, provider: "azure" }, { ...model, api: "openai-codex-responses", diff --git a/packages/ai/test/openai-responses-tool-result-images.test.ts b/packages/ai/test/openai-responses-tool-result-images.test.ts index f881118b6..b7d247875 100644 --- a/packages/ai/test/openai-responses-tool-result-images.test.ts +++ b/packages/ai/test/openai-responses-tool-result-images.test.ts @@ -153,7 +153,7 @@ describe("Responses API tool result images", () => { }); describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider (gpt-4o-mini)", () => { - const model = getModel("azure-openai-responses", "gpt-4o-mini"); + const model = getModel("azure", "gpt-4o-mini"); const azureDeploymentName = resolveAzureDeploymentName(model.id); const azureOptions = azureDeploymentName ? { azureDeploymentName } : {}; diff --git a/packages/ai/test/responseid.test.ts b/packages/ai/test/responseid.test.ts index cd5660575..eac8e872f 100644 --- a/packages/ai/test/responseid.test.ts +++ b/packages/ai/test/responseid.test.ts @@ -78,7 +78,7 @@ describe("responseId E2E Tests", () => { }); describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider", () => { - const llm = getModel("azure-openai-responses", "gpt-4o-mini"); + const llm = getModel("azure", "gpt-4o-mini"); const azureDeploymentName = resolveAzureDeploymentName(llm.id); const azureOptions = azureDeploymentName ? { azureDeploymentName } : {}; diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 44a2c0d19..6becd2253 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -519,7 +519,7 @@ describe("Generate E2E Tests", () => { }); describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider (gpt-4o-mini)", () => { - const llm = getModel("azure-openai-responses", "gpt-4o-mini"); + const llm = getModel("azure", "gpt-4o-mini"); const azureDeploymentName = resolveAzureDeploymentName(llm.id); const azureOptions = azureDeploymentName ? { azureDeploymentName } : {}; diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index 31e381dee..dca51aecb 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -113,7 +113,7 @@ describe("getSupportedThinkingLevels", () => { it("does not support off for GPT-6.1 Sol", () => { const expected = { openai: ["low", "medium", "high", "xhigh", "max"], - "azure-openai-responses": ["low", "medium", "high", "xhigh", "max"], + azure: ["low", "medium", "high", "xhigh", "max"], "openai-codex": ["minimal", "low", "medium", "high", "xhigh", "max"], } as const; for (const [provider, levels] of Object.entries(expected)) { diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index 2355cd992..e48b580fd 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -114,7 +114,7 @@ describe("Token Statistics on Abort", () => { }); describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider", () => { - const llm = getModel("azure-openai-responses", "gpt-4o-mini"); + const llm = getModel("azure", "gpt-4o-mini"); const azureDeploymentName = resolveAzureDeploymentName(llm.id); const azureOptions = azureDeploymentName ? { azureDeploymentName } : {}; diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 2937ffdcb..c0651817c 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -125,7 +125,7 @@ describe("Tool Call Without Result Tests", () => { }); describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider", () => { - const model = getModel("azure-openai-responses", "gpt-4o-mini"); + const model = getModel("azure", "gpt-4o-mini"); const azureDeploymentName = resolveAzureDeploymentName(model.id); const azureOptions = azureDeploymentName ? { azureDeploymentName } : {}; diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index 1d558631a..33f8b99b9 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -200,7 +200,7 @@ describe("totalTokens field", () => { "gpt-4o-mini - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { - const llm = getModel("azure-openai-responses", "gpt-4o-mini"); + const llm = getModel("azure", "gpt-4o-mini"); const azureDeploymentName = resolveAzureDeploymentName(llm.id); const azureOptions = azureDeploymentName ? { azureDeploymentName } : {}; diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index d9f825341..25a262d86 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -332,7 +332,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider Unicode Handling", () => { - const llm = getModel("azure-openai-responses", "gpt-4o-mini"); + const llm = getModel("azure", "gpt-4o-mini"); const azureDeploymentName = resolveAzureDeploymentName(llm.id); const azureOptions = azureDeploymentName ? { azureDeploymentName } : {}; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6b3550acb..9edab99f9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Breaking Changes + +- Renamed the Azure provider from `azure-openai-responses` to `azure`. Rename the provider key in `auth.json` (or run `/login` again), in `models.json`, and in `settings.json` (`defaultProvider`, `enabledModels` patterns, and `modelThinkingLevels` keys). Sessions that used the old provider fall back to another model when resumed, and their prompt cache is not reused. The `AZURE_OPENAI_*` environment variables are unchanged ([#9714](https://github.com/earendil-works/pi/pull/9714) by [@jsanter27](https://github.com/jsanter27)) + +### Added + +- Added Azure Foundry Chat Completions deployments, starting with `azure/deepseek-v4-pro` ([#9645](https://github.com/earendil-works/pi/issues/9645), [#9714](https://github.com/earendil-works/pi/pull/9714) by [@jsanter27](https://github.com/jsanter27)) + ### Changed - Codemode `image()` now also saves each image to a temp file and names the path in the result, so later turns can copy or move generated images ([#10310](https://github.com/earendil-works/pi/issues/10310)) diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 5e5e9698d..a59d78e36 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -22,7 +22,7 @@ export const defaultModelPerProvider: Partial> = { "ant-ling": "Ring-2.6-1T", anthropic: "claude-opus-4-8", openai: "gpt-5.5", - "azure-openai-responses": "gpt-5.4", + azure: "gpt-5.4", "openai-codex": "gpt-6.1-sol", radius: "balanced", nvidia: "nvidia/nemotron-3-ultra-550b-a55b", diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 20522c7f3..866a6990f 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -432,7 +432,7 @@ describe("resolveCliModel", () => { ...mockModels[1], id: "gpt-5.6-sol", name: "GPT 5.6 Sol", - provider: "azure-openai-responses", + provider: "azure", }; const codexModel: Model<"anthropic-messages"> = { ...mockModels[1], @@ -460,7 +460,7 @@ describe("resolveCliModel", () => { ...mockModels[1], id: "gpt-5.6-sol", name: "GPT 5.6 Sol", - provider: "azure-openai-responses", + provider: "azure", }; const codexModel: Model<"anthropic-messages"> = { ...mockModels[1], @@ -480,7 +480,7 @@ describe("resolveCliModel", () => { expect(result.model).toBeUndefined(); expect(result.error).toContain('Model "gpt-5.6-sol" is ambiguous across providers'); - expect(result.error).toContain("azure-openai-responses/gpt-5.6-sol"); + expect(result.error).toContain("azure/gpt-5.6-sol"); expect(result.error).toContain("openai-codex/gpt-5.6-sol"); expect(result.error).toContain("Use --provider or provider/model"); });