llm.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. /**
  2. * Unified LLM Call Layer
  3. *
  4. * All LLM interactions should go through callLLM / streamLLM.
  5. */
  6. import { generateText, streamText } from 'ai';
  7. import type { GenerateTextResult, StreamTextResult } from 'ai';
  8. import { createLogger } from '@/lib/logger';
  9. import { PROVIDERS } from './providers';
  10. import { thinkingContext } from './thinking-context';
  11. import type { ProviderType, ThinkingCapability, ThinkingConfig } from '@/lib/types/provider';
  12. const log = createLogger('LLM');
  13. // Re-export for external use
  14. export type { ThinkingConfig } from '@/lib/types/provider';
  15. // Re-export the parameter types accepted by AI SDK
  16. type GenerateTextParams = Parameters<typeof generateText>[0];
  17. type StreamTextParams = Parameters<typeof streamText>[0];
  18. function _extractRequestInfo(params: GenerateTextParams | StreamTextParams) {
  19. const tools = params.tools ? Object.keys(params.tools as Record<string, unknown>) : undefined;
  20. const p = params as Record<string, unknown>;
  21. return {
  22. system: p.system as string | undefined,
  23. prompt: p.prompt as string | undefined,
  24. messages: p.messages as unknown[] | undefined,
  25. tools,
  26. maxOutputTokens: p.maxOutputTokens as number | undefined,
  27. };
  28. }
  29. function getModelId(params: GenerateTextParams | StreamTextParams): string {
  30. const m = params.model;
  31. if (typeof m === 'string') return m;
  32. if (m && typeof m === 'object' && 'modelId' in m) return (m as { modelId: string }).modelId;
  33. return 'unknown';
  34. }
  35. // ---------------------------------------------------------------------------
  36. // Thinking / Reasoning Adapter
  37. //
  38. // Builds a lookup table from PROVIDERS at module load time, then uses it to
  39. // map a unified ThinkingConfig into provider-specific providerOptions.
  40. // Currently handles: openai (native), anthropic (native), google (native).
  41. // OpenAI-compatible providers (DeepSeek, Qwen, Kimi, GLM, etc.) are NOT
  42. // handled — their vendor-specific thinking params can't be reliably passed
  43. // through Vercel AI SDK's createOpenAI.
  44. // ---------------------------------------------------------------------------
  45. interface ModelThinkingInfo {
  46. providerType: ProviderType;
  47. thinking?: ThinkingCapability;
  48. }
  49. /** Model ID → provider type + thinking capability (built once at module load) */
  50. const MODEL_THINKING_MAP: Map<string, ModelThinkingInfo> = (() => {
  51. const map = new Map<string, ModelThinkingInfo>();
  52. for (const provider of Object.values(PROVIDERS)) {
  53. for (const model of provider.models) {
  54. map.set(model.id, {
  55. providerType: provider.type,
  56. thinking: model.capabilities?.thinking,
  57. });
  58. }
  59. }
  60. return map;
  61. })();
  62. /** Global thinking override from environment variable */
  63. function getGlobalThinkingConfig(): ThinkingConfig | undefined {
  64. if (process.env.LLM_THINKING_DISABLED === 'true') {
  65. return { enabled: false };
  66. }
  67. return undefined;
  68. }
  69. type ProviderOptions = Record<string, Record<string, unknown>>;
  70. /**
  71. * Build providerOptions to disable thinking, using the lowest possible
  72. * intensity for models that cannot be fully turned off.
  73. */
  74. function buildDisableThinking(
  75. modelId: string,
  76. providerType: ProviderType,
  77. _thinking: ThinkingCapability,
  78. ): ProviderOptions | undefined {
  79. switch (providerType) {
  80. case 'openai': {
  81. // GPT-5.1/5.2: support effort=none (fully off)
  82. // GPT-5/mini/nano: lowest is minimal
  83. // o-series: lowest is low
  84. let effort: string;
  85. if (modelId.startsWith('gpt-5.')) {
  86. effort = 'none';
  87. } else if (modelId.startsWith('gpt-5')) {
  88. effort = 'minimal';
  89. } else if (modelId.startsWith('o')) {
  90. effort = 'low';
  91. } else {
  92. // Non-thinking OpenAI models (gpt-4o etc.) — no injection needed
  93. return undefined;
  94. }
  95. if (!_thinking.toggleable && effort !== 'none') {
  96. log.info(
  97. `[thinking-adapter] Model ${modelId} cannot fully disable thinking, using effort=${effort}`,
  98. );
  99. }
  100. return { openai: { reasoningEffort: effort } };
  101. }
  102. case 'anthropic':
  103. // All Claude models support type=disabled
  104. return { anthropic: { thinking: { type: 'disabled' } } };
  105. case 'google': {
  106. // Gemini 3.x: uses thinkingLevel (cannot fully disable)
  107. // Gemini 2.5 Flash/Flash-Lite: uses thinkingBudget=0 (fully off)
  108. // Gemini 2.5 Pro: minimum thinkingBudget=128 (cannot fully disable)
  109. if (modelId.startsWith('gemini-3')) {
  110. const level = modelId.includes('flash') ? 'minimal' : 'low';
  111. log.info(
  112. `[thinking-adapter] Model ${modelId} cannot fully disable thinking, using thinkingLevel=${level}`,
  113. );
  114. return { google: { thinkingConfig: { thinkingLevel: level } } };
  115. }
  116. if (modelId === 'gemini-2.5-pro') {
  117. log.info(
  118. `[thinking-adapter] Model ${modelId} cannot fully disable thinking, using thinkingBudget=128`,
  119. );
  120. return { google: { thinkingConfig: { thinkingBudget: 128 } } };
  121. }
  122. // gemini-2.5-flash / flash-lite: can fully disable
  123. return { google: { thinkingConfig: { thinkingBudget: 0 } } };
  124. }
  125. default:
  126. return undefined;
  127. }
  128. }
  129. /**
  130. * Build providerOptions to enable thinking, optionally with a budget hint.
  131. */
  132. function buildEnableThinking(
  133. modelId: string,
  134. providerType: ProviderType,
  135. _thinking: ThinkingCapability,
  136. budgetTokens?: number,
  137. ): ProviderOptions | undefined {
  138. switch (providerType) {
  139. case 'openai':
  140. // OpenAI uses discrete effort levels, no token-based budget.
  141. // Don't inject anything — let the model use its default effort.
  142. return undefined;
  143. case 'anthropic': {
  144. // 4.6 models: prefer adaptive (model decides depth automatically)
  145. // 4.5 models: require explicit budget
  146. if (modelId.includes('4-6')) {
  147. if (budgetTokens !== undefined) {
  148. return { anthropic: { thinking: { type: 'enabled', budgetTokens } } };
  149. }
  150. return { anthropic: { thinking: { type: 'adaptive' } } };
  151. }
  152. // Sonnet 4.5 / Haiku 4.5: must use enabled + budgetTokens
  153. const budget = budgetTokens ?? 10240; // sensible default
  154. return {
  155. anthropic: {
  156. thinking: { type: 'enabled', budgetTokens: Math.max(1024, budget) },
  157. },
  158. };
  159. }
  160. case 'google': {
  161. // Gemini 3.x: uses thinkingLevel (no numeric budget)
  162. if (modelId.startsWith('gemini-3')) {
  163. return { google: { thinkingConfig: { thinkingLevel: 'high' } } };
  164. }
  165. // Gemini 2.5: uses thinkingBudget
  166. if (budgetTokens !== undefined) {
  167. const min = modelId === 'gemini-2.5-pro' ? 128 : 0;
  168. return {
  169. google: {
  170. thinkingConfig: {
  171. thinkingBudget: Math.max(min, Math.min(24576, budgetTokens)),
  172. },
  173. },
  174. };
  175. }
  176. // No budget specified — let model use dynamic default
  177. return undefined;
  178. }
  179. default:
  180. return undefined;
  181. }
  182. }
  183. /**
  184. * Map a unified ThinkingConfig to provider-specific providerOptions.
  185. */
  186. function buildThinkingProviderOptions(
  187. modelId: string,
  188. config: ThinkingConfig,
  189. ): ProviderOptions | undefined {
  190. const info = MODEL_THINKING_MAP.get(modelId);
  191. if (!info?.thinking) return undefined; // model has no thinking capability
  192. if (config.enabled === undefined) return undefined; // use model default
  193. if (config.enabled === false) {
  194. return buildDisableThinking(modelId, info.providerType, info.thinking);
  195. }
  196. // enabled === true
  197. return buildEnableThinking(modelId, info.providerType, info.thinking, config.budgetTokens);
  198. }
  199. /**
  200. * Default providerOptions for specific models (fallback when no ThinkingConfig is provided).
  201. * Gemini 3.x models use thinkingLevel instead of thinkingBudget.
  202. */
  203. function getDefaultProviderOptions(modelId: string): ProviderOptions | undefined {
  204. if (modelId === 'gemini-3.1-pro-preview') {
  205. return { google: { thinkingConfig: { thinkingLevel: 'high' } } };
  206. }
  207. return undefined;
  208. }
  209. /**
  210. * Inject provider-specific thinking options into LLM call params.
  211. *
  212. * For native providers (OpenAI/Anthropic/Google), this sets providerOptions.
  213. * For OpenAI-compatible providers, providerOptions won't work (stripped by
  214. * zod schema) — those are handled by the custom fetch wrapper via thinkingContext.
  215. *
  216. * Priority: caller's providerOptions > ThinkingConfig > model defaults
  217. */
  218. function injectProviderOptions<T extends GenerateTextParams | StreamTextParams>(
  219. params: T,
  220. thinking?: ThinkingConfig,
  221. ): T {
  222. if ((params as Record<string, unknown>).providerOptions) return params; // caller explicitly set providerOptions
  223. const modelId = getModelId(params);
  224. if (thinking) {
  225. const opts = buildThinkingProviderOptions(modelId, thinking);
  226. if (opts) return { ...params, providerOptions: opts };
  227. }
  228. // No thinking config — use model defaults (backward compat)
  229. const defaults = getDefaultProviderOptions(modelId);
  230. if (defaults) return { ...params, providerOptions: defaults };
  231. return params;
  232. }
  233. /**
  234. * Options for LLM call retry on validation failure.
  235. * This is separate from the AI SDK's built-in maxRetries (which handles network/5xx errors).
  236. */
  237. export interface LLMRetryOptions {
  238. /** Max retry attempts when validate() fails or the response is empty (default: 0 = no retry) */
  239. retries?: number;
  240. /** Custom validation function. Return true to accept the result, false to retry.
  241. * Default: checks that response text is non-empty. */
  242. validate?: (text: string) => boolean;
  243. }
  244. const DEFAULT_VALIDATE = (text: string) => text.trim().length > 0;
  245. /**
  246. * Unified wrapper around `generateText`.
  247. *
  248. * @param params - Same parameters as AI SDK's `generateText`
  249. * @param source - A short label for log grouping (e.g. 'scene-stream', 'pbl-chat')
  250. * @param retryOptions - Optional retry-on-validation-failure settings
  251. * @param thinking - Optional per-call thinking config (overrides global LLM_THINKING_DISABLED)
  252. */
  253. export async function callLLM<T extends GenerateTextParams>(
  254. params: T,
  255. source: string,
  256. retryOptions?: LLMRetryOptions,
  257. thinking?: ThinkingConfig,
  258. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  259. ): Promise<GenerateTextResult<any, any>> {
  260. const maxAttempts = (retryOptions?.retries ?? 0) + 1;
  261. const validate = retryOptions?.validate ?? (maxAttempts > 1 ? DEFAULT_VALIDATE : undefined);
  262. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  263. let lastResult: GenerateTextResult<any, any> | undefined;
  264. let lastError: unknown;
  265. for (let attempt = 1; attempt <= maxAttempts; attempt++) {
  266. try {
  267. // Resolve effective thinking config: per-call > global env > undefined
  268. const effectiveThinking = thinking ?? getGlobalThinkingConfig();
  269. const injectedParams = injectProviderOptions(params, effectiveThinking);
  270. // Wrap in thinkingContext so the custom fetch wrapper in providers.ts
  271. // can read the config and inject vendor-specific body params for
  272. // OpenAI-compatible providers.
  273. const result = await thinkingContext.run(effectiveThinking, () =>
  274. generateText(injectedParams),
  275. );
  276. // Validate result (only when retries are configured)
  277. if (validate && !validate(result.text)) {
  278. log.warn(
  279. `[${source}] Validation failed (attempt ${attempt}/${maxAttempts}), ${attempt < maxAttempts ? 'retrying...' : 'giving up'}`,
  280. );
  281. lastResult = result;
  282. continue;
  283. }
  284. return result;
  285. } catch (error) {
  286. lastError = error;
  287. if (attempt < maxAttempts) {
  288. log.warn(`[${source}] Call failed (attempt ${attempt}/${maxAttempts}), retrying...`, error);
  289. continue;
  290. }
  291. }
  292. }
  293. // All attempts exhausted — return last result or throw last error
  294. if (lastResult) return lastResult;
  295. throw lastError;
  296. }
  297. /**
  298. * Unified wrapper around `streamText`.
  299. *
  300. * Returns the same StreamTextResult.
  301. *
  302. * @param params - Same parameters as AI SDK's `streamText`
  303. * @param source - A short label for log grouping
  304. * @param thinking - Optional per-call thinking config (overrides global LLM_THINKING_DISABLED)
  305. */
  306. export function streamLLM<T extends StreamTextParams>(
  307. params: T,
  308. source: string,
  309. thinking?: ThinkingConfig,
  310. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  311. ): StreamTextResult<any, any> {
  312. // Resolve effective thinking config and wrap in thinkingContext
  313. const effectiveThinking = thinking ?? getGlobalThinkingConfig();
  314. const injectedParams = injectProviderOptions(params, effectiveThinking);
  315. const result = thinkingContext.run(effectiveThinking, () => streamText(injectedParams));
  316. return result;
  317. }