Skip to content

Commit c5080a1

Browse files
committed
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 and deployment name onto the model first, since the shared openai-completions implementation reads both 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. closes #9645
1 parent 1629239 commit c5080a1

5 files changed

Lines changed: 407 additions & 115 deletions

File tree

packages/ai/scripts/generate-models.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,15 @@ const DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP = {
287287
...DEEPSEEK_V4_THINKING_LEVEL_MAP,
288288
low: "low",
289289
} as const;
290+
// Azure Foundry rejects DeepSeek's own max effort.
291+
const AZURE_DEEPSEEK_V4_THINKING_LEVEL_MAP = {
292+
minimal: null,
293+
low: "low",
294+
medium: "medium",
295+
high: "high",
296+
xhigh: null,
297+
max: null,
298+
} as const;
290299
// Verified against Fireworks Messages raw_output on 2026-09-10 (#9323).
291300
// Fall back to verified support when models.dev omits effort metadata; this is
292301
// not an allowlist. Any Fireworks Messages model advertising effort uses adaptive thinking.
@@ -1029,10 +1038,12 @@ function applyThinkingLevelMetadata(model: Model<any>): void {
10291038
model,
10301039
model.provider === "openrouter"
10311040
? { ...DEEPSEEK_V4_THINKING_LEVEL_MAP, xhigh: "xhigh", max: null }
1032-
: (model.provider === "deepseek" || model.provider === "opencode" || model.provider === "opencode-go") &&
1033-
model.id.includes("deepseek-v4-flash")
1034-
? DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP
1035-
: DEEPSEEK_V4_THINKING_LEVEL_MAP,
1041+
: model.provider === "azure-openai-responses"
1042+
? AZURE_DEEPSEEK_V4_THINKING_LEVEL_MAP
1043+
: (model.provider === "deepseek" || model.provider === "opencode" || model.provider === "opencode-go") &&
1044+
model.id.includes("deepseek-v4-flash")
1045+
? DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP
1046+
: DEEPSEEK_V4_THINKING_LEVEL_MAP,
10361047
);
10371048
}
10381049
if (model.provider === "groq" && model.id === "qwen/qwen3.6-27b") {
@@ -3016,6 +3027,27 @@ async function generateModels() {
30163027
}));
30173028
allModels.push(...azureOpenAiModels);
30183029

3030+
// Azure resells DeepSeek at its own rates. US data zone, checked 2026-09-16.
3031+
// https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/
3032+
const AZURE_DEEPSEEK_V4_PRO_COST: ModelCost = { input: 1.925, output: 3.828, cacheRead: 0.165, cacheWrite: 0 };
3033+
// Azure 400s on DeepSeek's `thinking` field and on every prompt cache parameter, and
3034+
// discards a `developer` system message unbilled once reasoning_effort is set (#9645).
3035+
const azureDeepSeekModels: Model<Api>[] = allModels
3036+
.filter((model) => model.provider === "deepseek" && model.id === "deepseek-v4-pro")
3037+
.map((model) => ({
3038+
...model,
3039+
provider: "azure-openai-responses",
3040+
baseUrl: "",
3041+
cost: AZURE_DEEPSEEK_V4_PRO_COST,
3042+
compat: {
3043+
...(model.compat as OpenAICompletionsCompat),
3044+
supportsDeveloperRole: false,
3045+
thinkingFormat: "openai",
3046+
supportsLongCacheRetention: false,
3047+
},
3048+
}));
3049+
allModels.push(...azureDeepSeekModels);
3050+
30193051
for (const model of allModels) {
30203052
applyOpenAICompletionsCompatMetadata(model);
30213053
applyAnthropicMessagesCompatMetadata(model);
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { Api, Model, StreamOptions } from "../types.ts";
2+
import { getProviderEnvValue } from "../utils/provider-env.ts";
3+
4+
const DEFAULT_AZURE_API_VERSION = "v1";
5+
6+
/** Azure models ship without a baseUrl and address a deployment rather than a model id, so both resolve per request. */
7+
export interface AzureEndpointOptions extends StreamOptions {
8+
azureApiVersion?: string;
9+
azureResourceName?: string;
10+
azureBaseUrl?: string;
11+
azureDeploymentName?: string;
12+
}
13+
14+
function parseDeploymentNameMap(value: string | undefined): Map<string, string> {
15+
const map = new Map<string, string>();
16+
if (!value) return map;
17+
for (const entry of value.split(",")) {
18+
const trimmed = entry.trim();
19+
if (!trimmed) continue;
20+
const [modelId, deploymentName] = trimmed.split("=", 2);
21+
if (!modelId || !deploymentName) continue;
22+
map.set(modelId.trim(), deploymentName.trim());
23+
}
24+
return map;
25+
}
26+
27+
export function resolveDeploymentName(model: Pick<Model<Api>, "id">, options?: AzureEndpointOptions): string {
28+
if (options?.azureDeploymentName) {
29+
return options.azureDeploymentName;
30+
}
31+
const mappedDeployment = parseDeploymentNameMap(
32+
getProviderEnvValue("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", options?.env),
33+
).get(model.id);
34+
return mappedDeployment || model.id;
35+
}
36+
37+
function normalizeAzureBaseUrl(baseUrl: string): string {
38+
const trimmed = baseUrl.trim().replace(/\/+$/, "");
39+
let url: URL;
40+
try {
41+
url = new URL(trimmed);
42+
} catch {
43+
throw new Error(`Invalid Azure OpenAI base URL: ${baseUrl}`);
44+
}
45+
46+
const isAzureHost =
47+
url.hostname.endsWith(".openai.azure.com") ||
48+
url.hostname.endsWith(".cognitiveservices.azure.com") ||
49+
url.hostname.endsWith(".ai.azure.com");
50+
const normalizedPath = url.pathname.replace(/\/+$/, "");
51+
52+
// Ensure Azure hosts have /openai/v1 as base path so the AzureOpenAI SDK
53+
// can append /deployments/<model>/... and ?api-version=v1 correctly.
54+
if (
55+
isAzureHost &&
56+
(normalizedPath === "" ||
57+
normalizedPath === "/" ||
58+
normalizedPath === "/openai" ||
59+
normalizedPath === "/openai/v1/responses")
60+
) {
61+
url.pathname = "/openai/v1";
62+
url.search = "";
63+
}
64+
65+
return url.toString().replace(/\/+$/, "");
66+
}
67+
68+
function buildDefaultBaseUrl(resourceName: string): string {
69+
return `https://${resourceName}.openai.azure.com/openai/v1`;
70+
}
71+
72+
export function resolveAzureConfig(
73+
model: Pick<Model<Api>, "baseUrl">,
74+
options?: AzureEndpointOptions,
75+
): { baseUrl: string; apiVersion: string } {
76+
const apiVersion =
77+
options?.azureApiVersion ||
78+
getProviderEnvValue("AZURE_OPENAI_API_VERSION", options?.env) ||
79+
DEFAULT_AZURE_API_VERSION;
80+
81+
const baseUrl =
82+
options?.azureBaseUrl?.trim() || getProviderEnvValue("AZURE_OPENAI_BASE_URL", options?.env)?.trim() || undefined;
83+
const resourceName = options?.azureResourceName || getProviderEnvValue("AZURE_OPENAI_RESOURCE_NAME", options?.env);
84+
85+
let resolvedBaseUrl = baseUrl;
86+
87+
if (!resolvedBaseUrl && resourceName) {
88+
resolvedBaseUrl = buildDefaultBaseUrl(resourceName);
89+
}
90+
91+
if (!resolvedBaseUrl && model.baseUrl) {
92+
resolvedBaseUrl = model.baseUrl;
93+
}
94+
95+
if (!resolvedBaseUrl) {
96+
throw new Error(
97+
"Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or pass azureBaseUrl, azureResourceName, or model.baseUrl.",
98+
);
99+
}
100+
101+
return {
102+
baseUrl: normalizeAzureBaseUrl(resolvedBaseUrl),
103+
apiVersion,
104+
};
105+
}

packages/ai/src/api/azure-openai-responses.ts

Lines changed: 3 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,32 @@
11
import { AzureOpenAI } from "openai";
22
import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
33
import { clampThinkingLevel } from "../models.ts";
4-
import type {
5-
Api,
6-
AssistantMessage,
7-
Model,
8-
SimpleStreamOptions,
9-
StreamFunction,
10-
StreamOptions,
11-
TranscriptContext,
12-
} from "../types.ts";
4+
import type { Api, AssistantMessage, Model, SimpleStreamOptions, StreamFunction, TranscriptContext } from "../types.ts";
135
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
146
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
157
import { headersToRecord } from "../utils/headers.ts";
168
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
17-
import { getProviderEnvValue } from "../utils/provider-env.ts";
189
import { retryProviderRequest } from "../utils/provider-retry.ts";
1910
import { getDeclaredTools, resolveTranscript, resolveTranscriptTools } from "../utils/transcript.ts";
11+
import { type AzureEndpointOptions, resolveAzureConfig, resolveDeploymentName } from "./azure-openai-config.ts";
2012
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
2113
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
2214
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
2315
import { buildBaseOptions } from "./simple-options.ts";
2416

25-
const DEFAULT_AZURE_API_VERSION = "v1";
2617
const AZURE_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode", "azure-openai-responses"]);
2718
// OpenAI Responses rejects max_output_tokens below 16: https://github.com/earendil-works/pi/issues/6265
2819
const OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16;
2920

30-
function parseDeploymentNameMap(value: string | undefined): Map<string, string> {
31-
const map = new Map<string, string>();
32-
if (!value) return map;
33-
for (const entry of value.split(",")) {
34-
const trimmed = entry.trim();
35-
if (!trimmed) continue;
36-
const [modelId, deploymentName] = trimmed.split("=", 2);
37-
if (!modelId || !deploymentName) continue;
38-
map.set(modelId.trim(), deploymentName.trim());
39-
}
40-
return map;
41-
}
42-
43-
function resolveDeploymentName(model: Model<"azure-openai-responses">, options?: AzureOpenAIResponsesOptions): string {
44-
if (options?.azureDeploymentName) {
45-
return options.azureDeploymentName;
46-
}
47-
const mappedDeployment = parseDeploymentNameMap(
48-
getProviderEnvValue("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", options?.env),
49-
).get(model.id);
50-
return mappedDeployment || model.id;
51-
}
52-
5321
function formatAzureOpenAIError(error: unknown): string {
5422
return formatProviderError(normalizeProviderError(error), "Azure OpenAI API error");
5523
}
5624

5725
// Azure OpenAI Responses-specific options
58-
export interface AzureOpenAIResponsesOptions extends StreamOptions {
26+
export interface AzureOpenAIResponsesOptions extends AzureEndpointOptions {
5927
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
6028
toolChoice?: ResponseCreateParamsStreaming["tool_choice"];
6129
reasoningSummary?: "auto" | "detailed" | "concise" | null;
62-
azureApiVersion?: string;
63-
azureResourceName?: string;
64-
azureBaseUrl?: string;
65-
azureDeploymentName?: string;
6630
}
6731

6832
/**
@@ -185,76 +149,6 @@ export const streamSimple: StreamFunction<"azure-openai-responses", SimpleStream
185149
} satisfies AzureOpenAIResponsesOptions);
186150
};
187151

188-
function normalizeAzureBaseUrl(baseUrl: string): string {
189-
const trimmed = baseUrl.trim().replace(/\/+$/, "");
190-
let url: URL;
191-
try {
192-
url = new URL(trimmed);
193-
} catch {
194-
throw new Error(`Invalid Azure OpenAI base URL: ${baseUrl}`);
195-
}
196-
197-
const isAzureHost =
198-
url.hostname.endsWith(".openai.azure.com") ||
199-
url.hostname.endsWith(".cognitiveservices.azure.com") ||
200-
url.hostname.endsWith(".ai.azure.com");
201-
const normalizedPath = url.pathname.replace(/\/+$/, "");
202-
203-
// Ensure Azure hosts have /openai/v1 as base path so the AzureOpenAI SDK
204-
// can append /deployments/<model>/... and ?api-version=v1 correctly.
205-
if (
206-
isAzureHost &&
207-
(normalizedPath === "" ||
208-
normalizedPath === "/" ||
209-
normalizedPath === "/openai" ||
210-
normalizedPath === "/openai/v1/responses")
211-
) {
212-
url.pathname = "/openai/v1";
213-
url.search = "";
214-
}
215-
216-
return url.toString().replace(/\/+$/, "");
217-
}
218-
219-
function buildDefaultBaseUrl(resourceName: string): string {
220-
return `https://${resourceName}.openai.azure.com/openai/v1`;
221-
}
222-
223-
function resolveAzureConfig(
224-
model: Model<"azure-openai-responses">,
225-
options?: AzureOpenAIResponsesOptions,
226-
): { baseUrl: string; apiVersion: string } {
227-
const apiVersion =
228-
options?.azureApiVersion ||
229-
getProviderEnvValue("AZURE_OPENAI_API_VERSION", options?.env) ||
230-
DEFAULT_AZURE_API_VERSION;
231-
232-
const baseUrl =
233-
options?.azureBaseUrl?.trim() || getProviderEnvValue("AZURE_OPENAI_BASE_URL", options?.env)?.trim() || undefined;
234-
const resourceName = options?.azureResourceName || getProviderEnvValue("AZURE_OPENAI_RESOURCE_NAME", options?.env);
235-
236-
let resolvedBaseUrl = baseUrl;
237-
238-
if (!resolvedBaseUrl && resourceName) {
239-
resolvedBaseUrl = buildDefaultBaseUrl(resourceName);
240-
}
241-
242-
if (!resolvedBaseUrl && model.baseUrl) {
243-
resolvedBaseUrl = model.baseUrl;
244-
}
245-
246-
if (!resolvedBaseUrl) {
247-
throw new Error(
248-
"Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or pass azureBaseUrl, azureResourceName, or model.baseUrl.",
249-
);
250-
}
251-
252-
return {
253-
baseUrl: normalizeAzureBaseUrl(resolvedBaseUrl),
254-
apiVersion,
255-
};
256-
}
257-
258152
function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) {
259153
const headers = { "User-Agent": getPiUserAgent(), ...model.headers };
260154

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,43 @@
1+
import { resolveAzureConfig, resolveDeploymentName } from "../api/azure-openai-config.ts";
12
import { azureOpenAIResponsesApi } from "../api/azure-openai-responses.lazy.ts";
3+
import { lazyStream } from "../api/lazy.ts";
4+
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
25
import { envApiKeyAuth } from "../auth/helpers.ts";
36
import { createProvider, type Provider } from "../models.ts";
7+
import type { Api, Model, ProviderStreams, StreamOptions } from "../types.ts";
48
import { AZURE_OPENAI_RESPONSES_MODELS } from "./azure-openai-responses.models.ts";
59

6-
export function azureOpenAIResponsesProvider(): Provider<"azure-openai-responses"> {
10+
function resolveAzureModel(model: Model<Api>, options: StreamOptions | undefined): Model<Api> {
11+
return {
12+
...model,
13+
baseUrl: resolveAzureConfig(model, options).baseUrl,
14+
id: resolveDeploymentName(model, options),
15+
};
16+
}
17+
18+
/**
19+
* Resolve the Azure endpoint and deployment onto the model before dispatch, inside
20+
* `lazyStream` so an unconfigured endpoint errors on the stream instead of throwing.
21+
*/
22+
function azureStreams(streams: ProviderStreams): ProviderStreams {
23+
return {
24+
...streams,
25+
stream: (model, context, options) =>
26+
lazyStream(model, async () => streams.stream(resolveAzureModel(model, options), context, options)),
27+
streamSimple: (model, context, options) =>
28+
lazyStream(model, async () => streams.streamSimple(resolveAzureModel(model, options), context, options)),
29+
};
30+
}
31+
32+
export function azureOpenAIResponsesProvider(): Provider<"azure-openai-responses" | "openai-completions"> {
733
return createProvider({
834
id: "azure-openai-responses",
935
name: "Azure OpenAI",
1036
auth: { apiKey: envApiKeyAuth("Azure OpenAI API key", ["AZURE_OPENAI_API_KEY"]) },
1137
models: Object.values(AZURE_OPENAI_RESPONSES_MODELS),
12-
api: azureOpenAIResponsesApi(),
38+
api: {
39+
"azure-openai-responses": azureOpenAIResponsesApi(),
40+
"openai-completions": azureStreams(openAICompletionsApi()),
41+
},
1342
});
1443
}

0 commit comments

Comments
 (0)