mirror of
https://github.com/lukaszraczylo/llm-testbench.git
synced 2026-10-12 00:45:19 +00:00
Adds real OpenAI function-calling support end to end and two test tranches. Client (internal/llm): Request.Tools advertises tools with tool_choice auto; Response.ToolCalls carries the model's calls, decoding each function's JSON-encoded argument string into an object (malformed arguments preserved raw with Decoded=false). Verified live: the gateway round-trips tools and the qwen/deepseek tool-call parsers emit the expected format. Runner: when a test declares Tools, it serializes the model's tool calls into a canonical JSON envelope and scores that, not the free text. Eval (toolcall.go): ToolCalled, NoToolCalled, ToolCallWithArgs (subset match, number/string coercion), ToolSequence, FirstToolIs, ToolNotCalled, ToolArgValuesCover - all parse the envelope, no llm dependency. Tests: 12 function-calling tests (tools/function-calling) covering tool selection, argument extraction, enum constraints, chained and parallel calls, no-tool-needed and missing-param abstention, destructive-tool safety, and name-vs-description traps; 8 ultra-hard single-shot tasks in existing subcategories. Ground-truth self-consistency tests assert every documented answer and ideal tool envelope scores 1.0.
231 lines
7.6 KiB
Go
231 lines
7.6 KiB
Go
// Package runner fans a test catalog out across models with bounded
|
|
// concurrency and collects per-(model,test) results.
|
|
package runner
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
|
"github.com/lukaszraczylo/llm-testbench/internal/llm"
|
|
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
|
)
|
|
|
|
// serializeToolEnvelope renders a response's tool calls into the canonical
|
|
// JSON envelope the eval.ToolCalled family parses: {"tool_calls":[{"name":
|
|
// ...,"arguments":{...}}],"content":...}. A tool call whose arguments the
|
|
// model emitted as malformed JSON serializes with a null arguments object
|
|
// and arguments_malformed=true so a test can still see the call happened.
|
|
func serializeToolEnvelope(resp llm.Response) string {
|
|
type envItem struct {
|
|
Arguments map[string]any `json:"arguments"`
|
|
Name string `json:"name"`
|
|
Malformed bool `json:"arguments_malformed,omitempty"`
|
|
}
|
|
env := struct {
|
|
Content string `json:"content"`
|
|
ToolCalls []envItem `json:"tool_calls"`
|
|
}{Content: resp.Text, ToolCalls: []envItem{}}
|
|
for _, c := range resp.ToolCalls {
|
|
env.ToolCalls = append(env.ToolCalls, envItem{
|
|
Name: c.Name,
|
|
Arguments: c.Arguments,
|
|
Malformed: !c.Decoded,
|
|
})
|
|
}
|
|
b, err := json.Marshal(env)
|
|
if err != nil {
|
|
return `{"tool_calls":[],"content":""}`
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// truncatedDetailPrefix is prepended to Score.Detail when the model's
|
|
// response was cut off by the token budget (llm.FinishReasonLength), so
|
|
// the reason a test scored the way it did is visible without cross-
|
|
// referencing FinishReason separately.
|
|
const truncatedDetailPrefix = "TRUNCATED: "
|
|
|
|
// Result is the outcome of running one test against one model. Attempt is
|
|
// the zero-based index of this sample when Config.Repeat > 1; it is 0 for
|
|
// single-sample runs.
|
|
type Result struct {
|
|
Err error
|
|
Model string
|
|
TestID string
|
|
ResponseText string
|
|
FinishReason string
|
|
Score eval.Score
|
|
Latency time.Duration
|
|
PromptTokens int
|
|
CompletionTokens int
|
|
Attempt int
|
|
}
|
|
|
|
// Truncated reports whether the model's response was cut off by the token
|
|
// budget before it finished (finish_reason=length), rather than completing
|
|
// normally. A truncated response's Score is still whatever the evaluator
|
|
// computed on the partial text; Truncated is the signal that the score may
|
|
// not reflect what the model would have said with a larger budget.
|
|
func (r Result) Truncated() bool {
|
|
return r.FinishReason == llm.FinishReasonLength
|
|
}
|
|
|
|
// TotalTokens returns PromptTokens + CompletionTokens, the total token
|
|
// usage billed for this (model, test) call.
|
|
func (r Result) TotalTokens() int {
|
|
return r.PromptTokens + r.CompletionTokens
|
|
}
|
|
|
|
// Config controls how the Runner fans work out.
|
|
type Config struct {
|
|
Reporter ProgressReporter
|
|
Concurrency int
|
|
Temperature float64
|
|
MaxTokensDefault int
|
|
// Repeat runs each (model, test) combination this many times (minimum
|
|
// 1). Even at temperature 0, reasoning models sample differently per
|
|
// call; repeats expose that instability so a score can be trusted
|
|
// (stable across attempts) or flagged (flaky) instead of silently
|
|
// treated as a capability measurement.
|
|
Repeat int
|
|
}
|
|
|
|
// Runner executes a set of tests against a set of models.
|
|
type Runner struct {
|
|
client llm.Client
|
|
cfg Config
|
|
}
|
|
|
|
// New builds a Runner that issues requests through client.
|
|
func New(client llm.Client, cfg Config) *Runner {
|
|
if cfg.Reporter == nil {
|
|
cfg.Reporter = NoopProgressReporter{}
|
|
}
|
|
return &Runner{client: client, cfg: cfg}
|
|
}
|
|
|
|
// Run executes every combination of models x tests, bounded by
|
|
// cfg.Concurrency in-flight calls, and returns one Result per combination
|
|
// (times Config.Repeat samples each). A per-call failure is captured in
|
|
// Result.Err rather than aborting the run.
|
|
func (r *Runner) Run(ctx context.Context, models []string, tests []testkit.Test) []Result {
|
|
type job struct {
|
|
model string
|
|
test testkit.Test
|
|
attempt int
|
|
}
|
|
|
|
repeat := max(r.cfg.Repeat, 1)
|
|
|
|
jobs := make([]job, 0, len(models)*len(tests)*repeat)
|
|
for _, m := range models {
|
|
for _, t := range tests {
|
|
for a := range repeat {
|
|
jobs = append(jobs, job{model: m, test: t, attempt: a})
|
|
}
|
|
}
|
|
}
|
|
|
|
results := make([]Result, len(jobs))
|
|
|
|
g, gctx := errgroup.WithContext(ctx)
|
|
concurrency := r.cfg.Concurrency
|
|
if concurrency <= 0 {
|
|
concurrency = 1
|
|
}
|
|
g.SetLimit(concurrency)
|
|
|
|
r.cfg.Reporter.ReportStart(len(tests), len(models), concurrency)
|
|
|
|
var completed int32
|
|
// go.mod's go directive is 1.22+, so loop variables are already
|
|
// per-iteration scoped; no manual i, j := i, j shadowing needed (N4).
|
|
for i, j := range jobs {
|
|
g.Go(func() error {
|
|
res := r.runOne(gctx, j.model, j.test)
|
|
res.Attempt = j.attempt
|
|
results[i] = res
|
|
// atomic.AddInt32 makes the completion counter safe under
|
|
// Config.Concurrency-many concurrent goroutines; ReportDone
|
|
// itself must also be concurrency-safe per the ProgressReporter
|
|
// contract.
|
|
done := atomic.AddInt32(&completed, 1)
|
|
r.cfg.Reporter.ReportDone(int(done), len(jobs), res)
|
|
return nil
|
|
})
|
|
}
|
|
// Errors are captured per-result, not surfaced from Run; g.Wait() only
|
|
// ever returns non-nil if a goroutine itself returned an error, which
|
|
// runOne never does.
|
|
_ = g.Wait()
|
|
|
|
return results
|
|
}
|
|
|
|
// runOne executes a single (model, test) combination.
|
|
func (r *Runner) runOne(ctx context.Context, model string, test testkit.Test) Result {
|
|
// cfg.MaxTokensDefault is a floor, not a fallback used only when the
|
|
// test leaves MaxTokens unset: a per-test value only ever raises the
|
|
// budget, it never lowers it below the default. A reasoning model can
|
|
// burn thousands of tokens on reasoning_content before ever writing an
|
|
// answer to content, so a small per-test MaxTokens (sized for the
|
|
// answer alone) previously starved the model into finish_reason=length
|
|
// with an empty answer.
|
|
maxTokens := max(test.MaxTokens, r.cfg.MaxTokensDefault)
|
|
|
|
messages := make([]llm.Message, 0, 2)
|
|
if test.System != "" {
|
|
messages = append(messages, llm.Message{Role: "system", Content: test.System})
|
|
}
|
|
messages = append(messages, llm.Message{Role: "user", Content: test.Prompt})
|
|
|
|
req := llm.Request{
|
|
Model: model,
|
|
Messages: messages,
|
|
Tools: test.Tools,
|
|
MaxTokens: maxTokens,
|
|
Temperature: r.cfg.Temperature,
|
|
}
|
|
|
|
// Wall-clock across all attempts, recorded even on error: an error row
|
|
// with zero latency reads as an instant rejection when it may be three
|
|
// exhausted timeouts (a 651-second generation looked "instant" in the
|
|
// 2026-08-30 run and misdirected the diagnosis).
|
|
callStart := time.Now()
|
|
resp, err := r.client.Complete(ctx, req)
|
|
if err != nil {
|
|
return Result{Model: model, TestID: test.ID, Err: err, Latency: time.Since(callStart)}
|
|
}
|
|
|
|
// A function-calling test is scored on the tool-call envelope, not the
|
|
// free-text answer: serialize the model's tool calls into the canonical
|
|
// JSON the eval.ToolCalled family parses. The envelope is also what gets
|
|
// stored as ResponseText, so the artifact shows exactly what was scored.
|
|
var scored string
|
|
if len(test.Tools) > 0 {
|
|
scored = serializeToolEnvelope(resp)
|
|
} else {
|
|
scored = testkit.Normalize(resp.Text)
|
|
}
|
|
score := test.Eval.Evaluate(ctx, scored)
|
|
if resp.FinishReason == llm.FinishReasonLength {
|
|
score.Detail = truncatedDetailPrefix + score.Detail
|
|
}
|
|
|
|
return Result{
|
|
Model: model,
|
|
TestID: test.ID,
|
|
Score: score,
|
|
ResponseText: scored,
|
|
FinishReason: resp.FinishReason,
|
|
Latency: resp.Latency,
|
|
PromptTokens: resp.PromptTokens,
|
|
CompletionTokens: resp.CompletionTokens,
|
|
}
|
|
}
|