Merge main into model-registry
This commit is contained in:
@@ -5,6 +5,7 @@ import type {
|
||||
MessageCreateParamsStreaming,
|
||||
MessageParam,
|
||||
RawMessageStreamEvent,
|
||||
RefusalStopDetails,
|
||||
} from "@anthropic-ai/sdk/resources/messages.js";
|
||||
import { calculateCost } from "../models.ts";
|
||||
import type {
|
||||
@@ -16,6 +17,7 @@ import type {
|
||||
ImageContent,
|
||||
Message,
|
||||
Model,
|
||||
ProviderEnv,
|
||||
SimpleStreamOptions,
|
||||
StopReason,
|
||||
StreamFunction,
|
||||
@@ -29,6 +31,7 @@ import type {
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
|
||||
import { resolveCloudflareBaseUrl } from "./cloudflare.ts";
|
||||
@@ -40,11 +43,11 @@ import { transformMessages } from "./transform-messages.ts";
|
||||
* Resolve cache retention preference.
|
||||
* Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.
|
||||
*/
|
||||
function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention {
|
||||
function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention {
|
||||
if (cacheRetention) {
|
||||
return cacheRetention;
|
||||
}
|
||||
if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") {
|
||||
if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") {
|
||||
return "long";
|
||||
}
|
||||
return "short";
|
||||
@@ -53,8 +56,9 @@ function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention
|
||||
function getCacheControl(
|
||||
model: Model<"anthropic-messages">,
|
||||
cacheRetention?: CacheRetention,
|
||||
env?: ProviderEnv,
|
||||
): { retention: CacheRetention; cacheControl?: CacheControlEphemeral } {
|
||||
const retention = resolveCacheRetention(cacheRetention);
|
||||
const retention = resolveCacheRetention(cacheRetention, env);
|
||||
if (retention === "none") {
|
||||
return { retention };
|
||||
}
|
||||
@@ -493,7 +497,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
});
|
||||
}
|
||||
|
||||
const cacheRetention = options?.cacheRetention ?? resolveCacheRetention();
|
||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
|
||||
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
|
||||
|
||||
const created = createClient(
|
||||
@@ -504,6 +508,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
options?.headers,
|
||||
copilotDynamicHeaders,
|
||||
cacheSessionId,
|
||||
options?.env,
|
||||
);
|
||||
client = created.client;
|
||||
isOAuth = created.isOAuthToken;
|
||||
@@ -534,6 +539,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
output.usage.output = event.message.usage.output_tokens || 0;
|
||||
output.usage.cacheRead = event.message.usage.cache_read_input_tokens || 0;
|
||||
output.usage.cacheWrite = event.message.usage.cache_creation_input_tokens || 0;
|
||||
output.usage.cacheWrite1h = event.message.usage.cache_creation?.ephemeral_1h_input_tokens || 0;
|
||||
// Anthropic doesn't provide total_tokens, compute from components
|
||||
output.usage.totalTokens =
|
||||
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
||||
@@ -660,7 +666,11 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
}
|
||||
} else if (event.type === "message_delta") {
|
||||
if (event.delta.stop_reason) {
|
||||
output.stopReason = mapStopReason(event.delta.stop_reason);
|
||||
const stopReasonResult = mapStopReason(event.delta.stop_reason, event.delta.stop_details);
|
||||
output.stopReason = stopReasonResult.stopReason;
|
||||
if (stopReasonResult.errorMessage) {
|
||||
output.errorMessage = stopReasonResult.errorMessage;
|
||||
}
|
||||
}
|
||||
// Only update usage fields if present (not null).
|
||||
// Preserves input_tokens from message_start when proxies omit it in message_delta.
|
||||
@@ -688,7 +698,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
}
|
||||
|
||||
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
||||
throw new Error("An unknown error occurred");
|
||||
throw new Error(output.errorMessage || "An unknown error occurred");
|
||||
}
|
||||
|
||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||
@@ -788,6 +798,7 @@ function createClient(
|
||||
optionsHeaders?: Record<string, string>,
|
||||
dynamicHeaders?: Record<string, string>,
|
||||
sessionId?: string,
|
||||
env?: ProviderEnv,
|
||||
): { client: Anthropic; isOAuthToken: boolean } {
|
||||
// Adaptive thinking models have interleaved thinking built in, so skip the beta header.
|
||||
const needsInterleavedBeta = interleavedThinking && model.compat?.forceAdaptiveThinking !== true;
|
||||
@@ -803,7 +814,7 @@ function createClient(
|
||||
const client = new Anthropic({
|
||||
apiKey: null,
|
||||
authToken: null,
|
||||
baseURL: resolveCloudflareBaseUrl(model),
|
||||
baseURL: resolveCloudflareBaseUrl(model, env),
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders: mergeHeaders(
|
||||
{
|
||||
@@ -896,7 +907,7 @@ function buildParams(
|
||||
isOAuthToken: boolean,
|
||||
options?: AnthropicOptions,
|
||||
): MessageCreateParamsStreaming {
|
||||
const { cacheControl } = getCacheControl(model, options?.cacheRetention);
|
||||
const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env);
|
||||
const compat = getAnthropicCompat(model);
|
||||
const params: MessageCreateParamsStreaming = {
|
||||
model: model.id,
|
||||
@@ -1202,22 +1213,28 @@ function convertTools(
|
||||
});
|
||||
}
|
||||
|
||||
function mapStopReason(reason: Anthropic.Messages.StopReason | string): StopReason {
|
||||
function mapStopReason(
|
||||
reason: Anthropic.Messages.StopReason | string,
|
||||
stopDetails?: RefusalStopDetails | null,
|
||||
): { stopReason: StopReason; errorMessage?: string } {
|
||||
switch (reason) {
|
||||
case "end_turn":
|
||||
return "stop";
|
||||
return { stopReason: "stop" };
|
||||
case "max_tokens":
|
||||
return "length";
|
||||
return { stopReason: "length" };
|
||||
case "tool_use":
|
||||
return "toolUse";
|
||||
return { stopReason: "toolUse" };
|
||||
case "refusal":
|
||||
return "error";
|
||||
return {
|
||||
stopReason: "error",
|
||||
errorMessage: stopDetails?.explanation || `The model refused to complete the request`,
|
||||
};
|
||||
case "pause_turn": // Stop is good enough -> resubmit
|
||||
return "stop";
|
||||
return { stopReason: "stop" };
|
||||
case "stop_sequence":
|
||||
return "stop"; // We don't supply stop sequences, so this should never happen
|
||||
return { stopReason: "stop" }; // We don't supply stop sequences, so this should never happen
|
||||
case "sensitive": // Content flagged by safety filters (not yet in SDK types)
|
||||
return "error";
|
||||
return { stopReason: "error" };
|
||||
default:
|
||||
// Handle unknown stop reasons gracefully (API may add new values)
|
||||
throw new Error(`Unhandled stop reason: ${reason}`);
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from "../types.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
|
||||
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
@@ -36,7 +37,9 @@ function resolveDeploymentName(model: Model<"azure-openai-responses">, options?:
|
||||
if (options?.azureDeploymentName) {
|
||||
return options.azureDeploymentName;
|
||||
}
|
||||
const mappedDeployment = parseDeploymentNameMap(process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP).get(model.id);
|
||||
const mappedDeployment = parseDeploymentNameMap(
|
||||
getProviderEnvValue("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", options?.env),
|
||||
).get(model.id);
|
||||
return mappedDeployment || model.id;
|
||||
}
|
||||
|
||||
@@ -198,10 +201,14 @@ function resolveAzureConfig(
|
||||
model: Model<"azure-openai-responses">,
|
||||
options?: AzureOpenAIResponsesOptions,
|
||||
): { baseUrl: string; apiVersion: string } {
|
||||
const apiVersion = options?.azureApiVersion || process.env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION;
|
||||
const apiVersion =
|
||||
options?.azureApiVersion ||
|
||||
getProviderEnvValue("AZURE_OPENAI_API_VERSION", options?.env) ||
|
||||
DEFAULT_AZURE_API_VERSION;
|
||||
|
||||
const baseUrl = options?.azureBaseUrl?.trim() || process.env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
|
||||
const resourceName = options?.azureResourceName || process.env.AZURE_OPENAI_RESOURCE_NAME;
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Agent as HttpsAgent } from "node:https";
|
||||
import {
|
||||
BedrockRuntimeClient,
|
||||
type BedrockRuntimeClientConfig,
|
||||
@@ -23,6 +24,8 @@ import {
|
||||
} from "@aws-sdk/client-bedrock-runtime";
|
||||
import { NodeHttpHandler } from "@smithy/node-http-handler";
|
||||
import type { BuildMiddleware, DocumentType, MetadataBearer } from "@smithy/types";
|
||||
import { HttpProxyAgent } from "http-proxy-agent";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import { calculateCost } from "../models.ts";
|
||||
import type {
|
||||
Api,
|
||||
@@ -31,6 +34,7 @@ import type {
|
||||
Context,
|
||||
ImageContent,
|
||||
Model,
|
||||
ProviderEnv,
|
||||
SimpleStreamOptions,
|
||||
StopReason,
|
||||
StreamFunction,
|
||||
@@ -45,7 +49,8 @@ import type {
|
||||
} from "../types.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { createHttpProxyAgentsForTarget } from "../utils/node-http-proxy.ts";
|
||||
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { adjustMaxTokensForThinking, buildBaseOptions, clampReasoning } from "./simple-options.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
@@ -119,18 +124,18 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> =
|
||||
const blocks = output.content as Block[];
|
||||
|
||||
const config: BedrockRuntimeClientConfig = {
|
||||
profile: options.profile,
|
||||
profile: options.profile || getProviderEnvValue("AWS_PROFILE", options.env),
|
||||
};
|
||||
const configuredRegion = getConfiguredBedrockRegion(options);
|
||||
const hasConfiguredProfile = hasConfiguredBedrockProfile();
|
||||
const hasAmbientConfiguredProfile = Boolean(getProviderEnvValue("AWS_PROFILE"));
|
||||
const endpointRegion = getStandardBedrockEndpointRegion(model.baseUrl);
|
||||
const useExplicitEndpoint = shouldUseExplicitBedrockEndpoint(
|
||||
model.baseUrl,
|
||||
configuredRegion,
|
||||
hasConfiguredProfile,
|
||||
hasAmbientConfiguredProfile,
|
||||
);
|
||||
|
||||
// Only pin standard AWS Bedrock runtime endpoints when no region/profile is configured.
|
||||
// Only pin standard AWS Bedrock runtime endpoints when no region or ambient AWS_PROFILE is configured.
|
||||
// This preserves custom endpoints (VPC/proxy) from #3402 without forcing built-in
|
||||
// catalog defaults such as us-east-1 to override AWS_REGION/AWS_PROFILE.
|
||||
if (useExplicitEndpoint) {
|
||||
@@ -138,8 +143,10 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> =
|
||||
}
|
||||
|
||||
// Resolve bearer token for Bedrock API key auth.
|
||||
const bearerToken = options.bearerToken || process.env.AWS_BEARER_TOKEN_BEDROCK || undefined;
|
||||
const useBearerToken = bearerToken !== undefined && process.env.AWS_BEDROCK_SKIP_AUTH !== "1";
|
||||
const skipAuth = getProviderEnvValue("AWS_BEDROCK_SKIP_AUTH", options.env) === "1";
|
||||
const bearerToken =
|
||||
options.bearerToken || getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", options.env) || undefined;
|
||||
const useBearerToken = bearerToken !== undefined && !skipAuth;
|
||||
|
||||
// in Node.js/Bun environment only
|
||||
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
|
||||
@@ -153,25 +160,33 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> =
|
||||
config.region = configuredRegion;
|
||||
} else if (endpointRegion && useExplicitEndpoint) {
|
||||
config.region = endpointRegion;
|
||||
} else if (!hasConfiguredProfile) {
|
||||
} else if (!hasAmbientConfiguredProfile) {
|
||||
config.region = "us-east-1";
|
||||
}
|
||||
|
||||
// Support proxies that don't need authentication
|
||||
if (process.env.AWS_BEDROCK_SKIP_AUTH === "1") {
|
||||
if (skipAuth) {
|
||||
config.credentials = {
|
||||
accessKeyId: "dummy-access-key",
|
||||
secretAccessKey: "dummy-secret-key",
|
||||
};
|
||||
}
|
||||
|
||||
const proxyAgents = createHttpProxyAgentsForTarget(model.baseUrl);
|
||||
if (proxyAgents) {
|
||||
const credentials = getConfiguredBedrockCredentials(options.env);
|
||||
if (!skipAuth && credentials) {
|
||||
config.credentials = credentials;
|
||||
}
|
||||
|
||||
const proxyUrl = resolveHttpProxyUrlForTarget(model.baseUrl, options.env);
|
||||
if (proxyUrl) {
|
||||
// Bedrock runtime uses NodeHttp2Handler by default since v3.798.0, which is based
|
||||
// on `http2` module and has no support for http agent.
|
||||
// Use NodeHttpHandler to support HTTP(S) proxy agents.
|
||||
config.requestHandler = new NodeHttpHandler(proxyAgents);
|
||||
} else if (process.env.AWS_BEDROCK_FORCE_HTTP1 === "1") {
|
||||
config.requestHandler = new NodeHttpHandler({
|
||||
httpAgent: new HttpProxyAgent(proxyUrl),
|
||||
httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent,
|
||||
});
|
||||
} else if (getProviderEnvValue("AWS_BEDROCK_FORCE_HTTP1", options.env) === "1") {
|
||||
// Some custom endpoints require HTTP/1.1 instead of HTTP/2
|
||||
config.requestHandler = new NodeHttpHandler();
|
||||
}
|
||||
@@ -192,12 +207,12 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> =
|
||||
if (options.headers && Object.keys(options.headers).length > 0) {
|
||||
addCustomHeadersMiddleware(client, options.headers);
|
||||
}
|
||||
const cacheRetention = resolveCacheRetention(options.cacheRetention);
|
||||
const cacheRetention = resolveCacheRetention(options.cacheRetention, options.env);
|
||||
const inferenceMaxTokens = options.maxTokens ?? (isAnthropicClaudeModel(model) ? model.maxTokens : undefined);
|
||||
let commandInput = {
|
||||
modelId: model.id,
|
||||
messages: convertMessages(context, model, cacheRetention),
|
||||
system: buildSystemPrompt(context.systemPrompt, model, cacheRetention),
|
||||
messages: convertMessages(context, model, cacheRetention, options.env),
|
||||
system: buildSystemPrompt(context.systemPrompt, model, cacheRetention, options.env),
|
||||
inferenceConfig: {
|
||||
...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }),
|
||||
...(options.temperature !== undefined && { temperature: options.temperature }),
|
||||
@@ -578,11 +593,11 @@ function mapThinkingLevelToEffort(
|
||||
* Resolve cache retention preference.
|
||||
* Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.
|
||||
*/
|
||||
function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention {
|
||||
function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention {
|
||||
if (cacheRetention) {
|
||||
return cacheRetention;
|
||||
}
|
||||
if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") {
|
||||
if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") {
|
||||
return "long";
|
||||
}
|
||||
return "short";
|
||||
@@ -617,14 +632,14 @@ function isAnthropicClaudeModel(model: Model<"bedrock-converse-stream">): boolea
|
||||
* As a last resort, set AWS_BEDROCK_FORCE_CACHE=1 to enable cache points.
|
||||
* Amazon Nova models have automatic caching and don't need explicit cache points.
|
||||
*/
|
||||
function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean {
|
||||
function supportsPromptCaching(model: Model<"bedrock-converse-stream">, env?: ProviderEnv): boolean {
|
||||
const candidates = getModelMatchCandidates(model.id, model.name);
|
||||
|
||||
const hasClaudeRef = candidates.some((s) => s.includes("claude"));
|
||||
if (!hasClaudeRef) {
|
||||
// Application inference profiles don't contain the model name in the ARN.
|
||||
// Allow users to force cache points via environment variable.
|
||||
if (typeof process !== "undefined" && process.env.AWS_BEDROCK_FORCE_CACHE === "1") return true;
|
||||
if (getProviderEnvValue("AWS_BEDROCK_FORCE_CACHE", env) === "1") return true;
|
||||
return false;
|
||||
}
|
||||
// Claude 4.x models (opus-4, sonnet-4, haiku-4)
|
||||
@@ -652,13 +667,14 @@ function buildSystemPrompt(
|
||||
systemPrompt: string | undefined,
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
cacheRetention: CacheRetention,
|
||||
env?: ProviderEnv,
|
||||
): SystemContentBlock[] | undefined {
|
||||
if (!systemPrompt) return undefined;
|
||||
|
||||
const blocks: SystemContentBlock[] = [{ text: sanitizeSurrogates(systemPrompt) }];
|
||||
|
||||
// Add cache point for supported Claude models when caching is enabled
|
||||
if (cacheRetention !== "none" && supportsPromptCaching(model)) {
|
||||
if (cacheRetention !== "none" && supportsPromptCaching(model, env)) {
|
||||
blocks.push({
|
||||
cachePoint: { type: CachePointType.DEFAULT, ...(cacheRetention === "long" ? { ttl: CacheTTL.ONE_HOUR } : {}) },
|
||||
});
|
||||
@@ -699,6 +715,7 @@ function convertMessages(
|
||||
context: Context,
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
cacheRetention: CacheRetention,
|
||||
env?: ProviderEnv,
|
||||
): Message[] {
|
||||
const result: Message[] = [];
|
||||
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
||||
@@ -844,7 +861,7 @@ function convertMessages(
|
||||
}
|
||||
|
||||
// Add cache point to the last user message for supported Claude models when caching is enabled
|
||||
if (cacheRetention !== "none" && supportsPromptCaching(model) && result.length > 0) {
|
||||
if (cacheRetention !== "none" && supportsPromptCaching(model, env) && result.length > 0) {
|
||||
const lastMessage = result[result.length - 1];
|
||||
if (lastMessage.role === ConversationRole.USER && lastMessage.content) {
|
||||
(lastMessage.content as ContentBlock[]).push({
|
||||
@@ -906,19 +923,26 @@ function mapStopReason(reason: string | undefined): StopReason {
|
||||
}
|
||||
|
||||
function getConfiguredBedrockRegion(options: BedrockOptions): string | undefined {
|
||||
if (typeof process === "undefined") {
|
||||
return options.region;
|
||||
}
|
||||
|
||||
return options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || undefined;
|
||||
return (
|
||||
options.region ||
|
||||
getProviderEnvValue("AWS_REGION", options.env) ||
|
||||
getProviderEnvValue("AWS_DEFAULT_REGION", options.env) ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
function hasConfiguredBedrockProfile(): boolean {
|
||||
if (typeof process === "undefined") {
|
||||
return false;
|
||||
function getConfiguredBedrockCredentials(env?: ProviderEnv): BedrockRuntimeClientConfig["credentials"] | undefined {
|
||||
const accessKeyId = getProviderEnvValue("AWS_ACCESS_KEY_ID", env);
|
||||
const secretAccessKey = getProviderEnvValue("AWS_SECRET_ACCESS_KEY", env);
|
||||
if (!accessKeyId || !secretAccessKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Boolean(process.env.AWS_PROFILE);
|
||||
const sessionToken = getProviderEnvValue("AWS_SESSION_TOKEN", env);
|
||||
return {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
...(sessionToken ? { sessionToken } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getStandardBedrockEndpointRegion(baseUrl: string | undefined): string | undefined {
|
||||
@@ -938,14 +962,14 @@ function getStandardBedrockEndpointRegion(baseUrl: string | undefined): string |
|
||||
function shouldUseExplicitBedrockEndpoint(
|
||||
baseUrl: string,
|
||||
configuredRegion: string | undefined,
|
||||
hasConfiguredProfile: boolean,
|
||||
hasAmbientConfiguredProfile: boolean,
|
||||
): boolean {
|
||||
const endpointRegion = getStandardBedrockEndpointRegion(baseUrl);
|
||||
if (!endpointRegion) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !configuredRegion && !hasConfiguredProfile;
|
||||
return !configuredRegion && !hasAmbientConfiguredProfile;
|
||||
}
|
||||
|
||||
function isGovCloudBedrockTarget(model: Model<"bedrock-converse-stream">, options: BedrockOptions): boolean {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Api, Model } from "../types.ts";
|
||||
import type { Api, Model, ProviderEnv } from "../types.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
|
||||
/** Workers AI direct endpoint. */
|
||||
export const CLOUDFLARE_WORKERS_AI_BASE_URL =
|
||||
@@ -20,12 +21,12 @@ export function isCloudflareProvider(provider: string): boolean {
|
||||
return provider === "cloudflare-workers-ai" || provider === "cloudflare-ai-gateway";
|
||||
}
|
||||
|
||||
/** Substitute `{VAR}` placeholders in a Cloudflare baseUrl from process.env. */
|
||||
export function resolveCloudflareBaseUrl(model: Model<Api>): string {
|
||||
/** Substitute `{VAR}` placeholders in a Cloudflare baseUrl from provider env or process.env. */
|
||||
export function resolveCloudflareBaseUrl(model: Model<Api>, env?: ProviderEnv): string {
|
||||
const url = model.baseUrl;
|
||||
if (!url.includes("{")) return url;
|
||||
const baseUrl = url.replace(/\{([A-Z_][A-Z0-9_]*)\}/g, (_match, name: string) => {
|
||||
const value = process.env[name];
|
||||
const value = getProviderEnvValue(name, env);
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required for provider ${model.provider} but is not set.`);
|
||||
}
|
||||
|
||||
@@ -406,7 +406,8 @@ function isGemini3ProModel(model: Model<"google-generative-ai">): boolean {
|
||||
}
|
||||
|
||||
function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
|
||||
return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase());
|
||||
const id = model.id.toLowerCase();
|
||||
return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest";
|
||||
}
|
||||
|
||||
function getDisabledThinkingConfig(model: Model<"google-generative-ai">): ThinkingConfig {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
Context,
|
||||
Model,
|
||||
ThinkingLevel as PiThinkingLevel,
|
||||
ProviderEnv,
|
||||
SimpleStreamOptions,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
@@ -23,6 +24,7 @@ import type {
|
||||
ToolCall,
|
||||
} from "../types.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import type { GoogleThinkingLevel } from "./google-shared.ts";
|
||||
import {
|
||||
@@ -91,7 +93,7 @@ export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = (
|
||||
// Create the client using either a Vertex API key, if provided, or ADC with project and location
|
||||
const client = apiKey
|
||||
? createClientWithApiKey(model, apiKey, options?.headers)
|
||||
: createClient(model, resolveProject(options), resolveLocation(options), options?.headers);
|
||||
: createClient(model, resolveProject(options), resolveLocation(options), options?.headers, options?.env);
|
||||
let params = buildParams(model, context, options);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
@@ -333,12 +335,15 @@ function createClient(
|
||||
project: string,
|
||||
location: string,
|
||||
optionsHeaders?: Record<string, string>,
|
||||
env?: ProviderEnv,
|
||||
): GoogleGenAI {
|
||||
const googleAuthOptions = buildGoogleAuthOptions(env);
|
||||
return new GoogleGenAI({
|
||||
vertexai: true,
|
||||
project,
|
||||
location,
|
||||
apiVersion: API_VERSION,
|
||||
...(googleAuthOptions ? { googleAuthOptions } : {}),
|
||||
httpOptions: buildHttpOptions(model, optionsHeaders),
|
||||
});
|
||||
}
|
||||
@@ -394,6 +399,11 @@ function baseUrlIncludesApiVersion(baseUrl: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function buildGoogleAuthOptions(env?: ProviderEnv): { keyFilename: string } | undefined {
|
||||
const keyFilename = getProviderEnvValue("GOOGLE_APPLICATION_CREDENTIALS", env);
|
||||
return keyFilename ? { keyFilename } : undefined;
|
||||
}
|
||||
|
||||
function resolveApiKey(options?: GoogleVertexOptions): string | undefined {
|
||||
const apiKey = options?.apiKey?.trim();
|
||||
if (!apiKey || apiKey === GCP_VERTEX_CREDENTIALS_MARKER || isPlaceholderApiKey(apiKey)) {
|
||||
@@ -407,7 +417,10 @@ function isPlaceholderApiKey(apiKey: string): boolean {
|
||||
}
|
||||
|
||||
function resolveProject(options?: GoogleVertexOptions): string {
|
||||
const project = options?.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT;
|
||||
const project =
|
||||
options?.project ||
|
||||
getProviderEnvValue("GOOGLE_CLOUD_PROJECT", options?.env) ||
|
||||
getProviderEnvValue("GCLOUD_PROJECT", options?.env);
|
||||
if (!project) {
|
||||
throw new Error(
|
||||
"Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT or pass project in options.",
|
||||
@@ -417,7 +430,7 @@ function resolveProject(options?: GoogleVertexOptions): string {
|
||||
}
|
||||
|
||||
function resolveLocation(options?: GoogleVertexOptions): string {
|
||||
const location = options?.location || process.env.GOOGLE_CLOUD_LOCATION;
|
||||
const location = options?.location || getProviderEnvValue("GOOGLE_CLOUD_LOCATION", options?.env);
|
||||
if (!location) {
|
||||
throw new Error("Vertex AI requires a location. Set GOOGLE_CLOUD_LOCATION or pass location in options.");
|
||||
}
|
||||
@@ -490,7 +503,8 @@ function isGemini3ProModel(model: Model<"google-generative-ai">): boolean {
|
||||
}
|
||||
|
||||
function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
|
||||
return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase());
|
||||
const id = model.id.toLowerCase();
|
||||
return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest";
|
||||
}
|
||||
|
||||
function getDisabledThinkingConfig(model: Model<"google-vertex">): ThinkingConfig {
|
||||
|
||||
@@ -226,7 +226,7 @@ function buildRequestOptions(model: Model<"mistral-conversations">, options?: Mi
|
||||
|
||||
// Mistral infrastructure uses `x-affinity` for KV-cache reuse (prefix caching).
|
||||
// Respect explicit caller-provided header values.
|
||||
if (options?.sessionId && !headers["x-affinity"]) {
|
||||
if (shouldUsePromptCaching(options) && !headers["x-affinity"]) {
|
||||
headers["x-affinity"] = options.sessionId;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ function buildChatPayload(
|
||||
if (options?.toolChoice) payload.toolChoice = mapToolChoice(options.toolChoice);
|
||||
if (options?.promptMode) payload.promptMode = options.promptMode;
|
||||
if (options?.reasoningEffort) payload.reasoningEffort = options.reasoningEffort;
|
||||
if (shouldUsePromptCaching(options)) payload.promptCacheKey = options.sessionId;
|
||||
|
||||
if (context.systemPrompt) {
|
||||
payload.messages.unshift({
|
||||
@@ -266,6 +267,31 @@ function buildChatPayload(
|
||||
return payload;
|
||||
}
|
||||
|
||||
function shouldUsePromptCaching(options?: MistralOptions): options is MistralOptions & { sessionId: string } {
|
||||
return options?.cacheRetention !== "none" && !!options?.sessionId;
|
||||
}
|
||||
|
||||
function getMistralCachedPromptTokens(usage: unknown, promptTokens: number): number {
|
||||
const rawUsage = usage as {
|
||||
promptTokensDetails?: { cachedTokens?: unknown } | null;
|
||||
prompt_tokens_details?: { cached_tokens?: unknown } | null;
|
||||
promptTokenDetails?: { cachedTokens?: unknown } | null;
|
||||
prompt_token_details?: { cached_tokens?: unknown } | null;
|
||||
numCachedTokens?: unknown;
|
||||
num_cached_tokens?: unknown;
|
||||
};
|
||||
const rawCachedTokens =
|
||||
rawUsage.promptTokensDetails?.cachedTokens ??
|
||||
rawUsage.prompt_tokens_details?.cached_tokens ??
|
||||
rawUsage.promptTokenDetails?.cachedTokens ??
|
||||
rawUsage.prompt_token_details?.cached_tokens ??
|
||||
rawUsage.numCachedTokens ??
|
||||
rawUsage.num_cached_tokens ??
|
||||
0;
|
||||
const cachedTokens = typeof rawCachedTokens === "number" && Number.isFinite(rawCachedTokens) ? rawCachedTokens : 0;
|
||||
return Math.min(promptTokens, Math.max(0, cachedTokens));
|
||||
}
|
||||
|
||||
async function consumeChatStream(
|
||||
model: Model<"mistral-conversations">,
|
||||
output: AssistantMessage,
|
||||
@@ -305,11 +331,16 @@ async function consumeChatStream(
|
||||
output.responseId ||= chunk.id;
|
||||
|
||||
if (chunk.usage) {
|
||||
output.usage.input = chunk.usage.promptTokens || 0;
|
||||
const promptTokens = chunk.usage.promptTokens || 0;
|
||||
const cachedPromptTokens = getMistralCachedPromptTokens(chunk.usage, promptTokens);
|
||||
|
||||
output.usage.input = Math.max(0, promptTokens - cachedPromptTokens);
|
||||
output.usage.output = chunk.usage.completionTokens || 0;
|
||||
output.usage.cacheRead = 0;
|
||||
output.usage.cacheRead = cachedPromptTokens;
|
||||
output.usage.cacheWrite = 0;
|
||||
output.usage.totalTokens = chunk.usage.totalTokens || output.usage.input + output.usage.output;
|
||||
output.usage.totalTokens =
|
||||
chunk.usage.totalTokens ||
|
||||
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
||||
calculateCost(model, output.usage);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
AssistantMessage,
|
||||
Context,
|
||||
Model,
|
||||
ProviderEnv,
|
||||
SimpleStreamOptions,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
} from "../utils/diagnostics.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
|
||||
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
|
||||
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
@@ -53,7 +55,9 @@ const JWT_CLAIM_PATH = "https://api.openai.com/auth" as const;
|
||||
const DEFAULT_MAX_RETRIES = 0;
|
||||
const BASE_DELAY_MS = 1000;
|
||||
const DEFAULT_MAX_RETRY_DELAY_MS = 60_000;
|
||||
const DEFAULT_SSE_HEADER_TIMEOUT_MS = 10_000;
|
||||
// Keep a bounded pre-header timeout so zero-event Codex SSE stalls fail instead of
|
||||
// leaving callers stuck on "Working..." indefinitely. See #4945.
|
||||
const DEFAULT_SSE_HEADER_TIMEOUT_MS = 20_000;
|
||||
const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000;
|
||||
const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
|
||||
const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009;
|
||||
@@ -812,19 +816,13 @@ type WebSocketConstructor = new (
|
||||
) => WebSocketLike;
|
||||
|
||||
let _cachedWebsocket: WebSocketConstructor | null = null;
|
||||
async function getWebSocketConstructor(): Promise<WebSocketConstructor | null> {
|
||||
if (_cachedWebsocket) return _cachedWebsocket;
|
||||
async function getWebSocketConstructor(env?: ProviderEnv): Promise<WebSocketConstructor | null> {
|
||||
if (!env && _cachedWebsocket) return _cachedWebsocket;
|
||||
|
||||
// bun doesn't respect http proxy envs, ref: https://github.com/oven-sh/bun/issues/15489
|
||||
// TODO: remove this when bun supports proxy envs in websocket.
|
||||
if (
|
||||
process?.versions?.bun &&
|
||||
(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
|
||||
) {
|
||||
const m = await dynamicImport("proxy-from-env");
|
||||
const getProxyForUrl = (m as { getProxyForUrl: (url: string | object | URL) => string }).getProxyForUrl;
|
||||
|
||||
_cachedWebsocket = class extends WebSocket {
|
||||
if (typeof process !== "undefined" && process.versions?.bun) {
|
||||
const WebSocketWithProxy = class extends WebSocket {
|
||||
constructor(url: string | URL, options?: string | string[] | Record<string, unknown>) {
|
||||
let _opts: Record<string, unknown> = {};
|
||||
if (Array.isArray(options) || typeof options === "string") {
|
||||
@@ -833,11 +831,17 @@ async function getWebSocketConstructor(): Promise<WebSocketConstructor | null> {
|
||||
_opts = { ...options };
|
||||
}
|
||||
|
||||
const proxy = getProxyForUrl(url.toString().replace(/^wss:/, "https:").replace(/^ws:/, "http:"));
|
||||
super(url, { ..._opts, ...(proxy ? { proxy } : {}) } as any);
|
||||
const proxyUrl = resolveHttpProxyUrlForTarget(
|
||||
url.toString().replace(/^wss:/, "https:").replace(/^ws:/, "http:"),
|
||||
env,
|
||||
);
|
||||
super(url, { ..._opts, ...(proxyUrl ? { proxy: proxyUrl.toString() } : {}) } as any);
|
||||
}
|
||||
};
|
||||
return _cachedWebsocket;
|
||||
if (!env) {
|
||||
_cachedWebsocket = WebSocketWithProxy;
|
||||
}
|
||||
return WebSocketWithProxy;
|
||||
}
|
||||
|
||||
const ctor = (globalThis as { WebSocket?: unknown }).WebSocket;
|
||||
@@ -892,8 +896,9 @@ async function connectWebSocket(
|
||||
headers: Headers,
|
||||
signal?: AbortSignal,
|
||||
connectTimeoutMs = DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS,
|
||||
env?: ProviderEnv,
|
||||
): Promise<WebSocketLike> {
|
||||
const WebSocketCtor = await getWebSocketConstructor();
|
||||
const WebSocketCtor = await getWebSocketConstructor(env);
|
||||
if (!WebSocketCtor) {
|
||||
throw new Error("WebSocket transport is not available in this runtime");
|
||||
}
|
||||
@@ -970,6 +975,7 @@ async function acquireWebSocket(
|
||||
sessionId: string | undefined,
|
||||
signal?: AbortSignal,
|
||||
connectTimeoutMs?: number,
|
||||
env?: ProviderEnv,
|
||||
): Promise<{
|
||||
socket: WebSocketLike;
|
||||
entry?: CachedWebSocketConnection;
|
||||
@@ -977,7 +983,7 @@ async function acquireWebSocket(
|
||||
release: (options?: { keep?: boolean }) => void;
|
||||
}> {
|
||||
if (!sessionId) {
|
||||
const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs);
|
||||
const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs, env);
|
||||
return {
|
||||
socket,
|
||||
reused: false,
|
||||
@@ -1009,7 +1015,7 @@ async function acquireWebSocket(
|
||||
};
|
||||
}
|
||||
if (cached.busy) {
|
||||
const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs);
|
||||
const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs, env);
|
||||
return {
|
||||
socket,
|
||||
reused: false,
|
||||
@@ -1024,7 +1030,7 @@ async function acquireWebSocket(
|
||||
}
|
||||
}
|
||||
|
||||
const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs);
|
||||
const socket = await connectWebSocket(url, headers, signal, connectTimeoutMs, env);
|
||||
const entry: CachedWebSocketConnection = { socket, busy: true };
|
||||
websocketSessionCache.set(sessionId, entry);
|
||||
return {
|
||||
@@ -1310,6 +1316,7 @@ async function processWebSocketStream(
|
||||
options?.sessionId,
|
||||
options?.signal,
|
||||
websocketConnectTimeoutMs,
|
||||
options?.env,
|
||||
);
|
||||
let keepConnection = true;
|
||||
const useCachedContext = options?.transport === "websocket-cached" || options?.transport === "auto";
|
||||
|
||||
@@ -14,11 +14,13 @@ import { calculateCost, clampThinkingLevel } from "../models.ts";
|
||||
import type {
|
||||
AssistantMessage,
|
||||
CacheRetention,
|
||||
ChatTemplateKwargValue,
|
||||
Context,
|
||||
ImageContent,
|
||||
Message,
|
||||
Model,
|
||||
OpenAICompletionsCompat,
|
||||
ProviderEnv,
|
||||
SimpleStreamOptions,
|
||||
StopReason,
|
||||
StreamFunction,
|
||||
@@ -32,6 +34,7 @@ import type {
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.ts";
|
||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
|
||||
@@ -74,6 +77,20 @@ function isImageContentBlock(block: { type: string }): block is ImageContent {
|
||||
return block.type === "image";
|
||||
}
|
||||
|
||||
function isEncryptedReasoningDetail(detail: unknown): detail is OpenAIEncryptedReasoningDetail {
|
||||
if (typeof detail !== "object" || detail === null) {
|
||||
return false;
|
||||
}
|
||||
const candidate = detail as Record<string, unknown>;
|
||||
return (
|
||||
candidate.type === "reasoning.encrypted" &&
|
||||
typeof candidate.id === "string" &&
|
||||
candidate.id.length > 0 &&
|
||||
typeof candidate.data === "string" &&
|
||||
candidate.data.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export interface OpenAICompletionsOptions extends StreamOptions {
|
||||
toolChoice?: "auto" | "none" | "required" | { type: "function"; function: { name: string } };
|
||||
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
@@ -88,8 +105,16 @@ type ResolvedOpenAICompletionsCompat = Omit<Required<OpenAICompletionsCompat>, "
|
||||
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
|
||||
};
|
||||
|
||||
type ResolvedChatTemplateKwargValue = string | number | boolean | null;
|
||||
|
||||
type ChatCompletionInstructionMessageParam = ChatCompletionDeveloperMessageParam | ChatCompletionSystemMessageParam;
|
||||
|
||||
type OpenAIEncryptedReasoningDetail = {
|
||||
type: "reasoning.encrypted";
|
||||
id: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
type ChatCompletionTextPartWithCacheControl = ChatCompletionContentPartText & {
|
||||
cache_control?: OpenAICompatCacheControl;
|
||||
};
|
||||
@@ -98,11 +123,11 @@ type ChatCompletionToolWithCacheControl = OpenAI.Chat.Completions.ChatCompletion
|
||||
cache_control?: OpenAICompatCacheControl;
|
||||
};
|
||||
|
||||
function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention {
|
||||
function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention {
|
||||
if (cacheRetention) {
|
||||
return cacheRetention;
|
||||
}
|
||||
if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") {
|
||||
if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") {
|
||||
return "long";
|
||||
}
|
||||
return "short";
|
||||
@@ -140,9 +165,9 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
||||
throw new Error(`No API key for provider: ${model.provider}`);
|
||||
}
|
||||
const compat = getCompat(model);
|
||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
|
||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
|
||||
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
|
||||
const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat);
|
||||
const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat, options?.env);
|
||||
let params = buildParams(model, context, options, compat, cacheRetention);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
@@ -171,6 +196,7 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
||||
let hasFinishReason = false;
|
||||
const toolCallBlocksByIndex = new Map<number, StreamingToolCallBlock>();
|
||||
const toolCallBlocksById = new Map<string, StreamingToolCallBlock>();
|
||||
const pendingReasoningDetailsByToolCallId = new Map<string, string>();
|
||||
const blocks = output.content as StreamingBlock[];
|
||||
const getContentIndex = (block: StreamingBlock) => blocks.indexOf(block);
|
||||
const finishBlock = (block: StreamingBlock) => {
|
||||
@@ -226,6 +252,16 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
||||
}
|
||||
return thinkingBlock;
|
||||
};
|
||||
const applyPendingReasoningDetail = (block: StreamingToolCallBlock) => {
|
||||
if (!block.id) {
|
||||
return;
|
||||
}
|
||||
const pendingReasoningDetail = pendingReasoningDetailsByToolCallId.get(block.id);
|
||||
if (pendingReasoningDetail) {
|
||||
block.thoughtSignature = pendingReasoningDetail;
|
||||
pendingReasoningDetailsByToolCallId.delete(block.id);
|
||||
}
|
||||
};
|
||||
const ensureToolCallBlock = (toolCall: StreamingToolCallDelta) => {
|
||||
const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined;
|
||||
let block = streamIndex !== undefined ? toolCallBlocksByIndex.get(streamIndex) : undefined;
|
||||
@@ -261,6 +297,7 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
||||
if (toolCall.id) {
|
||||
toolCallBlocksById.set(toolCall.id, block);
|
||||
}
|
||||
applyPendingReasoningDetail(block);
|
||||
return block;
|
||||
};
|
||||
|
||||
@@ -370,15 +407,16 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
||||
}
|
||||
}
|
||||
|
||||
const reasoningDetails = (choice.delta as any).reasoning_details;
|
||||
if (reasoningDetails && Array.isArray(reasoningDetails)) {
|
||||
const reasoningDetails = (choice.delta as { reasoning_details?: unknown }).reasoning_details;
|
||||
if (Array.isArray(reasoningDetails)) {
|
||||
for (const detail of reasoningDetails) {
|
||||
if (detail.type === "reasoning.encrypted" && detail.id && detail.data) {
|
||||
const matchingToolCall = output.content.find(
|
||||
(b) => b.type === "toolCall" && b.id === detail.id,
|
||||
) as ToolCall | undefined;
|
||||
if (isEncryptedReasoningDetail(detail)) {
|
||||
const serializedDetail = JSON.stringify(detail);
|
||||
const matchingToolCall = toolCallBlocksById.get(detail.id);
|
||||
if (matchingToolCall) {
|
||||
matchingToolCall.thoughtSignature = JSON.stringify(detail);
|
||||
matchingToolCall.thoughtSignature = serializedDetail;
|
||||
} else {
|
||||
pendingReasoningDetailsByToolCallId.set(detail.id, serializedDetail);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,6 +492,7 @@ function createClient(
|
||||
optionsHeaders?: Record<string, string>,
|
||||
sessionId?: string,
|
||||
compat: ResolvedOpenAICompletionsCompat = getCompat(model),
|
||||
env?: ProviderEnv,
|
||||
) {
|
||||
const headers = { ...model.headers };
|
||||
if (model.provider === "github-copilot") {
|
||||
@@ -487,7 +526,7 @@ function createClient(
|
||||
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model) : model.baseUrl,
|
||||
baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model, env) : model.baseUrl,
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders,
|
||||
});
|
||||
@@ -498,7 +537,7 @@ function buildParams(
|
||||
context: Context,
|
||||
options?: OpenAICompletionsOptions,
|
||||
compat: ResolvedOpenAICompletionsCompat = getCompat(model),
|
||||
cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention),
|
||||
cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env),
|
||||
) {
|
||||
const messages = convertMessages(model, context, compat);
|
||||
const cacheControl = getCompatCacheControl(compat, cacheRetention);
|
||||
@@ -554,8 +593,18 @@ function buildParams(
|
||||
}
|
||||
|
||||
if (compat.thinkingFormat === "zai" && model.reasoning) {
|
||||
const zaiParams = params as typeof params & { thinking?: { type: "enabled" | "disabled" } };
|
||||
const zaiParams = params as Omit<typeof params, "reasoning_effort"> & {
|
||||
thinking?: { type: "enabled" | "disabled" };
|
||||
reasoning_effort?: string;
|
||||
};
|
||||
zaiParams.thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" };
|
||||
if (options?.reasoningEffort && compat.supportsReasoningEffort) {
|
||||
const mappedEffort = model.thinkingLevelMap?.[options.reasoningEffort];
|
||||
const effort = mappedEffort === undefined ? options.reasoningEffort : mappedEffort;
|
||||
if (typeof effort === "string") {
|
||||
zaiParams.reasoning_effort = effort;
|
||||
}
|
||||
}
|
||||
} else if (compat.thinkingFormat === "qwen" && model.reasoning) {
|
||||
(params as any).enable_thinking = !!options?.reasoningEffort;
|
||||
} else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) {
|
||||
@@ -563,8 +612,17 @@ function buildParams(
|
||||
enable_thinking: !!options?.reasoningEffort,
|
||||
preserve_thinking: true,
|
||||
};
|
||||
} else if (compat.thinkingFormat === "chat-template" && model.reasoning) {
|
||||
const chatTemplateKwargs = buildChatTemplateKwargs(model, options, compat);
|
||||
if (chatTemplateKwargs) {
|
||||
(params as any).chat_template_kwargs = chatTemplateKwargs;
|
||||
}
|
||||
} else if (compat.thinkingFormat === "deepseek" && model.reasoning) {
|
||||
(params as any).thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" };
|
||||
if (options?.reasoningEffort) {
|
||||
(params as any).thinking = { type: "enabled" };
|
||||
} else if (model.thinkingLevelMap?.off !== null) {
|
||||
(params as any).thinking = { type: "disabled" };
|
||||
}
|
||||
if (options?.reasoningEffort && compat.supportsReasoningEffort) {
|
||||
(params as any).reasoning_effort =
|
||||
model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort;
|
||||
@@ -629,6 +687,44 @@ function buildParams(
|
||||
return params;
|
||||
}
|
||||
|
||||
function buildChatTemplateKwargs(
|
||||
model: Model<"openai-completions">,
|
||||
options: OpenAICompletionsOptions | undefined,
|
||||
compat: ResolvedOpenAICompletionsCompat,
|
||||
): Record<string, ResolvedChatTemplateKwargValue> | undefined {
|
||||
const kwargs: Record<string, ResolvedChatTemplateKwargValue> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(compat.chatTemplateKwargs)) {
|
||||
const resolved = resolveChatTemplateKwargValue(model, options, value);
|
||||
if (resolved !== undefined) {
|
||||
kwargs[key] = resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(kwargs).length > 0 ? kwargs : undefined;
|
||||
}
|
||||
|
||||
function resolveChatTemplateKwargValue(
|
||||
model: Model<"openai-completions">,
|
||||
options: OpenAICompletionsOptions | undefined,
|
||||
value: ChatTemplateKwargValue,
|
||||
): ResolvedChatTemplateKwargValue | undefined {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const reasoningEffort = options?.reasoningEffort;
|
||||
if (!reasoningEffort && value.omitWhenOff) {
|
||||
return undefined;
|
||||
}
|
||||
if (value.$var === "thinking.enabled") {
|
||||
return !!reasoningEffort;
|
||||
}
|
||||
|
||||
const mappedValue = reasoningEffort ? model.thinkingLevelMap?.[reasoningEffort] : model.thinkingLevelMap?.off;
|
||||
return mappedValue === undefined ? reasoningEffort : typeof mappedValue === "string" ? mappedValue : undefined;
|
||||
}
|
||||
|
||||
function getCompatCacheControl(
|
||||
compat: ResolvedOpenAICompletionsCompat,
|
||||
cacheRetention: CacheRetention,
|
||||
@@ -1141,6 +1237,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
|
||||
: "openai",
|
||||
openRouterRouting: {},
|
||||
vercelGatewayRouting: {},
|
||||
chatTemplateKwargs: {},
|
||||
zaiToolStream: false,
|
||||
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia,
|
||||
cacheControlFormat,
|
||||
@@ -1179,6 +1276,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion
|
||||
thinkingFormat: model.compat.thinkingFormat ?? detected.thinkingFormat,
|
||||
openRouterRouting: model.compat.openRouterRouting ?? {},
|
||||
vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting,
|
||||
chatTemplateKwargs: model.compat.chatTemplateKwargs ?? detected.chatTemplateKwargs,
|
||||
zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream,
|
||||
supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
|
||||
cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat,
|
||||
|
||||
@@ -456,7 +456,8 @@ export async function processResponsesStream<TApi extends Api>(
|
||||
});
|
||||
currentBlock = null;
|
||||
} else if (item.type === "message" && currentBlock?.type === "text") {
|
||||
currentBlock.text = item.content.map((c) => (c.type === "output_text" ? c.text : c.refusal)).join("");
|
||||
currentBlock.text =
|
||||
item.content?.map((c) => (c.type === "output_text" ? c.text : c.refusal)).join("") || "";
|
||||
currentBlock.textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined);
|
||||
stream.push({
|
||||
type: "text_end",
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
Context,
|
||||
Model,
|
||||
OpenAIResponsesCompat,
|
||||
ProviderEnv,
|
||||
SimpleStreamOptions,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
} from "../types.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.ts";
|
||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
|
||||
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
|
||||
@@ -27,11 +29,11 @@ const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"
|
||||
* Resolve cache retention preference.
|
||||
* Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.
|
||||
*/
|
||||
function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention {
|
||||
function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention {
|
||||
if (cacheRetention) {
|
||||
return cacheRetention;
|
||||
}
|
||||
if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") {
|
||||
if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") {
|
||||
return "long";
|
||||
}
|
||||
return "short";
|
||||
@@ -111,9 +113,9 @@ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions>
|
||||
if (!apiKey) {
|
||||
throw new Error(`No API key for provider: ${model.provider}`);
|
||||
}
|
||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
|
||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
|
||||
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
|
||||
const client = createClient(model, context, apiKey, options?.headers, cacheSessionId);
|
||||
const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, options?.env);
|
||||
let params = buildParams(model, context, options);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
@@ -185,6 +187,7 @@ function createClient(
|
||||
apiKey: string,
|
||||
optionsHeaders?: Record<string, string>,
|
||||
sessionId?: string,
|
||||
env?: ProviderEnv,
|
||||
) {
|
||||
const compat = getCompat(model);
|
||||
const headers = { ...model.headers };
|
||||
@@ -220,7 +223,7 @@ function createClient(
|
||||
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model) : model.baseUrl,
|
||||
baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model, env) : model.baseUrl,
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders,
|
||||
});
|
||||
@@ -229,7 +232,7 @@ function createClient(
|
||||
function buildParams(model: Model<"openai-responses">, context: Context, options?: OpenAIResponsesOptions) {
|
||||
const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS);
|
||||
|
||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
|
||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
|
||||
const compat = getCompat(model);
|
||||
const params: ResponseCreateParamsStreaming = {
|
||||
model: model.id,
|
||||
|
||||
@@ -17,6 +17,7 @@ export function buildBaseOptions(_model: Model<Api>, options?: SimpleStreamOptio
|
||||
maxRetries: options?.maxRetries,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
metadata: options?.metadata,
|
||||
env: options?.env,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user