Skip to content

Commit dddb4ad

Browse files
catomeanclaude
andcommitted
refactor: adopt ai-kit's tryChain + createHealthTracker
botsmann's own generateWithBestProvider/getProviderChain and llm-health.ts were the source this session extracted ai-kit v0.5.0's tryChain and createHealthTracker from. This adopts that package for real, closing the gap flagged by a fleet-wide audit: botsmann had ai-kit installed for model lists only, while the actual retry/health logic stayed hand-rolled here. - generateWithBestProvider now builds one flat provider+model chain via ai-kit's usableChain/freeChain and walks it with tryChain, instead of two separate hand-rolled loops (provider here, model inside generateWithGroq/generateWithOpenRouter). Ollama stays a special-cased first check -- its availability is a live ping, not an API key, so it does not fit ai-kit's Provider shape. - generateWithGroq/generateWithOpenRouter are unchanged in behavior, refactored into thin loops over new callGroqModel/callOpenRouterModel single-shot primitives -- the same primitives generateWithBestProvider's chain walk now calls directly, so there is exactly one fetch implementation per vendor, not two. - llm-health.ts keeps its exact five-function API (every route and test still imports recordLLMSuccess/recordLLMFailure/getLLMHealth/ resetLLMHealth unchanged) but now wraps ai-kit's createHealthTracker instead of hand-rolled module state. One test's assertion on the aggregate-failure error wording updated ("provider(s)" -> "link(s)") to match ai-kit's more precise per-link failure report; no other test needed changes. Full verify green: format, lint, typecheck, 272 tests, production build. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent a92db9e commit dddb4ad

5 files changed

Lines changed: 183 additions & 160 deletions

File tree

lib/llm-client.ts

Lines changed: 152 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* - Ollama (local)
88
*/
99

10-
import { freeChain, providerModels } from 'ai-kit';
10+
import { freeChain, providerModels, usableChain, tryChain } from 'ai-kit';
1111
import { API_CONFIG } from '@/lib/constants';
1212
import { getServerEnv, getClientEnv } from '@/lib/config/env';
1313
import { logger } from './logger';
@@ -53,6 +53,11 @@ interface LLMResponse {
5353
// Ollama configuration (lazy to avoid calling getServerEnv at module scope during SSG)
5454
const 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
*/
80128
async 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
*/
139201
async 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
*/
364374
export 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

Comments
 (0)