From 43aa78abc5d151aa076228c0a489c0962241b90a Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sun, 7 Jun 2026 13:05:59 +0100 Subject: [PATCH] feat(llm): client interface and fake test double --- internal/llm/client.go | 36 ++++++++++++++++++++++++++++++++++++ internal/llm/fake.go | 39 +++++++++++++++++++++++++++++++++++++++ internal/llm/fake_test.go | 21 +++++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 internal/llm/client.go create mode 100644 internal/llm/fake.go create mode 100644 internal/llm/fake_test.go diff --git a/internal/llm/client.go b/internal/llm/client.go new file mode 100644 index 0000000..5325f0f --- /dev/null +++ b/internal/llm/client.go @@ -0,0 +1,36 @@ +// Package llm defines a provider-agnostic LLM client for vision and audio +// transcription, with concrete HTTP implementations per provider. +package llm + +import "context" + +// Image is a decoded image to send to a vision model. +type Image struct { + MediaType string // e.g. "image/png", "image/jpeg" + Data []byte +} + +// Options are per-call overrides. Zero values mean "use the client default". +type Options struct { + Model string + Language string + Temperature float64 + MaxTokens int +} + +// Client is the abstraction every provider implements and every handler depends on. +type Client interface { + // Vision sends a prompt plus images and returns the model's text answer. + Vision(ctx context.Context, prompt string, images []Image, opts Options) (string, error) + // Transcribe converts audio bytes to text. filename carries the extension + // for providers that infer the audio format from it. Returns ErrUnsupported + // if the provider has no audio capability. + Transcribe(ctx context.Context, audio []byte, filename string, opts Options) (string, error) +} + +// ErrUnsupported is returned by Transcribe on providers without audio support. +type ErrUnsupported struct{ Provider string } + +func (e ErrUnsupported) Error() string { + return "audio transcription unsupported by provider " + e.Provider +} diff --git a/internal/llm/fake.go b/internal/llm/fake.go new file mode 100644 index 0000000..f909832 --- /dev/null +++ b/internal/llm/fake.go @@ -0,0 +1,39 @@ +package llm + +import "context" + +// Fake is a deterministic Client for tests. +type Fake struct { + VisionResp string + TranscribeResp string + VisionErr error + TranscribeErr error + VisionCalls []VisionCall + TranscribeCalls []TranscribeCall +} + +// VisionCall records a Vision invocation. +type VisionCall struct { + Prompt string + Opts Options + Images []Image +} + +// TranscribeCall records a Transcribe invocation. +type TranscribeCall struct { + Filename string + Opts Options + Audio []byte +} + +// Vision implements Client. +func (f *Fake) Vision(_ context.Context, prompt string, images []Image, opts Options) (string, error) { + f.VisionCalls = append(f.VisionCalls, VisionCall{Prompt: prompt, Images: images, Opts: opts}) + return f.VisionResp, f.VisionErr +} + +// Transcribe implements Client. +func (f *Fake) Transcribe(_ context.Context, audio []byte, filename string, opts Options) (string, error) { + f.TranscribeCalls = append(f.TranscribeCalls, TranscribeCall{Audio: audio, Filename: filename, Opts: opts}) + return f.TranscribeResp, f.TranscribeErr +} diff --git a/internal/llm/fake_test.go b/internal/llm/fake_test.go new file mode 100644 index 0000000..a3771e0 --- /dev/null +++ b/internal/llm/fake_test.go @@ -0,0 +1,21 @@ +package llm + +import ( + "context" + "testing" +) + +func TestFakeClient(t *testing.T) { + f := &Fake{VisionResp: "hello", TranscribeResp: "spoken"} + got, err := f.Vision(context.Background(), "p", []Image{{MediaType: "image/png", Data: []byte{1}}}, Options{}) + if err != nil || got != "hello" { + t.Fatalf("vision got %q err %v", got, err) + } + if len(f.VisionCalls) != 1 || f.VisionCalls[0].Prompt != "p" { + t.Fatalf("vision call not recorded: %+v", f.VisionCalls) + } + tr, err := f.Transcribe(context.Background(), []byte{1}, "audio.mp3", Options{Language: "en"}) + if err != nil || tr != "spoken" { + t.Fatalf("transcribe got %q err %v", tr, err) + } +}