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"); });