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>
This commit is contained in:
Justin Santer 2026-10-05 03:46:48 -04:00 • committed by GitHub
parent b2b5c42f61
commit a37306d437
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 559 additions and 188 deletions

View file

@ -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

View file

@ -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<Api>): 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<any>): 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<Api>[] = 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);

View file

@ -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<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,
};
}

View file

@ -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<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;
}
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/<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`;
}
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 };

View file

@ -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",

View file

@ -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,

View file

@ -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(),

View file

@ -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<typeof values, "azure-openai-responses"> =
flattenChatModelCatalog("azure-openai-responses", values);
export const AZURE_OPENAI_RESPONSES_IMAGE_MODELS: ImageModelCatalog<typeof values, "azure-openai-responses"> =
flattenImageModelCatalog("azure-openai-responses", values);
export const AZURE_OPENAI_RESPONSES_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "azure-openai-responses"> =
flattenClassifierModelCatalog("azure-openai-responses", values);

View file

@ -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(),
});
}

View file

@ -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<typeof values, "azure"> =
flattenChatModelCatalog("azure", values);
export const AZURE_IMAGE_MODELS: ImageModelCatalog<typeof values, "azure"> =
flattenImageModelCatalog("azure", values);
export const AZURE_CLASSIFIER_MODELS: ClassifierModelCatalog<typeof values, "azure"> =
flattenClassifierModelCatalog("azure", values);

View file

@ -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<Api>, options: StreamOptions | undefined): Model<Api> {
return { ...model, baseUrl: resolveAzureBaseUrl(model, options) };
}
/** Send the deployment name as the request's model, keeping `model.id` as the catalog id. */
function withDeploymentName<T extends StreamOptions>(model: Model<Api>, 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()),
},
});
}

View file

@ -43,7 +43,7 @@ export type KnownProvider =
| "google"
| "google-vertex"
| "openai"
| "azure-openai-responses"
| "azure"
| "openai-codex"
| "radius"
| "typesafe"

View file

@ -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 } : {};

View file

@ -101,14 +101,14 @@ afterEach(() => {
async function captureClientBaseUrl(baseUrl: string): Promise<string> {
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<string, string>): Promise<Record<string, string>> {
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<string, string>): Promise<R
return azureMock.constructorCalls[0].defaultHeaders ?? {};
}
describe("azure-openai-responses base URL normalization", () => {
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<Api>[] = [];
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);
});

View file

@ -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<typeof stream> & {
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");
});
});

View file

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

View file

@ -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"],

View file

@ -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);

View file

@ -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<string | undefined> {
* 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") {

View file

@ -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 } : {};

View file

@ -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 } : {};

View file

@ -195,7 +195,7 @@ describe("OpenAI Responses tool-call namespaces", () => {
);
const targetModels: Model<Api>[] = [
{ ...model, id: "gpt-5.2", name: "GPT-5.2" },
{ ...model, provider: "azure-openai-responses" },
{ ...model, provider: "azure" },
{
...model,
api: "openai-codex-responses",

View file

@ -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 } : {};

View file

@ -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 } : {};

View file

@ -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 } : {};

View file

@ -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)) {

View file

@ -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 } : {};

View file

@ -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 } : {};

View file

@ -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 } : {};

View file

@ -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 } : {};

View file

@ -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))

View file

@ -22,7 +22,7 @@ export const defaultModelPerProvider: Partial<Record<KnownProvider, string>> = {
"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",

View file

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