77 * - Ollama (local)
88 */
99
10- import { freeChain , providerModels } from 'ai-kit' ;
10+ import { freeChain , providerModels , usableChain , tryChain } from 'ai-kit' ;
1111import { API_CONFIG } from '@/lib/constants' ;
1212import { getServerEnv , getClientEnv } from '@/lib/config/env' ;
1313import { logger } from './logger' ;
@@ -53,6 +53,11 @@ interface LLMResponse {
5353// Ollama configuration (lazy to avoid calling getServerEnv at module scope during SSG)
5454const getOllamaModel = ( ) => getServerEnv ( ) . OLLAMA_MODEL ;
5555
56+ /** Trim whitespace and strip literal/escaped newlines a pasted key can carry. */
57+ function cleanApiKey ( raw : string | null | undefined ) : string | undefined {
58+ return raw ?. trim ( ) . replace ( / \\ n / g, '' ) . replace ( / \n / g, '' ) ;
59+ }
60+
5661/**
5762 * Generate a response using the specified LLM provider
5863 */
@@ -75,66 +80,123 @@ export async function generateLLMResponse(
7580}
7681
7782/**
78- * Generate with Groq (free tier)
83+ * One call, one model, at Groq. The single-shot primitive both
84+ * `generateWithGroq`'s model loop and the ai-kit chain in
85+ * `generateWithBestProvider` walk over — a 404 here means the id was
86+ * retired, a 429 means this model is busy or spent, and either way the
87+ * caller's job is to try the next link, not this function's.
88+ */
89+ async function callGroqModel (
90+ model : string ,
91+ key : string ,
92+ messages : LLMMessage [ ] ,
93+ temperature : number ,
94+ maxTokens : number ,
95+ ) : Promise < LLMResponse > {
96+ const response = await fetch ( API_CONFIG . GROQ_API_URL , {
97+ method : 'POST' ,
98+ headers : {
99+ Authorization : `Bearer ${ key } ` ,
100+ 'Content-Type' : 'application/json' ,
101+ } ,
102+ body : JSON . stringify ( {
103+ model,
104+ messages,
105+ temperature,
106+ max_tokens : maxTokens ,
107+ } ) ,
108+ } ) ;
109+
110+ if ( ! response . ok ) {
111+ const text = await response . text ( ) ;
112+ logger . error ( `Groq API error: ${ response . status } (model ${ model } )` , text ) ;
113+ throw new Error ( `Groq API error: ${ response . status } ` ) ;
114+ }
115+
116+ const data = await response . json ( ) ;
117+ return {
118+ content : data . choices [ 0 ] ?. message ?. content || '' ,
119+ provider : 'groq' ,
120+ model,
121+ } ;
122+ }
123+
124+ /**
125+ * Generate with Groq (free tier), trying every model in the fleet's chain
126+ * before giving up.
79127 */
80128async function generateWithGroq (
81129 messages : LLMMessage [ ] ,
82130 apiKey : string | null | undefined ,
83131 temperature : number ,
84132 maxTokens : number ,
85133) : Promise < LLMResponse > {
86- // Use provided key or fallback to server-side key
87- // Clean the key: trim whitespace and remove any literal \n or escaped newlines
88- const rawKey = apiKey || getServerEnv ( ) . GROQ_API_KEY ;
89- const key = rawKey ?. trim ( ) . replace ( / \\ n / g, '' ) . replace ( / \n / g, '' ) ;
134+ // Use provided key or fallback to server-side key.
135+ const key = cleanApiKey ( apiKey || getServerEnv ( ) . GROQ_API_KEY ) ;
90136
91137 if ( ! key ) {
92138 throw new Error ( 'Groq API key not configured' ) ;
93139 }
94140
95141 const models = groqModels ( ) ;
96- let lastStatus = 0 ;
97- let lastError = '' ;
142+ let lastError : Error = new Error ( 'Groq API error: no model attempted' ) ;
98143
99144 for ( const model of models ) {
100- const response = await fetch ( API_CONFIG . GROQ_API_URL , {
101- method : 'POST' ,
102- headers : {
103- Authorization : `Bearer ${ key } ` ,
104- 'Content-Type' : 'application/json' ,
105- } ,
106- body : JSON . stringify ( {
107- model,
108- messages,
109- temperature,
110- max_tokens : maxTokens ,
111- } ) ,
112- } ) ;
113-
114- if ( ! response . ok ) {
115- lastStatus = response . status ;
116- lastError = await response . text ( ) ;
117- // A 404 here means the id was retired, which is the whole reason this is
118- // a loop; a 429 means this model is busy or spent. Either way the next id
119- // is a different model and worth asking.
120- logger . error ( `Groq API error: ${ response . status } (model ${ model } )` , lastError ) ;
121- continue ;
145+ try {
146+ return await callGroqModel ( model , key , messages , temperature , maxTokens ) ;
147+ } catch ( error ) {
148+ lastError = error instanceof Error ? error : new Error ( String ( error ) ) ;
122149 }
150+ }
123151
124- const data = await response . json ( ) ;
125- return {
126- content : data . choices [ 0 ] ?. message ?. content || '' ,
127- provider : 'groq' ,
152+ throw new Error ( `${ lastError . message } — all ${ models . length } model(s) failed` ) ;
153+ }
154+
155+ /**
156+ * One call, one model, at OpenRouter — the single-shot primitive both
157+ * `generateWithOpenRouter`'s model loop and the ai-kit chain walk over.
158+ * Supports Claude, GPT-4, Gemini, Grok, Llama, Mistral, and more.
159+ */
160+ async function callOpenRouterModel (
161+ model : string ,
162+ apiKey : string ,
163+ messages : LLMMessage [ ] ,
164+ temperature : number ,
165+ maxTokens : number ,
166+ ) : Promise < LLMResponse > {
167+ const response = await fetch ( API_CONFIG . OPENROUTER_API_URL , {
168+ method : 'POST' ,
169+ headers : {
170+ Authorization : `Bearer ${ apiKey } ` ,
171+ 'Content-Type' : 'application/json' ,
172+ 'HTTP-Referer' : getClientEnv ( ) . NEXT_PUBLIC_APP_URL ,
173+ 'X-Title' : 'Botsmann' ,
174+ } ,
175+ body : JSON . stringify ( {
128176 model,
129- } ;
177+ messages,
178+ temperature,
179+ max_tokens : maxTokens ,
180+ } ) ,
181+ } ) ;
182+
183+ if ( ! response . ok ) {
184+ const text = await response . text ( ) ;
185+ logger . error ( `OpenRouter API error: ${ response . status } (model ${ model } )` , text ) ;
186+ throw new Error ( `OpenRouter API error: ${ response . status } ` ) ;
130187 }
131188
132- throw new Error ( `Groq API error: ${ lastStatus } — all ${ models . length } model(s) failed` ) ;
189+ const data = await response . json ( ) ;
190+ return {
191+ content : data . choices [ 0 ] ?. message ?. content || '' ,
192+ provider : 'openrouter' ,
193+ model,
194+ } ;
133195}
134196
135197/**
136- * Generate with OpenRouter (100+ models)
137- * Supports Claude, GPT-4, Gemini, Grok, Llama, Mistral, and more
198+ * Generate with OpenRouter (100+ models), trying every model in the fleet's
199+ * chain before giving up.
138200 */
139201async function generateWithOpenRouter (
140202 messages : LLMMessage [ ] ,
@@ -150,44 +212,17 @@ async function generateWithOpenRouter(
150212 // An explicit caller override is honoured as-is and alone: if someone names a
151213 // model, silently answering from a different one is worse than failing.
152214 const models = model ? [ model ] : openRouterModels ( ) ;
153- let lastStatus = 0 ;
154- let lastError = '' ;
215+ let lastError : Error = new Error ( 'OpenRouter API error: no model attempted' ) ;
155216
156217 for ( const selectedModel of models ) {
157- const response = await fetch ( API_CONFIG . OPENROUTER_API_URL , {
158- method : 'POST' ,
159- headers : {
160- Authorization : `Bearer ${ apiKey } ` ,
161- 'Content-Type' : 'application/json' ,
162- 'HTTP-Referer' : getClientEnv ( ) . NEXT_PUBLIC_APP_URL ,
163- 'X-Title' : 'Botsmann' ,
164- } ,
165- body : JSON . stringify ( {
166- model : selectedModel ,
167- messages,
168- temperature,
169- max_tokens : maxTokens ,
170- } ) ,
171- } ) ;
172-
173- if ( ! response . ok ) {
174- lastStatus = response . status ;
175- lastError = await response . text ( ) ;
176- logger . error ( `OpenRouter API error: ${ response . status } (model ${ selectedModel } )` , lastError ) ;
177- continue ;
218+ try {
219+ return await callOpenRouterModel ( selectedModel , apiKey , messages , temperature , maxTokens ) ;
220+ } catch ( error ) {
221+ lastError = error instanceof Error ? error : new Error ( String ( error ) ) ;
178222 }
179-
180- const data = await response . json ( ) ;
181- return {
182- content : data . choices [ 0 ] ?. message ?. content || '' ,
183- provider : 'openrouter' ,
184- model : selectedModel ,
185- } ;
186223 }
187224
188- throw new Error (
189- `OpenRouter API request failed: ${ lastStatus } — all ${ models . length } model(s) failed` ,
190- ) ;
225+ throw new Error ( `${ lastError . message } — all ${ models . length } model(s) failed` ) ;
191226}
192227
193228/**
@@ -321,74 +356,64 @@ export async function getBestProvider(): Promise<{
321356}
322357
323358/**
324- * Generate a response using the best available provider
325- */
326- /**
327- * Every provider that is configured, in preference order.
359+ * Generate using the first link \u2014 provider AND model \u2014 that actually answers.
328360 *
329- * Ollama first (local, private, free), then Groq (free tier), then OpenRouter
330- * (paid). Being configured is not the same as working -- a key can be present
331- * and revoked -- so this returns the whole chain and lets the caller demote.
332- */
333- export async function getProviderChain ( ) : Promise <
334- Array < { provider : ModelProvider ; reason : string } >
335- > {
336- const chain : Array < { provider : ModelProvider ; reason : string } > = [ ] ;
337- const env = getServerEnv ( ) ;
338-
339- if ( await isOllamaAvailable ( ) ) {
340- chain . push ( { provider : 'ollama' , reason : 'Local Ollama running' } ) ;
341- }
342- if ( env . GROQ_API_KEY ) {
343- chain . push ( { provider : 'groq' , reason : 'Groq API key configured' } ) ;
344- }
345- if ( env . OPENROUTER_API_KEY ) {
346- chain . push ( { provider : 'openrouter' , reason : 'OpenRouter API key configured' } ) ;
347- }
348-
349- return chain ;
350- }
351-
352- /**
353- * Generate using the first provider that actually answers.
361+ * This used to be two hand-rolled loops: this function walked PROVIDERS,
362+ * and `generateWithGroq`/`generateWithOpenRouter` separately walked MODELS
363+ * within whichever provider got picked. That let a configured-but-revoked
364+ * key look identical to having no provider at all \u2014 botsmann's Groq key
365+ * started returning 401 and the whole AI layer went down while an
366+ * OpenRouter key sat unused. Now it is ONE chain, built and walked by
367+ * `ai-kit` (`usableChain`/`tryChain`): provider and model demote together,
368+ * in a single pass, and `ai-kit` owns the ordering so a fix to the chain
369+ * lands here without a matching edit in this file.
354370 *
355- * This used to pick one provider and call it once, so a configured-but-revoked
356- * key was indistinguishable from having no provider at all: botsmann's Groq key
357- * started returning 401 and the whole AI layer went down while an OpenRouter
358- * key sat unused. Being chosen must not mean being trusted -- each provider
359- * gets demoted on failure and the next one is tried.
360- *
361- * generateLLMResponse already walks the model list within a provider, so this
362- * is the layer above that: models, then providers.
371+ * Ollama stays outside that chain and is tried first: its availability is a
372+ * live ping, not an API key, which does not fit `ai-kit`'s `Provider` shape.
363373 */
364374export async function generateWithBestProvider (
365375 messages : LLMMessage [ ] ,
366376 options ?: Partial < Omit < LLMOptions , 'provider' > > ,
367377) : Promise < LLMResponse & { providerInfo : string } > {
368- const chain = await getProviderChain ( ) ;
369-
370- if ( chain . length === 0 ) {
371- throw new Error ( 'No LLM provider available. Start Ollama or configure API keys.' ) ;
372- }
373-
378+ const { temperature = 0.7 , maxTokens = 1024 } = options ?? { } ;
374379 const env = getServerEnv ( ) ;
375- const failures : string [ ] = [ ] ;
376380
377- for ( const { provider , reason } of chain ) {
381+ if ( await isOllamaAvailable ( ) ) {
378382 try {
379- const response = await generateLLMResponse ( messages , {
380- provider,
381- apiKey : provider === 'groq' ? env . GROQ_API_KEY : env . OPENROUTER_API_KEY ,
382- ollamaUrl : env . OLLAMA_URL ,
383- ...options ,
384- } ) ;
385- return { ...response , providerInfo : reason } ;
383+ const response = await generateWithOllama ( messages , env . OLLAMA_URL , temperature , maxTokens ) ;
384+ return { ...response , providerInfo : 'Local Ollama running' } ;
386385 } catch ( error ) {
387- const message = error instanceof Error ? error . message : String ( error ) ;
388- failures . push ( ` ${ provider } : ${ message } ` ) ;
389- logger . warn ( `[LLM] ${ provider } failed, trying next provider` , { error : message } ) ;
386+ logger . warn ( '[LLM] ollama failed, trying cloud chain' , {
387+ error : error instanceof Error ? error . message : String ( error ) ,
388+ } ) ;
390389 }
391390 }
392391
393- throw new Error ( `All ${ chain . length } provider(s) failed \u2014 ${ failures . join ( '; ' ) } ` ) ;
392+ const chain = usableChain ( freeChain ( 'BOTSMANN' ) , {
393+ GROQ_API_KEY : env . GROQ_API_KEY ,
394+ OPENROUTER_API_KEY : env . OPENROUTER_API_KEY ,
395+ } ) ;
396+
397+ if ( chain . length === 0 ) {
398+ throw new Error ( 'No LLM provider available. Start Ollama or configure API keys.' ) ;
399+ }
400+
401+ const response = await tryChain ( chain , {
402+ attempt : ( { provider, model } ) => {
403+ if ( provider . id === 'groq' ) {
404+ const key = cleanApiKey ( env . GROQ_API_KEY ) ;
405+ if ( ! key ) throw new Error ( 'Groq API key not configured' ) ;
406+ return callGroqModel ( model , key , messages , temperature , maxTokens ) ;
407+ }
408+ if ( ! env . OPENROUTER_API_KEY ) throw new Error ( 'OpenRouter API key required' ) ;
409+ return callOpenRouterModel ( model , env . OPENROUTER_API_KEY , messages , temperature , maxTokens ) ;
410+ } ,
411+ onLinkFailure : ( link , error ) => {
412+ logger . warn ( `[LLM] ${ link . provider . id } /${ link . model } failed, trying next` , {
413+ error : error instanceof Error ? error . message : String ( error ) ,
414+ } ) ;
415+ } ,
416+ } ) ;
417+
418+ return { ...response , providerInfo : `${ response . provider } (${ response . model } )` } ;
394419}
0 commit comments