mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 14:06:42 +02:00
Create a shared LLM client package that provides a unified interface to the mana-llm service, replacing 9 individual fetch-based integrations with consistent error handling, retry logic, and JSON extraction. Package (@manacore/shared-llm): - LlmModule with forRoot/forRootAsync (NestJS dynamic module) - LlmClientService: chat, json, vision, visionJson, embed, stream - LlmClient standalone class for non-NestJS consumers - extractJson utility (consolidates 3 markdown-stripping implementations) - retryFetch with exponential backoff (429, 5xx, network errors) - 44 unit tests (json-extractor, retry, llm-client) Migrated backends: - mana-core-auth: raw fetch → llm.json() - planta: raw fetch + vision → llm.visionJson() - nutriphi: raw fetch + regex → llm.visionJson() + llm.json() - chat: custom OllamaService (175 LOC) → llm.chatMessages() - context: raw fetch → llm.chat() (keeps token tracking) - traces: 2x raw fetch → llm.chat() - manadeck: @google/genai SDK → llm.json() + llm.visionJson() - bot-services: raw Ollama API → LlmClient standalone - matrix-ollama-bot: raw fetch → llm.chatMessages() + llm.vision() New credit operations: - AI_PLANT_ANALYSIS (2 credits, planta) - AI_GUIDE_GENERATION (5 credits, traces) - AI_CONTEXT_GENERATION (2 credits, context) - AI_BOT_CHAT (0.1 credits, matrix) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
118 lines
3.7 KiB
TypeScript
118 lines
3.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { retryFetch } from '../utils/retry';
|
|
|
|
// Mock global fetch
|
|
const mockFetch = vi.fn();
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
function mockResponse(status: number, body = ''): Response {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
statusText: `Status ${status}`,
|
|
text: () => Promise.resolve(body),
|
|
json: () => Promise.resolve(JSON.parse(body || '{}')),
|
|
headers: new Headers(),
|
|
} as unknown as Response;
|
|
}
|
|
|
|
describe('retryFetch', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('returns on first successful attempt', async () => {
|
|
mockFetch.mockResolvedValueOnce(mockResponse(200, '{"ok": true}'));
|
|
|
|
const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 });
|
|
expect(response.ok).toBe(true);
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('retries on 503 and succeeds', async () => {
|
|
mockFetch
|
|
.mockResolvedValueOnce(mockResponse(503))
|
|
.mockResolvedValueOnce(mockResponse(200, '{}'));
|
|
|
|
const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 });
|
|
expect(response.ok).toBe(true);
|
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('retries on 429 rate limit', async () => {
|
|
mockFetch
|
|
.mockResolvedValueOnce(mockResponse(429))
|
|
.mockResolvedValueOnce(mockResponse(200, '{}'));
|
|
|
|
const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 });
|
|
expect(response.ok).toBe(true);
|
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('retries on network error and succeeds', async () => {
|
|
mockFetch
|
|
.mockRejectedValueOnce(new Error('ECONNREFUSED'))
|
|
.mockResolvedValueOnce(mockResponse(200, '{}'));
|
|
|
|
const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 });
|
|
expect(response.ok).toBe(true);
|
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('does NOT retry on 400 client error', async () => {
|
|
mockFetch.mockResolvedValueOnce(mockResponse(400, 'Bad Request'));
|
|
|
|
const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 });
|
|
expect(response.status).toBe(400);
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does NOT retry on 401 unauthorized', async () => {
|
|
mockFetch.mockResolvedValueOnce(mockResponse(401));
|
|
|
|
const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 });
|
|
expect(response.status).toBe(401);
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does NOT retry on 404 not found', async () => {
|
|
mockFetch.mockResolvedValueOnce(mockResponse(404));
|
|
|
|
const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 });
|
|
expect(response.status).toBe(404);
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('throws after exhausting all retries', async () => {
|
|
mockFetch
|
|
.mockResolvedValueOnce(mockResponse(503))
|
|
.mockResolvedValueOnce(mockResponse(503))
|
|
.mockResolvedValueOnce(mockResponse(503));
|
|
|
|
await expect(retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 })).rejects.toThrow(
|
|
'HTTP 503'
|
|
);
|
|
|
|
expect(mockFetch).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
|
|
});
|
|
|
|
it('throws after exhausting retries on network errors', async () => {
|
|
mockFetch
|
|
.mockRejectedValueOnce(new Error('ECONNREFUSED'))
|
|
.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
|
|
|
await expect(retryFetch('http://test', {}, { maxRetries: 1, baseDelay: 10 })).rejects.toThrow(
|
|
'ECONNREFUSED'
|
|
);
|
|
|
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('works with maxRetries: 0 (no retries)', async () => {
|
|
mockFetch.mockResolvedValueOnce(mockResponse(503));
|
|
|
|
await expect(retryFetch('http://test', {}, { maxRetries: 0, baseDelay: 10 })).rejects.toThrow();
|
|
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|