Skip to content

Commit c27b0b3

Browse files
committed
ai: add free model fallback chain (deepseek + llama) with 429 handling and modelUsed tagging
1 parent 6ca8523 commit c27b0b3

File tree

2 files changed

+64
-42
lines changed

2 files changed

+64
-42
lines changed

src/lib/stores/ruixen.ts

Lines changed: 48 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ class RateLimiter {
106106
class Ruixen {
107107
private apiKey = '';
108108
private model = 'openai/gpt-oss-120b:free';
109+
private fallbackModels: string[] = [
110+
'openai/gpt-oss-120b:free',
111+
'deepseek/deepseek-chat-v3.1:free',
112+
'deepseek/deepseek-r1:free',
113+
'meta-llama/llama-3.3-70b-instruct:free',
114+
];
109115
private analyzer: AIAnalyzer | null = null;
110116
private limiter = new RateLimiter();
111117
private queue: QueueTask[] = [];
@@ -136,24 +142,11 @@ class Ruixen {
136142
return;
137143
}
138144

139-
// Try immediate if allowed
145+
// Try immediate if allowed (with fallback model sequence on 429)
140146
if (this.limiter.canRequest(this.model)) {
141147
this.limiter.record(this.model);
142148
this.dailyUsage.set(loadDailyCount().count);
143-
this.analyzer
144-
.analyzeJournalEntry(pet, fullEntry)
145-
.then(resolve)
146-
.catch((e) => {
147-
console.error('Ruixen immediate analysis failed:', e);
148-
const msg = String((e as Error)?.message || e);
149-
if (msg.includes('429') || /Rate limit exceeded/i.test(msg)) {
150-
const today = new Date().toDateString();
151-
setExhausted(today);
152-
saveDailyCount({ date: today, count: DAILY_FREE_LIMIT });
153-
this.dailyUsage.set(DAILY_FREE_LIMIT);
154-
}
155-
resolve(this.offlineHeuristic(pet, fullEntry));
156-
});
149+
this.tryModelsSequentially(fullEntry, pet).then(resolve);
157150
return;
158151
}
159152

@@ -225,26 +218,11 @@ class Ruixen {
225218

226219
this.limiter.record(this.model);
227220
this.dailyUsage.set(loadDailyCount().count);
228-
try {
229-
const res = await this.analyzer.analyzeJournalEntry(
230-
next.pet,
231-
this.toJournalEntry(next.entry, next.pet)
232-
);
233-
next.resolve(res);
234-
} catch (e) {
235-
console.error('Ruixen queued analysis failed:', e);
236-
const msg = String((e as Error)?.message || e);
237-
if (msg.includes('429') || /Rate limit exceeded/i.test(msg)) {
238-
const today = new Date().toDateString();
239-
setExhausted(today);
240-
saveDailyCount({ date: today, count: DAILY_FREE_LIMIT });
241-
this.dailyUsage.set(DAILY_FREE_LIMIT);
242-
}
243-
next.resolve(this.offlineHeuristic(next.pet, this.toJournalEntry(next.entry, next.pet)));
244-
} finally {
245-
this.queue.shift();
246-
this.queueSize.set(this.queue.length);
247-
}
221+
const journalEntry = this.toJournalEntry(next.entry, next.pet);
222+
const res = await this.tryModelsSequentially(journalEntry, next.pet);
223+
next.resolve(res);
224+
this.queue.shift();
225+
this.queueSize.set(this.queue.length);
248226
}
249227
this.running = false;
250228
};
@@ -417,6 +395,41 @@ class Ruixen {
417395
},
418396
];
419397
}
398+
399+
/**
400+
* Attempt analysis across fallback free models sequentially.
401+
* On 429 from a model, tries the next. Other errors short-circuit to offline heuristic.
402+
* If all fail with 429, mark day exhausted and return offline heuristic.
403+
*/
404+
private async tryModelsSequentially(
405+
entry: JournalEntry,
406+
pet: PetPanelData
407+
): Promise<AnalysisResult> {
408+
if (!this.analyzer) return this.offlineHeuristic(pet, entry);
409+
for (let i = 0; i < this.fallbackModels.length; i++) {
410+
const model = this.fallbackModels[i];
411+
try {
412+
const res = await this.analyzer.analyzeJournalEntry(pet, entry, model);
413+
return res;
414+
} catch (e) {
415+
const msg = String((e as Error)?.message || e);
416+
const is429 = /429|Rate limit exceeded/i.test(msg);
417+
if (!is429) {
418+
console.warn('Model attempt failed (non-429), aborting fallbacks:', model, e);
419+
return this.offlineHeuristic(pet, entry);
420+
}
421+
console.warn('Model rate-limited, trying next free model:', model);
422+
if (i === this.fallbackModels.length - 1) {
423+
const today = new Date().toDateString();
424+
setExhausted(today);
425+
saveDailyCount({ date: today, count: DAILY_FREE_LIMIT });
426+
this.dailyUsage.set(DAILY_FREE_LIMIT);
427+
return this.offlineHeuristic(pet, entry);
428+
}
429+
}
430+
}
431+
return this.offlineHeuristic(pet, entry);
432+
}
420433
}
421434

422435
export const ruixen = new Ruixen();

src/lib/utils/ai-analysis.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export interface AnalysisResult {
88
healthConcerns: string[];
99
recommendations: string[];
1010
nextCheckupSuggestion?: string;
11+
/** Which model actually produced this analysis (for fallback visibility) */
12+
modelUsed?: string;
1113
}
1214

1315
export class AIAnalyzer {
@@ -17,12 +19,16 @@ export class AIAnalyzer {
1719

1820
constructor(apiKey: string, _model: string = 'openai/gpt-oss-120b:free') {
1921
this.apiKey = apiKey;
20-
// Hard-enforce the chosen free model for all calls
21-
this.model = 'openai/gpt-oss-120b:free';
22+
this.model = _model || 'openai/gpt-oss-120b:free';
2223
}
2324

24-
async analyzeJournalEntry(pet: PetPanelData, entry: JournalEntry): Promise<AnalysisResult> {
25+
async analyzeJournalEntry(
26+
pet: PetPanelData,
27+
entry: JournalEntry,
28+
modelOverride?: string
29+
): Promise<AnalysisResult> {
2530
const prompt = this.buildAnalysisPrompt(pet, entry);
31+
const model = modelOverride || this.model;
2632
const referer = typeof window !== 'undefined' ? window.location.origin : undefined;
2733

2834
if (typeof window !== 'undefined' && (import.meta as any)?.env?.DEV) {
@@ -41,7 +47,7 @@ export class AIAnalyzer {
4147
},
4248
body: JSON.stringify({
4349
apiKey: this.apiKey,
44-
model: this.model,
50+
model,
4551
messages: [
4652
{
4753
role: 'system',
@@ -71,14 +77,16 @@ export class AIAnalyzer {
7177
console.debug('[Ruixen] Response (journal) preview', String(content).slice(0, 300));
7278
}
7379
const parsed = this.parseAnalysisResponse(content);
74-
return this.sanitizeAnalysis(parsed, pet, entry);
80+
const clean = this.sanitizeAnalysis(parsed, pet, entry);
81+
clean.modelUsed = model;
82+
return clean;
7583
} catch (error) {
7684
console.error('AI Analysis Error:', error);
7785
throw error;
7886
}
7987
}
8088

81-
async analyzeWeeklySummary(pet: PetPanelData): Promise<string> {
89+
async analyzeWeeklySummary(pet: PetPanelData, modelOverride?: string): Promise<string> {
8290
const sevenDays =
8391
(pet.journalEntries || [])
8492
.slice()
@@ -98,14 +106,15 @@ PET: ${pet.name} (${pet.breed || pet.species || 'pet'}, ${pet.age ?? 'unknown'}y
98106
LAST 7 DAYS:\n${sevenDays}`;
99107

100108
const referer = typeof window !== 'undefined' ? window.location.origin : undefined;
109+
const model = modelOverride || this.model;
101110
const response = await fetch(this.baseUrl, {
102111
method: 'POST',
103112
headers: {
104113
'Content-Type': 'application/json',
105114
},
106115
body: JSON.stringify({
107116
apiKey: this.apiKey,
108-
model: this.model,
117+
model,
109118
messages: [
110119
{
111120
role: 'system',

0 commit comments

Comments
 (0)