import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock" import { createAnthropic } from "@ai-sdk/anthropic" import { azure, createAzure } from "@ai-sdk/azure" import { createDeepSeek, deepseek } from "@ai-sdk/deepseek" import { createGateway, gateway } from "@ai-sdk/gateway" import { createGoogleGenerativeAI, google } from "@ai-sdk/google" import { createVertex } from "@ai-sdk/google-vertex" import { createOpenAI, openai } from "@ai-sdk/openai" import { aihubmix, createAihubmix } from "@aihubmix/ai-sdk-provider" import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { createOpenRouter } from "@openrouter/ai-sdk-provider" import { createOllama, ollama } from "ollama-ai-provider-v2" import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config" export type { ProviderName } export const AIHUBMIX_APP_CODE = "MSBS9675" interface ModelConfig { model: any providerOptions?: any headers?: Record modelId: string provider: ProviderName } // Providers that only support a single system message export const SINGLE_SYSTEM_PROVIDERS = new Set([ "minimax", "glm", "qwen", "kimi", "qiniu", "novita", "mimo", ]) /** * Normalize MiniMax base URL for AI SDK compatibility. * MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints. */ export function normalizeMiniMaxBaseURL(rawUrl: string): { baseURL: string isAnthropicCompatible: boolean } { const isAnthropicCompatible = rawUrl.includes("/anthropic") let baseURL = rawUrl.replace(/\/$/, "") if (isAnthropicCompatible) { if (!baseURL.endsWith("/anthropic/v1")) { if (baseURL.endsWith("/anthropic")) { baseURL = `${baseURL}/v1` } else { baseURL = `${baseURL}/anthropic/v1` } } } else { if (!baseURL.endsWith("/v1")) { baseURL = `${baseURL}/v1` } } return { baseURL, isAnthropicCompatible } } export function isAihubmixStandardBaseURL( rawUrl: string | null | undefined, ): boolean { if (!rawUrl) return true const baseURL = rawUrl.replace(/\/+$/, "") return ( baseURL === "https://aihubmix.com" || baseURL === "https://aihubmix.com/v1" ) } export interface ClientOverrides { provider?: string | null baseUrl?: string | null apiKey?: string | null modelId?: string | null // AWS Bedrock credentials awsAccessKeyId?: string | null awsSecretAccessKey?: string | null awsRegion?: string | null awsSessionToken?: string | null // Vertex AI config vertexApiKey?: string | null // Express Mode API key // Custom headers (e.g., for EdgeOne cookie auth) headers?: Record // Custom env var name(s) for server models // Can be a single string or array of strings for load balancing apiKeyEnv?: string | string[] baseUrlEnv?: string } // Providers that can be selected from client settings const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [ "openai", "anthropic", "google", "vertexai", "azure", "bedrock", "openrouter", "aihubmix", "deepseek", "siliconflow", "sglang", "gateway", "edgeone", "ollama", "doubao", "modelscope", "glm", "qwen", "qiniu", "kimi", "minimax", "novita", "mimo", "atlascloud", ] // Bedrock provider options for Anthropic beta features const BEDROCK_ANTHROPIC_BETA = { bedrock: { anthropicBeta: ["fine-grained-tool-streaming-2025-05-14"], }, } // Direct Anthropic API headers for beta features const ANTHROPIC_BETA_HEADERS = { "anthropic-beta": "fine-grained-tool-streaming-2025-05-14", } /** * Resolve baseURL based on whether user is providing their own API key. * When user provides their own API key, we should NOT fall back to server's * baseURL environment variable - user credentials should only be sent to * user-specified endpoints or official provider endpoints. * * @param userApiKey - User-provided API key (if any) * @param userBaseUrl - User-provided base URL (if any) * @param serverBaseUrl - Server's base URL from environment variable * @param defaultBaseUrl - Provider's official/default base URL (optional) * @returns The resolved base URL to use */ export function resolveBaseURL( userApiKey: string | null | undefined, userBaseUrl: string | null | undefined, serverBaseUrl: string | undefined, defaultBaseUrl?: string, ): string | undefined { if (userApiKey) { // User provides their own API key - only use user's baseUrl or default return userBaseUrl || defaultBaseUrl || undefined } // No user API key - fall back to server config return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined } /** * Resolve API key from custom env var name or default env var. * Supports multiple API keys per provider via ai-models.json apiKeyEnv config. * When multiple keys are configured, randomly selects one for load balancing. * * Priority: * 1. User-provided API key (overrides.apiKey) * 2. Custom env var(s) from ai-models.json (overrides.apiKeyEnv) * - If array, randomly picks one with a valid value * 3. Default provider env var (defaultEnvVar) */ function resolveApiKey( overrides: ClientOverrides | undefined, defaultEnvVar: string, ): string | undefined { if (overrides?.apiKey) return overrides.apiKey if (overrides?.apiKeyEnv) { // Handle array of env var names - randomly select one if (Array.isArray(overrides.apiKeyEnv)) { // Filter to only env vars that have values const validEnvVars = overrides.apiKeyEnv.filter( (envVar) => process.env[envVar], ) if (validEnvVars.length > 0) { // Randomly select one const selectedEnvVar = validEnvVars[ Math.floor(Math.random() * validEnvVars.length) ] console.log( `[API Key Routing] Selected ${selectedEnvVar} from ${validEnvVars.length} available keys`, ) return process.env[selectedEnvVar] } } else { return process.env[overrides.apiKeyEnv] } } return process.env[defaultEnvVar] } /** * Resolve base URL from custom env var name or default env var. * Supports multiple base URLs per provider via ai-models.json baseUrlEnv config. */ function resolveBaseUrlEnv( overrides: ClientOverrides | undefined, defaultEnvVar: string, ): string | undefined { if (overrides?.baseUrlEnv) return process.env[overrides.baseUrlEnv] return process.env[defaultEnvVar] } /** * Safely parse integer from environment variable with validation */ function parseIntSafe( value: string | undefined, varName: string, min?: number, max?: number, ): number | undefined { if (!value) return undefined const parsed = Number.parseInt(value, 10) if (Number.isNaN(parsed)) { throw new Error(`${varName} must be a valid integer, got: ${value}`) } if (min !== undefined && parsed < min) { throw new Error(`${varName} must be >= ${min}, got: ${parsed}`) } if (max !== undefined && parsed > max) { throw new Error(`${varName} must be <= ${max}, got: ${parsed}`) } return parsed } /** * Build provider-specific options from environment variables * Supports various AI SDK providers with their unique configuration options * * Environment variables: * - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for o1/o3/o4/gpt-5 * - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (auto/detailed) - auto-enabled for o1/o3/o4/gpt-5 * - ANTHROPIC_THINKING_BUDGET_TOKENS: Anthropic thinking budget in tokens (1024-64000) * - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled) * - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000) * - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (low/high) * - GOOGLE_VERTEX_THINKING_BUDGET: Vertex AI Gemini 2.5 thinking budget in tokens (1024-100000) * - GOOGLE_VERTEX_THINKING_LEVEL: Vertex AI Gemini 3 thinking level (low/high) * - AZURE_REASONING_EFFORT: Azure/OpenAI reasoning effort (low/medium/high) * - AZURE_REASONING_SUMMARY: Azure reasoning summary (none/brief/detailed) * - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000) * - BEDROCK_REASONING_EFFORT: Bedrock Nova reasoning effort (low/medium/high) * - OLLAMA_ENABLE_THINKING: Enable Ollama thinking mode (set to "true") */ function buildProviderOptions( provider: ProviderName, modelId?: string, ): Record | undefined { const options: Record = {} switch (provider) { case "openai": { const reasoningEffort = process.env.OPENAI_REASONING_EFFORT const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY // OpenAI reasoning models (o1, o3, o4, gpt-5) need reasoningSummary to return thoughts if ( modelId && (modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4") || modelId.includes("gpt-5")) ) { options.openai = { // Auto-enable reasoning summary for reasoning models // Use 'auto' as default since not all models support 'detailed' reasoningSummary: (reasoningSummary as "auto" | "detailed") || "auto", } // Optionally configure reasoning effort if (reasoningEffort) { options.openai.reasoningEffort = reasoningEffort as | "minimal" | "low" | "medium" | "high" } } else if (reasoningEffort || reasoningSummary) { // Non-reasoning models: only apply if explicitly configured options.openai = {} if (reasoningEffort) { options.openai.reasoningEffort = reasoningEffort as | "minimal" | "low" | "medium" | "high" } if (reasoningSummary) { options.openai.reasoningSummary = reasoningSummary as | "auto" | "detailed" } } break } case "anthropic": { const thinkingBudget = parseIntSafe( process.env.ANTHROPIC_THINKING_BUDGET_TOKENS, "ANTHROPIC_THINKING_BUDGET_TOKENS", 1024, 64000, ) const thinkingType = process.env.ANTHROPIC_THINKING_TYPE || "enabled" if (thinkingBudget) { options.anthropic = { thinking: { type: thinkingType, budgetTokens: thinkingBudget, }, } } break } case "google": { const reasoningEffort = process.env.GOOGLE_REASONING_EFFORT const thinkingBudgetVal = parseIntSafe( process.env.GOOGLE_THINKING_BUDGET, "GOOGLE_THINKING_BUDGET", 1024, 100000, ) const thinkingLevel = process.env.GOOGLE_THINKING_LEVEL // Google Gemini 2.5/3 models think by default, but need includeThoughts: true // to return the reasoning in the response if ( modelId && (modelId.includes("gemini-2") || modelId.includes("gemini-3") || modelId.includes("gemini2") || modelId.includes("gemini3")) ) { const thinkingConfig: Record = { includeThoughts: true, } // Optionally configure thinking budget or level if ( thinkingBudgetVal && (modelId.includes("2.5") || modelId.includes("2-5")) ) { thinkingConfig.thinkingBudget = thinkingBudgetVal } else if ( thinkingLevel && (modelId.includes("gemini-3") || modelId.includes("gemini3")) ) { thinkingConfig.thinkingLevel = thinkingLevel as | "low" | "high" } options.google = { thinkingConfig } } else if (reasoningEffort) { options.google = { reasoningEffort: reasoningEffort as | "low" | "medium" | "high", } } // Keep existing Google options const options_obj: Record = {} const candidateCount = parseIntSafe( process.env.GOOGLE_CANDIDATE_COUNT, "GOOGLE_CANDIDATE_COUNT", 1, 8, ) if (candidateCount) { options_obj.candidateCount = candidateCount } const topK = parseIntSafe( process.env.GOOGLE_TOP_K, "GOOGLE_TOP_K", 1, 100, ) if (topK) { options_obj.topK = topK } if (process.env.GOOGLE_TOP_P) { const topP = Number.parseFloat(process.env.GOOGLE_TOP_P) if (Number.isNaN(topP) || topP < 0 || topP > 1) { throw new Error( `GOOGLE_TOP_P must be a number between 0 and 1, got: ${process.env.GOOGLE_TOP_P}`, ) } options_obj.topP = topP } if (Object.keys(options_obj).length > 0) { options.google = { ...options.google, ...options_obj } } break } case "vertexai": { const thinkingBudget = parseIntSafe( process.env.GOOGLE_VERTEX_THINKING_BUDGET, "GOOGLE_VERTEX_THINKING_BUDGET", 1024, 100000, ) const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL if ( modelId && (modelId.includes("gemini-2") || modelId.includes("gemini-3") || modelId.includes("gemini2") || modelId.includes("gemini3")) ) { const thinkingConfig: Record = { includeThoughts: true, } const isGemini3 = modelId?.includes("gemini-3") || modelId?.includes("gemini3") const isGemini25 = modelId?.includes("2.5") || modelId?.includes("2-5") if (isGemini3 && thinkingLevel) { // Vertex AI provider in AI SDK supports more granular levels (minimal/low/medium/high) thinkingConfig.thinkingLevel = thinkingLevel as | "minimal" | "low" | "medium" | "high" } else if (isGemini25 && thinkingBudget) { thinkingConfig.thinkingBudget = thinkingBudget } options.google = { thinkingConfig } } break } case "azure": { const reasoningEffort = process.env.AZURE_REASONING_EFFORT const reasoningSummary = process.env.AZURE_REASONING_SUMMARY if (reasoningEffort || reasoningSummary) { options.azure = {} if (reasoningEffort) { options.azure.reasoningEffort = reasoningEffort as | "low" | "medium" | "high" } if (reasoningSummary) { options.azure.reasoningSummary = reasoningSummary as | "none" | "brief" | "detailed" } } break } case "bedrock": { const budgetTokens = parseIntSafe( process.env.BEDROCK_REASONING_BUDGET_TOKENS, "BEDROCK_REASONING_BUDGET_TOKENS", 1024, 64000, ) const reasoningEffort = process.env.BEDROCK_REASONING_EFFORT // Bedrock reasoning ONLY for Claude and Nova models // Other models (MiniMax, etc.) don't support reasoningConfig if ( modelId && (budgetTokens || reasoningEffort) && (modelId.includes("claude") || modelId.includes("anthropic") || modelId.includes("nova") || modelId.includes("amazon")) ) { const reasoningConfig: Record = { type: "enabled" } // Claude models: use budgetTokens (1024-64000) if ( budgetTokens && (modelId.includes("claude") || modelId.includes("anthropic")) ) { reasoningConfig.budgetTokens = budgetTokens } // Nova models: use maxReasoningEffort (low/medium/high) else if ( reasoningEffort && (modelId.includes("nova") || modelId.includes("amazon")) ) { reasoningConfig.maxReasoningEffort = reasoningEffort as | "low" | "medium" | "high" } options.bedrock = { reasoningConfig } } break } case "ollama": { const enableThinking = process.env.OLLAMA_ENABLE_THINKING // Ollama supports reasoning with think: true for models like qwen3 if (enableThinking === "true") { options.ollama = { think: true } } break } case "deepseek": case "openrouter": case "aihubmix": case "siliconflow": case "sglang": case "gateway": case "modelscope": case "doubao": case "minimax": case "glm": case "qwen": case "kimi": case "qiniu": case "novita": case "atlascloud": case "mimo": { // These providers don't have reasoning configs in AI SDK yet // Gateway passes through to underlying providers which handle their own configs break } default: break } return Object.keys(options).length > 0 ? options : undefined } // Map of provider to required environment variable export const PROVIDER_ENV_VARS: Record = { bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally openai: "OPENAI_API_KEY", anthropic: "ANTHROPIC_API_KEY", google: "GOOGLE_GENERATIVE_AI_API_KEY", vertexai: "GOOGLE_VERTEX_API_KEY", azure: "AZURE_API_KEY", ollama: null, // No credentials needed for local Ollama openrouter: "OPENROUTER_API_KEY", aihubmix: "AIHUBMIX_API_KEY", deepseek: "DEEPSEEK_API_KEY", siliconflow: "SILICONFLOW_API_KEY", sglang: "SGLANG_API_KEY", gateway: "AI_GATEWAY_API_KEY", edgeone: null, // No credentials needed - uses EdgeOne Edge AI doubao: "DOUBAO_API_KEY", modelscope: "MODELSCOPE_API_KEY", glm: "GLM_API_KEY", qwen: "QWEN_API_KEY", qiniu: "QINIU_API_KEY", kimi: "KIMI_API_KEY", minimax: "MINIMAX_API_KEY", novita: "NOVITA_API_KEY", mimo: "MIMO_API_KEY", atlascloud: "ATLASCLOUD_API_KEY", } /** * Auto-detect provider based on available API keys * Returns the provider if exactly one is configured, otherwise null */ function detectProvider(): ProviderName | null { const configuredProviders: ProviderName[] = [] for (const [provider, envVar] of Object.entries(PROVIDER_ENV_VARS)) { if (envVar === null) { // Skip ollama - it doesn't require credentials continue } // Anthropic accepts ANTHROPIC_AUTH_TOKEN (Bearer auth) as alternative to ANTHROPIC_API_KEY const hasCredential = provider === "anthropic" ? !!( process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN ) : !!process.env[envVar] if (hasCredential) { // Azure requires additional config (baseURL or resourceName) if (provider === "azure") { const hasBaseUrl = !!process.env.AZURE_BASE_URL const hasResourceName = !!process.env.AZURE_RESOURCE_NAME if (hasBaseUrl || hasResourceName) { configuredProviders.push(provider as ProviderName) } } else { configuredProviders.push(provider as ProviderName) } } } if (configuredProviders.length === 1) { return configuredProviders[0] } return null } /** * Validate that required API keys are present for the selected provider * @param provider - The provider to validate * @param customApiKeyEnv - Optional custom env var name(s) (from ai-models.json apiKeyEnv) */ function validateProviderCredentials( provider: ProviderName, customApiKeyEnv?: string | string[], ): void { // Handle array of env var names - at least one must be set if (Array.isArray(customApiKeyEnv)) { const hasAnyKey = customApiKeyEnv.some((envVar) => process.env[envVar]) if (!hasAnyKey) { throw new Error( `At least one of [${customApiKeyEnv.join(", ")}] environment variables is required for ${provider} provider. ` + `Please set at least one in your .env.local file.`, ) } return } // Anthropic accepts ANTHROPIC_AUTH_TOKEN (Bearer auth) as alternative to ANTHROPIC_API_KEY if (provider === "anthropic" && !customApiKeyEnv) { const hasCredential = !!( process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN ) if (!hasCredential) { throw new Error( `Either ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN environment variable is required for anthropic provider. ` + `Please set one in your .env.local file.`, ) } } else { // Use custom env var name if provided, otherwise use default const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider] if (requiredVar && !process.env[requiredVar]) { throw new Error( `${requiredVar} environment variable is required for ${provider} provider. ` + `Please set it in your .env.local file.`, ) } } // Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME in addition to API key if (provider === "azure") { const hasBaseUrl = !!process.env.AZURE_BASE_URL const hasResourceName = !!process.env.AZURE_RESOURCE_NAME if (!hasBaseUrl && !hasResourceName) { throw new Error( `Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME to be set. ` + `Please set one in your .env.local file.`, ) } } } /** * Get the AI model based on environment variables * * Environment variables: * - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, modelscope) * - AI_MODEL: The model ID/name for the selected provider * * Provider-specific env vars: * - OPENAI_API_KEY: OpenAI API key * - OPENAI_BASE_URL: Custom OpenAI-compatible endpoint (optional) * - ANTHROPIC_API_KEY: Anthropic API key * - GOOGLE_GENERATIVE_AI_API_KEY: Google API key * - AZURE_RESOURCE_NAME, AZURE_API_KEY: Azure OpenAI credentials * - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials * - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api) * - OPENROUTER_API_KEY: OpenRouter API key * - AIHUBMIX_API_KEY: AIHubMix API key * - DEEPSEEK_API_KEY: DeepSeek API key * - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional) * - SILICONFLOW_API_KEY: SiliconFlow API key * - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.cn/v1) * - SGLANG_API_KEY: SGLang API key * - SGLANG_BASE_URL: SGLang endpoint (optional) * - MODELSCOPE_API_KEY: ModelScope API key * - MODELSCOPE_BASE_URL: ModelScope endpoint (optional) */ export function getAIModel(overrides?: ClientOverrides): ModelConfig { // SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm) // If a custom baseUrl is provided, an API key MUST also be provided. // This prevents attackers from redirecting server API keys to malicious endpoints. // Exception: EdgeOne doesn't require API keys. // Ollama is exempt only when no server OLLAMA_API_KEY is configured; // when it IS configured, the outer guard also enforces client apiKey for custom baseUrls. if ( overrides?.baseUrl && !overrides?.apiKey && !(overrides?.provider === "vertexai" && overrides?.vertexApiKey) && overrides?.provider !== "edgeone" && !(overrides?.provider === "ollama" && !process.env.OLLAMA_API_KEY) ) { throw new Error( `API key is required when using a custom base URL. ` + `Please provide your own API key in Settings.`, ) } // Check if client is providing their own provider override const isClientOverride = !!( overrides?.provider && (overrides?.apiKey || (overrides?.provider === "vertexai" && overrides?.vertexApiKey)) ) // Use client override if provided, otherwise fall back to env vars. // AI_MODEL may be comma-separated (multi-model fallback); pick the first. const envModel = process.env.AI_MODEL?.split(",")[0]?.trim() || undefined const modelId = overrides?.modelId || envModel if (!modelId) { if (isClientOverride) { throw new Error( `Model ID is required when using custom AI provider. Please specify a model in Settings.`, ) } throw new Error( `AI_MODEL environment variable is required. Example: AI_MODEL=claude-sonnet-4-5`, ) } // Determine provider: client override > explicit config > auto-detect > error let provider: ProviderName if (overrides?.provider) { // Validate client-provided provider if ( !ALLOWED_CLIENT_PROVIDERS.includes( overrides.provider as ProviderName, ) ) { throw new Error( `Invalid provider: ${overrides.provider}. Allowed providers: ${ALLOWED_CLIENT_PROVIDERS.join(", ")}`, ) } provider = overrides.provider as ProviderName } else if (process.env.AI_PROVIDER) { provider = process.env.AI_PROVIDER as ProviderName } else { const detected = detectProvider() if (detected) { provider = detected console.log(`[AI Provider] Auto-detected provider: ${provider}`) } else { // List configured providers for better error message const configured = Object.entries(PROVIDER_ENV_VARS) .filter(([, envVar]) => envVar && process.env[envVar as string]) .map(([p]) => p) if (configured.length === 0) { throw new Error( `No AI provider configured. Please set one of the following API keys in your .env.local file:\n` + `- AI_GATEWAY_API_KEY for Vercel AI Gateway\n` + `- DEEPSEEK_API_KEY for DeepSeek\n` + `- OPENAI_API_KEY for OpenAI\n` + `- ANTHROPIC_API_KEY for Anthropic\n` + `- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` + `- AWS_ACCESS_KEY_ID for Bedrock\n` + `- OPENROUTER_API_KEY for OpenRouter\n` + `- AIHUBMIX_API_KEY for AIHubMix\n` + `- AZURE_API_KEY for Azure\n` + `- SILICONFLOW_API_KEY for SiliconFlow\n` + `- SGLANG_API_KEY for SGLang\n` + `- MODELSCOPE_API_KEY for ModelScope\n` + `Or set AI_PROVIDER=ollama for local Ollama.`, ) } else { throw new Error( `Multiple AI providers configured (${configured.join(", ")}). ` + `Please set AI_PROVIDER to specify which one to use.`, ) } } } // Only validate server credentials if client isn't providing their own API key if (!isClientOverride) { validateProviderCredentials(provider, overrides?.apiKeyEnv) } console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`) let model: any let providerOptions: any let headers: Record | undefined // Build provider-specific options from environment variables const customProviderOptions = buildProviderOptions(provider, modelId) switch (provider) { case "bedrock": { // Use client-provided credentials if available, otherwise fall back to IAM/env vars const hasClientCredentials = overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey const bedrockRegion = overrides?.awsRegion || process.env.AWS_REGION || "us-west-2" const bedrockProvider = hasClientCredentials ? createAmazonBedrock({ region: bedrockRegion, accessKeyId: overrides.awsAccessKeyId as string, secretAccessKey: overrides.awsSecretAccessKey as string, ...(overrides?.awsSessionToken && { sessionToken: overrides.awsSessionToken, }), }) : createAmazonBedrock({ region: bedrockRegion, credentialProvider: fromNodeProviderChain(), }) model = bedrockProvider(modelId) // Add Anthropic beta options if using Claude models via Bedrock if (modelId.includes("anthropic.claude")) { // Deep merge to preserve both anthropicBeta and reasoningConfig providerOptions = { bedrock: { ...BEDROCK_ANTHROPIC_BETA.bedrock, ...(customProviderOptions?.bedrock || {}), }, } } else if (customProviderOptions) { providerOptions = customProviderOptions } break } case "openai": { const apiKey = resolveApiKey(overrides, "OPENAI_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "OPENAI_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, ) if (baseURL) { // Custom base URL = third-party proxy, use Chat Completions API // for compatibility (most proxies don't support /responses endpoint) const customOpenAI = createOpenAI({ apiKey, baseURL }) model = customOpenAI.chat(modelId) } else if (overrides?.apiKey) { // Custom API key but official OpenAI endpoint, use Responses API // to support reasoning for gpt-5, o1, o3, o4 models const customOpenAI = createOpenAI({ apiKey }) model = customOpenAI(modelId) } else { model = openai(modelId) } break } case "anthropic": { const apiKey = resolveApiKey(overrides, "ANTHROPIC_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "ANTHROPIC_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, "https://api.anthropic.com/v1", ) // Anthropic supports two auth methods (mutually exclusive): // - apiKey: sends as `x-api-key` header // - authToken: sends as `Authorization: Bearer ` header // Prefer apiKey if present (including client overrides); fall back // to ANTHROPIC_AUTH_TOKEN env var only when no apiKey is available. const authToken = !apiKey ? process.env.ANTHROPIC_AUTH_TOKEN : undefined const customProvider = createAnthropic({ ...(authToken ? { authToken } : { apiKey }), baseURL, headers: ANTHROPIC_BETA_HEADERS, }) model = customProvider(modelId) // Add beta headers for fine-grained tool streaming headers = ANTHROPIC_BETA_HEADERS break } case "google": { const apiKey = resolveApiKey( overrides, "GOOGLE_GENERATIVE_AI_API_KEY", ) const serverBaseUrl = resolveBaseUrlEnv( overrides, "GOOGLE_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, ) if (baseURL || overrides?.apiKey) { const customGoogle = createGoogleGenerativeAI({ apiKey, ...(baseURL && { baseURL }), }) model = customGoogle(modelId) } else { model = google(modelId) } break } case "vertexai": { // Express Mode: Use API key for authentication const vertexApiKey = overrides?.vertexApiKey || process.env.GOOGLE_VERTEX_API_KEY if (!vertexApiKey) { throw new Error( "Vertex AI requires an API key for Express Mode. " + "Get one from Google Cloud Console or set GOOGLE_VERTEX_API_KEY environment variable.", ) } // Support custom base URL from env or client override const baseURL = overrides?.baseUrl || process.env.GOOGLE_VERTEX_BASE_URL const vertexProvider = createVertex({ apiKey: vertexApiKey, ...(baseURL && { baseURL }), }) model = vertexProvider(modelId) break } case "azure": { const apiKey = resolveApiKey(overrides, "AZURE_API_KEY") const serverBaseUrl = resolveBaseUrlEnv(overrides, "AZURE_BASE_URL") const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, ) // Only use server's resourceName if user is NOT providing their own API key const resourceName = overrides?.apiKey ? undefined : process.env.AZURE_RESOURCE_NAME // Azure requires either baseURL or resourceName to construct the endpoint // resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path} if (baseURL || resourceName || overrides?.apiKey) { const customAzure = createAzure({ apiKey, // baseURL takes precedence over resourceName per SDK behavior ...(baseURL && { baseURL }), ...(!baseURL && resourceName && { resourceName }), }) model = customAzure(modelId) } else { model = azure(modelId) } break } case "ollama": { const baseURL = overrides?.baseUrl || process.env.OLLAMA_BASE_URL // SECURITY: When client provides a custom base URL, only use // client-provided API key. Never fall back to server OLLAMA_API_KEY // to prevent leaking server credentials to user-controlled endpoints. const apiKey = overrides?.baseUrl ? overrides?.apiKey || undefined : resolveApiKey(overrides, "OLLAMA_API_KEY") if (baseURL || apiKey) { const customOllama = createOllama({ ...(baseURL && { baseURL }), ...(apiKey && { headers: { Authorization: `Bearer ${apiKey}` }, }), }) model = customOllama(modelId) } else { model = ollama(modelId) } break } case "openrouter": { const apiKey = resolveApiKey(overrides, "OPENROUTER_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "OPENROUTER_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, ) const openrouter = createOpenRouter({ apiKey, ...(baseURL && { baseURL }), }) model = openrouter(modelId) break } case "aihubmix": { const apiKey = resolveApiKey(overrides, "AIHUBMIX_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "AIHUBMIX_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, PROVIDER_INFO.aihubmix.defaultBaseUrl, ) const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl if ( isAihubmixStandardBaseURL(baseURL) || baseURL === defaultBaseURL ) { const aihubmixProvider = overrides?.apiKey || apiKey ? createAihubmix({ apiKey, appCode: AIHUBMIX_APP_CODE, }) : aihubmix model = aihubmixProvider(modelId) } else { const aihubmixCompatibleProvider = createOpenAI({ apiKey, baseURL, }) model = aihubmixCompatibleProvider.chat(modelId) } break } case "deepseek": { const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "DEEPSEEK_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, ) if (baseURL || overrides?.apiKey) { const customDeepSeek = createDeepSeek({ apiKey, ...(baseURL && { baseURL }), }) model = customDeepSeek(modelId) } else { model = deepseek(modelId) } break } case "siliconflow": { const apiKey = resolveApiKey(overrides, "SILICONFLOW_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "SILICONFLOW_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, "https://api.siliconflow.cn/v1", ) const siliconflowProvider = createOpenAI({ apiKey, baseURL, }) model = siliconflowProvider.chat(modelId) break } case "sglang": { const apiKey = resolveApiKey(overrides, "SGLANG_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "SGLANG_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, ) const sglangProvider = createOpenAI({ apiKey, ...(baseURL && { baseURL }), // Add a custom fetch wrapper to intercept and fix the stream from sglang fetch: async (url, options) => { const response = await fetch(url, options) if (!response.body) { return response } // Create a transform stream to fix the non-compliant sglang stream let buffer = "" const decoder = new TextDecoder() const transformStream = new TransformStream({ transform(chunk, controller) { buffer += decoder.decode(chunk, { stream: true }) // Process all complete messages in the buffer let messageEndPos while ( (messageEndPos = buffer.indexOf("\n\n")) !== -1 ) { const message = buffer.substring( 0, messageEndPos, ) buffer = buffer.substring(messageEndPos + 2) // Move past the '\n\n' if (message.startsWith("data: ")) { const jsonStr = message.substring(6).trim() if (jsonStr === "[DONE]") { controller.enqueue( new TextEncoder().encode( message + "\n\n", ), ) continue } try { const data = JSON.parse(jsonStr) const delta = data.choices?.[0]?.delta if (delta) { // Fix 1: remove invalid empty role if (delta.role === "") { delete delta.role } // Fix 2: remove non-standard reasoning_content field if ("reasoning_content" in delta) { delete delta.reasoning_content } } // Re-serialize and forward the corrected data with the correct SSE format controller.enqueue( new TextEncoder().encode( `data: ${JSON.stringify(data)}\n\n`, ), ) } catch (_e) { // If parsing fails, forward the original message to avoid breaking the stream. controller.enqueue( new TextEncoder().encode( message + "\n\n", ), ) } } else if (message.trim() !== "") { // Pass through other message types (e.g., 'event: ...') controller.enqueue( new TextEncoder().encode( message + "\n\n", ), ) } } }, flush(controller) { // If there's anything left in the buffer, forward it. if (buffer.trim()) { controller.enqueue( new TextEncoder().encode(buffer), ) } }, }) const transformedBody = response.body.pipeThrough(transformStream) // Return a new response with the transformed body return new Response(transformedBody, { status: response.status, statusText: response.statusText, headers: response.headers, }) }, }) model = sglangProvider.chat(modelId) break } case "gateway": { // Vercel AI Gateway - unified access to multiple AI providers // Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5" // See: https://vercel.com/ai-gateway const apiKey = resolveApiKey(overrides, "AI_GATEWAY_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "AI_GATEWAY_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, ) // Only use custom configuration if explicitly set (local dev or custom Gateway) // Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC if (baseURL || overrides?.apiKey) { const customGateway = createGateway({ apiKey, ...(baseURL && { baseURL }), }) model = customGateway(modelId) } else { model = gateway(modelId) } break } case "edgeone": { // EdgeOne Pages Edge AI - uses OpenAI-compatible API // AI SDK appends /chat/completions to baseURL // /api/edgeai + /chat/completions = /api/edgeai/chat/completions const baseURL = overrides?.baseUrl || "/api/edgeai" const edgeoneProvider = createOpenAI({ apiKey: "edgeone", // Dummy key - EdgeOne doesn't require API key baseURL, // Pass cookies for EdgeOne Pages authentication (eo_token, eo_time) ...(overrides?.headers && { headers: overrides.headers }), }) model = edgeoneProvider.chat(modelId) break } case "doubao": { const apiKey = resolveApiKey(overrides, "DOUBAO_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "DOUBAO_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, "https://ark.cn-beijing.volces.com/api/v3", ) const lowerModelId = modelId.toLowerCase() // Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support) if ( lowerModelId.includes("deepseek") || lowerModelId.includes("kimi") ) { const doubaoProvider = createDeepSeek({ apiKey, baseURL, }) model = doubaoProvider(modelId) } else { const doubaoProvider = createOpenAI({ apiKey, baseURL, }) model = doubaoProvider.chat(modelId) } break } case "modelscope": { const apiKey = resolveApiKey(overrides, "MODELSCOPE_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "MODELSCOPE_BASE_URL", ) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, "https://api-inference.modelscope.cn/v1", ) const modelscopeProvider = createOpenAI({ apiKey, baseURL, }) model = modelscopeProvider.chat(modelId) break } case "minimax": { const apiKey = resolveApiKey(overrides, "MINIMAX_API_KEY") const serverBaseUrl = resolveBaseUrlEnv( overrides, "MINIMAX_BASE_URL", ) const rawBaseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, serverBaseUrl, PROVIDER_INFO.minimax.defaultBaseUrl, ) if (!rawBaseURL) { throw new Error( "MiniMax base URL could not be resolved. Set MINIMAX_BASE_URL or configure a base URL in settings.", ) } const { baseURL, isAnthropicCompatible } = normalizeMiniMaxBaseURL(rawBaseURL) if (isAnthropicCompatible) { const minimax = createAnthropic({ apiKey, baseURL }) model = minimax.chat(modelId) } else { const minimax = createOpenAI({ apiKey, baseURL }) model = minimax.chat(modelId) } break } case "mimo": { const apiKey = resolveApiKey(overrides, "MIMO_API_KEY") const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, resolveBaseUrlEnv(overrides, "MIMO_BASE_URL"), PROVIDER_INFO.mimo?.defaultBaseUrl, ) // Use createDeepSeek to properly handle reasoning_content for MiMo // thinking models (e.g., mimo-v2.5-pro). MiMo's API requires // reasoning_content to be passed back during multi-turn tool calls // (returns 400 otherwise), same convention as DeepSeek and Kimi. const mimoProvider = createDeepSeek({ apiKey, baseURL }) model = mimoProvider(modelId) break } case "glm": case "qwen": case "qiniu": case "novita": case "atlascloud": { const envVar = PROVIDER_ENV_VARS[provider] if (!envVar) { throw new Error( `API key environment variable not defined for provider: ${provider}`, ) } const apiKey = resolveApiKey(overrides, envVar) const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, resolveBaseUrlEnv( overrides, `${provider.toUpperCase()}_BASE_URL`, ), PROVIDER_INFO[provider]?.defaultBaseUrl, ) const customProvider = createOpenAI({ apiKey, baseURL, }) model = customProvider.chat(modelId) break } case "kimi": { const apiKey = resolveApiKey(overrides, "KIMI_API_KEY") const baseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, resolveBaseUrlEnv(overrides, "KIMI_BASE_URL"), PROVIDER_INFO.kimi?.defaultBaseUrl, ) // Use createDeepSeek to properly handle reasoning_content for Kimi // thinking models (e.g., kimi-k2.6). Kimi's API uses the same // reasoning_content field as DeepSeek, so this provider correctly // captures and replays reasoning in multi-turn conversations. const customProvider = createDeepSeek({ apiKey, baseURL }) model = customProvider(modelId) break } default: throw new Error( `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud`, ) } // Apply provider-specific options for all providers except bedrock (which has special handling) if (customProviderOptions && provider !== "bedrock" && !providerOptions) { providerOptions = customProviderOptions } return { model, providerOptions, headers, modelId, provider } } /** * Check if a model supports prompt caching. * Currently only Claude models on Bedrock support prompt caching. */ export function supportsPromptCaching(modelId: string): boolean { // Bedrock prompt caching is supported for Claude models return ( modelId.includes("claude") || modelId.includes("anthropic") || modelId.startsWith("us.anthropic") || modelId.startsWith("eu.anthropic") ) } /** * Get the AI model for diagram validation. * Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL. * * Note: we no longer guess whether the model supports image input from its * name — that heuristic misfired on newer models (see issue #874). If a * configured validation model can't handle images, the API call simply errors * and the validate-diagram route falls back to "valid". */ export function getValidationModel(): ReturnType["model"] { // AI_MODEL may be comma-separated (multi-model fallback); pick the first. const envFallback = process.env.AI_MODEL?.split(",")[0]?.trim() || undefined const modelId = process.env.VALIDATION_MODEL || envFallback if (!modelId) { throw new Error( "No validation model configured. Set VALIDATION_MODEL or AI_MODEL.", ) } const { model } = getAIModel({ modelId }) return model }