Skip to content

Commit adf0c61

Browse files
Merge pull request #73 from Stackbilt-dev/feat/groq-builtin-tools-response
feat(groq): parse executed_tools → metadata.builtInToolResults (#69 S5)
2 parents 173ed3d + fc4a729 commit adf0c61

4 files changed

Lines changed: 201 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ Groq built-in tools (issue #69), landing across stacked PRs. Additive only.
1616
- **`RESEARCH` `ModelRecommendationUseCase`** — new use case with `scoreUseCase` weights and a `MODEL_RECOMMENDATIONS.RESEARCH` list; honored by `factory.resolveUseCase()` via `metadata.useCase`. Not inferred from request shape (opt-in only).
1717
- **Capability-aware built-in-tools routing**`openai/gpt-oss-120b` is hosted by both Cerebras and Groq; a `builtInTools` request is steered to Groq (the capable host) while plain requests keep the prior default. Resolves the catalog collision via `getProvidersForCatalogModel`.
1818
- **Groq built-in-tools request fork + boundary gating** — Compound systems send tools on `compound_custom.tools.enabled_tools` (identifiers verbatim); `openai/gpt-oss-120b` sends OpenAI-style `tools: [{ type }]` with `web_search``browser_search` translation, merged alongside function tools. Unsupported `(model, tool)` pairs throw `ConfigurationError` naming the capable models.
19+
- **Groq built-in tool result parsing**`message.executed_tools[]` is parsed into `LLMResponse.metadata.builtInToolResults` (`Array<{ type, name?, arguments?, results: [{ title, url, content, score }] }>`). Only executions carrying a non-empty `search_results.results` surface; non-search runs (e.g. `code_interpreter`) are omitted, and the field is absent when no search ran. The model's internal reasoning surfaces on `metadata.reasoning` when present. `GROQ_RESPONSE_SCHEMA` extended with an optional, shallow `executed_tools` entry (validates `type` only — citation sub-fields are intentionally unguarded to avoid false `SchemaDriftError` fallback on a single sampled shape).
1920

2021
### Notes
2122
- Built-in tool surcharges are billed by the provider and are **not** attributed per-call in `TokenUsage`; use `CreditLedger` for accounting.
22-
- Structured result surfacing (`metadata.builtInToolResults`) for the Groq adapter is being wired in a follow-up PR; the request path and gating are complete.
2323

2424
## [1.9.0] — 2026-05-22
2525

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,13 @@ Notes:
426426
- **Capability-aware routing.** `openai/gpt-oss-120b` is hosted by both Cerebras and Groq; only Groq runs built-in tools, so a `builtInTools` request is steered to Groq automatically. Plain requests keep the default routing.
427427
- **Provenance.** The Compound systems are tagged `RESEARCH`-only in the catalog and are not auto-selected for generic use cases — pin the model (or request the `RESEARCH` use case) to use them, since selecting a Compound model can incur per-search surcharges.
428428
- **Cost.** Built-in tool surcharges (e.g. web search ~$5/1k requests) are billed by the provider and are **not** attributed per-call in `TokenUsage`; track them via `CreditLedger` if needed.
429-
- **Citations.** Structured search results surface on `LLMResponse.metadata.builtInToolResults` (`{ type, name?, arguments?, results: [{ title, url, content, score }] }`). Result parsing for the Groq adapter is being wired in a follow-up; the request path and gating described here are live today.
429+
- **Citations.** Structured search results surface on `LLMResponse.metadata.builtInToolResults``Array<{ type, name?, arguments?, results: [{ title, url, content, score }] }>`. Only executions that ran a web search appear (e.g. `code_interpreter` runs, which carry no citations, are omitted); the field is absent when no search ran. Citation sub-fields are passed through as the provider returns them — treat them as best-effort and validate URLs before use.
430+
- **Reasoning.** When the model exposes its internal reasoning (the queries it searched), it surfaces on `LLMResponse.metadata.reasoning` as a string. Absent when the model doesn't emit it.
431+
432+
```typescript
433+
const citations = res.metadata?.builtInToolResults?.[0]?.results ?? [];
434+
// → [{ title, url, content, score }, …]
435+
```
430436

431437
## Prompt Cache Hints
432438

src/__tests__/groq.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,118 @@ describe('GroqProvider', () => {
588588
});
589589
});
590590

591+
describe('built-in tool results (S5)', () => {
592+
// Real wire shape locked from the S0 spike: executed_tools[].search_results
593+
// is an object { results: [...] }, results carry {title,url,content,score}.
594+
const searchResponse = (model: string, message: Record<string, unknown>) => ({
595+
ok: true,
596+
json: async () => ({
597+
id: 'chatcmpl-bi-res',
598+
object: 'chat.completion',
599+
created: 1700000000,
600+
model,
601+
choices: [{ index: 0, message: { role: 'assistant', content: 'answer', ...message }, finish_reason: 'stop' }],
602+
usage: { prompt_tokens: 30, completion_tokens: 40, total_tokens: 70 }
603+
}),
604+
headers: new Headers({ 'content-type': 'application/json' })
605+
});
606+
607+
it('flattens compound executed_tools into metadata.builtInToolResults (all four citation fields)', async () => {
608+
mockFetch.mockResolvedValueOnce(searchResponse('groq/compound', {
609+
reasoning: 'I should search the web.',
610+
executed_tools: [{
611+
index: 0,
612+
type: 'search',
613+
arguments: '{"query":"authoritative sources on X"}',
614+
search_results: {
615+
results: [
616+
{ title: 'Source A', url: 'https://a.example/x', content: 'snippet A', score: 0.91 },
617+
{ title: 'Source B', url: 'https://b.example/x', content: 'snippet B', score: 0.84 },
618+
]
619+
}
620+
}]
621+
}));
622+
623+
const res = await provider.generateResponse({
624+
messages: [{ role: 'user', content: 'Find sources.' }],
625+
model: 'groq/compound',
626+
builtInTools: [{ type: 'web_search' }],
627+
maxTokens: 100,
628+
});
629+
630+
const results = res.metadata?.builtInToolResults as Array<Record<string, unknown>>;
631+
expect(results).toHaveLength(1);
632+
expect(results[0].type).toBe('search');
633+
expect(results[0].arguments).toBe('{"query":"authoritative sources on X"}');
634+
expect(results[0].name).toBeUndefined(); // compound omits name
635+
const citations = results[0].results as Array<Record<string, unknown>>;
636+
expect(citations).toHaveLength(2);
637+
// Direct assertion on the four citation fields (the binding S5 note).
638+
expect(citations[0]).toEqual({ title: 'Source A', url: 'https://a.example/x', content: 'snippet A', score: 0.91 });
639+
// reasoning surfaces too
640+
expect(res.metadata?.reasoning).toBe('I should search the web.');
641+
});
642+
643+
it('keeps only search executions and preserves gpt-oss name/arguments', async () => {
644+
mockFetch.mockResolvedValueOnce(searchResponse('openai/gpt-oss-120b', {
645+
executed_tools: [
646+
{
647+
index: 0,
648+
type: 'browser_search',
649+
name: 'browser.search',
650+
arguments: '{"query":"X"}',
651+
search_results: { results: [{ title: 'T', url: 'https://t.example', content: 'c', score: 0.5 }] }
652+
},
653+
// Non-search execution (no search_results) — dropped by design.
654+
{ index: 1, type: 'browser.open', name: 'browser.open', arguments: '{"id":1}' },
655+
]
656+
}));
657+
658+
const res = await provider.generateResponse({
659+
messages: [{ role: 'user', content: 'Find.' }],
660+
model: 'openai/gpt-oss-120b',
661+
builtInTools: [{ type: 'web_search' }],
662+
maxTokens: 100,
663+
});
664+
665+
const results = res.metadata?.builtInToolResults as Array<Record<string, unknown>>;
666+
expect(results).toHaveLength(1);
667+
expect(results[0].type).toBe('browser_search');
668+
expect(results[0].name).toBe('browser.search');
669+
});
670+
671+
it('omits builtInToolResults when no execution carries results', async () => {
672+
mockFetch.mockResolvedValueOnce(searchResponse('groq/compound', {
673+
executed_tools: [
674+
{ index: 0, type: 'code_interpreter', arguments: '{}', output: '42' },
675+
{ index: 1, type: 'search', search_results: { results: [] } },
676+
]
677+
}));
678+
679+
const res = await provider.generateResponse({
680+
messages: [{ role: 'user', content: 'Compute.' }],
681+
model: 'groq/compound',
682+
builtInTools: [{ type: 'code_interpreter' }],
683+
maxTokens: 100,
684+
});
685+
686+
expect(res.metadata?.builtInToolResults).toBeUndefined();
687+
});
688+
689+
it('omits builtInToolResults entirely for a plain response (no executed_tools)', async () => {
690+
mockFetch.mockResolvedValueOnce(searchResponse('llama-3.3-70b-versatile', {}));
691+
692+
const res = await provider.generateResponse({
693+
messages: [{ role: 'user', content: 'hi' }],
694+
model: 'llama-3.3-70b-versatile',
695+
maxTokens: 100,
696+
});
697+
698+
expect(res.metadata?.builtInToolResults).toBeUndefined();
699+
expect(res.metadata?.reasoning).toBeUndefined();
700+
});
701+
});
702+
591703
describe('healthCheck', () => {
592704
it('should return true when API is healthy', async () => {
593705
mockFetch.mockResolvedValueOnce({

src/providers/groq.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Implementation for Groq fast inference models (OpenAI-compatible API)
44
*/
55

6-
import type { LLMRequest, LLMResponse, GroqConfig, ModelCapabilities, ProviderBalance, ToolCall, TokenUsage, BuiltInTool, BuiltInToolType } from '../types.js';
6+
import type { LLMRequest, LLMResponse, GroqConfig, ModelCapabilities, ProviderBalance, ToolCall, TokenUsage, BuiltInTool, BuiltInToolType, BuiltInToolResult } from '../types.js';
77
import { BaseProvider } from './base.js';
88
import {
99
LLMErrorFactory,
@@ -44,6 +44,25 @@ const GROQ_RESPONSE_SCHEMA: SchemaField[] = [
4444
},
4545
},
4646
},
47+
// Built-in tool executions (issue #69 S5). Validated SHALLOW on purpose:
48+
// only the always-present `type` is checked. `search_results.results`
49+
// sub-fields ({title,url,content,score}) are NOT validated here —
50+
// SchemaDriftError routes through the fallback chain, and the fallback
51+
// host (Cerebras gpt-oss) doesn't run built-in tools, so a false drift
52+
// on a citation sub-field (sampled n=1 in the S0 spike) would silently
53+
// degrade a working search response into a tool-less one. The parser
54+
// soft-degrades instead; citation-field coverage lives in a parser unit
55+
// test (the binding note's accepted alternative to a deep fixture).
56+
{
57+
path: 'message.executed_tools',
58+
type: 'array',
59+
optional: true,
60+
items: {
61+
shape: [
62+
{ path: 'type', type: 'string' },
63+
],
64+
},
65+
},
4766
],
4867
},
4968
},
@@ -106,11 +125,27 @@ interface GroqResponse {
106125
message: {
107126
role: string;
108127
content: string | null;
128+
// The model's internal reasoning (exposes built-in search queries).
129+
// Present on both compound and gpt-oss when built-in tools run.
130+
reasoning?: string;
109131
tool_calls?: Array<{
110132
id: string;
111133
type: 'function';
112134
function: { name: string; arguments: string };
113135
}>;
136+
// Server-side built-in tool executions (issue #69). Open-ended `type`
137+
// (compound: 'search'; gpt-oss: 'browser_search'/'browser.open'/…); only
138+
// search executions carry `search_results.results`. Verified live in S0.
139+
executed_tools?: Array<{
140+
index?: number;
141+
type: string;
142+
name?: string;
143+
arguments?: string;
144+
output?: string;
145+
search_results?: {
146+
results?: Array<{ title: string; url: string; content: string; score: number }>;
147+
};
148+
}>;
114149
};
115150
finish_reason: 'stop' | 'length' | 'content_filter' | 'tool_calls';
116151
}>;
@@ -613,6 +648,8 @@ export class GroqProvider extends BaseProvider {
613648
toolCalls = this.validateToolCalls(raw);
614649
}
615650

651+
const builtInToolResults = this.extractBuiltInToolResults(choice.message.executed_tools);
652+
616653
return {
617654
id: data.id,
618655
message: content,
@@ -625,11 +662,53 @@ export class GroqProvider extends BaseProvider {
625662
toolCalls,
626663
metadata: {
627664
systemFingerprint: data.system_fingerprint,
628-
created: data.created
665+
created: data.created,
666+
// Surface only when present, to keep metadata clean for plain responses.
667+
...(builtInToolResults ? { builtInToolResults } : {}),
668+
...(choice.message.reasoning ? { reasoning: choice.message.reasoning } : {}),
629669
}
630670
};
631671
}
632672

673+
/**
674+
* Map Groq's `message.executed_tools[]` → normalized `BuiltInToolResult[]`
675+
* (issue #69, verified live in S0).
676+
*
677+
* Keeps only executions that carry a non-empty `search_results.results` and
678+
* flattens those into `results`, preserving the per-execution `type` / `name`
679+
* / `arguments`. Non-search executions (e.g. `code_interpreter`) have no
680+
* `search_results` and so drop out by design — that's the locked spec, not a
681+
* bug. Citation sub-fields are mapped as-is: any field the provider omits
682+
* surfaces as `undefined` (soft degrade) rather than throwing, since the
683+
* schema deliberately doesn't guard them (consumers HEAD-probe URLs anyway).
684+
*/
685+
private extractBuiltInToolResults(
686+
executed: GroqResponse['choices'][number]['message']['executed_tools']
687+
): BuiltInToolResult[] | undefined {
688+
if (!executed || executed.length === 0) return undefined;
689+
690+
const out: BuiltInToolResult[] = [];
691+
for (const exec of executed) {
692+
const results = exec.search_results?.results;
693+
if (!Array.isArray(results) || results.length === 0) continue;
694+
695+
const entry: BuiltInToolResult = {
696+
type: exec.type,
697+
results: results.map(r => ({
698+
title: r.title,
699+
url: r.url,
700+
content: r.content,
701+
score: r.score,
702+
})),
703+
};
704+
if (exec.name !== undefined) entry.name = exec.name;
705+
if (exec.arguments !== undefined) entry.arguments = exec.arguments;
706+
out.push(entry);
707+
}
708+
709+
return out.length > 0 ? out : undefined;
710+
}
711+
633712
private getDefaultModel(request: LLMRequest): string {
634713
return getProviderDefaultModel('groq', request);
635714
}

0 commit comments

Comments
 (0)