Skip to content

Commit c312978

Browse files
feat: add tool calling support to GroqProvider (#21)
Enable tool calling for openai/gpt-oss-120b and llama-3.3-70b-versatile on Groq, unblocking the AEGIS dispatch chain's cheapest tool-calling path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ad51d7a commit c312978

4 files changed

Lines changed: 284 additions & 17 deletions

File tree

src/__tests__/groq.test.ts

Lines changed: 180 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ describe('GroqProvider', () => {
3636
expect(provider.name).toBe('groq');
3737
expect(provider.models).toContain('llama-3.3-70b-versatile');
3838
expect(provider.models).toContain('llama-3.1-8b-instant');
39+
expect(provider.models).toContain('openai/gpt-oss-120b');
3940
expect(provider.supportsStreaming).toBe(true);
40-
expect(provider.supportsTools).toBe(false);
41+
expect(provider.supportsTools).toBe(true);
4142
expect(provider.supportsBatching).toBe(false);
4243
});
4344

@@ -69,7 +70,7 @@ describe('GroqProvider', () => {
6970
describe('getModels', () => {
7071
it('should return available models', () => {
7172
const models = provider.getModels();
72-
expect(models).toEqual(['llama-3.3-70b-versatile', 'llama-3.1-8b-instant']);
73+
expect(models).toEqual(['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'openai/gpt-oss-120b']);
7374
});
7475

7576
it('should return a copy of the models array', () => {
@@ -222,6 +223,14 @@ describe('GroqProvider', () => {
222223
expect(cost).toBeGreaterThan(0);
223224
});
224225

226+
it('should estimate cost for openai/gpt-oss-120b', () => {
227+
const cost = provider.estimateCost({
228+
...testRequest,
229+
model: 'openai/gpt-oss-120b'
230+
});
231+
expect(cost).toBeGreaterThan(0);
232+
});
233+
225234
it('should return 0 for unknown model', () => {
226235
const cost = provider.estimateCost({
227236
...testRequest,
@@ -237,6 +246,175 @@ describe('GroqProvider', () => {
237246
});
238247
});
239248

249+
describe('tool calling', () => {
250+
const toolRequest: LLMRequest = {
251+
messages: [{ role: 'user', content: 'What is the weather?' }],
252+
model: 'openai/gpt-oss-120b',
253+
maxTokens: 100,
254+
tools: [{
255+
type: 'function',
256+
function: {
257+
name: 'get_weather',
258+
description: 'Get current weather',
259+
parameters: { type: 'object', properties: { location: { type: 'string' } } }
260+
}
261+
}],
262+
toolChoice: 'auto'
263+
};
264+
265+
const toolCallResponse = {
266+
id: 'chatcmpl-tool-1',
267+
object: 'chat.completion',
268+
created: 1700000000,
269+
model: 'openai/gpt-oss-120b',
270+
choices: [{
271+
index: 0,
272+
message: {
273+
role: 'assistant',
274+
content: null,
275+
tool_calls: [{
276+
id: 'call_abc123',
277+
type: 'function',
278+
function: { name: 'get_weather', arguments: '{"location":"London"}' }
279+
}]
280+
},
281+
finish_reason: 'tool_calls'
282+
}],
283+
usage: { prompt_tokens: 20, completion_tokens: 15, total_tokens: 35 }
284+
};
285+
286+
it('should pass tools and parse tool_calls in response', async () => {
287+
mockFetch.mockResolvedValueOnce({
288+
ok: true,
289+
json: async () => toolCallResponse,
290+
headers: new Headers({ 'content-type': 'application/json' })
291+
});
292+
293+
const response = await provider.generateResponse(toolRequest);
294+
295+
// Verify response has tool calls
296+
expect(response.toolCalls).toHaveLength(1);
297+
expect(response.toolCalls![0].id).toBe('call_abc123');
298+
expect(response.toolCalls![0].function.name).toBe('get_weather');
299+
expect(response.toolCalls![0].function.arguments).toBe('{"location":"London"}');
300+
expect(response.finishReason).toBe('tool_calls');
301+
302+
// Verify request body included tools
303+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
304+
expect(body.tools).toHaveLength(1);
305+
expect(body.tools[0].function.name).toBe('get_weather');
306+
expect(body.tool_choice).toBe('auto');
307+
});
308+
309+
it('should handle multi-turn tool conversations', async () => {
310+
const multiTurnRequest: LLMRequest = {
311+
messages: [
312+
{ role: 'user', content: 'What is the weather?' },
313+
{
314+
role: 'assistant',
315+
content: '',
316+
toolCalls: [{
317+
id: 'call_abc123',
318+
type: 'function',
319+
function: { name: 'get_weather', arguments: '{"location":"London"}' }
320+
}]
321+
},
322+
{
323+
role: 'user',
324+
content: '',
325+
toolResults: [{
326+
id: 'call_abc123',
327+
output: '{"temp": 15, "condition": "cloudy"}'
328+
}]
329+
}
330+
],
331+
model: 'openai/gpt-oss-120b',
332+
maxTokens: 100,
333+
tools: toolRequest.tools
334+
};
335+
336+
mockFetch.mockResolvedValueOnce({
337+
ok: true,
338+
json: async () => ({
339+
id: 'chatcmpl-tool-2',
340+
object: 'chat.completion',
341+
created: 1700000000,
342+
model: 'openai/gpt-oss-120b',
343+
choices: [{
344+
index: 0,
345+
message: { role: 'assistant', content: 'It is 15°C and cloudy in London.' },
346+
finish_reason: 'stop'
347+
}],
348+
usage: { prompt_tokens: 40, completion_tokens: 15, total_tokens: 55 }
349+
}),
350+
headers: new Headers({ 'content-type': 'application/json' })
351+
});
352+
353+
const response = await provider.generateResponse(multiTurnRequest);
354+
355+
expect(response.message).toBe('It is 15°C and cloudy in London.');
356+
357+
// Verify the serialized messages include tool_calls and tool role
358+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
359+
const assistantMsg = body.messages.find((m: Record<string, unknown>) => m.role === 'assistant');
360+
expect(assistantMsg.tool_calls).toHaveLength(1);
361+
expect(assistantMsg.tool_calls[0].id).toBe('call_abc123');
362+
363+
const toolMsg = body.messages.find((m: Record<string, unknown>) => m.role === 'tool');
364+
expect(toolMsg.tool_call_id).toBe('call_abc123');
365+
expect(toolMsg.content).toBe('{"temp": 15, "condition": "cloudy"}');
366+
});
367+
368+
it('should not include tools for non-tool-capable models', async () => {
369+
mockFetch.mockResolvedValueOnce({
370+
ok: true,
371+
json: async () => ({
372+
id: 'chatcmpl-123',
373+
object: 'chat.completion',
374+
created: 1700000000,
375+
model: 'llama-3.1-8b-instant',
376+
choices: [{
377+
index: 0,
378+
message: { role: 'assistant', content: 'I cannot call tools.' },
379+
finish_reason: 'stop'
380+
}],
381+
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
382+
}),
383+
headers: new Headers({ 'content-type': 'application/json' })
384+
});
385+
386+
await provider.generateResponse({
387+
...toolRequest,
388+
model: 'llama-3.1-8b-instant'
389+
});
390+
391+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
392+
expect(body.tools).toBeUndefined();
393+
expect(body.tool_choice).toBeUndefined();
394+
});
395+
396+
it('should support tool calling on llama-3.3-70b-versatile', async () => {
397+
mockFetch.mockResolvedValueOnce({
398+
ok: true,
399+
json: async () => ({
400+
...toolCallResponse,
401+
model: 'llama-3.3-70b-versatile'
402+
}),
403+
headers: new Headers({ 'content-type': 'application/json' })
404+
});
405+
406+
const response = await provider.generateResponse({
407+
...toolRequest,
408+
model: 'llama-3.3-70b-versatile'
409+
});
410+
411+
expect(response.toolCalls).toHaveLength(1);
412+
413+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
414+
expect(body.tools).toHaveLength(1);
415+
});
416+
});
417+
240418
describe('healthCheck', () => {
241419
it('should return true when API is healthy', async () => {
242420
mockFetch.mockResolvedValueOnce({

src/factory.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,8 +312,8 @@ export class LLMProviderFactory {
312312
return 'cloudflare';
313313
}
314314

315-
// Groq models
316-
if (model.includes('-versatile') || model.includes('-instant')) {
315+
// Groq models (openai/gpt-oss-120b is Groq-hosted, not @cf/ prefixed)
316+
if (model.includes('-versatile') || model.includes('-instant') || model === 'openai/gpt-oss-120b') {
317317
return 'groq';
318318
}
319319

src/index.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,8 @@ export const MODELS = {
332332

333333
// Groq models
334334
GROQ_LLAMA_3_3_70B_VERSATILE: 'llama-3.3-70b-versatile',
335-
GROQ_LLAMA_3_1_8B_INSTANT: 'llama-3.1-8b-instant'
335+
GROQ_LLAMA_3_1_8B_INSTANT: 'llama-3.1-8b-instant',
336+
GROQ_GPT_OSS_120B: 'openai/gpt-oss-120b'
336337
} as const;
337338

338339
/**
@@ -371,7 +372,9 @@ export const MODEL_RECOMMENDATIONS = {
371372
MODELS.GPT_4O,
372373
MODELS.CLAUDE_SONNET_4,
373374
MODELS.CEREBRAS_ZAI_GLM_4_7,
374-
MODELS.CEREBRAS_QWEN_3_235B
375+
MODELS.CEREBRAS_QWEN_3_235B,
376+
MODELS.GROQ_GPT_OSS_120B,
377+
MODELS.GROQ_LLAMA_3_3_70B_VERSATILE
375378
],
376379

377380
// Long context tasks
@@ -407,7 +410,7 @@ export function getRecommendedModel(
407410
|| model.startsWith('zai-glm') || model.startsWith('qwen-3-235b')) && availableProviders.includes('cerebras')) {
408411
return model;
409412
}
410-
if ((model.includes('-versatile') || model.includes('-instant')) && availableProviders.includes('groq')) {
413+
if ((model.includes('-versatile') || model.includes('-instant') || model === 'openai/gpt-oss-120b') && availableProviders.includes('groq')) {
411414
return model;
412415
}
413416
}

0 commit comments

Comments
 (0)