import { AzureOpenAI } from "openai"; import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; import { clampThinkingLevel } from "../models.ts"; import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions, StreamFunction, StreamOptions, } 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 { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.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 } from "./simple-options.ts"; const DEFAULT_AZURE_API_VERSION = "v1"; const AZURE_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode", "azure-openai-responses"]); // OpenAI Responses rejects max_output_tokens below 16: https://github.com/earendil-works/pi/issues/6265 const OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16; function parseDeploymentNameMap(value: string | undefined): Map { const map = new Map(); if (!value) return map; for (const entry of value.split(",")) { const trimmed = entry.trim(); if (!trimmed) continue; const [modelId, deploymentName] = trimmed.split("=", 2); if (!modelId || !deploymentName) continue; map.set(modelId.trim(), deploymentName.trim()); } return map; } function resolveDeploymentName(model: Model<"azure-openai-responses">, options?: AzureOpenAIResponsesOptions): string { if (options?.azureDeploymentName) { return options.azureDeploymentName; } const mappedDeployment = parseDeploymentNameMap( getProviderEnvValue("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", options?.env), ).get(model.id); return mappedDeployment || model.id; } function formatAzureOpenAIError(error: unknown): string { return formatProviderError(normalizeProviderError(error), "Azure OpenAI API error"); } // Azure OpenAI Responses-specific options export interface AzureOpenAIResponsesOptions extends StreamOptions { reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; reasoningSummary?: "auto" | "detailed" | "concise" | null; azureApiVersion?: string; azureResourceName?: string; azureBaseUrl?: string; azureDeploymentName?: string; } /** * Generate function for Azure OpenAI Responses API */ export const stream: StreamFunction<"azure-openai-responses", AzureOpenAIResponsesOptions> = ( model: Model<"azure-openai-responses">, context: Context, options?: AzureOpenAIResponsesOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); // Start async processing (async () => { const deploymentName = resolveDeploymentName(model, options); const output: AssistantMessage = { role: "assistant", content: [], api: "azure-openai-responses" as Api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", timestamp: Date.now(), }; try { // Create Azure OpenAI client const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } const client = createClient(model, apiKey, options); const grammarToolInputProperties = createGrammarToolInputProperties( context.tools, model.compat?.supportsOpenAIGrammarTools ?? false, ); let params = buildParams(model, context, options, deploymentName, grammarToolInputProperties); const nextParams = await options?.onPayload?.(params, model); if (nextParams !== undefined) { params = nextParams as ResponseCreateParamsStreaming; } const requestOptions = { ...(options?.signal ? { signal: options.signal } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), maxRetries: 0, }; const { data: openaiStream, response } = await retryProviderRequest( () => client.responses.create(params, requestOptions).withResponse(), { maxRetries: options?.maxRetries, maxRetryDelayMs: options?.maxRetryDelayMs, signal: options?.signal, }, ); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); stream.push({ type: "start", partial: output }); await processResponsesStream(openaiStream, output, stream, model, { grammarToolInputProperties }); if (options?.signal?.aborted) { throw new Error("Request was aborted"); } if (output.stopReason === "aborted" || output.stopReason === "error") { throw new Error("An unknown error occurred"); } stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { for (const block of output.content) { delete (block as { index?: number }).index; // Streaming scratch buffers are only used during parsing; never persist them. delete (block as { partialJson?: string }).partialJson; delete (block as { customInput?: unknown }).customInput; } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = formatAzureOpenAIError(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } })(); return stream; }; export const streamSimple: StreamFunction<"azure-openai-responses", SimpleStreamOptions> = ( model: Model<"azure-openai-responses">, context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream => { const apiKey = options?.apiKey; if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); } const base = buildBaseOptions(model, context, options, apiKey); const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; return stream(model, context, { ...base, reasoningEffort, } satisfies AzureOpenAIResponsesOptions); }; function normalizeAzureBaseUrl(baseUrl: string): string { const trimmed = baseUrl.trim().replace(/\/+$/, ""); let url: URL; try { url = new URL(trimmed); } catch { throw new Error(`Invalid Azure OpenAI base URL: ${baseUrl}`); } const isAzureHost = url.hostname.endsWith(".openai.azure.com") || url.hostname.endsWith(".cognitiveservices.azure.com") || url.hostname.endsWith(".ai.azure.com"); const normalizedPath = url.pathname.replace(/\/+$/, ""); // Ensure Azure hosts have /openai/v1 as base path so the AzureOpenAI SDK // can append /deployments//... and ?api-version=v1 correctly. if ( isAzureHost && (normalizedPath === "" || normalizedPath === "/" || normalizedPath === "/openai" || normalizedPath === "/openai/v1/responses") ) { url.pathname = "/openai/v1"; url.search = ""; } return url.toString().replace(/\/+$/, ""); } function buildDefaultBaseUrl(resourceName: string): string { return `https://${resourceName}.openai.azure.com/openai/v1`; } function resolveAzureConfig( model: Model<"azure-openai-responses">, options?: AzureOpenAIResponsesOptions, ): { baseUrl: string; apiVersion: string } { const apiVersion = options?.azureApiVersion || getProviderEnvValue("AZURE_OPENAI_API_VERSION", options?.env) || DEFAULT_AZURE_API_VERSION; const baseUrl = options?.azureBaseUrl?.trim() || getProviderEnvValue("AZURE_OPENAI_BASE_URL", options?.env)?.trim() || undefined; const resourceName = options?.azureResourceName || getProviderEnvValue("AZURE_OPENAI_RESOURCE_NAME", options?.env); let resolvedBaseUrl = baseUrl; if (!resolvedBaseUrl && resourceName) { resolvedBaseUrl = buildDefaultBaseUrl(resourceName); } if (!resolvedBaseUrl && model.baseUrl) { resolvedBaseUrl = model.baseUrl; } if (!resolvedBaseUrl) { throw new Error( "Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or pass azureBaseUrl, azureResourceName, or model.baseUrl.", ); } return { baseUrl: normalizeAzureBaseUrl(resolvedBaseUrl), apiVersion, }; } function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) { const headers = { ...model.headers }; if (options?.headers) { Object.assign(headers, options.headers); } const { baseUrl, apiVersion } = resolveAzureConfig(model, options); return new AzureOpenAI({ apiKey, apiVersion, dangerouslyAllowBrowser: true, defaultHeaders: headers, baseURL: baseUrl, }); } function buildParams( model: Model<"azure-openai-responses">, context: Context, options: AzureOpenAIResponsesOptions | undefined, deploymentName: string, grammarToolInputProperties: ReadonlyMap = createGrammarToolInputProperties( context.tools, model.compat?.supportsOpenAIGrammarTools ?? false, ), ) { const messages = convertResponsesMessages(model, context, AZURE_TOOL_CALL_PROVIDERS, { grammarToolInputProperties, }); const params: ResponseCreateParamsStreaming = { model: deploymentName, input: messages, stream: true, prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId), store: false, }; if (options?.maxTokens) { params.max_output_tokens = Math.max(options.maxTokens, OPENAI_RESPONSES_MIN_OUTPUT_TOKENS); } if (options?.temperature !== undefined) { params.temperature = options?.temperature; } if (context.tools && context.tools.length > 0) { params.tools = convertResponsesTools(context.tools, { supportsStrictMode: model.compat?.supportsStrictMode ?? true, supportsOpenAIGrammarTools: model.compat?.supportsOpenAIGrammarTools ?? false, }); } if (model.reasoning) { if (options?.reasoningEffort || options?.reasoningSummary) { const effort = options?.reasoningEffort ? (model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort) : "medium"; params.reasoning = { effort: effort as NonNullable["effort"], summary: options?.reasoningSummary || "auto", }; params.include = ["reasoning.encrypted_content"]; } else if (model.thinkingLevelMap?.off !== null) { params.reasoning = { effort: (model.thinkingLevelMap?.off ?? "none") as NonNullable["effort"], }; } } return params; }