mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
b2b5c42f61
commit
a37306d437
33 changed files with 559 additions and 188 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
107
packages/ai/src/api/azure-openai-config.ts
Normal file
107
packages/ai/src/api/azure-openai-config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
14
packages/ai/src/providers/azure.models.ts
Normal file
14
packages/ai/src/providers/azure.models.ts
Normal 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);
|
||||
55
packages/ai/src/providers/azure.ts
Normal file
55
packages/ai/src/providers/azure.ts
Normal 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()),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ export type KnownProvider =
|
|||
| "google"
|
||||
| "google-vertex"
|
||||
| "openai"
|
||||
| "azure-openai-responses"
|
||||
| "azure"
|
||||
| "openai-codex"
|
||||
| "radius"
|
||||
| "typesafe"
|
||||
|
|
|
|||
|
|
@ -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 } : {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
280
packages/ai/test/azure-openai-completions.test.ts
Normal file
280
packages/ai/test/azure-openai-completions.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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 } : {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 } : {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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 } : {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue