Skip to content

Commit d6807bf

Browse files
stackbilt-adminAegis
andauthored
fix: route groq helpers through llm providers (#62)
Co-authored-by: Aegis <Aegis@stackbilt.dev>
1 parent 85bf810 commit d6807bf

2 files changed

Lines changed: 122 additions & 104 deletions

File tree

web/src/groq.ts

Lines changed: 52 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,24 @@
1-
// Edge-native Groq client — fetch-based, no OpenAI SDK dependency
1+
// Edge-native Groq helpers backed by @stackbilt/llm-providers
2+
3+
import { createLLMProviderFactory, type LLMMessage } from '@stackbilt/llm-providers';
4+
import { tokenize, jaccardSimilarity } from './kernel/memory/index.js';
5+
import { cosineSimilarity } from './kernel/memory/semantic.js';
6+
import type { MemoryServiceBinding } from './types.js';
7+
8+
function buildGroqFactory(apiKey: string, baseUrl: string) {
9+
return createLLMProviderFactory({
10+
groq: { apiKey, baseUrl },
11+
fallbackRules: [],
12+
enableCircuitBreaker: true,
13+
enableRetries: true,
14+
});
15+
}
16+
17+
function coerceText(content: unknown): string {
18+
if (typeof content === 'string') return content;
19+
if (content == null) return '';
20+
return typeof content === 'object' ? JSON.stringify(content) : String(content);
21+
}
222

323
export async function askGroq(
424
apiKey: string,
@@ -7,39 +27,21 @@ export async function askGroq(
727
userPrompt: string,
828
baseUrl = 'https://api.groq.com',
929
): Promise<string> {
10-
const response = await fetch(`${baseUrl}/openai/v1/chat/completions`, {
11-
method: 'POST',
12-
headers: {
13-
'Content-Type': 'application/json',
14-
'Authorization': `Bearer ${apiKey}`,
15-
},
16-
body: JSON.stringify({
30+
try {
31+
const result = await buildGroqFactory(apiKey, baseUrl).generateResponse({
1732
model,
33+
systemPrompt,
1834
temperature: 0.3,
19-
max_tokens: 500,
35+
maxTokens: 500,
2036
messages: [
21-
{ role: 'system', content: systemPrompt },
2237
{ role: 'user', content: userPrompt },
2338
],
24-
}),
25-
});
26-
27-
if (!response.ok) {
28-
const errText = await response.text();
29-
throw new Error(`Groq API error ${response.status}: ${errText}`);
39+
});
40+
return coerceText(result.message);
41+
} catch (err) {
42+
const msg = err instanceof Error ? err.message : String(err);
43+
throw new Error(`Groq API error: ${msg}`);
3044
}
31-
32-
const data = await response.json<{
33-
choices: { message: { content: unknown } }[];
34-
usage?: { total_tokens: number };
35-
}>();
36-
37-
const content = data.choices[0]?.message?.content;
38-
if (typeof content === 'string') return content;
39-
if (content == null) return '';
40-
// Some Groq-routed models (notably gpt-oss tool-calling variants) return content
41-
// as an array of content blocks. Coerce so downstream string operations don't crash.
42-
return typeof content === 'object' ? JSON.stringify(content) : String(content);
4345
}
4446

4547
// ─── Logprobs-enabled classification ─────────────────────────
@@ -130,10 +132,6 @@ export async function askGroqWithLogprobs(
130132
// Jaccard when memoryBinding is unavailable.
131133
// Returns σ metric: 0=all agree, 0.5=partial, 1.0=disagree.
132134

133-
import { tokenize, jaccardSimilarity } from './kernel/memory/index.js';
134-
import { cosineSimilarity } from './kernel/memory/semantic.js';
135-
import type { MemoryServiceBinding } from './types.js';
136-
137135
const PROBE_TIMEOUT_MS = 3_000;
138136
const JACCARD_AGREEMENT_THRESHOLD = 0.5;
139137
const COSINE_AGREEMENT_THRESHOLD = 0.85;
@@ -210,43 +208,38 @@ export async function askGroqJson<T = unknown>(
210208
baseUrl = 'https://api.groq.com',
211209
options?: { maxTokens?: number; temperature?: number; prefill?: string },
212210
): Promise<{ parsed: T; raw: string; usage?: { prompt_tokens: number; completion_tokens: number } }> {
213-
const messages: Array<{ role: string; content: string }> = [
214-
{ role: 'system', content: systemPrompt },
211+
const messages: LLMMessage[] = [
215212
{ role: 'user', content: userPrompt },
216213
];
217214
// Prefilling: seed the assistant response to steer tone/format
218215
if (options?.prefill) {
219216
messages.push({ role: 'assistant', content: options.prefill });
220217
}
221218

222-
const response = await fetch(`${baseUrl}/openai/v1/chat/completions`, {
223-
method: 'POST',
224-
headers: {
225-
'Content-Type': 'application/json',
226-
'Authorization': `Bearer ${apiKey}`,
227-
},
228-
body: JSON.stringify({
219+
try {
220+
const result = await buildGroqFactory(apiKey, baseUrl).generateResponse({
229221
model,
222+
systemPrompt,
230223
temperature: options?.temperature ?? 0.2,
231-
max_tokens: options?.maxTokens ?? 2000,
224+
maxTokens: options?.maxTokens ?? 2000,
232225
response_format: { type: 'json_object' },
233226
messages,
234-
}),
235-
});
236-
237-
if (!response.ok) {
238-
const errText = await response.text();
239-
throw new Error(`Groq API error ${response.status}: ${errText}`);
227+
});
228+
229+
const completion = result.message ?? '{}';
230+
// If prefilled, the model continues from the prefill — concatenate for valid JSON
231+
const raw = options?.prefill ? options.prefill + completion : completion;
232+
const parsed = JSON.parse(raw) as T;
233+
return {
234+
parsed,
235+
raw,
236+
usage: {
237+
prompt_tokens: result.usage.inputTokens,
238+
completion_tokens: result.usage.outputTokens,
239+
},
240+
};
241+
} catch (err) {
242+
const msg = err instanceof Error ? err.message : String(err);
243+
throw new Error(`Groq API error: ${msg}`);
240244
}
241-
242-
const data = await response.json<{
243-
choices: { message: { content: string } }[];
244-
usage?: { prompt_tokens: number; completion_tokens: number };
245-
}>();
246-
247-
const completion = data.choices[0]?.message?.content ?? '{}';
248-
// If prefilled, the model continues from the prefill — concatenate for valid JSON
249-
const raw = options?.prefill ? options.prefill + completion : completion;
250-
const parsed = JSON.parse(raw) as T;
251-
return { parsed, raw, usage: data.usage };
252245
}

web/tests/groq.test.ts

Lines changed: 70 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1-
// Groq client tests — askGroq, askGroqJson, askGroqWithLogprobs, probeConsistency
2-
// Mocks fetch() to test API interaction without real calls
1+
// Groq helper tests — askGroq, askGroqJson, askGroqWithLogprobs, probeConsistency
2+
// Mocks provider factory and fetch() to test API interaction without real calls
33

44
import { describe, it, expect, vi, beforeEach } from 'vitest';
55

6+
const providerMocks = vi.hoisted(() => ({
7+
createLLMProviderFactory: vi.fn(),
8+
generateResponse: vi.fn(),
9+
}));
10+
11+
vi.mock('@stackbilt/llm-providers', () => ({
12+
createLLMProviderFactory: providerMocks.createLLMProviderFactory,
13+
}));
14+
615
// Mock tokenize/jaccardSimilarity before importing groq.ts
716
vi.mock('../src/kernel/memory/index.js', () => ({
817
tokenize: (text: string) => new Set(text.toLowerCase().split(/\s+/)),
@@ -19,11 +28,14 @@ vi.stubGlobal('fetch', mockFetch);
1928

2029
const { askGroq, askGroqJson, askGroqWithLogprobs, probeConsistency } = await import('../src/groq.js');
2130

22-
function groqResponse(content: string, usage?: { prompt_tokens: number; completion_tokens: number }) {
23-
return new Response(JSON.stringify({
24-
choices: [{ message: { content } }],
31+
function providerResponse(content: unknown, usage = { inputTokens: 100, outputTokens: 50, totalTokens: 150, cost: 0.001 }) {
32+
return {
33+
message: content,
2534
usage,
26-
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
35+
model: 'llama-test',
36+
provider: 'groq',
37+
responseTime: 10,
38+
};
2739
}
2840

2941
function groqLogprobResponse(content: string, logprobs: Array<{ token: string; logprob: number }>) {
@@ -36,52 +48,59 @@ function groqLogprobResponse(content: string, logprobs: Array<{ token: string; l
3648
}
3749

3850
describe('askGroq', () => {
39-
beforeEach(() => vi.clearAllMocks());
51+
beforeEach(() => {
52+
vi.clearAllMocks();
53+
providerMocks.createLLMProviderFactory.mockReturnValue({ generateResponse: providerMocks.generateResponse });
54+
});
4055

4156
it('returns content from Groq API', async () => {
42-
mockFetch.mockResolvedValue(groqResponse('Hello!'));
57+
providerMocks.generateResponse.mockResolvedValue(providerResponse('Hello!'));
4358
const result = await askGroq('key', 'model', 'system', 'user');
4459
expect(result).toBe('Hello!');
45-
expect(mockFetch).toHaveBeenCalledOnce();
60+
expect(providerMocks.generateResponse).toHaveBeenCalledOnce();
4661
});
4762

4863
it('sends correct request shape', async () => {
49-
mockFetch.mockResolvedValue(groqResponse('ok'));
64+
providerMocks.generateResponse.mockResolvedValue(providerResponse('ok'));
5065
await askGroq('test-key', 'llama-70b', 'sys prompt', 'user prompt', 'https://custom.api');
5166

52-
const [url, opts] = mockFetch.mock.calls[0];
53-
expect(url).toBe('https://custom.api/openai/v1/chat/completions');
54-
expect(opts.method).toBe('POST');
55-
expect(opts.headers['Authorization']).toBe('Bearer test-key');
56-
const body = JSON.parse(opts.body);
57-
expect(body.model).toBe('llama-70b');
58-
expect(body.messages).toHaveLength(2);
59-
expect(body.messages[0].role).toBe('system');
60-
expect(body.messages[1].role).toBe('user');
67+
expect(providerMocks.createLLMProviderFactory).toHaveBeenCalledWith({
68+
groq: { apiKey: 'test-key', baseUrl: 'https://custom.api' },
69+
fallbackRules: [],
70+
enableCircuitBreaker: true,
71+
enableRetries: true,
72+
});
73+
expect(providerMocks.generateResponse).toHaveBeenCalledWith({
74+
model: 'llama-70b',
75+
systemPrompt: 'sys prompt',
76+
temperature: 0.3,
77+
maxTokens: 500,
78+
messages: [{ role: 'user', content: 'user prompt' }],
79+
});
6180
});
6281

6382
it('throws on API error', async () => {
64-
mockFetch.mockResolvedValue(new Response('rate limited', { status: 429 }));
65-
await expect(askGroq('key', 'model', 'sys', 'user')).rejects.toThrow('Groq API error 429');
83+
providerMocks.generateResponse.mockRejectedValue(new Error('rate limited'));
84+
await expect(askGroq('key', 'model', 'sys', 'user')).rejects.toThrow('Groq API error: rate limited');
6685
});
6786

6887
it('returns empty string when no content', async () => {
69-
mockFetch.mockResolvedValue(new Response(
70-
JSON.stringify({ choices: [{ message: { content: null } }] }),
71-
{ status: 200, headers: { 'Content-Type': 'application/json' } },
72-
));
88+
providerMocks.generateResponse.mockResolvedValue(providerResponse(null));
7389
const result = await askGroq('key', 'model', 'sys', 'user');
7490
expect(result).toBe('');
7591
});
7692
});
7793

7894
describe('askGroqJson', () => {
79-
beforeEach(() => vi.clearAllMocks());
95+
beforeEach(() => {
96+
vi.clearAllMocks();
97+
providerMocks.createLLMProviderFactory.mockReturnValue({ generateResponse: providerMocks.generateResponse });
98+
});
8099

81100
it('parses JSON response', async () => {
82-
mockFetch.mockResolvedValue(groqResponse(
101+
providerMocks.generateResponse.mockResolvedValue(providerResponse(
83102
'{"name":"test","value":42}',
84-
{ prompt_tokens: 100, completion_tokens: 50 },
103+
{ inputTokens: 100, outputTokens: 50, totalTokens: 150, cost: 0.001 },
85104
));
86105
const { parsed, raw, usage } = await askGroqJson<{ name: string; value: number }>(
87106
'key', 'model', 'sys', 'user',
@@ -93,9 +112,9 @@ describe('askGroqJson', () => {
93112
});
94113

95114
it('handles prefill by concatenating', async () => {
96-
mockFetch.mockResolvedValue(groqResponse(
115+
providerMocks.generateResponse.mockResolvedValue(providerResponse(
97116
'hello","done":true}',
98-
{ prompt_tokens: 50, completion_tokens: 20 },
117+
{ inputTokens: 50, outputTokens: 20, totalTokens: 70, cost: 0.001 },
99118
));
100119
const { parsed } = await askGroqJson<{ greeting: string; done: boolean }>(
101120
'key', 'model', 'sys', 'user', undefined,
@@ -106,20 +125,24 @@ describe('askGroqJson', () => {
106125
});
107126

108127
it('sends json_object response_format', async () => {
109-
mockFetch.mockResolvedValue(groqResponse('{}'));
128+
providerMocks.generateResponse.mockResolvedValue(providerResponse('{}'));
110129
await askGroqJson('key', 'model', 'sys', 'user');
111-
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
112-
expect(body.response_format).toEqual({ type: 'json_object' });
130+
expect(providerMocks.generateResponse).toHaveBeenCalledWith(expect.objectContaining({
131+
response_format: { type: 'json_object' },
132+
}));
113133
});
114134

115135
it('throws on API error', async () => {
116-
mockFetch.mockResolvedValue(new Response('server error', { status: 500 }));
117-
await expect(askGroqJson('key', 'model', 'sys', 'user')).rejects.toThrow('Groq API error 500');
136+
providerMocks.generateResponse.mockRejectedValue(new Error('server error'));
137+
await expect(askGroqJson('key', 'model', 'sys', 'user')).rejects.toThrow('Groq API error: server error');
118138
});
119139
});
120140

121141
describe('askGroqWithLogprobs', () => {
122-
beforeEach(() => vi.clearAllMocks());
142+
beforeEach(() => {
143+
vi.clearAllMocks();
144+
providerMocks.createLLMProviderFactory.mockReturnValue({ generateResponse: providerMocks.generateResponse });
145+
});
123146

124147
it('parses classification with token confidence', async () => {
125148
mockFetch.mockResolvedValue(groqLogprobResponse(
@@ -161,30 +184,32 @@ describe('askGroqWithLogprobs', () => {
161184
});
162185

163186
describe('probeConsistency', () => {
164-
beforeEach(() => vi.clearAllMocks());
187+
beforeEach(() => {
188+
vi.clearAllMocks();
189+
providerMocks.createLLMProviderFactory.mockReturnValue({ generateResponse: providerMocks.generateResponse });
190+
});
165191

166192
it('returns sigma=0 when all responses agree', async () => {
167-
// Each call needs a fresh Response (body can only be read once)
168-
mockFetch.mockImplementation(() => Promise.resolve(groqResponse('The answer is 42')));
193+
providerMocks.generateResponse.mockResolvedValue(providerResponse('The answer is 42'));
169194
const result = await probeConsistency('key', 'model', 'sys', 'user');
170195
expect(result.sigma).toBe(0);
171196
expect(result.agreedText).toBe('The answer is 42');
172197
expect(result.responses).toHaveLength(3);
173198
});
174199

175200
it('returns sigma=1.0 when responses completely disagree', async () => {
176-
mockFetch
177-
.mockResolvedValueOnce(groqResponse('alpha beta gamma delta epsilon'))
178-
.mockResolvedValueOnce(groqResponse('one two three four five six seven'))
179-
.mockResolvedValueOnce(groqResponse('red green blue purple orange yellow'));
201+
providerMocks.generateResponse
202+
.mockResolvedValueOnce(providerResponse('alpha beta gamma delta epsilon'))
203+
.mockResolvedValueOnce(providerResponse('one two three four five six seven'))
204+
.mockResolvedValueOnce(providerResponse('red green blue purple orange yellow'));
180205
const result = await probeConsistency('key', 'model', 'sys', 'user');
181206
expect(result.sigma).toBe(1.0);
182207
expect(result.agreedText).toBeNull();
183208
});
184209

185210
it('makes exactly 3 parallel calls', async () => {
186-
mockFetch.mockImplementation(() => Promise.resolve(groqResponse('same')));
211+
providerMocks.generateResponse.mockResolvedValue(providerResponse('same'));
187212
await probeConsistency('key', 'model', 'sys', 'user');
188-
expect(mockFetch).toHaveBeenCalledTimes(3);
213+
expect(providerMocks.generateResponse).toHaveBeenCalledTimes(3);
189214
});
190215
});

0 commit comments

Comments
 (0)