pi-mono/packages/ai/src/api/azure-openai-config.ts
Justin Santer a37306d437
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 <davidbrai@gmail.com>
2026-10-05 09:46:48 +02:00

107 lines
3.3 KiB
TypeScript

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<string, string> {
const map = new Map<string, string>();
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<Model<Api>, "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/<model>/... 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<Model<Api>, "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<Model<Api>, "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,
};
}