feat(ai): complete models runtime migration

This commit is contained in:
Mario Zechner
2026-06-23 15:29:17 +02:00
parent 470a4736a3
commit 129eb460cd
47 changed files with 1502 additions and 576 deletions
+49 -62
View File
@@ -18,6 +18,7 @@ import type {
Message,
Model,
ProviderEnv,
ProviderHeaders,
SimpleStreamOptions,
StopReason,
StreamFunction,
@@ -34,7 +35,6 @@ 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";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options.ts";
import { transformMessages } from "./transform-messages.ts";
@@ -170,16 +170,11 @@ const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14";
function getAnthropicCompat(
model: Model<"anthropic-messages">,
): Required<Omit<AnthropicMessagesCompat, "forceAdaptiveThinking">> {
// Auto-detect session affinity and cache control support from provider
const isFireworks = model.provider === "fireworks";
const isCloudflareAiGatewayAnthropic =
model.provider === "cloudflare-ai-gateway" && model.baseUrl.includes("anthropic");
return {
supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? !isFireworks,
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? !isFireworks,
sendSessionAffinityHeaders:
model.compat?.sendSessionAffinityHeaders ?? !!(isFireworks || isCloudflareAiGatewayAnthropic),
supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? !isFireworks,
supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? true,
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
sendSessionAffinityHeaders: model.compat?.sendSessionAffinityHeaders ?? false,
supportsCacheControlOnTools: model.compat?.supportsCacheControlOnTools ?? true,
supportsTemperature: model.compat?.supportsTemperature ?? true,
allowEmptySignature: model.compat?.allowEmptySignature ?? false,
};
@@ -247,8 +242,8 @@ export interface AnthropicOptions extends StreamOptions {
client?: Anthropic;
}
function mergeHeaders(...headerSources: (Record<string, string | null> | undefined)[]): Record<string, string | null> {
const merged: Record<string, string | null> = {};
function mergeHeaders(...headerSources: (ProviderHeaders | undefined)[]): ProviderHeaders {
const merged: ProviderHeaders = {};
for (const headers of headerSources) {
if (headers) {
Object.assign(merged, headers);
@@ -257,6 +252,27 @@ function mergeHeaders(...headerSources: (Record<string, string | null> | undefin
return merged;
}
function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean {
if (!headers) return false;
const expected = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === expected && value !== null && value.trim().length > 0) return true;
}
return false;
}
function assertRequestAuth(provider: string, apiKey: string | undefined, headers: ProviderHeaders | undefined): void {
if (apiKey) return;
if (
hasHeader(headers, "authorization") ||
hasHeader(headers, "x-api-key") ||
hasHeader(headers, "cf-aig-authorization")
) {
return;
}
throw new Error(`No API key for provider: ${provider}`);
}
interface ServerSentEvent {
event: string | null;
data: string;
@@ -484,9 +500,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
isOAuth = false;
} else {
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
assertRequestAuth(model.provider, apiKey, options?.headers);
let copilotDynamicHeaders: Record<string, string> | undefined;
if (model.provider === "github-copilot") {
@@ -508,7 +522,6 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
options?.headers,
copilotDynamicHeaders,
cacheSessionId,
options?.env,
);
client = created.client;
isOAuth = created.isOAuthToken;
@@ -748,12 +761,9 @@ export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOpti
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
assertRequestAuth(model.provider, options?.apiKey, options?.headers);
const base = buildBaseOptions(model, options, apiKey);
const base = buildBaseOptions(model, options, options?.apiKey);
if (!options?.reasoning) {
return stream(model, context, { ...base, thinkingEnabled: false } satisfies AnthropicOptions);
}
@@ -792,13 +802,12 @@ function isOAuthToken(apiKey: string): boolean {
function createClient(
model: Model<"anthropic-messages">,
apiKey: string,
apiKey: string | undefined,
interleavedThinking: boolean,
useFineGrainedToolStreamingBeta: boolean,
optionsHeaders?: Record<string, string>,
optionsHeaders?: ProviderHeaders,
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;
@@ -810,34 +819,11 @@ function createClient(
betaFeatures.push(INTERLEAVED_THINKING_BETA);
}
if (model.provider === "cloudflare-ai-gateway") {
const client = new Anthropic({
apiKey: null,
authToken: null,
baseURL: resolveCloudflareBaseUrl(model, env),
dangerouslyAllowBrowser: true,
defaultHeaders: mergeHeaders(
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
"cf-aig-authorization": `Bearer ${apiKey}`,
"x-api-key": null,
Authorization: null,
...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}),
},
model.headers,
optionsHeaders,
),
});
return { client, isOAuthToken: false };
}
// Copilot: Bearer auth, selective betas.
if (model.provider === "github-copilot") {
const client = new Anthropic({
apiKey: null,
authToken: apiKey,
authToken: apiKey ?? null,
baseURL: model.baseUrl,
dangerouslyAllowBrowser: true,
defaultHeaders: mergeHeaders(
@@ -856,7 +842,7 @@ function createClient(
}
// OAuth: Bearer auth, Claude Code identity headers
if (isOAuthToken(apiKey)) {
if (apiKey && isOAuthToken(apiKey)) {
const client = new Anthropic({
apiKey: null,
authToken: apiKey,
@@ -878,24 +864,25 @@ function createClient(
return { client, isOAuthToken: true };
}
// API key auth
const sessionAffinityHeaders: Record<string, string | null> =
// API key or header-owned auth.
const sessionAffinityHeaders: ProviderHeaders =
sessionId && getAnthropicCompat(model).sendSessionAffinityHeaders ? { "x-session-affinity": sessionId } : {};
const defaultHeaders = mergeHeaders(
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}),
},
sessionAffinityHeaders,
model.headers,
optionsHeaders,
);
const client = new Anthropic({
apiKey,
apiKey: apiKey ?? null,
authToken: null,
baseURL: model.baseUrl,
dangerouslyAllowBrowser: true,
defaultHeaders: mergeHeaders(
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}),
},
sessionAffinityHeaders,
model.headers,
optionsHeaders,
),
defaultHeaders,
});
return { client, isOAuthToken: false };