feat(ai): complete models runtime migration
This commit is contained in:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user