68 Commits

Author SHA1 Message Date
steve 8d4e99be1a ci: optimize workflow by merging jobs and adding module cache
CI / Build, Test & Lint (push) Successful in 11m31s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 23:28:30 -04:00
steve 834b093282 fix(ci): write correct YAML workflow (was double-encoded as base64)
CI / Lint (push) Successful in 17s
CI / V2 Module (push) Successful in 1m40s
The previous CI fix commits stored the workflow file as a literal base64
string instead of decoded YAML, so Gitea could not parse it as a valid
workflow and no CI runs triggered. This writes the correct YAML content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:24:52 -04:00
steve 74e1170bf3 fix(ci): remove root-module job, scope all jobs to v2/ working directory
The repo has no go.mod at the root — the only module is under v2/.
The root-module job and the root-level go mod tidy in lint both fail with
'go: go.mod file not found'. Drop root-module entirely and ensure both
v2-module and lint use working-directory: v2."
2026-05-24 03:13:52 +00:00
steve 12b229e13a ci: fix workflow to use v2/ module directory 2026-05-24 03:02:42 +00:00
steve 5d4c4f91af docs(v2): update CLAUDE.md for native Ollama provider
CI / Lint (push) Failing after 32s
CI / Root Module (push) Failing after 35s
CI / V2 Module (push) Successful in 1m55s
2026-05-01 18:31:00 +00:00
steve 012c11b775 feat(v2/registry): register ollama-cloud as a first-class provider
Ollama Cloud now appears in Providers() alongside local Ollama, with its
own EnvKey (OLLAMA_API_KEY) and a curated cloud-model list for picker UIs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-01 18:30:40 +00:00
steve 74b3c53f36 feat(v2): add OllamaCloud() constructor for Ollama Cloud
Ollama Cloud (https://ollama.com) requires a Bearer-token API key and
exposes the same /api/chat surface as local Ollama. OllamaCloud(apiKey)
returns a client preconfigured for the cloud endpoint; Ollama() remains
the local-instance constructor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-01 18:30:20 +00:00
steve a3e9982d49 refactor(v2/ollama): drop openaicompat shim, use native provider
The Ollama provider now targets /api/chat directly via the native provider
introduced in the previous commits. Public API is unchanged for callers
that go through llm.Ollama() (and is extended by Task 5's OllamaCloud()
constructor).

DefaultBaseURL was renamed to DefaultLocalBaseURL (without the trailing
/v1 segment used by the OpenAI-compat path). registry.go is updated
correspondingly; no other callers referenced the old name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-01 18:29:59 +00:00
steve f70c7c0842 feat(v2/ollama): implement native Stream() with NDJSON parsing
Reads Ollama's NDJSON stream (one JSON object per line) and emits
provider.StreamEvent values for text, thinking, tool-call start/delta/end,
and a final Done event carrying assembled Response and Usage. Uses
bufio.Scanner with a 4 MiB max-line buffer so multi-KB tool-call deltas
parse cleanly, and accepts tool-call arguments delivered either as
escaped string fragments (delta-style) or a complete JSON object
(one-shot).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-01 18:29:04 +00:00
steve 583f8724b2 feat(v2/ollama): implement native Complete() with tools, vision, thinking
Non-streaming /api/chat support including:
- Vision via images: []base64
- Tool calls on assistant + tool-role response messages
- think field accepting string reasoning levels (or "true"/"false")
- Authorization header when apiKey is non-empty (cloud mode)

Tool-call arguments are passed as JSON objects to the wire and surfaced
as JSON-string Arguments on provider.ToolCall. Tool calls are assigned
synthetic IDs (tc_<index>) when Ollama omits one, so the round-trip
back as an assistant tool_calls + tool-role message remains correlated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-01 18:24:02 +00:00
steve 0e358148eb feat(v2/ollama): scaffold native /api/chat provider
Adds wire types and a Provider struct that will replace the
openaicompat-based Ollama shim with a native /api/chat implementation.
Complete and Stream methods are stubs; subsequent commits implement them.

Adjusts the existing ollama.go to drop the type alias on
openaicompat.Provider (renaming the legacy shim to a temporary internal
helper) so the new native Provider type does not collide. Public New()
still returns the openaicompat-backed provider until Task 4 swaps it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-01 18:22:11 +00:00
steve 5c5d861915 fix(v2): coerce string-encoded numbers/bools in tool arguments
CI / Root Module (push) Failing after 30s
CI / Lint (push) Failing after 3s
CI / V2 Module (push) Successful in 1m54s
LLMs occasionally return numeric or boolean tool-call fields as JSON
strings (e.g. "3" instead of 3, "true" instead of true), which Go's
strict json.Unmarshal rejects. The strict unmarshal stays as the happy
path; on failure we retry with a coercion pass that walks the target
struct (recursing into nested structs, slices, maps, and pointer fields)
and converts strings to the appropriate kind. Returns the original error
if coercion can't recover.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 22:12:56 +00:00
steve cbaf41f50c feat(v2): add ReasoningLevel option; thinking/reasoning across providers
CI / Root Module (push) Failing after 1m30s
CI / Lint (push) Failing after 1m1s
CI / V2 Module (push) Successful in 3m41s
Introduces an opt-in level-based reasoning toggle (low/medium/high) that
each provider translates to its native parameter:

- Anthropic: thinking.budget_tokens (1024/8000/24000), with temperature
  forced to default and MaxTokens auto-grown above the budget.
- OpenAI/xAI/Groq via openaicompat: reasoning_effort string, gated by a
  new Rules.SupportsReasoning predicate so non-reasoning models don't
  receive the parameter. xAI uses Rules.MapReasoningEffort to remap
  "medium" to "high" since its API only accepts low|high.
- Google: thinking_config.thinking_budget + include_thoughts:true.
- DeepSeek: SupportsReasoning=false (reasoner is always-on; the
  reasoning_content trace was already extracted via openaicompat).

Reasoning content is surfaced as Response.Thinking on Complete and as
StreamEventThinking deltas during streaming. Provider-side: extracted
from Anthropic thinking content blocks, Google's part.Thought=true
parts, and the non-standard reasoning_content field that DeepSeek and
Groq emit (parsed out of raw JSON since openai-go doesn't type it).

Public API:
  - llm.ReasoningLevel + ReasoningLow/Medium/High constants
  - llm.WithReasoning(level) request option
  - Model.WithReasoning(level) for baked-in defaults
  - provider.Request.Reasoning, provider.Response.Thinking
  - provider.StreamEventThinking

Tests cover Rules-based gating, MapReasoningEffort, reasoning_content
extraction (Complete + Stream), Anthropic budget mapping, and
temperature suppression when thinking is enabled. Existing behavior is
unchanged when Reasoning is the empty string.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 03:58:42 +00:00
steve 34119e5a00 feat: add DeepSeek, Moonshot, xAI, Groq, Ollama; drop v1; migrate TUI to v2
CI / Root Module (push) Failing after 30s
CI / Lint (push) Failing after 50s
CI / V2 Module (push) Successful in 2m14s
Five OpenAI-compatible providers join the library as first-class constructors
(llm.DeepSeek, llm.Moonshot, llm.XAI, llm.Groq, llm.Ollama). Their wire-level
implementation is shared via a new v2/openaicompat package which is the
extracted guts of the old v2/openai provider; each provider supplies its own
Rules value to declare per-model constraints (e.g., DeepSeek Reasoner rejects
tools and temperature, Moonshot/xAI accept images only on *-vision* models,
Groq rejects audio input). v2/openai itself becomes a thin wrapper that sets
RestrictTemperature for o-series and gpt-5 models.

A new provider registry (v2/registry.go) exposes llm.Providers() and drives
the TUI's provider picker so adding a provider in future is a single-file
change.

The TUI at cmd/llm was migrated from v1 to v2 and moved to v2/cmd/llm. With
nothing else depending on v1, the v1 code at the repo root (all .go files,
schema/, internal/, provider/, root go.mod/go.sum) is deleted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:34:39 +00:00
steve 9b91b2f794 test(v2): end-to-end cache-hint propagation through Chat.Send
CI / Lint (push) Successful in 9m35s
CI / Root Module (push) Successful in 10m54s
CI / V2 Module (push) Successful in 11m15s
Verifies that WithPromptCaching() on a Chat results in CacheHints being
set on the provider.Request that reaches the provider layer, and that
omitting the option leaves CacheHints nil (no behavior change for
existing callers).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 19:40:39 +00:00
steve 34b2e29019 feat(v2/anthropic): apply cache_control markers from CacheHints
buildRequest now tracks a source-index → built-message-index mapping
during the role-merge pass, then uses the mapping to attach
cache_control: {type: ephemeral} markers at the positions indicated by
Request.CacheHints. The last tool, the last system part, and the last
non-system message each get a marker when the corresponding hint is set.

Covers the merge-induced index drift that would otherwise cause the
breakpoint to land on the wrong content block when consecutive same-role
source messages are combined into a single Anthropic message with
multiple content blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 19:33:25 +00:00
steve 4c6dfb9058 test(v2/anthropic): drop placeholder import sentinel from cache_test.go
Removes the blank-assign workaround that was only needed because the
anth import was being kept alive for Task 5's use. Task 5 will bring
the import back when it actually references anth.CacheControlTypeEphemeral.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 19:29:14 +00:00
steve a6b5544674 refactor(v2/anthropic): use MultiSystem for system prompts
Switches buildRequest to emit anthReq.MultiSystem instead of anthReq.System
whenever a system message is present. Upstream's MarshalJSON prefers
MultiSystem when non-empty, so the wire format is unchanged for requests
without cache_control. This refactor is a prerequisite for attaching
cache_control markers to system parts in the next commit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 19:26:55 +00:00
steve 01b18dcf32 test(v2): cover empty-messages and disabled-but-non-nil cacheConfig edges
Adds two boundary tests suggested by code review:
- TestBuildProviderRequest_CachingEnabled_EmptyMessages: verifies
  that caching with an empty message list still emits a CacheHints
  with LastCacheableMessageIndex=-1, not a spurious breakpoint.
- TestBuildProviderRequest_CachingNonNilButDisabled: verifies that
  an explicitly-disabled cacheConfig (non-nil, enabled=false)
  produces nil CacheHints, exercising the &&-guard branch that
  the previous "disabled" test left untested.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 19:24:55 +00:00
steve 4b401fcc0d feat(v2): populate CacheHints on provider.Request when caching enabled
CI / Lint (push) Successful in 9m36s
CI / Root Module (push) Successful in 10m55s
CI / V2 Module (push) Successful in 11m14s
buildProviderRequest now computes cache-breakpoint positions automatically
when the WithPromptCaching() option is set. It places up to 3 hints:
tools, system, and the index of the last non-system message. Providers
that don't support caching (OpenAI, Google) ignore the field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 19:22:00 +00:00
steve c4fe0026a2 feat(v2): add WithPromptCaching() request option
CI / Lint (push) Failing after 2m2s
CI / V2 Module (push) Failing after 2m3s
CI / Root Module (push) Has been cancelled
Introduces an opt-in RequestOption that callers can pass to enable
automatic prompt-caching markers. The option populates a cacheConfig
on requestConfig but has no effect yet — plumbing through to
provider.Request and on to the Anthropic provider lands in subsequent
commits.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 19:17:55 +00:00
steve b4bf73136a feat(v2/provider): add CacheHints to Request for prompt caching
Adds an optional CacheHints field on provider.Request that carries
cache-breakpoint placement directives from the public llm package down
to individual provider implementations. Anthropic will consume these in
a follow-up commit; OpenAI and Google ignore them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 19:14:44 +00:00
Claude bcb91e14b0 Merge pull request 'feat: comprehensive token usage tracking for V2' (#3) from feature/comprehensive-token-usage-tracking into main
CI / Lint (push) Successful in 9m38s
CI / V2 Module (push) Successful in 11m6s
CI / Root Module (push) Successful in 11m53s
2026-03-02 04:35:42 +00:00
steve 5b687839b2 feat: comprehensive token usage tracking for V2
CI / Lint (pull_request) Successful in 10m18s
CI / Root Module (pull_request) Successful in 11m4s
CI / V2 Module (pull_request) Successful in 11m5s
Add provider-specific usage details, fix streaming usage, and return
usage from all high-level APIs (Chat.Send, Generate[T], Agent.Run).

Breaking changes:
- Chat.Send/SendMessage/SendWithImages now return (string, *Usage, error)
- Generate[T]/GenerateWith[T] now return (T, *Usage, error)
- Agent.Run/RunMessages now return (string, *Usage, error)

New features:
- Usage.Details map for provider-specific token breakdowns
  (reasoning, cached, audio, thoughts tokens)
- OpenAI streaming now captures usage via StreamOptions.IncludeUsage
- Google streaming now captures UsageMetadata from final chunk
- UsageTracker.Details() for accumulated detail totals
- ModelPricing and PricingRegistry for cost computation

Closes #2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 04:33:18 +00:00
steve 7e1705c385 feat: add audio input support to v2 providers
CI / Lint (push) Successful in 9m37s
CI / Root Module (push) Successful in 10m53s
CI / V2 Module (push) Successful in 11m9s
Add Audio struct alongside Image for sending audio attachments to
multimodal LLMs. OpenAI uses input_audio content parts (wav/mp3),
Google Gemini uses genai.NewPartFromBytes, and Anthropic skips
audio gracefully since it's not supported.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:00:56 -05:00
steve fc2218b5fe Add comprehensive test suite for sandbox package (78 tests)
CI / Lint (push) Successful in 9m35s
CI / V2 Module (push) Successful in 10m39s
CI / Root Module (push) Successful in 11m2s
Expanded from 22 basic tests to 78 tests covering error injection,
task polling, IP discovery, context cancellation, HTTP error codes,
concurrent access, SSH lifecycle, and request verification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 01:10:59 -05:00
steve 23c9068022 Add sandbox package for isolated Linux containers via Proxmox LXC
CI / V2 Module (push) Successful in 11m46s
CI / Root Module (push) Successful in 11m50s
CI / Lint (push) Successful in 9m28s
Provides a complete lifecycle manager for ephemeral sandbox environments:
- ProxmoxClient: thin REST wrapper for container CRUD, IP discovery, internet toggle
- SSHExecutor: persistent SSH/SFTP for command execution and file transfer
- Manager/Sandbox: high-level orchestrator tying Proxmox + SSH together
- 22 unit tests with mock Proxmox HTTP server
- Proxmox setup & hardening guide (docs/sandbox-setup.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 00:47:45 -05:00
steve 87ec56a2be Add agent sub-package for composable LLM agents
CI / Lint (push) Successful in 9m46s
CI / V2 Module (push) Successful in 12m5s
CI / Root Module (push) Successful in 12m6s
Introduces v2/agent with a minimal API: Agent, New(), Run(), and AsTool().
Agents wrap a model + system prompt + tools. AsTool() turns an agent into
a llm.Tool, enabling parent agents to delegate to sub-agents through the
normal tool-call loop — no channels, pools, or orchestration needed.

Also exports NewClient(provider.Provider) for custom provider integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 23:17:19 -05:00
steve be572a76f4 Add structured output support with Generate[T] and GenerateWith[T]
CI / Lint (push) Successful in 9m35s
CI / V2 Module (push) Successful in 11m43s
CI / Root Module (push) Successful in 11m53s
Generic functions that use the "hidden tool" technique to force models
to return structured JSON matching a Go struct's schema, replacing the
verbose "tool as structured output" pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 22:36:33 -05:00
steve 6a7eeef619 Add comprehensive test suite for v2 module with mock provider
CI / Lint (push) Successful in 9m36s
CI / V2 Module (push) Successful in 11m33s
CI / Root Module (push) Successful in 11m35s
Cover all core library logic (Client, Model, Chat, middleware, streaming,
message conversion, request building) using a configurable mock provider
that avoids real API calls. ~50 tests across 7 files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 22:00:49 -05:00
steve cbe340ced0 Fix corrupted checksum for charmbracelet/bubbles in go.sum
CI / Lint (push) Successful in 9m34s
CI / V2 Module (push) Successful in 11m34s
CI / Root Module (push) Successful in 11m35s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 21:39:32 -05:00
steve 9e288954f2 Add transcription API to v2 module
CI / Lint (push) Failing after 5m0s
CI / Root Module (push) Failing after 5m3s
CI / V2 Module (push) Successful in 10m48s
Migrate speech-to-text transcription types and OpenAI transcriber
implementation from v1. Types are defined in provider/ to avoid
import cycles and re-exported via type aliases from the root package.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 20:24:20 -05:00
steve 9d6d2c61c3 Add Gitea CI workflow for build, test, vet, and lint
CI / Lint (push) Failing after 29s
CI / Root Module (push) Failing after 5m19s
CI / V2 Module (push) Successful in 11m9s
Runs on all pushes and PRs:
- Build, vet, and test both root and v2 modules (with -race)
- Verify go.mod/go.sum tidiness for both modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 20:01:01 -05:00
steve a4cb4baab5 Add go-llm v2: redesigned API for simpler LLM abstraction
v2 is a new Go module (v2/) with a dramatically simpler API:
- Unified Message type (no more Input marker interface)
- Define[T] for ergonomic tool creation with standard context.Context
- Chat session with automatic tool-call loop (agent loop)
- Streaming via pull-based StreamReader
- MCP one-call connect (MCPStdioServer, MCPHTTPServer, MCPSSEServer)
- Middleware support (logging, retry, timeout, usage tracking)
- Decoupled JSON Schema (map[string]any, no provider coupling)
- Sample tools: WebSearch, Browser, Exec, ReadFile, WriteFile, HTTP
- Providers: OpenAI, Anthropic, Google (all with streaming)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 20:00:08 -05:00
steve 85a848d96e Update openaiTranscriber to handle audio file metadata in transcription parameters 2026-01-25 02:38:45 -05:00
steve 8801ce5945 Add OpenAI-based transcriber implementation
- Introduce `openaiTranscriber` for integrating OpenAI's Whisper audio transcription capabilities.
- Define `Transcriber` interface and associated types (`Transcription`, `TranscriptionOptions`, segments, and words).
- Implement transcription logic supporting features like languages, prompts, temperature, and timestamp granularities.
- Add `audioFileToWav` utility using `ffmpeg` for audio file conversion to WAV format.
- Ensure response parsing for structured and verbose JSON outputs.
2026-01-25 01:46:29 -05:00
steve 9c1b4f7e9f Fix checksum typo for github.com/charmbracelet/bubbles in go.sum 2026-01-24 16:59:55 -05:00
steve 2cf75ae07d Add MCP integration with MCPServer for tool-based interactions
- Introduce `MCPServer` to support connecting to MCP servers via stdio, SSE, or HTTP.
- Implement tool fetching, management, and invocation through MCP.
- Add `WithMCPServer` method to `ToolBox` for seamless tool integration.
- Extend schema package to handle raw JSON schemas for MCP tools.
- Update documentation with MCP usage guidelines and examples.
2026-01-24 16:25:28 -05:00
steve 97d54c10ae Implement interactive CLI for LLM providers with chat, tools, and image support
- Add Bubble Tea-based CLI interface for LLM interactions.
- Implement `.env.example` for environment variable setup.
- Add provider, model, and tool selection screens.
- Include support for API key configuration.
- Enable chat interactions with optional image and tool support.
- Introduce core utility functions: image handling, tool execution, chat request management, and response rendering.
- Implement style customization with Lip Gloss.
2026-01-24 15:53:36 -05:00
steve bf7c86ab2a Refactor: modularize and streamline LLM providers and utility functions
- Migrate `compress_image.go` to `internal/imageutil` for better encapsulation.
- Reorganize LLM provider implementations into distinct packages (`google`, `openai`, and `anthropic`).
- Replace `go_llm` package name with `llm`.
- Refactor internal APIs for improved clarity, including renaming `anthropic` to `anthropicImpl` and `google` to `googleImpl`.
- Add helper methods and restructure message handling for better separation of concerns.
2026-01-24 15:40:38 -05:00
steve be99af3597 Update all dependencies and migrate to new Google genai SDK
- Update all Go dependencies to latest versions
- Migrate from github.com/google/generative-ai-go/genai to google.golang.org/genai
- Fix google.go to use the new SDK API (NewPartFromText, NewContentFromParts, etc.)
- Update schema package imports to use the new genai package
- Add CLAUDE.md with README maintenance guideline

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:22:34 -05:00
steve 1927f4d187 woops messed up restriction 2025-08-08 11:07:40 -04:00
steve 5fa7c7e5c7 Restrict temperature override for unsupported models (o* and gpt-5*). 2025-08-08 10:37:09 -04:00
steve 07a04d08a9 Add WithDescription method to Function struct
Extend the `Function` struct with a `WithDescription` method to allow setting descriptions fluently.
2025-07-31 01:51:28 -04:00
Steve Dudenhoeffer 31766134ef bubble up the mime type 2025-07-21 23:14:55 -04:00
Steve Dudenhoeffer e0adc40661 fix junie's bad idea 2025-07-21 22:53:11 -04:00
Steve Dudenhoeffer c73c63a8aa Merge remote-tracking branch 'origin/main' 2025-07-21 21:53:21 -04:00
Steve Dudenhoeffer 101291abd9 handle base64 image resizing for anthropic 2025-07-21 21:53:13 -04:00
steve e9baf7910e Add LLM parsing functionality
Introduce `Providers` struct to handle different language model providers. Implement `Parse` method to extract and validate provider/model from input string, then return a chat completion interface. Add error handling for invalid formats or unknown providers.
2025-05-01 22:11:23 -04:00
steve 39ffb82237 Add image handling for Gemini requests with URL download and base64 support 2025-04-12 15:07:44 -04:00
steve 916f07be18 added anthropic tool support i think 2025-04-12 03:41:48 -04:00
steve 3093b988f8 Refactor toolbox and function handling to support synthetic fields and improve type definitions 2025-04-12 02:20:40 -04:00
steve 2ae583e9f3 Add support for required fields in parameter generation
Previously, required fields were not handled in OpenAI and Google parameter generation. This update adds logic to include a "required" list for both, ensuring mandatory fields are accurately captured in the schema outputs.
2025-04-07 20:33:21 -04:00
steve 5ba0d5df7e instead of having an openai => google translation layer, just add sister functions to the types that construct the google request just like openai's 2025-04-07 01:57:02 -04:00
steve 58552ee226 Fix enum typing 2025-04-06 15:35:05 -04:00
steve 14961bfbc6 Refactor candidate parsing logic in Google adapter, which fixes only one tool call per execution 2025-04-06 14:35:22 -04:00
steve 7c9eb08cb4 Add support for integers and tool configuration in schema handling
This update introduces support for `jsonschema.Integer` types and updates the logic to handle nested items in schemas. Added a new default error log for unknown types using `slog.Error`. Also, integrated tool configuration with a `FunctionCallingConfig` when `dontRequireTool` is false.
2025-04-06 01:23:10 -04:00
steve ff5e4ca7b0 Add support for integers and tool configuration in schema handling
This update introduces support for `jsonschema.Integer` types and updates the logic to handle nested items in schemas. Added a new default error log for unknown types using `slog.Error`. Also, integrated tool configuration with a `FunctionCallingConfig` when `dontRequireTool` is false.
2025-04-04 20:13:46 -04:00
steve 82feb7d8b4 Change function result type from string to any
Updated the return type of functions and related code from `string` to `any` to improve flexibility and support more diverse outputs. Adjusted function implementations, signatures, and handling of results accordingly.
2025-03-25 23:53:09 -04:00
steve 5ba42056ad Add toolbox features for function removal and callback execution
Introduced `WithFunctionRemoved` and `ExecuteCallbacks` methods to enhance `ToolBox` functionality. This allows dynamic function removal and execution of custom callbacks during tool call processing. Also cleaned up logging and improved handling for required tools in `openai.go`.
2025-03-21 11:09:32 -04:00
steve 52533238d3 Add getter methods for response and toolcall in Context
Introduce `Response()` and `ToolCall()` methods to access the respective fields from the `Context` struct. This enhances encapsulation and provides a standardized way to retrieve these values.
2025-03-18 03:45:38 -04:00
steve 88fbf89a63 Fix handling of OpenAI messages with content and multi-content.
Previously, OpenAI messages containing both `Content` and `MultiContent` could cause inconsistent behavior. This update ensures `Content` is converted into a `MultiContent` entry to maintain compatibility.
2025-03-18 01:01:46 -04:00
steve e5a046a70b Handle execution errors by appending them to the result.
Previously, execution errors were only returned in the refusal field. This update appends errors to the result field if present, ensuring they are included in the tool's output. This change improves visibility and clarity for error reporting.
2025-03-17 23:41:48 -04:00
steve 2737a5b2be Refactor response handling for clarity and consistency.
Simplified how responses and tool calls are appended to conversations. Adjusted structure in message formatting to better align with tool call requirements, ensuring consistent data representation.
2025-03-17 00:18:32 -04:00
steve 7f5e34e437 Refactor entire system to be more contextual so that conversation flow can be more easily managed 2025-03-16 22:38:58 -04:00
steve 0d909edd44 Refactor Google LLM adapter to support tool schemas.
Enhanced the `requestToChatHistory` method to include OpenAI schema conversion logic and integrate tools with generative AI schemas. This change improves flexibility when working with different schema types and tool definitions. Adjusted response handling to return a modified model alongside chat sessions and parts.
2025-01-22 23:56:20 -05:00
steve 388a44fa79 Refactor Google LLM API to use chat session interface.
Replace message handling with a chat session model, aligning the logic with new API requirements. Adjust functions to properly build chat history and send messages via chat sessions, improving compatibility and extensibility.
2025-01-22 22:07:20 -05:00
steve e7b7aab62e Refactor Toolbox handling in Google LLM integration.
Implemented a nil check for Toolbox to prevent potential nil pointer dereferences. Cleaned up and reorganized code for better readability and maintainability while keeping placeholder functionality intact.
2025-01-22 19:49:37 -05:00
97 changed files with 16033 additions and 1336 deletions
+39
View File
@@ -0,0 +1,39 @@
name: CI
on:
push:
branches: ["*"]
pull_request:
branches: ["*"]
jobs:
ci:
name: Build, Test & Lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: v2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: v2/go.mod
cache-dependency-path: v2/go.sum
- name: Download dependencies
run: go mod download
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Test
run: go test -race -count=1 ./...
- name: Check v2 module tidiness
run: |
go mod tidy
git diff --exit-code go.mod go.sum
+4
View File
@@ -0,0 +1,4 @@
.claude
.idea
*.exe
.env
+31
View File
@@ -0,0 +1,31 @@
# CLAUDE.md for go-llm
All Go code now lives under `v2/`. The module path is
`gitea.stevedudenhoeffer.com/steve/go-llm/v2`. There is no module at the
repository root anymore; the v1 code at the root was deleted after all
consumers migrated to v2.
See `v2/CLAUDE.md` for build/test commands and per-package guidance.
## CLI
The interactive TUI lives at `v2/cmd/llm`:
```
cd v2 && go run ./cmd/llm
```
It iterates `llm.Providers()` so every registered provider (OpenAI, Anthropic,
Google, DeepSeek, Moonshot, xAI, Groq, Ollama) appears in the picker
automatically. Status is derived from each provider's env var; Ollama shows as
"(local)" because it needs no key.
### Key bindings
- `Enter` — Send message
- `Ctrl+I` — Add image
- `Ctrl+T` — Toggle tools panel
- `Ctrl+P` — Change provider
- `Ctrl+M` — Change model
- `Ctrl+S` — Settings
- `Ctrl+N` — New conversation
- `Esc` — Exit/Cancel
-205
View File
@@ -1,205 +0,0 @@
package go_llm
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"log/slog"
"net/http"
anth "github.com/liushuangls/go-anthropic/v2"
)
type anthropic struct {
key string
model string
}
var _ LLM = anthropic{}
func (a anthropic) ModelVersion(modelVersion string) (ChatCompletion, error) {
a.model = modelVersion
// TODO: model verification?
return a, nil
}
func deferClose(c io.Closer) {
err := c.Close()
if err != nil {
slog.Error("error closing", "error", err)
}
}
func (a anthropic) requestToAnthropicRequest(req Request) anth.MessagesRequest {
res := anth.MessagesRequest{
Model: anth.Model(a.model),
MaxTokens: 1000,
}
msgs := []anth.Message{}
// we gotta convert messages into anthropic messages, however
// anthropic does not have a "system" message type, so we need to
// append it to the res.System field instead
for _, msg := range req.Messages {
if msg.Role == RoleSystem {
if len(res.System) > 0 {
res.System += "\n"
}
res.System += msg.Text
} else {
role := anth.RoleUser
if msg.Role == RoleAssistant {
role = anth.RoleAssistant
}
m := anth.Message{
Role: role,
Content: []anth.MessageContent{},
}
if msg.Text != "" {
m.Content = append(m.Content, anth.MessageContent{
Type: anth.MessagesContentTypeText,
Text: &msg.Text,
})
}
for _, img := range msg.Images {
// anthropic doesn't allow the assistant to send images, so we need to say it's from the user
if m.Role == anth.RoleAssistant {
m.Role = anth.RoleUser
}
if img.Base64 != "" {
m.Content = append(m.Content, anth.NewImageMessageContent(
anth.NewMessageContentSource(
anth.MessagesContentSourceTypeBase64,
img.ContentType,
img.Base64,
)))
} else if img.Url != "" {
// download the image
cl, err := http.NewRequest(http.MethodGet, img.Url, nil)
if err != nil {
log.Println("failed to create request", err)
continue
}
resp, err := http.DefaultClient.Do(cl)
if err != nil {
log.Println("failed to download image", err)
continue
}
defer deferClose(resp.Body)
img.ContentType = resp.Header.Get("Content-Type")
// read the image
b, err := io.ReadAll(resp.Body)
if err != nil {
log.Println("failed to read image", err)
continue
}
// base64 encode the image
img.Base64 = string(b)
m.Content = append(m.Content, anth.NewImageMessageContent(
anth.NewMessageContentSource(
anth.MessagesContentSourceTypeBase64,
img.ContentType,
img.Base64,
)))
}
}
// if this has the same role as the previous message, we can append it to the previous message
// as anthropic expects alternating assistant and user roles
if len(msgs) > 0 && msgs[len(msgs)-1].Role == role {
m2 := &msgs[len(msgs)-1]
m2.Content = append(m2.Content, m.Content...)
} else {
msgs = append(msgs, m)
}
}
}
if req.Toolbox != nil {
for _, tool := range req.Toolbox.funcs {
res.Tools = append(res.Tools, anth.ToolDefinition{
Name: tool.Name,
Description: tool.Description,
InputSchema: tool.Parameters,
})
}
}
res.Messages = msgs
if req.Temperature != nil {
res.Temperature = req.Temperature
}
log.Println("llm request to anthropic request", res)
return res
}
func (a anthropic) responseToLLMResponse(in anth.MessagesResponse) Response {
res := Response{}
for _, msg := range in.Content {
choice := ResponseChoice{}
switch msg.Type {
case anth.MessagesContentTypeText:
if msg.Text != nil {
choice.Content = *msg.Text
}
case anth.MessagesContentTypeToolUse:
if msg.MessageContentToolUse != nil {
b, e := json.Marshal(msg.MessageContentToolUse.Input)
if e != nil {
log.Println("failed to marshal input", e)
} else {
choice.Calls = append(choice.Calls, ToolCall{
ID: msg.MessageContentToolUse.ID,
FunctionCall: FunctionCall{
Name: msg.MessageContentToolUse.Name,
Arguments: string(b),
},
})
}
}
}
res.Choices = append(res.Choices, choice)
}
log.Println("anthropic response to llm response", res)
return res
}
func (a anthropic) ChatComplete(ctx context.Context, req Request) (Response, error) {
cl := anth.NewClient(a.key)
res, err := cl.CreateMessages(ctx, a.requestToAnthropicRequest(req))
if err != nil {
return Response{}, fmt.Errorf("failed to chat complete: %w", err)
}
return a.responseToLLMResponse(res), nil
}
+575
View File
@@ -0,0 +1,575 @@
# Sandbox Setup & Hardening Guide
Complete guide for setting up a Proxmox VE host to run isolated LXC sandbox containers for the go-llm sandbox package.
## Table of Contents
1. [Prerequisites](#1-prerequisites)
2. [Proxmox Host Preparation](#2-proxmox-host-preparation)
3. [Network Setup](#3-network-setup)
4. [LXC Template Creation](#4-lxc-template-creation)
5. [SSH Key Setup](#5-ssh-key-setup)
6. [Configuration](#6-configuration)
7. [Hardening Checklist](#7-hardening-checklist)
8. [Monitoring & Maintenance](#8-monitoring--maintenance)
9. [Troubleshooting](#9-troubleshooting)
---
## 1. Prerequisites
### Hardware/VM Requirements
| Resource | Minimum | Recommended |
|----------|---------|-------------|
| CPU | 4 cores | 8+ cores |
| RAM | 8 GB | 16+ GB |
| Storage | 100 GB SSD | 250+ GB SSD |
| Network | 1 NIC | 2 NICs (mgmt + sandbox) |
### Software
- Proxmox VE 8.x ([installation guide](https://pve.proxmox.com/wiki/Installation))
- During install, configure the management interface on `vmbr0`
---
## 2. Proxmox Host Preparation
### Create Resource Pool
Scope sandbox containers to a dedicated resource pool to limit API token access:
```bash
pvesh create /pools --poolid sandbox-pool
```
### Create API User and Token
```bash
# Create dedicated user
pveum useradd mort-sandbox@pve
# Create role with minimum required permissions
pveum roleadd SandboxAdmin -privs "VM.Allocate,VM.Clone,VM.Audit,VM.PowerMgmt,VM.Console,Datastore.AllocateSpace,Datastore.Audit"
# Grant role on the sandbox pool only
pveum aclmod /pool/sandbox-pool -user mort-sandbox@pve -role SandboxAdmin
# Grant access to the template storage
pveum aclmod /storage/local -user mort-sandbox@pve -role PVEDatastoreUser
# Create API token (privsep=0 means token inherits user's permissions)
pveum user token add mort-sandbox@pve sandbox-token --privsep=0
```
Save the output — it contains the token secret:
```
┌──────────┬──────────────────────────────────────────┐
│ key │ value │
╞══════════╪══════════════════════════════════════════╡
│ full-tokenid │ mort-sandbox@pve!sandbox-token │
│ value │ xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx │
└──────────┴──────────────────────────────────────────┘
```
Store the secret securely (environment variable, secret manager, etc.). Never commit it to source control.
---
## 3. Network Setup
### 3.1 Create Isolated Bridge
Add to `/etc/network/interfaces` on the Proxmox host:
```
auto vmbr1
iface vmbr1 inet static
address 10.99.0.1/16
bridge-ports none
bridge-stp off
bridge-fd 0
post-up echo 1 > /proc/sys/net/ipv4/ip_forward
# NAT for optional internet access (controlled per-container by nftables)
post-up nft add table nat 2>/dev/null; true
post-up nft add chain nat postrouting { type nat hook postrouting priority 100 \; } 2>/dev/null; true
post-up nft add rule nat postrouting oifname "vmbr0" ip saddr 10.99.0.0/16 masquerade 2>/dev/null; true
```
Apply the configuration:
```bash
ifreload -a
```
### 3.2 Install and Configure DHCP
```bash
apt-get install -y dnsmasq
```
Create `/etc/dnsmasq.d/sandbox.conf`:
```
interface=vmbr1
bind-interfaces
dhcp-range=10.99.1.1,10.99.254.254,255.255.0.0,1h
dhcp-option=option:router,10.99.0.1
dhcp-option=option:dns-server,1.1.1.1,8.8.8.8
```
Restart dnsmasq:
```bash
systemctl restart dnsmasq
systemctl enable dnsmasq
```
### 3.3 Configure nftables Firewall
Create `/etc/nftables.conf`:
```nft
#!/usr/sbin/nft -f
flush ruleset
table inet sandbox {
# Dynamic set of container IPs allowed internet access.
# Populated/cleared by the sandbox manager via the Proxmox API.
set internet_allowed {
type ipv4_addr
}
chain forward {
type filter hook forward priority 0; policy drop;
# Allow established/related connections
ct state established,related accept
# Allow inter-bridge traffic (host ↔ containers via vmbr1)
iifname "vmbr1" oifname "vmbr1" accept
# Allow DNS for all containers (needed for apt)
ip saddr 10.99.0.0/16 udp dport 53 accept
ip saddr 10.99.0.0/16 tcp dport 53 accept
# Allow HTTP/HTTPS only for containers in the internet_allowed set
ip saddr @internet_allowed tcp dport { 80, 443 } accept
# Rate limit: max 50 new connections per second per container
ip saddr 10.99.0.0/16 ct state new limit rate over 50/second drop
# Block everything else from containers
ip saddr 10.99.0.0/16 drop
# Allow host → containers (for SSH from the application)
ip daddr 10.99.0.0/16 accept
}
chain input {
type filter hook input priority 0; policy accept;
# Block containers from accessing Proxmox management ports
# (only SSH is allowed for the sandbox manager)
iifname "vmbr1" ip daddr 10.99.0.1 tcp dport != 22 drop
}
}
# NAT table for optional internet access
table nat {
chain postrouting {
type nat hook postrouting priority 100;
oifname "vmbr0" ip saddr 10.99.0.0/16 masquerade
}
}
```
Apply and persist:
```bash
nft -f /etc/nftables.conf
systemctl enable nftables
```
Verify:
```bash
nft list ruleset
```
### 3.4 Test Network Isolation
From a test container on `vmbr1`:
```bash
# Should work: DNS resolution
dig google.com
# Should be blocked: HTTP (not in internet_allowed set)
curl -s --connect-timeout 5 https://google.com && echo "FAIL: should be blocked" || echo "OK: blocked"
# Should be blocked: access to LAN
ping -c 1 -W 2 192.168.1.1 && echo "FAIL: LAN reachable" || echo "OK: LAN blocked"
# Should be blocked: access to Proxmox management
curl -s --connect-timeout 5 https://10.99.0.1:8006 && echo "FAIL: Proxmox reachable" || echo "OK: Proxmox blocked"
```
---
## 4. LXC Template Creation
### 4.1 Download Base Image
```bash
pveam update
pveam download local ubuntu-24.04-standard_24.04-1_amd64.tar.zst
```
### 4.2 Create Template Container
```bash
pct create 9000 local:vztmpl/ubuntu-24.04-standard_24.04-1_amd64.tar.zst \
--hostname sandbox-template \
--memory 1024 \
--swap 0 \
--cores 1 \
--rootfs local-lvm:8 \
--net0 name=eth0,bridge=vmbr1,ip=dhcp \
--unprivileged 1 \
--features nesting=0 \
--ostype ubuntu \
--ssh-public-keys /root/.ssh/mort_sandbox.pub \
--pool sandbox-pool \
--start 0
```
### 4.3 Install Base Packages
```bash
pct start 9000
pct exec 9000 -- bash -c '
apt-get update && apt-get install -y --no-install-recommends \
build-essential \
python3 python3-pip python3-venv \
nodejs npm \
git curl wget jq \
vim nano \
htop tree \
ca-certificates \
openssh-server \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
'
```
### 4.4 Create Sandbox User
```bash
pct exec 9000 -- bash -c '
# Create unprivileged sandbox user with sudo
useradd -m -s /bin/bash -G sudo sandbox
echo "sandbox ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/sandbox
# Set up SSH access
mkdir -p /home/sandbox/.ssh
cp /root/.ssh/authorized_keys /home/sandbox/.ssh/
chown -R sandbox:sandbox /home/sandbox/.ssh
chmod 700 /home/sandbox/.ssh
chmod 600 /home/sandbox/.ssh/authorized_keys
# Create uploads directory
mkdir -p /home/sandbox/uploads
chown sandbox:sandbox /home/sandbox/uploads
# Enable SSH
systemctl enable ssh
'
```
### 4.5 Security Hardening
```bash
pct exec 9000 -- bash -c '
# Process limits (prevent fork bombs)
echo "* soft nproc 256" >> /etc/security/limits.conf
echo "* hard nproc 512" >> /etc/security/limits.conf
# Disable core dumps
echo "* hard core 0" >> /etc/security/limits.conf
# Disable unnecessary services
systemctl disable systemd-resolved 2>/dev/null || true
systemctl disable snapd 2>/dev/null || true
'
```
### 4.6 Convert to Template
```bash
pct stop 9000
pct template 9000
```
### 4.7 Verify Template
Clone and test manually:
```bash
pct clone 9000 9999 --hostname test-sandbox --full
pct start 9999
# Wait for DHCP, then SSH in
ssh sandbox@<container-ip>
# Run some commands, verify packages installed
sudo apt-get update
python3 --version
node --version
# Clean up
exit
pct stop 9999
pct destroy 9999
```
---
## 5. SSH Key Setup
### 5.1 Generate Key Pair
```bash
ssh-keygen -t ed25519 -f /etc/mort/sandbox_key -N "" -C "mort-sandbox"
```
### 5.2 Install Public Key in Template
This was done in step 4.2 with `--ssh-public-keys`. If you need to update it:
```bash
pct start 9000 # Only if template — you'll need to untemplate first
# Copy key
cat /etc/mort/sandbox_key.pub | pct exec 9000 -- tee /home/sandbox/.ssh/authorized_keys
pct exec 9000 -- chown sandbox:sandbox /home/sandbox/.ssh/authorized_keys
pct exec 9000 -- chmod 600 /home/sandbox/.ssh/authorized_keys
pct stop 9000
pct template 9000
```
### 5.3 Set Permissions
```bash
chmod 600 /etc/mort/sandbox_key
chmod 644 /etc/mort/sandbox_key.pub
# If running as a specific user:
chown mort:mort /etc/mort/sandbox_key /etc/mort/sandbox_key.pub
```
---
## 6. Configuration
### Go Configuration
```go
signer, _ := sandbox.LoadSSHKey("/etc/mort/sandbox_key")
mgr, _ := sandbox.NewManager(sandbox.Config{
Proxmox: sandbox.ProxmoxConfig{
BaseURL: "https://proxmox.local:8006",
TokenID: "mort-sandbox@pve!sandbox-token",
Secret: os.Getenv("SANDBOX_PROXMOX_SECRET"),
Node: "pve",
TemplateID: 9000,
Pool: "sandbox-pool",
Bridge: "vmbr1",
InsecureSkipVerify: true, // Only for self-signed certs
},
SSH: sandbox.SSHConfig{
Signer: signer,
User: "sandbox", // default
ConnectTimeout: 10 * time.Second, // default
CommandTimeout: 60 * time.Second, // default
},
Defaults: sandbox.ContainerConfig{
CPUs: 1,
MemoryMB: 1024,
DiskGB: 8,
},
})
```
### Environment Variables
| Variable | Description |
|----------|-------------|
| `SANDBOX_PROXMOX_SECRET` | Proxmox API token secret |
| `SANDBOX_SSH_KEY_PATH` | Path to SSH private key (alternative to config) |
---
## 7. Hardening Checklist
Run through this checklist after setup:
### Container Isolation
- [ ] Containers are unprivileged (verify UID mapping in `/etc/pve/lxc/<id>.conf`)
- [ ] Nesting is disabled (`features: nesting=0`)
- [ ] Swap is disabled on containers (`swap: 0`)
- [ ] Resource pool scoping: API token can only touch `sandbox-pool`
### Network Isolation
- [ ] `vmbr1` has no physical ports (`bridge-ports none`)
- [ ] nftables rules loaded: `nft list ruleset` shows sandbox table
- [ ] nftables persists across reboots: `systemctl is-enabled nftables`
- [ ] Default-deny outbound for containers
- [ ] DNS (port 53) allowed for all containers
- [ ] HTTP/HTTPS only for containers in `internet_allowed` set
- [ ] Rate limiting active (50 conn/sec)
- [ ] Containers cannot reach Proxmox management (port 8006 blocked)
### Security Profiles
- [ ] AppArmor profile active: `lxc-container-default-cgns`
- [ ] Process limits in `/etc/security/limits.conf` (nproc 256/512)
- [ ] Core dumps disabled
- [ ] Capability drops verified in container config
### Functional Tests
- [ ] **Fork bomb test**: run `:(){ :|:& };:` in container → PID limit fires, container survives
- [ ] **OOM test**: allocate >1GB memory → container OOM-killed, host unaffected
- [ ] **Network scan test**: `nmap` from container → blocked by nftables
- [ ] **Container escape test**: attempt to mount host filesystem → denied
- [ ] **LAN access test**: ping LAN hosts → blocked
- [ ] **Cross-container test**: ping other sandbox containers → blocked
- [ ] **Internet access test**: HTTP without being in `internet_allowed` → blocked
- [ ] **Internet access test**: add to `internet_allowed` → HTTP works
- [ ] **Cleanup test**: destroy container → verify no orphan volumes
### Operational Tests
- [ ] Clone template → container starts → SSH connects → commands work
- [ ] File upload/download via SFTP works
- [ ] Container destroy removes all resources
- [ ] Orphan cleanup: kill application mid-session, restart, verify cleanup
---
## 8. Monitoring & Maintenance
### Log Rotation
Sandbox session logs should be rotated to prevent disk exhaustion. If using slog to a file:
```
# /etc/logrotate.d/sandbox
/var/log/sandbox/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
}
```
### Storage Cleanup
Verify destroyed containers don't leave orphan volumes:
```bash
# List all LVM volumes in the sandbox storage
lvs | grep sandbox
# Compare with running containers
pct list | grep sandbox
```
### Template Updates
Periodically update the template with latest packages:
```bash
# Un-template (creates a regular container from template)
# Note: you can't un-template directly; clone then replace
pct clone 9000 9001 --hostname template-update --full
pct start 9001
pct exec 9001 -- bash -c 'apt-get update && apt-get upgrade -y && apt-get clean'
pct stop 9001
# Destroy old template and create new one
pct destroy 9000
# Rename 9001 → 9000 (or update your config to use the new ID)
pct template 9001
```
### Proxmox Host Updates
```bash
apt-get update && apt-get dist-upgrade -y
# Reboot if kernel was updated
# Verify nftables rules are still loaded after reboot
nft list ruleset
```
---
## 9. Troubleshooting
### Container won't start
```bash
# Check task log
pct start <id>
# If error, check:
journalctl -u pve-container@<id> -n 50
# Common issues:
# - Storage full: check `df -h` and `lvs`
# - UID mapping issues: verify /etc/subuid and /etc/subgid
```
### SSH connection refused
```bash
# Verify container is running
pct status <id>
# Check if SSH is running inside container
pct exec <id> -- systemctl status ssh
# Verify IP assignment
pct exec <id> -- ip addr show eth0
# Check DHCP leases
cat /var/lib/misc/dnsmasq.leases
```
### Container has no internet (when it should)
```bash
# Verify container IP is in the internet_allowed set
nft list set inet sandbox internet_allowed
# Manually add for testing
nft add element inet sandbox internet_allowed { 10.99.1.5 }
# Verify NAT is working
nft list table nat
# Check if IP forwarding is enabled
cat /proc/sys/net/ipv4/ip_forward # Should be 1
```
### nftables rules lost after reboot
```bash
# Verify nftables is enabled
systemctl is-enabled nftables
# If rules are missing, reload
nft -f /etc/nftables.conf
# Make sure the config file is correct
nft -c -f /etc/nftables.conf # Check syntax without applying
```
### Orphaned containers
```bash
# List all containers in the sandbox pool
pvesh get /pools/sandbox-pool --output-format json | jq '.members[] | select(.type == "lxc")'
# Destroy orphans manually
pct stop <id> && pct destroy <id> --force --purge
```
-21
View File
@@ -1,21 +0,0 @@
package go_llm
import "fmt"
// Error is essentially just an error, but it is used to differentiate between a normal error and a fatal error.
type Error struct {
error
Source error
Parameter error
}
func newError(parent error, err error) Error {
e := fmt.Errorf("%w: %w", parent, err)
return Error{
error: e,
Source: parent,
Parameter: err,
}
}
-92
View File
@@ -1,92 +0,0 @@
package go_llm
import (
"context"
"encoding/json"
"fmt"
"gitea.stevedudenhoeffer.com/steve/go-llm/schema"
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
"reflect"
"time"
)
type Function struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Strict bool `json:"strict,omitempty"`
Parameters schema.Type `json:"parameters"`
Forced bool `json:"forced,omitempty"`
// Timeout is the maximum time to wait for the function to complete
Timeout time.Duration `json:"-"`
// fn is the function to call, only set if this is constructed with NewFunction
fn reflect.Value
paramType reflect.Type
// definition is a cache of the openaiImpl jsonschema definition
definition *jsonschema.Definition
}
func (f *Function) Execute(ctx context.Context, input string) (string, error) {
if !f.fn.IsValid() {
return "", fmt.Errorf("function %s is not implemented", f.Name)
}
// first, we need to parse the input into the struct
p := reflect.New(f.paramType)
fmt.Println("Function.Execute", f.Name, "input:", input)
//m := map[string]any{}
err := json.Unmarshal([]byte(input), p.Interface())
if err != nil {
return "", fmt.Errorf("failed to unmarshal input: %w (input: %s)", err, input)
}
// now we can call the function
exec := func(ctx context.Context) (string, error) {
out := f.fn.Call([]reflect.Value{reflect.ValueOf(ctx), p.Elem()})
if len(out) != 2 {
return "", fmt.Errorf("function %s must return two values, got %d", f.Name, len(out))
}
if out[1].IsNil() {
return out[0].String(), nil
}
return "", out[1].Interface().(error)
}
var cancel context.CancelFunc
if f.Timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, f.Timeout)
defer cancel()
}
return exec(ctx)
}
func (f *Function) toOpenAIFunction() *openai.FunctionDefinition {
return &openai.FunctionDefinition{
Name: f.Name,
Description: f.Description,
Strict: f.Strict,
Parameters: f.Parameters,
}
}
func (f *Function) toOpenAIDefinition() jsonschema.Definition {
if f.definition == nil {
def := f.Parameters.Definition()
f.definition = &def
}
return *f.definition
}
type FunctionCall struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
}
-35
View File
@@ -1,35 +0,0 @@
package go_llm
import (
"context"
"gitea.stevedudenhoeffer.com/steve/go-llm/schema"
"reflect"
)
// Parse takes a function pointer and returns a function object.
// fn must be a pointer to a function that takes a context.Context as its first argument, and then a struct that contains
// the parameters for the function. The struct must contain only the types: string, int, float64, bool, and pointers to
// those types.
// The struct parameters can have the following tags:
// - Description: a string that describes the parameter, passed to openaiImpl to tell it what the parameter is for
func NewFunction[T any](name string, description string, fn func(context.Context, T) (string, error)) *Function {
var o T
res := Function{
Name: name,
Description: description,
Parameters: schema.GetType(o),
fn: reflect.ValueOf(fn),
paramType: reflect.TypeOf(o),
}
if res.fn.Kind() != reflect.Func {
panic("fn must be a function")
}
if res.paramType.Kind() != reflect.Struct {
panic("function parameter must be a struct")
}
return &res
}
-44
View File
@@ -1,44 +0,0 @@
module gitea.stevedudenhoeffer.com/steve/go-llm
go 1.23.1
require (
github.com/google/generative-ai-go v0.19.0
github.com/liushuangls/go-anthropic/v2 v2.13.0
github.com/sashabaranov/go-openai v1.36.1
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67
google.golang.org/api v0.214.0
)
require (
cloud.google.com/go v0.117.0 // indirect
cloud.google.com/go/ai v0.9.0 // indirect
cloud.google.com/go/auth v0.13.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
cloud.google.com/go/longrunning v0.6.3 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
go.opentelemetry.io/otel v1.33.0 // indirect
go.opentelemetry.io/otel/metric v1.33.0 // indirect
go.opentelemetry.io/otel/trace v1.33.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.8.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241223144023-3abc09e42ca8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
google.golang.org/grpc v1.69.2 // indirect
google.golang.org/protobuf v1.36.1 // indirect
)
-89
View File
@@ -1,89 +0,0 @@
cloud.google.com/go v0.117.0 h1:Z5TNFfQxj7WG2FgOGX1ekC5RiXrYgms6QscOm32M/4s=
cloud.google.com/go v0.117.0/go.mod h1:ZbwhVTb1DBGt2Iwb3tNO6SEK4q+cplHZmLWH+DelYYc=
cloud.google.com/go/ai v0.9.0 h1:r1Ig8O8+Qr3Ia3WfoO+gokD0fxB2Rk4quppuKjmGMsY=
cloud.google.com/go/ai v0.9.0/go.mod h1:28bKM/oxmRgxmRgI1GLumFv+NSkt+DscAg/gF+54zzY=
cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/longrunning v0.6.3 h1:A2q2vuyXysRcwzqDpMMLSI6mb6o39miS52UEG/Rd2ng=
cloud.google.com/go/longrunning v0.6.3/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg=
github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/liushuangls/go-anthropic/v2 v2.13.0 h1:f7KJ54IHxIpHPPhrCzs3SrdP2PfErXiJcJn7DUVstSA=
github.com/liushuangls/go-anthropic/v2 v2.13.0/go.mod h1:5ZwRLF5TQ+y5s/MC9Z1IJYx9WUFgQCKfqFM2xreIQLk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sashabaranov/go-openai v1.36.0 h1:fcSrn8uGuorzPWCBp8L0aCR95Zjb/Dd+ZSML0YZy9EI=
github.com/sashabaranov/go-openai v1.36.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sashabaranov/go-openai v1.36.1 h1:EVfRXwIlW2rUzpx6vR+aeIKCK/xylSrVYAx1TMTSX3g=
github.com/sashabaranov/go-openai v1.36.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 h1:PS8wXpbyaDJQ2VDHHncMe9Vct0Zn1fEjpsjrLxGJoSc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0/go.mod h1:HDBUsEjOuRC0EzKZ1bSaRGZWUBAzo+MhAcUUORSr4D0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk=
go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0=
go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc=
go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo=
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/api v0.214.0 h1:h2Gkq07OYi6kusGOaT/9rnNljuXmqPnaig7WGPmKbwA=
google.golang.org/api v0.214.0/go.mod h1:bYPpLG8AyeMWwDU6NXoB00xC0DFkikVvd5MfwoxjLqE=
google.golang.org/genproto/googleapis/api v0.0.0-20241223144023-3abc09e42ca8 h1:st3LcW/BPi75W4q1jJTEor/QWwbNlPlDG0JTn6XhZu0=
google.golang.org/genproto/googleapis/api v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:klhJGKFyG8Tn50enBn7gizg4nXGXJ+jqEREdCWaPcV4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA=
google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU=
google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-113
View File
@@ -1,113 +0,0 @@
package go_llm
import (
"context"
"encoding/json"
"fmt"
"github.com/google/generative-ai-go/genai"
"google.golang.org/api/option"
)
type google struct {
key string
model string
}
func (g google) ModelVersion(modelVersion string) (ChatCompletion, error) {
g.model = modelVersion
return g, nil
}
func (g google) requestToGoogleRequest(in Request, model *genai.GenerativeModel) []genai.Part {
if in.Temperature != nil {
model.GenerationConfig.Temperature = in.Temperature
}
res := []genai.Part{}
for _, c := range in.Messages {
res = append(res, genai.Text(c.Text))
}
for _, tool := range in.Toolbox.funcs {
panic("google ToolBox is todo" + tool.Name)
/*
t := genai.Tool{}
t.FunctionDeclarations = append(t.FunctionDeclarations, &genai.FunctionDeclaration{
Name: tool.Name,
Description: tool.Description,
Parameters: nil, //tool.Parameters,
})
*/
}
return res
}
func (g google) responseToLLMResponse(in *genai.GenerateContentResponse) (Response, error) {
res := Response{}
for _, c := range in.Candidates {
if c.Content != nil {
for _, p := range c.Content.Parts {
switch p.(type) {
case genai.Text:
res.Choices = append(res.Choices, ResponseChoice{
Content: string(p.(genai.Text)),
})
case genai.FunctionCall:
v := p.(genai.FunctionCall)
choice := ResponseChoice{}
choice.Content = v.Name
b, e := json.Marshal(v.Args)
if e != nil {
return Response{}, fmt.Errorf("error marshalling args: %w", e)
}
call := ToolCall{
ID: v.Name,
FunctionCall: FunctionCall{
Name: v.Name,
Arguments: string(b),
},
}
choice.Calls = append(choice.Calls, call)
res.Choices = append(res.Choices, choice)
default:
return Response{}, fmt.Errorf("unknown part type: %T", p)
}
}
}
}
return res, nil
}
func (g google) ChatComplete(ctx context.Context, req Request) (Response, error) {
cl, err := genai.NewClient(ctx, option.WithAPIKey(g.key))
if err != nil {
return Response{}, fmt.Errorf("error creating genai client: %w", err)
}
model := cl.GenerativeModel(g.model)
parts := g.requestToGoogleRequest(req, model)
resp, err := model.GenerateContent(ctx, parts...)
if err != nil {
return Response{}, fmt.Errorf("error generating content: %w", err)
}
return g.responseToLLMResponse(resp)
}
-69
View File
@@ -1,69 +0,0 @@
package go_llm
import (
"context"
)
type Role string
const (
RoleSystem Role = "system"
RoleUser Role = "user"
RoleAssistant Role = "assistant"
)
type Image struct {
Base64 string
ContentType string
Url string
}
type Message struct {
Role Role
Name string
Text string
Images []Image
}
type Request struct {
Messages []Message
Toolbox *ToolBox
Temperature *float32
}
type ToolCall struct {
ID string
FunctionCall FunctionCall
}
type ResponseChoice struct {
Index int
Role Role
Content string
Refusal string
Name string
Calls []ToolCall
}
type Response struct {
Choices []ResponseChoice
}
type ChatCompletion interface {
ChatComplete(ctx context.Context, req Request) (Response, error)
}
type LLM interface {
ModelVersion(modelVersion string) (ChatCompletion, error)
}
func OpenAI(key string) LLM {
return openaiImpl{key: key}
}
func Anthropic(key string) LLM {
return anthropic{key: key}
}
func Google(key string) LLM {
return google{key: key}
}
-151
View File
@@ -1,151 +0,0 @@
package go_llm
import (
"context"
"fmt"
"strings"
oai "github.com/sashabaranov/go-openai"
)
type openaiImpl struct {
key string
model string
}
var _ LLM = openaiImpl{}
func (o openaiImpl) requestToOpenAIRequest(request Request) oai.ChatCompletionRequest {
res := oai.ChatCompletionRequest{
Model: o.model,
}
for _, msg := range request.Messages {
m := oai.ChatCompletionMessage{
Content: msg.Text,
Role: string(msg.Role),
Name: msg.Name,
}
for _, img := range msg.Images {
if img.Base64 != "" {
m.MultiContent = append(m.MultiContent, oai.ChatMessagePart{
Type: "image_url",
ImageURL: &oai.ChatMessageImageURL{
URL: fmt.Sprintf("data:%s;base64,%s", img.ContentType, img.Base64),
},
})
} else if img.Url != "" {
m.MultiContent = append(m.MultiContent, oai.ChatMessagePart{
Type: "image_url",
ImageURL: &oai.ChatMessageImageURL{
URL: img.Url,
},
})
}
}
// openai does not allow Content and MultiContent to be set at the same time, so we need to check
if len(m.MultiContent) > 0 && m.Content != "" {
m.MultiContent = append([]oai.ChatMessagePart{{
Type: "text",
Text: m.Content,
}}, m.MultiContent...)
m.Content = ""
}
res.Messages = append(res.Messages, m)
}
if request.Toolbox != nil {
for _, tool := range request.Toolbox.funcs {
res.Tools = append(res.Tools, oai.Tool{
Type: "function",
Function: &oai.FunctionDefinition{
Name: tool.Name,
Description: tool.Description,
Strict: tool.Strict,
Parameters: tool.Parameters.Definition(),
},
})
fmt.Println("tool:", tool.Name, tool.Description, tool.Strict, tool.Parameters.Definition())
}
}
if request.Temperature != nil {
res.Temperature = *request.Temperature
}
// is this an o1-* model?
isO1 := strings.Split(o.model, "-")[0] == "o1"
if isO1 {
// o1 models do not support system messages, so if any messages are system messages, we need to convert them to
// user messages
for i, msg := range res.Messages {
if msg.Role == "system" {
res.Messages[i].Role = "user"
}
}
}
return res
}
func (o openaiImpl) responseToLLMResponse(response oai.ChatCompletionResponse) Response {
res := Response{}
for _, choice := range response.Choices {
var toolCalls []ToolCall
for _, call := range choice.Message.ToolCalls {
fmt.Println("responseToLLMResponse: call:", call.Function.Arguments)
toolCall := ToolCall{
ID: call.ID,
FunctionCall: FunctionCall{
Name: call.Function.Name,
Arguments: call.Function.Arguments,
},
}
fmt.Println("toolCall.FunctionCall.Arguments:", toolCall.FunctionCall.Arguments)
toolCalls = append(toolCalls, toolCall)
}
res.Choices = append(res.Choices, ResponseChoice{
Content: choice.Message.Content,
Role: Role(choice.Message.Role),
Name: choice.Message.Name,
Refusal: choice.Message.Refusal,
Calls: toolCalls,
})
}
return res
}
func (o openaiImpl) ChatComplete(ctx context.Context, request Request) (Response, error) {
cl := oai.NewClient(o.key)
req := o.requestToOpenAIRequest(request)
resp, err := cl.CreateChatCompletion(ctx, req)
fmt.Println("resp:", fmt.Sprintf("%#v", resp))
if err != nil {
return Response{}, fmt.Errorf("unhandled openaiImpl error: %w", err)
}
return o.responseToLLMResponse(resp), nil
}
func (o openaiImpl) ModelVersion(modelVersion string) (ChatCompletion, error) {
return openaiImpl{
key: o.key,
model: modelVersion,
}, nil
}
-125
View File
@@ -1,125 +0,0 @@
package schema
import (
"reflect"
"strings"
"github.com/sashabaranov/go-openai/jsonschema"
)
// GetType will, given an interface{} that is a struct (NOT a pointer to a struct), return the Type of the struct that
// can be used to generate a json schema and build an object from a parsed json object.
func GetType(a any) Type {
t := reflect.TypeOf(a)
if t.Kind() != reflect.Struct {
panic("GetType expects a struct")
}
return getObject(t)
}
func getFromType(t reflect.Type, b basic) Type {
if t.Kind() == reflect.Ptr {
t = t.Elem()
b.required = false
}
switch t.Kind() {
case reflect.String:
b.DataType = jsonschema.String
return b
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
b.DataType = jsonschema.Integer
return b
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
b.DataType = jsonschema.Integer
return b
case reflect.Float32, reflect.Float64:
b.DataType = jsonschema.Number
return b
case reflect.Bool:
b.DataType = jsonschema.Boolean
return b
case reflect.Struct:
o := getObject(t)
o.basic.required = b.required
o.basic.index = b.index
o.basic.description = b.description
return o
case reflect.Slice:
return getArray(t)
default:
panic("unhandled default case for " + t.Kind().String() + " in getFromType")
}
}
func getField(f reflect.StructField, index int) Type {
b := basic{
index: index,
required: true,
description: "",
}
t := f.Type
// if the tag "description" is set, use that as the description
if desc, ok := f.Tag.Lookup("description"); ok {
b.description = desc
}
// now if the tag "enum" is set, we need to create an enum type
if v, ok := f.Tag.Lookup("enum"); ok {
vals := strings.Split(v, ",")
for i := 0; i < len(vals); i++ {
vals[i] = strings.TrimSpace(vals[i])
if vals[i] == "" {
vals = append(vals[:i], vals[i+1:]...)
}
}
return enum{
basic: b,
values: vals,
}
}
return getFromType(t, b)
}
func getObject(t reflect.Type) object {
fields := make(map[string]Type, t.NumField())
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
fields[field.Name] = getField(field, i)
}
return object{
basic: basic{DataType: jsonschema.Object},
fields: fields,
}
}
func getArray(t reflect.Type) array {
res := array{
basic: basic{
DataType: jsonschema.Array,
},
}
res.items = getFromType(t.Elem(), basic{})
return res
}
-65
View File
@@ -1,65 +0,0 @@
package schema
import (
"errors"
"reflect"
"github.com/sashabaranov/go-openai/jsonschema"
)
type array struct {
basic
// items is the schema of the items in the array
items Type
}
func (a array) SchemaType() jsonschema.DataType {
return jsonschema.Array
}
func (a array) Definition() jsonschema.Definition {
def := a.basic.Definition()
def.Type = jsonschema.Array
i := a.items.Definition()
def.Items = &i
def.AdditionalProperties = false
return def
}
func (a array) FromAny(val any) (reflect.Value, error) {
v := reflect.ValueOf(val)
// first realize we may have a pointer to a slice if this type is not required
if !a.required && v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() != reflect.Slice {
return reflect.Value{}, errors.New("expected slice, got " + v.Kind().String())
}
// if the slice is nil, we can just return it
if v.IsNil() {
return v, nil
}
// if the slice is not nil, we need to convert each item
items := make([]reflect.Value, v.Len())
for i := 0; i < v.Len(); i++ {
item, err := a.items.FromAny(v.Index(i).Interface())
if err != nil {
return reflect.Value{}, err
}
items[i] = item
}
return reflect.ValueOf(items), nil
}
func (a array) SetValue(obj reflect.Value, val reflect.Value) {
if !a.required {
val = val.Addr()
}
obj.Field(a.index).Set(val)
}
-105
View File
@@ -1,105 +0,0 @@
package schema
import (
"errors"
"reflect"
"strconv"
"github.com/sashabaranov/go-openai/jsonschema"
)
// just enforcing that basic implements Type
var _ Type = basic{}
type basic struct {
jsonschema.DataType
// index is the position of the parameter in the StructField of the function's parameter struct
index int
// required is a flag that indicates whether the parameter is required in the function's parameter struct.
// this is inferred by if the parameter is a pointer type or not.
required bool
// description is a llm-readable description of the parameter passed to openai
description string
}
func (b basic) SchemaType() jsonschema.DataType {
return b.DataType
}
func (b basic) Definition() jsonschema.Definition {
return jsonschema.Definition{
Type: b.DataType,
Description: b.description,
}
}
func (b basic) Required() bool {
return b.required
}
func (b basic) Description() string {
return b.description
}
func (b basic) FromAny(val any) (reflect.Value, error) {
v := reflect.ValueOf(val)
switch b.DataType {
case jsonschema.String:
var val = v.String()
return reflect.ValueOf(val), nil
case jsonschema.Integer:
if v.Kind() == reflect.Float64 {
return v.Convert(reflect.TypeOf(int(0))), nil
} else if v.Kind() != reflect.Int {
return reflect.Value{}, errors.New("expected int, got " + v.Kind().String())
} else {
return v, nil
}
case jsonschema.Number:
if v.Kind() == reflect.Float64 {
return v.Convert(reflect.TypeOf(float64(0))), nil
} else if v.Kind() != reflect.Float64 {
return reflect.Value{}, errors.New("expected float64, got " + v.Kind().String())
} else {
return v, nil
}
case jsonschema.Boolean:
if v.Kind() == reflect.Bool {
return v, nil
} else if v.Kind() == reflect.String {
b, err := strconv.ParseBool(v.String())
if err != nil {
return reflect.Value{}, errors.New("expected bool, got " + v.Kind().String())
}
return reflect.ValueOf(b), nil
} else {
return reflect.Value{}, errors.New("expected bool, got " + v.Kind().String())
}
default:
return reflect.Value{}, errors.New("unknown type")
}
}
func (b basic) SetValueOnField(obj reflect.Value, val reflect.Value) {
// if this basic type is not required that means it's a pointer type
// so we need to create a new value of the type of the pointer
if !b.required {
vv := reflect.New(obj.Field(b.index).Type().Elem())
// and then set the value of the pointer to the new value
vv.Elem().Set(val)
obj.Field(b.index).Set(vv)
return
}
obj.Field(b.index).Set(val)
}
-47
View File
@@ -1,47 +0,0 @@
package schema
import (
"errors"
"reflect"
"golang.org/x/exp/slices"
"github.com/sashabaranov/go-openai/jsonschema"
)
type enum struct {
basic
values []string
}
func (e enum) SchemaType() jsonschema.DataType {
return jsonschema.String
}
func (e enum) Definition() jsonschema.Definition {
def := e.basic.Definition()
def.Enum = e.values
return def
}
func (e enum) FromAny(val any) (reflect.Value, error) {
v := reflect.ValueOf(val)
if v.Kind() != reflect.String {
return reflect.Value{}, errors.New("expected string, got " + v.Kind().String())
}
s := v.String()
if !slices.Contains(e.values, s) {
return reflect.Value{}, errors.New("value " + s + " not in enum")
}
return v, nil
}
func (e enum) SetValueOnField(obj reflect.Value, val reflect.Value) {
if !e.required {
val = val.Addr()
}
obj.Field(e.index).Set(val)
}
-78
View File
@@ -1,78 +0,0 @@
package schema
import (
"errors"
"reflect"
"github.com/sashabaranov/go-openai/jsonschema"
)
type object struct {
basic
ref reflect.Type
fields map[string]Type
}
func (o object) SchemaType() jsonschema.DataType {
return jsonschema.Object
}
func (o object) Definition() jsonschema.Definition {
def := o.basic.Definition()
def.Type = jsonschema.Object
def.Properties = make(map[string]jsonschema.Definition)
for k, v := range o.fields {
def.Properties[k] = v.Definition()
}
def.AdditionalProperties = false
return def
}
func (o object) FromAny(val any) (reflect.Value, error) {
// if the value is nil, we can't do anything
if val == nil {
return reflect.Value{}, nil
}
// now make a new object of the type we're trying to parse
obj := reflect.New(o.ref).Elem()
// now we need to iterate over the fields and set the values
for k, v := range o.fields {
// get the field by name
field := obj.FieldByName(k)
if !field.IsValid() {
return reflect.Value{}, errors.New("field " + k + " not found")
}
// get the value from the map
val2, ok := val.(map[string]interface{})[k]
if !ok {
return reflect.Value{}, errors.New("field " + k + " not found in map")
}
// now we need to convert the value to the correct type
val3, err := v.FromAny(val2)
if err != nil {
return reflect.Value{}, err
}
// now we need to set the value on the field
v.SetValueOnField(field, val3)
}
return obj, nil
}
func (o object) SetValueOnField(obj reflect.Value, val reflect.Value) {
// if this basic type is not required that means it's a pointer type so we need to set the value to the address of the value
if !o.required {
val = val.Addr()
}
obj.Field(o.index).Set(val)
}
-18
View File
@@ -1,18 +0,0 @@
package schema
import (
"reflect"
"github.com/sashabaranov/go-openai/jsonschema"
)
type Type interface {
SchemaType() jsonschema.DataType
Definition() jsonschema.Definition
Required() bool
Description() string
FromAny(any) (reflect.Value, error)
SetValueOnField(obj reflect.Value, val reflect.Value)
}
-79
View File
@@ -1,79 +0,0 @@
package go_llm
import (
"context"
"errors"
"fmt"
"github.com/sashabaranov/go-openai"
)
// ToolBox is a collection of tools that OpenAI can use to execute functions.
// It is a wrapper around a collection of functions, and provides a way to automatically call the correct function with
// the correct parameters.
type ToolBox struct {
funcs []Function
names map[string]Function
}
func NewToolBox(fns ...*Function) *ToolBox {
res := ToolBox{
funcs: []Function{},
names: map[string]Function{},
}
for _, f := range fns {
o := *f
res.names[o.Name] = o
res.funcs = append(res.funcs, o)
}
return &res
}
func (t *ToolBox) WithFunction(f Function) *ToolBox {
t2 := *t
t2.names[f.Name] = f
t2.funcs = append(t2.funcs, f)
return &t2
}
// ToOpenAI will convert the current ToolBox to a slice of openai.Tool, which can be used to send to the OpenAI API.
func (t *ToolBox) toOpenAI() []openai.Tool {
var res []openai.Tool
for _, f := range t.funcs {
res = append(res, openai.Tool{
Type: "function",
Function: f.toOpenAIFunction(),
})
}
return res
}
func (t *ToolBox) ToToolChoice() any {
if len(t.funcs) == 0 {
return nil
}
return "required"
}
var (
ErrFunctionNotFound = errors.New("function not found")
)
func (t *ToolBox) ExecuteFunction(ctx context.Context, functionName string, params string) (string, error) {
f, ok := t.names[functionName]
if !ok {
return "", newError(ErrFunctionNotFound, fmt.Errorf("function \"%s\" not found", functionName))
}
return f.Execute(ctx, params)
}
func (t *ToolBox) Execute(ctx context.Context, toolCall ToolCall) (string, error) {
return t.ExecuteFunction(ctx, toolCall.FunctionCall.Name, toolCall.FunctionCall.Arguments)
}
+34
View File
@@ -0,0 +1,34 @@
# CLAUDE.md for go-llm v2
## Build and Test Commands
- Build project: `cd v2 && go build ./...`
- Run all tests: `cd v2 && go test ./...`
- Run specific test: `cd v2 && go test -v -run <TestName> ./...`
- Tidy dependencies: `cd v2 && go mod tidy`
- Vet: `cd v2 && go vet ./...`
## Code Style Guidelines
- **Indentation**: Standard Go tabs
- **Naming**: `camelCase` for unexported, `PascalCase` for exported
- **Error Handling**: Always check and handle errors immediately. Wrap with `fmt.Errorf("%w: ...", err)`
- **Imports**: Standard library first, then third-party, then internal packages
## Package Structure
- Root package `llm` — public API (Client, Model, Chat, ToolBox, Message types)
- `provider/` — Provider interface that backends implement
- `openai/`, `anthropic/`, `google/` — Provider implementations
- `ollama/` — Native `/api/chat` provider, used by both `llm.Ollama()` (local) and `llm.OllamaCloud(apiKey)` (cloud).
- `tools/` — Ready-to-use sample tools (WebSearch, Browser, Exec, ReadFile, WriteFile, HTTP)
- `sandbox/` — Isolated Linux container environments via Proxmox LXC + SSH
- `internal/schema/` — JSON Schema generation from Go structs
- `internal/imageutil/` — Image compression utilities
## Key Design Decisions
1. Unified `Message` type instead of marker interfaces
2. `map[string]any` JSON Schema (no provider coupling)
3. Tool functions return `(string, error)`, use standard `context.Context`
4. `Chat.Send()` auto-loops tool calls; `Chat.SendRaw()` for manual control
5. MCP one-call connect: `MCPStdioServer(ctx, cmd, args...)`
6. Streaming via pull-based `StreamReader.Next()`
7. Middleware for logging, retry, timeout, usage tracking
8. Ollama uses the native `/api/chat` API rather than the OpenAI-compat `/v1` endpoint. Native API supports `think: false` for thinking-capable models, has more reliable tool calling, and is approximately 15-20% lower latency. Both local and cloud share the same provider; only the apiKey/baseURL differ. `llm.Ollama()` targets `http://localhost:11434` with no Authorization header; `llm.OllamaCloud(key)` targets `https://ollama.com` with `Authorization: Bearer <key>`.
+116
View File
@@ -0,0 +1,116 @@
// Package agent provides a simple agent abstraction built on top of go-llm.
//
// An Agent wraps a model, system prompt, and tools into a reusable unit.
// Agents can be turned into tools via AsTool, enabling parent agents to
// delegate work to specialized sub-agents through the normal tool-call loop.
//
// Example — orchestrator with sub-agents:
//
// researcher := agent.New(model, "You research topics via web search.",
// agent.WithTools(llm.NewToolBox(tools.WebSearch(apiKey))),
// )
// coder := agent.New(model, "You write and run code.",
// agent.WithTools(llm.NewToolBox(tools.Exec())),
// )
// orchestrator := agent.New(model, "You coordinate research and coding tasks.",
// agent.WithTools(llm.NewToolBox(
// researcher.AsTool("research", "Research a topic"),
// coder.AsTool("code", "Write and run code"),
// )),
// )
// result, _, err := orchestrator.Run(ctx, "Build a fibonacci function in Go")
package agent
import (
"context"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// Agent is a configured LLM agent with a system prompt and tools.
// Each call to Run creates a fresh conversation (no state is carried between runs).
type Agent struct {
model *llm.Model
system string
tools *llm.ToolBox
reqOpts []llm.RequestOption
}
// Option configures an Agent.
type Option func(*Agent)
// WithTools sets the tools available to the agent.
func WithTools(tb *llm.ToolBox) Option {
return func(a *Agent) { a.tools = tb }
}
// WithRequestOptions sets default request options (temperature, max tokens, etc.)
// applied to every completion call the agent makes.
func WithRequestOptions(opts ...llm.RequestOption) Option {
return func(a *Agent) { a.reqOpts = opts }
}
// New creates an agent with the given model and system prompt.
func New(model *llm.Model, system string, opts ...Option) *Agent {
a := &Agent{
model: model,
system: system,
}
for _, opt := range opts {
opt(a)
}
return a
}
// Run executes the agent with a user prompt. Each call is a fresh conversation.
// The agent loops tool calls automatically until it produces a text response.
// Returns the text response, accumulated token usage, and any error.
func (a *Agent) Run(ctx context.Context, prompt string) (string, *llm.Usage, error) {
return a.RunMessages(ctx, []llm.Message{llm.UserMessage(prompt)})
}
// RunMessages executes the agent with full message control.
// Each call is a fresh conversation. The agent loops tool calls automatically.
// Returns the text response, accumulated token usage, and any error.
func (a *Agent) RunMessages(ctx context.Context, messages []llm.Message) (string, *llm.Usage, error) {
chat := llm.NewChat(a.model, a.reqOpts...)
if a.system != "" {
chat.SetSystem(a.system)
}
if a.tools != nil {
chat.SetTools(a.tools)
}
// Send each message; the last one triggers the completion loop.
// All but the last are added as context.
for i, msg := range messages {
if i < len(messages)-1 {
chat.AddToolResults(msg) // AddToolResults just appends to history
continue
}
return chat.SendMessage(ctx, msg)
}
// Empty messages — send an empty user message
return chat.Send(ctx, "")
}
// delegateParams is the parameter struct for the tool created by AsTool.
type delegateParams struct {
Input string `json:"input" description:"The task or question to delegate to this agent"`
}
// AsTool creates a llm.Tool that delegates to this agent.
// When a parent agent calls this tool, it runs the agent with the provided input
// as the prompt and returns the agent's text response as the tool result.
//
// This enables sub-agent patterns where a parent agent can spawn specialized
// child agents through the normal tool-call mechanism.
func (a *Agent) AsTool(name, description string) llm.Tool {
return llm.Define[delegateParams](name, description,
func(ctx context.Context, p delegateParams) (string, error) {
text, _, err := a.Run(ctx, p.Input)
return text, err
},
)
}
+266
View File
@@ -0,0 +1,266 @@
package agent
import (
"context"
"errors"
"sync"
"testing"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
// mockProvider is a test helper that implements provider.Provider.
type mockProvider struct {
mu sync.Mutex
completeFunc func(ctx context.Context, req provider.Request) (provider.Response, error)
requests []provider.Request
}
func (m *mockProvider) Complete(ctx context.Context, req provider.Request) (provider.Response, error) {
m.mu.Lock()
m.requests = append(m.requests, req)
m.mu.Unlock()
return m.completeFunc(ctx, req)
}
func (m *mockProvider) Stream(ctx context.Context, req provider.Request, events chan<- provider.StreamEvent) error {
close(events)
return nil
}
func newMockModel(fn func(ctx context.Context, req provider.Request) (provider.Response, error)) *llm.Model {
mp := &mockProvider{completeFunc: fn}
return llm.NewClient(mp).Model("mock-model")
}
func newSimpleMockModel(text string) *llm.Model {
return newMockModel(func(ctx context.Context, req provider.Request) (provider.Response, error) {
return provider.Response{Text: text}, nil
})
}
func TestAgent_Run(t *testing.T) {
model := newSimpleMockModel("Hello from agent!")
a := New(model, "You are a helpful assistant.")
result, _, err := a.Run(context.Background(), "Say hello")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "Hello from agent!" {
t.Errorf("expected 'Hello from agent!', got %q", result)
}
}
func TestAgent_Run_WithTools(t *testing.T) {
callCount := 0
model := newMockModel(func(ctx context.Context, req provider.Request) (provider.Response, error) {
callCount++
if callCount == 1 {
// First call: model requests a tool call
return provider.Response{
ToolCalls: []provider.ToolCall{
{ID: "tc1", Name: "greet", Arguments: `{}`},
},
}, nil
}
// Second call: model returns text after seeing tool result
return provider.Response{Text: "Tool said: hello!"}, nil
})
tool := llm.DefineSimple("greet", "Says hello", func(ctx context.Context) (string, error) {
return "hello!", nil
})
a := New(model, "You are helpful.", WithTools(llm.NewToolBox(tool)))
result, _, err := a.Run(context.Background(), "Use the greet tool")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "Tool said: hello!" {
t.Errorf("expected 'Tool said: hello!', got %q", result)
}
if callCount != 2 {
t.Errorf("expected 2 calls (tool loop), got %d", callCount)
}
}
func TestAgent_AsTool(t *testing.T) {
// Create a child agent
childModel := newSimpleMockModel("child result: 42")
child := New(childModel, "You compute things.")
// Create the tool from the child agent
childTool := child.AsTool("compute", "Delegate computation to child agent")
// Verify tool metadata
if childTool.Name != "compute" {
t.Errorf("expected tool name 'compute', got %q", childTool.Name)
}
if childTool.Description != "Delegate computation to child agent" {
t.Errorf("expected correct description, got %q", childTool.Description)
}
// Execute the tool directly (simulating what the parent's Chat.Send loop does)
result, err := childTool.Execute(context.Background(), `{"input":"what is 6*7?"}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "child result: 42" {
t.Errorf("expected 'child result: 42', got %q", result)
}
}
func TestAgent_AsTool_ParentChild(t *testing.T) {
// Child agent that always returns a fixed result
childModel := newSimpleMockModel("researched: Go generics are great")
child := New(childModel, "You are a researcher.")
// Parent agent: first call returns tool call, second returns text
parentCallCount := 0
parentModel := newMockModel(func(ctx context.Context, req provider.Request) (provider.Response, error) {
parentCallCount++
if parentCallCount == 1 {
return provider.Response{
ToolCalls: []provider.ToolCall{
{ID: "tc1", Name: "research", Arguments: `{"input":"Tell me about Go generics"}`},
},
}, nil
}
// After getting tool result, parent synthesizes final answer
return provider.Response{Text: "Based on research: Go generics are great"}, nil
})
parent := New(parentModel, "You coordinate tasks.",
WithTools(llm.NewToolBox(
child.AsTool("research", "Research a topic"),
)),
)
result, _, err := parent.Run(context.Background(), "Tell me about Go generics")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "Based on research: Go generics are great" {
t.Errorf("expected synthesized result, got %q", result)
}
if parentCallCount != 2 {
t.Errorf("expected 2 parent calls (tool loop), got %d", parentCallCount)
}
}
func TestAgent_RunMessages(t *testing.T) {
model := newSimpleMockModel("I see the system and user messages")
a := New(model, "You are helpful.")
messages := []llm.Message{
llm.UserMessage("First question"),
llm.AssistantMessage("First answer"),
llm.UserMessage("Follow up"),
}
result, _, err := a.RunMessages(context.Background(), messages)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "I see the system and user messages" {
t.Errorf("unexpected result: %q", result)
}
}
func TestAgent_ContextCancellation(t *testing.T) {
model := newMockModel(func(ctx context.Context, req provider.Request) (provider.Response, error) {
return provider.Response{}, ctx.Err()
})
a := New(model, "You are helpful.")
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
_, _, err := a.Run(ctx, "This should fail")
if err == nil {
t.Fatal("expected error from cancelled context")
}
}
func TestAgent_WithRequestOptions(t *testing.T) {
var capturedReq provider.Request
model := newMockModel(func(ctx context.Context, req provider.Request) (provider.Response, error) {
capturedReq = req
return provider.Response{Text: "ok"}, nil
})
a := New(model, "You are helpful.",
WithRequestOptions(llm.WithTemperature(0.3), llm.WithMaxTokens(100)),
)
_, _, err := a.Run(context.Background(), "test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if capturedReq.Temperature == nil || *capturedReq.Temperature != 0.3 {
t.Errorf("expected temperature 0.3, got %v", capturedReq.Temperature)
}
if capturedReq.MaxTokens == nil || *capturedReq.MaxTokens != 100 {
t.Errorf("expected maxTokens 100, got %v", capturedReq.MaxTokens)
}
}
func TestAgent_Run_Error(t *testing.T) {
wantErr := errors.New("model failed")
model := newMockModel(func(ctx context.Context, req provider.Request) (provider.Response, error) {
return provider.Response{}, wantErr
})
a := New(model, "You are helpful.")
_, _, err := a.Run(context.Background(), "test")
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestAgent_EmptySystem(t *testing.T) {
model := newSimpleMockModel("no system prompt")
a := New(model, "") // Empty system prompt
result, _, err := a.Run(context.Background(), "test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "no system prompt" {
t.Errorf("unexpected result: %q", result)
}
}
func TestAgent_Run_ReturnsUsage(t *testing.T) {
model := newMockModel(func(ctx context.Context, req provider.Request) (provider.Response, error) {
return provider.Response{
Text: "result",
Usage: &provider.Usage{
InputTokens: 100,
OutputTokens: 50,
TotalTokens: 150,
},
}, nil
})
a := New(model, "You are helpful.")
result, usage, err := a.Run(context.Background(), "test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "result" {
t.Errorf("expected 'result', got %q", result)
}
if usage == nil {
t.Fatal("expected usage, got nil")
}
if usage.InputTokens != 100 {
t.Errorf("expected input 100, got %d", usage.InputTokens)
}
if usage.OutputTokens != 50 {
t.Errorf("expected output 50, got %d", usage.OutputTokens)
}
}
+107
View File
@@ -0,0 +1,107 @@
package agent_test
import (
"context"
"fmt"
"os"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/agent"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/tools"
)
// A researcher agent that can search the web and browse pages.
func Example_researcher() {
model := llm.OpenAI(os.Getenv("OPENAI_API_KEY")).Model("gpt-4o")
researcher := agent.New(model,
"You are a research assistant. Use web search to find information, "+
"then use the browser to read full articles when needed. "+
"Provide a concise summary of your findings.",
agent.WithTools(llm.NewToolBox(
tools.WebSearch(os.Getenv("BRAVE_API_KEY")),
tools.Browser(),
)),
agent.WithRequestOptions(llm.WithTemperature(0.3)),
)
result, _, err := researcher.Run(context.Background(), "What are the latest developments in Go generics?")
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println(result)
}
// A coder agent that can read, write, and execute code.
func Example_coder() {
model := llm.OpenAI(os.Getenv("OPENAI_API_KEY")).Model("gpt-4o")
coder := agent.New(model,
"You are a coding assistant. You can read files, write files, and execute commands. "+
"When asked to create a program, write the code to a file and then run it to verify it works.",
agent.WithTools(llm.NewToolBox(
tools.ReadFile(),
tools.WriteFile(),
tools.Exec(
tools.WithAllowedCommands([]string{"go", "python", "node", "cat", "ls"}),
tools.WithWorkDir(os.TempDir()),
),
)),
)
result, _, err := coder.Run(context.Background(),
"Create a Go program that prints the first 10 Fibonacci numbers. Save it and run it.")
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println(result)
}
// An orchestrator agent that delegates to specialized sub-agents.
// The orchestrator breaks a complex task into subtasks and dispatches them
// to the appropriate sub-agent via tool calls.
func Example_orchestrator() {
model := llm.OpenAI(os.Getenv("OPENAI_API_KEY")).Model("gpt-4o")
// Specialized sub-agents
researcher := agent.New(model,
"You are a research assistant. Search the web for information on the given topic "+
"and return a concise summary.",
agent.WithTools(llm.NewToolBox(
tools.WebSearch(os.Getenv("BRAVE_API_KEY")),
)),
)
coder := agent.New(model,
"You are a coding assistant. Write and test code as requested. "+
"Save files and run them to verify they work.",
agent.WithTools(llm.NewToolBox(
tools.ReadFile(),
tools.WriteFile(),
tools.Exec(tools.WithAllowedCommands([]string{"go", "python"})),
)),
)
// Orchestrator can delegate to both sub-agents
orchestrator := agent.New(model,
"You are a project manager. Break complex tasks into research and coding subtasks. "+
"Use delegate_research for information gathering and delegate_coding for implementation. "+
"Synthesize the results into a final answer.",
agent.WithTools(llm.NewToolBox(
researcher.AsTool("delegate_research",
"Delegate a research task. Provide a clear question or topic to research."),
coder.AsTool("delegate_coding",
"Delegate a coding task. Provide clear requirements for what to implement."),
)),
)
result, _, err := orchestrator.Run(context.Background(),
"Research how to implement a binary search tree in Go, then create one with insert and search operations.")
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println(result)
}
+404
View File
@@ -0,0 +1,404 @@
// Package anthropic implements the go-llm v2 provider interface for Anthropic.
package anthropic
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/internal/imageutil"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
anth "github.com/liushuangls/go-anthropic/v2"
)
// Provider implements the provider.Provider interface for Anthropic.
type Provider struct {
apiKey string
}
// New creates a new Anthropic provider.
func New(apiKey string) *Provider {
return &Provider{apiKey: apiKey}
}
// Complete performs a non-streaming completion.
func (p *Provider) Complete(ctx context.Context, req provider.Request) (provider.Response, error) {
cl := anth.NewClient(p.apiKey)
anthReq := p.buildRequest(req)
resp, err := cl.CreateMessages(ctx, anthReq)
if err != nil {
return provider.Response{}, fmt.Errorf("anthropic completion error: %w", err)
}
return p.convertResponse(resp), nil
}
// Stream performs a streaming completion.
func (p *Provider) Stream(ctx context.Context, req provider.Request, events chan<- provider.StreamEvent) error {
cl := anth.NewClient(p.apiKey)
anthReq := p.buildRequest(req)
resp, err := cl.CreateMessagesStream(ctx, anth.MessagesStreamRequest{
MessagesRequest: anthReq,
OnContentBlockDelta: func(data anth.MessagesEventContentBlockDeltaData) {
switch data.Delta.Type {
case anth.MessagesContentTypeTextDelta:
if data.Delta.Text != nil {
events <- provider.StreamEvent{
Type: provider.StreamEventText,
Text: *data.Delta.Text,
}
}
case anth.MessagesContentTypeThinkingDelta:
if data.Delta.MessageContentThinking != nil {
events <- provider.StreamEvent{
Type: provider.StreamEventThinking,
Text: data.Delta.Thinking,
}
}
}
},
})
if err != nil {
return fmt.Errorf("anthropic stream error: %w", err)
}
result := p.convertResponse(resp)
events <- provider.StreamEvent{
Type: provider.StreamEventDone,
Response: &result,
}
return nil
}
// Thinking budgets used by Anthropic for low/medium/high reasoning levels.
// Must each be >= 1024 (Anthropic minimum) and strictly less than MaxTokens.
const (
thinkingBudgetLow = 1024
thinkingBudgetMedium = 8000
thinkingBudgetHigh = 24000
)
// thinkingBudget returns the Anthropic budget_tokens value for a go-llm
// ReasoningLevel string. Returns 0 to mean "no thinking" / pass-through.
func thinkingBudget(level string) int {
switch level {
case "low":
return thinkingBudgetLow
case "medium":
return thinkingBudgetMedium
case "high":
return thinkingBudgetHigh
}
return 0
}
func (p *Provider) buildRequest(req provider.Request) anth.MessagesRequest {
anthReq := anth.MessagesRequest{
Model: anth.Model(req.Model),
MaxTokens: 4096,
}
if req.MaxTokens != nil {
anthReq.MaxTokens = *req.MaxTokens
}
// Extended thinking. Setting Thinking forces temperature to be unset
// (Anthropic only allows the default of 1.0) and requires MaxTokens to
// strictly exceed BudgetTokens. We grow MaxTokens if the caller's value
// is too small, so callers don't have to reason about budget arithmetic.
if budget := thinkingBudget(req.Reasoning); budget > 0 {
anthReq.Thinking = &anth.Thinking{
Type: anth.ThinkingTypeEnabled,
BudgetTokens: budget,
}
if anthReq.MaxTokens <= budget {
anthReq.MaxTokens = budget + 4096
}
}
var msgs []anth.Message
var systemText string
// sourceLanding[srcIdx] = (msgIndex, blockIndex) of the LAST content block
// corresponding to that source message, after merging. System-role source
// messages map to {-1, -1} since they don't appear in msgs.
type landing struct{ msg, block int }
sourceLanding := make([]landing, len(req.Messages))
for i := range sourceLanding {
sourceLanding[i] = landing{-1, -1}
}
for srcIdx, msg := range req.Messages {
if msg.Role == "system" {
if len(systemText) > 0 {
systemText += "\n"
}
systemText += msg.Content
continue
}
if msg.Role == "tool" {
// Tool results in Anthropic format - use the helper
toolUseID := msg.ToolCallID
content := msg.Content
isError := false
newMsg := anth.Message{
Role: anth.RoleUser,
Content: []anth.MessageContent{
{
Type: anth.MessagesContentTypeToolResult,
MessageContentToolResult: &anth.MessageContentToolResult{
ToolUseID: &toolUseID,
Content: []anth.MessageContent{
{
Type: anth.MessagesContentTypeText,
Text: &content,
},
},
IsError: &isError,
},
},
},
}
// Tool-result messages bypass the role-merge logic — they always
// create a new msgs entry. Preserve that.
msgs = append(msgs, newMsg)
sourceLanding[srcIdx] = landing{
msg: len(msgs) - 1,
block: len(newMsg.Content) - 1,
}
continue
}
role := anth.RoleUser
if msg.Role == "assistant" {
role = anth.RoleAssistant
}
m := anth.Message{
Role: role,
Content: []anth.MessageContent{},
}
if msg.Content != "" {
m.Content = append(m.Content, anth.MessageContent{
Type: anth.MessagesContentTypeText,
Text: &msg.Content,
})
}
// Handle tool calls in assistant messages
for _, tc := range msg.ToolCalls {
var input json.RawMessage
if tc.Arguments != "" {
input = json.RawMessage(tc.Arguments)
} else {
input = json.RawMessage("{}")
}
m.Content = append(m.Content, anth.MessageContent{
Type: anth.MessagesContentTypeToolUse,
MessageContentToolUse: &anth.MessageContentToolUse{
ID: tc.ID,
Name: tc.Name,
Input: input,
},
})
}
// Handle images
for _, img := range msg.Images {
if role == anth.RoleAssistant {
role = anth.RoleUser
m.Role = anth.RoleUser
}
if img.Base64 != "" {
b64 := img.Base64
contentType := img.ContentType
// Compress if > 5MiB
raw, err := base64.StdEncoding.DecodeString(b64)
if err == nil && len(raw) >= 5242880 {
compressed, mime, cerr := imageutil.CompressImage(b64, 5*1024*1024)
if cerr == nil {
b64 = compressed
contentType = mime
}
}
m.Content = append(m.Content, anth.NewImageMessageContent(
anth.NewMessageContentSource(
anth.MessagesContentSourceTypeBase64,
contentType,
b64,
)))
} else if img.URL != "" {
// Download and convert to base64 (Anthropic doesn't support URLs directly)
resp, err := http.Get(img.URL)
if err != nil {
continue
}
data, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
continue
}
contentType := resp.Header.Get("Content-Type")
b64 := base64.StdEncoding.EncodeToString(data)
m.Content = append(m.Content, anth.NewImageMessageContent(
anth.NewMessageContentSource(
anth.MessagesContentSourceTypeBase64,
contentType,
b64,
)))
}
}
// Audio is not supported by Anthropic — skip silently.
// Merge consecutive same-role messages (Anthropic requires alternating).
if len(msgs) > 0 && msgs[len(msgs)-1].Role == role {
// Track the landing BEFORE mutating: the source message lands in
// the existing last msgs entry, and its last block is at the
// current end-of-content plus len(m.Content)-1.
existingEnd := len(msgs[len(msgs)-1].Content)
msgs[len(msgs)-1].Content = append(msgs[len(msgs)-1].Content, m.Content...)
sourceLanding[srcIdx] = landing{
msg: len(msgs) - 1,
block: existingEnd + len(m.Content) - 1,
}
} else {
msgs = append(msgs, m)
sourceLanding[srcIdx] = landing{
msg: len(msgs) - 1,
block: len(m.Content) - 1,
}
}
}
for _, tool := range req.Tools {
anthReq.Tools = append(anthReq.Tools, anth.ToolDefinition{
Name: tool.Name,
Description: tool.Description,
InputSchema: tool.Schema,
})
}
anthReq.Messages = msgs
if systemText != "" {
anthReq.MultiSystem = []anth.MessageSystemPart{
anth.NewSystemMessagePart(systemText),
}
}
// Anthropic rejects a non-default temperature when extended thinking is
// enabled. Drop the caller's value silently in that case rather than
// erroring — the alternative is forcing every caller to reset
// temperature when they enable thinking.
if req.Temperature != nil && anthReq.Thinking == nil {
f := float32(*req.Temperature)
anthReq.Temperature = &f
}
if req.TopP != nil {
f := float32(*req.TopP)
anthReq.TopP = &f
}
if len(req.Stop) > 0 {
anthReq.StopSequences = req.Stop
}
// Apply cache_control markers from hints.
if req.CacheHints != nil {
h := req.CacheHints
if h.CacheTools && len(anthReq.Tools) > 0 {
anthReq.Tools[len(anthReq.Tools)-1].CacheControl = &anth.MessageCacheControl{
Type: anth.CacheControlTypeEphemeral,
}
}
if h.CacheSystem && len(anthReq.MultiSystem) > 0 {
anthReq.MultiSystem[len(anthReq.MultiSystem)-1].CacheControl = &anth.MessageCacheControl{
Type: anth.CacheControlTypeEphemeral,
}
}
if h.LastCacheableMessageIndex >= 0 && h.LastCacheableMessageIndex < len(sourceLanding) {
land := sourceLanding[h.LastCacheableMessageIndex]
if land.msg >= 0 && land.msg < len(anthReq.Messages) {
blocks := anthReq.Messages[land.msg].Content
if land.block >= 0 && land.block < len(blocks) {
blocks[land.block].CacheControl = &anth.MessageCacheControl{
Type: anth.CacheControlTypeEphemeral,
}
anthReq.Messages[land.msg].Content = blocks
}
}
}
}
return anthReq
}
func (p *Provider) convertResponse(resp anth.MessagesResponse) provider.Response {
var res provider.Response
var textParts []string
var thinkingParts []string
for _, block := range resp.Content {
switch block.Type {
case anth.MessagesContentTypeText:
if block.Text != nil {
textParts = append(textParts, *block.Text)
}
case anth.MessagesContentTypeThinking:
if block.MessageContentThinking != nil {
thinkingParts = append(thinkingParts, block.Thinking)
}
case anth.MessagesContentTypeToolUse:
if block.MessageContentToolUse != nil {
args, _ := json.Marshal(block.MessageContentToolUse.Input)
res.ToolCalls = append(res.ToolCalls, provider.ToolCall{
ID: block.MessageContentToolUse.ID,
Name: block.MessageContentToolUse.Name,
Arguments: string(args),
})
}
}
}
res.Text = strings.Join(textParts, "")
res.Thinking = strings.Join(thinkingParts, "")
res.Usage = &provider.Usage{
InputTokens: resp.Usage.InputTokens,
OutputTokens: resp.Usage.OutputTokens,
TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens,
}
details := map[string]int{}
if resp.Usage.CacheCreationInputTokens > 0 {
details[provider.UsageDetailCacheCreationTokens] = resp.Usage.CacheCreationInputTokens
}
if resp.Usage.CacheReadInputTokens > 0 {
details[provider.UsageDetailCachedInputTokens] = resp.Usage.CacheReadInputTokens
}
if len(details) > 0 {
res.Usage.Details = details
}
return res
}
+349
View File
@@ -0,0 +1,349 @@
package anthropic
import (
"testing"
anth "github.com/liushuangls/go-anthropic/v2"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
// TestBuildRequest_MultiSystemUsedWhenSystemPresent verifies that after the
// refactor, the Anthropic provider uses MultiSystem (multi-part) rather than
// the flat System string when a system message is present. This is
// behavior-preserving — the upstream client's MarshalJSON prefers
// MultiSystem when both are set.
func TestBuildRequest_MultiSystemUsedWhenSystemPresent(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Messages: []provider.Message{
{Role: "system", Content: "you are helpful"},
{Role: "user", Content: "hello"},
},
}
anthReq := p.buildRequest(req)
if len(anthReq.MultiSystem) != 1 {
t.Fatalf("expected 1 MultiSystem part, got %d", len(anthReq.MultiSystem))
}
if anthReq.MultiSystem[0].Text != "you are helpful" {
t.Errorf("expected MultiSystem text 'you are helpful', got %q", anthReq.MultiSystem[0].Text)
}
if anthReq.MultiSystem[0].Type != "text" {
t.Errorf("expected MultiSystem type 'text', got %q", anthReq.MultiSystem[0].Type)
}
if anthReq.System != "" {
t.Errorf("expected System string to be empty when MultiSystem is used, got %q", anthReq.System)
}
}
// TestBuildRequest_MultipleSystemMessagesConcatenated verifies that multiple
// system messages are joined into a single MultiSystem part (preserving
// existing newline-joined behavior from the old code).
func TestBuildRequest_MultipleSystemMessagesConcatenated(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Messages: []provider.Message{
{Role: "system", Content: "part A"},
{Role: "system", Content: "part B"},
{Role: "user", Content: "hello"},
},
}
anthReq := p.buildRequest(req)
if len(anthReq.MultiSystem) != 1 {
t.Fatalf("expected 1 MultiSystem part after concat, got %d", len(anthReq.MultiSystem))
}
expected := "part A\npart B"
if anthReq.MultiSystem[0].Text != expected {
t.Errorf("expected MultiSystem text %q, got %q", expected, anthReq.MultiSystem[0].Text)
}
}
// TestBuildRequest_NoSystemMessage verifies that when there's no system
// message, both System and MultiSystem are empty.
func TestBuildRequest_NoSystemMessage(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Messages: []provider.Message{
{Role: "user", Content: "hello"},
},
}
anthReq := p.buildRequest(req)
if len(anthReq.MultiSystem) != 0 {
t.Errorf("expected empty MultiSystem when no system message, got %d parts", len(anthReq.MultiSystem))
}
if anthReq.System != "" {
t.Errorf("expected empty System string, got %q", anthReq.System)
}
}
// TestBuildRequest_CacheHints_Tools verifies that the last tool definition
// gets a cache_control marker when CacheHints.CacheTools is set.
func TestBuildRequest_CacheHints_Tools(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Tools: []provider.ToolDef{
{Name: "a", Description: "tool a", Schema: map[string]any{}},
{Name: "b", Description: "tool b", Schema: map[string]any{}},
},
Messages: []provider.Message{{Role: "user", Content: "hi"}},
CacheHints: &provider.CacheHints{
CacheTools: true,
LastCacheableMessageIndex: -1,
},
}
anthReq := p.buildRequest(req)
if len(anthReq.Tools) != 2 {
t.Fatalf("expected 2 tools, got %d", len(anthReq.Tools))
}
if anthReq.Tools[0].CacheControl != nil {
t.Error("expected first tool to have no CacheControl")
}
if anthReq.Tools[1].CacheControl == nil {
t.Fatal("expected last tool to have CacheControl")
}
if anthReq.Tools[1].CacheControl.Type != anth.CacheControlTypeEphemeral {
t.Errorf("expected last tool CacheControl type ephemeral, got %q", anthReq.Tools[1].CacheControl.Type)
}
}
// TestBuildRequest_CacheHints_System verifies that the final system part
// gets a cache_control marker when CacheHints.CacheSystem is set.
func TestBuildRequest_CacheHints_System(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Messages: []provider.Message{
{Role: "system", Content: "you are helpful"},
{Role: "user", Content: "hi"},
},
CacheHints: &provider.CacheHints{
CacheSystem: true,
LastCacheableMessageIndex: -1,
},
}
anthReq := p.buildRequest(req)
if len(anthReq.MultiSystem) != 1 {
t.Fatalf("expected 1 MultiSystem part, got %d", len(anthReq.MultiSystem))
}
if anthReq.MultiSystem[0].CacheControl == nil {
t.Fatal("expected system part to have CacheControl")
}
if anthReq.MultiSystem[0].CacheControl.Type != anth.CacheControlTypeEphemeral {
t.Errorf("expected system CacheControl type ephemeral, got %q", anthReq.MultiSystem[0].CacheControl.Type)
}
}
// TestBuildRequest_CacheHints_LastMessage verifies that the last content
// block of the target message receives a cache_control marker.
func TestBuildRequest_CacheHints_LastMessage(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Messages: []provider.Message{
{Role: "user", Content: "first turn"},
{Role: "assistant", Content: "ok"},
{Role: "user", Content: "second turn"},
},
CacheHints: &provider.CacheHints{
LastCacheableMessageIndex: 2,
},
}
anthReq := p.buildRequest(req)
if len(anthReq.Messages) != 3 {
t.Fatalf("expected 3 messages, got %d", len(anthReq.Messages))
}
last := anthReq.Messages[2]
if len(last.Content) != 1 {
t.Fatalf("expected 1 content block on last message, got %d", len(last.Content))
}
if last.Content[0].CacheControl == nil {
t.Fatal("expected CacheControl on last content block of last message")
}
if anthReq.Messages[0].Content[0].CacheControl != nil {
t.Error("expected no CacheControl on first message")
}
}
// TestBuildRequest_CacheHints_IndexDriftFromMerge verifies that when the
// provider merges consecutive same-role messages, the cache breakpoint
// lands on the correct merged output message.
//
// Scenario: source [user:"a", user:"b", assistant:"c", user:"d"].
// After merging: msgs[0]=user[a,b], msgs[1]=assistant[c], msgs[2]=user[d].
// With LastCacheableMessageIndex=3 (source idx of "d"), marker should land on
// msgs[2].Content[0].
func TestBuildRequest_CacheHints_IndexDriftFromMerge(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Messages: []provider.Message{
{Role: "user", Content: "a"},
{Role: "user", Content: "b"},
{Role: "assistant", Content: "c"},
{Role: "user", Content: "d"},
},
CacheHints: &provider.CacheHints{
LastCacheableMessageIndex: 3,
},
}
anthReq := p.buildRequest(req)
if len(anthReq.Messages) != 3 {
t.Fatalf("expected 3 merged messages, got %d", len(anthReq.Messages))
}
if len(anthReq.Messages[2].Content) != 1 {
t.Fatalf("expected 1 content block on merged user, got %d", len(anthReq.Messages[2].Content))
}
if anthReq.Messages[2].Content[0].CacheControl == nil {
t.Fatal("expected CacheControl on last content block of merged user message")
}
for i, cb := range anthReq.Messages[0].Content {
if cb.CacheControl != nil {
t.Errorf("expected no CacheControl on anthReq.Messages[0].Content[%d]", i)
}
}
}
// TestBuildRequest_CacheHints_IndexDriftTargetIsMerged: scenario where
// LastCacheableMessageIndex points into a message that was merged.
// Source [user:"a", user:"b"] → msgs[0]=user[a,b]. Index 1 should land on
// msgs[0].Content[1] ("b").
func TestBuildRequest_CacheHints_IndexDriftTargetIsMerged(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Messages: []provider.Message{
{Role: "user", Content: "a"},
{Role: "user", Content: "b"},
},
CacheHints: &provider.CacheHints{
LastCacheableMessageIndex: 1,
},
}
anthReq := p.buildRequest(req)
if len(anthReq.Messages) != 1 {
t.Fatalf("expected 1 merged message, got %d", len(anthReq.Messages))
}
if len(anthReq.Messages[0].Content) != 2 {
t.Fatalf("expected 2 content blocks after merge, got %d", len(anthReq.Messages[0].Content))
}
if anthReq.Messages[0].Content[0].CacheControl != nil {
t.Error("expected no CacheControl on first content block (source index 0)")
}
if anthReq.Messages[0].Content[1].CacheControl == nil {
t.Fatal("expected CacheControl on second content block (source index 1)")
}
}
// TestBuildRequest_CacheHints_AllThree: tools + system + last-message all
// receive markers in a single request.
func TestBuildRequest_CacheHints_AllThree(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Tools: []provider.ToolDef{
{Name: "t1", Description: "tool", Schema: map[string]any{}},
},
Messages: []provider.Message{
{Role: "system", Content: "be helpful"},
{Role: "user", Content: "hi"},
},
CacheHints: &provider.CacheHints{
CacheTools: true,
CacheSystem: true,
LastCacheableMessageIndex: 1,
},
}
anthReq := p.buildRequest(req)
if anthReq.Tools[0].CacheControl == nil {
t.Error("expected tool CacheControl")
}
if anthReq.MultiSystem[0].CacheControl == nil {
t.Error("expected system CacheControl")
}
if len(anthReq.Messages) != 1 {
t.Fatalf("expected 1 message, got %d", len(anthReq.Messages))
}
if anthReq.Messages[0].Content[0].CacheControl == nil {
t.Error("expected user-message CacheControl")
}
}
// TestBuildRequest_CacheHints_Nil: nil CacheHints → no markers.
func TestBuildRequest_CacheHints_Nil(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Tools: []provider.ToolDef{
{Name: "t", Description: "d", Schema: map[string]any{}},
},
Messages: []provider.Message{
{Role: "system", Content: "s"},
{Role: "user", Content: "hi"},
},
}
anthReq := p.buildRequest(req)
if anthReq.Tools[0].CacheControl != nil {
t.Error("expected no tool CacheControl with nil hints")
}
if len(anthReq.MultiSystem) == 1 && anthReq.MultiSystem[0].CacheControl != nil {
t.Error("expected no system CacheControl with nil hints")
}
for i, m := range anthReq.Messages {
for j, cb := range m.Content {
if cb.CacheControl != nil {
t.Errorf("expected no CacheControl on anthReq.Messages[%d].Content[%d]", i, j)
}
}
}
}
// TestBuildRequest_CacheHints_ToolResultMessage: marker lands on the right
// tool-result message.
func TestBuildRequest_CacheHints_ToolResultMessage(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Messages: []provider.Message{
{Role: "user", Content: "use the tool"},
{Role: "assistant", Content: "", ToolCalls: []provider.ToolCall{
{ID: "tc1", Name: "x", Arguments: "{}"},
}},
{Role: "tool", Content: "result data", ToolCallID: "tc1"},
},
CacheHints: &provider.CacheHints{
LastCacheableMessageIndex: 2,
},
}
anthReq := p.buildRequest(req)
var toolMsg *anth.Message
for i := range anthReq.Messages {
for _, cb := range anthReq.Messages[i].Content {
if cb.MessageContentToolResult != nil {
toolMsg = &anthReq.Messages[i]
break
}
}
}
if toolMsg == nil {
t.Fatal("expected to find tool-result message")
}
lastBlock := toolMsg.Content[len(toolMsg.Content)-1]
if lastBlock.CacheControl == nil {
t.Error("expected CacheControl on last content block of tool-result message")
}
}
+83
View File
@@ -0,0 +1,83 @@
package anthropic
import (
"testing"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
anth "github.com/liushuangls/go-anthropic/v2"
)
func TestBuildRequest_ThinkingByLevel(t *testing.T) {
p := New("k")
cases := []struct {
level string
wantBudget int
}{
{"", 0},
{"low", thinkingBudgetLow},
{"medium", thinkingBudgetMedium},
{"high", thinkingBudgetHigh},
}
for _, tc := range cases {
t.Run("level="+tc.level, func(t *testing.T) {
req := provider.Request{
Model: "claude-opus-4-7",
Reasoning: tc.level,
Messages: []provider.Message{{Role: "user", Content: "hi"}},
}
out := p.buildRequest(req)
if tc.wantBudget == 0 {
if out.Thinking != nil {
t.Fatalf("Thinking should be nil for level=%q, got %+v", tc.level, out.Thinking)
}
return
}
if out.Thinking == nil {
t.Fatalf("Thinking should be set for level=%q", tc.level)
}
if out.Thinking.Type != anth.ThinkingTypeEnabled {
t.Errorf("Thinking.Type = %q, want enabled", out.Thinking.Type)
}
if out.Thinking.BudgetTokens != tc.wantBudget {
t.Errorf("BudgetTokens = %d, want %d", out.Thinking.BudgetTokens, tc.wantBudget)
}
if out.MaxTokens <= tc.wantBudget {
t.Errorf("MaxTokens (%d) must exceed BudgetTokens (%d)", out.MaxTokens, tc.wantBudget)
}
})
}
}
func TestBuildRequest_ThinkingDropsTemperature(t *testing.T) {
p := New("k")
temp := 0.7
req := provider.Request{
Model: "claude-opus-4-7",
Reasoning: "high",
Temperature: &temp,
Messages: []provider.Message{{Role: "user", Content: "hi"}},
}
out := p.buildRequest(req)
if out.Temperature != nil {
t.Errorf("Temperature should be dropped when thinking is enabled, got %v", *out.Temperature)
}
}
func TestBuildRequest_NoThinkingPreservesTemperature(t *testing.T) {
p := New("k")
temp := 0.7
req := provider.Request{
Model: "claude-opus-4-7",
Temperature: &temp,
Messages: []provider.Message{{Role: "user", Content: "hi"}},
}
out := p.buildRequest(req)
if out.Temperature == nil {
t.Fatal("Temperature should be set when thinking is disabled")
}
got := float64(*out.Temperature)
if got < 0.69 || got > 0.71 {
t.Errorf("Temperature should be ~0.7 when thinking is disabled, got %v", got)
}
}
+154
View File
@@ -0,0 +1,154 @@
package llm
import (
"context"
"fmt"
)
// Chat manages a multi-turn conversation with automatic history tracking
// and optional automatic tool-call execution.
type Chat struct {
model *Model
messages []Message
tools *ToolBox
opts []RequestOption
}
// NewChat creates a new conversation with the given model.
func NewChat(model *Model, opts ...RequestOption) *Chat {
return &Chat{
model: model,
opts: opts,
}
}
// SetSystem sets or replaces the system message.
func (c *Chat) SetSystem(text string) {
filtered := make([]Message, 0, len(c.messages)+1)
for _, m := range c.messages {
if m.Role != RoleSystem {
filtered = append(filtered, m)
}
}
c.messages = append([]Message{SystemMessage(text)}, filtered...)
}
// SetTools configures the tools available for this chat.
func (c *Chat) SetTools(tb *ToolBox) {
c.tools = tb
}
// Send sends a user message and returns the assistant's text response along with
// accumulated token usage from all iterations of the tool-call loop.
// If the model calls tools, they are executed automatically and the loop
// continues until the model produces a text response (the "agent loop").
func (c *Chat) Send(ctx context.Context, text string) (string, *Usage, error) {
return c.SendMessage(ctx, UserMessage(text))
}
// SendWithImages sends a user message with images attached.
func (c *Chat) SendWithImages(ctx context.Context, text string, images ...Image) (string, *Usage, error) {
return c.SendMessage(ctx, UserMessageWithImages(text, images...))
}
// SendMessage sends an arbitrary message and returns the final text response along with
// accumulated token usage from all iterations of the tool-call loop.
// Handles the full tool-call loop automatically.
func (c *Chat) SendMessage(ctx context.Context, msg Message) (string, *Usage, error) {
c.messages = append(c.messages, msg)
opts := c.buildOpts()
var totalUsage *Usage
for {
resp, err := c.model.Complete(ctx, c.messages, opts...)
if err != nil {
return "", totalUsage, fmt.Errorf("completion failed: %w", err)
}
totalUsage = addUsage(totalUsage, resp.Usage)
c.messages = append(c.messages, resp.Message())
if !resp.HasToolCalls() {
return resp.Text, totalUsage, nil
}
if c.tools == nil {
return "", totalUsage, ErrNoToolsConfigured
}
toolResults, err := c.tools.ExecuteAll(ctx, resp.ToolCalls)
if err != nil {
return "", totalUsage, fmt.Errorf("tool execution failed: %w", err)
}
c.messages = append(c.messages, toolResults...)
}
}
// SendRaw sends a message and returns the raw Response without automatic tool execution.
// Useful when you want to handle tool calls manually.
func (c *Chat) SendRaw(ctx context.Context, msg Message) (Response, error) {
c.messages = append(c.messages, msg)
opts := c.buildOpts()
resp, err := c.model.Complete(ctx, c.messages, opts...)
if err != nil {
return Response{}, err
}
c.messages = append(c.messages, resp.Message())
return resp, nil
}
// SendStream sends a user message and returns a StreamReader for streaming responses.
func (c *Chat) SendStream(ctx context.Context, text string) (*StreamReader, error) {
c.messages = append(c.messages, UserMessage(text))
cfg := c.model.newRequestConfig(c.buildOpts())
req := buildProviderRequest(c.model.model, c.messages, cfg)
return newStreamReader(ctx, c.model.provider, req)
}
// AddToolResults manually adds tool results to the conversation.
// Use with SendRaw when handling tool calls manually.
func (c *Chat) AddToolResults(results ...Message) {
c.messages = append(c.messages, results...)
}
// Messages returns the current conversation history (read-only copy).
func (c *Chat) Messages() []Message {
cp := make([]Message, len(c.messages))
copy(cp, c.messages)
return cp
}
// Reset clears the conversation history.
func (c *Chat) Reset() {
c.messages = nil
}
// Fork creates a copy of this chat with identical history, for branching conversations.
func (c *Chat) Fork() *Chat {
c2 := &Chat{
model: c.model,
messages: make([]Message, len(c.messages)),
tools: c.tools,
opts: c.opts,
}
copy(c2.messages, c.messages)
return c2
}
func (c *Chat) buildOpts() []RequestOption {
opts := make([]RequestOption, len(c.opts))
copy(opts, c.opts)
if c.tools != nil {
opts = append(opts, WithTools(c.tools))
}
return opts
}
+567
View File
@@ -0,0 +1,567 @@
package llm
import (
"context"
"errors"
"sync/atomic"
"testing"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
func TestChat_Send(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "Hello there!"})
model := newMockModel(mp)
chat := NewChat(model)
text, _, err := chat.Send(context.Background(), "Hi")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if text != "Hello there!" {
t.Errorf("expected 'Hello there!', got %q", text)
}
}
func TestChat_SendMessage(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "reply"})
model := newMockModel(mp)
chat := NewChat(model)
_, _, err := chat.SendMessage(context.Background(), UserMessage("msg1"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
msgs := chat.Messages()
if len(msgs) != 2 {
t.Fatalf("expected 2 messages (user + assistant), got %d", len(msgs))
}
if msgs[0].Role != RoleUser {
t.Errorf("expected first message role=user, got %v", msgs[0].Role)
}
if msgs[0].Content.Text != "msg1" {
t.Errorf("expected first message text='msg1', got %q", msgs[0].Content.Text)
}
if msgs[1].Role != RoleAssistant {
t.Errorf("expected second message role=assistant, got %v", msgs[1].Role)
}
if msgs[1].Content.Text != "reply" {
t.Errorf("expected second message text='reply', got %q", msgs[1].Content.Text)
}
}
func TestChat_SetSystem(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "ok"})
model := newMockModel(mp)
chat := NewChat(model)
chat.SetSystem("You are a bot")
msgs := chat.Messages()
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
if msgs[0].Role != RoleSystem {
t.Errorf("expected role=system, got %v", msgs[0].Role)
}
if msgs[0].Content.Text != "You are a bot" {
t.Errorf("expected system text, got %q", msgs[0].Content.Text)
}
// Replace system message
chat.SetSystem("You are a helpful bot")
msgs = chat.Messages()
if len(msgs) != 1 {
t.Fatalf("expected 1 message after replace, got %d", len(msgs))
}
if msgs[0].Content.Text != "You are a helpful bot" {
t.Errorf("expected replaced system text, got %q", msgs[0].Content.Text)
}
// System message stays first even after adding other messages
_, _, _ = chat.Send(context.Background(), "Hi")
chat.SetSystem("New system")
msgs = chat.Messages()
if msgs[0].Role != RoleSystem {
t.Errorf("expected system as first message, got %v", msgs[0].Role)
}
if msgs[0].Content.Text != "New system" {
t.Errorf("expected 'New system', got %q", msgs[0].Content.Text)
}
}
func TestChat_ToolCallLoop(t *testing.T) {
var callCount int32
mp := newMockProviderFunc(func(ctx context.Context, req provider.Request) (provider.Response, error) {
n := atomic.AddInt32(&callCount, 1)
if n == 1 {
// First call: request a tool
return provider.Response{
ToolCalls: []provider.ToolCall{
{ID: "tc1", Name: "greet", Arguments: "{}"},
},
}, nil
}
// Second call: return text
return provider.Response{Text: "done"}, nil
})
model := newMockModel(mp)
chat := NewChat(model)
tool := DefineSimple("greet", "Says hello", func(ctx context.Context) (string, error) {
return "hello!", nil
})
chat.SetTools(NewToolBox(tool))
text, _, err := chat.Send(context.Background(), "test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if text != "done" {
t.Errorf("expected 'done', got %q", text)
}
if atomic.LoadInt32(&callCount) != 2 {
t.Errorf("expected 2 provider calls, got %d", callCount)
}
// Check message history: user, assistant (tool call), tool result, assistant (text)
msgs := chat.Messages()
if len(msgs) != 4 {
t.Fatalf("expected 4 messages, got %d", len(msgs))
}
if msgs[0].Role != RoleUser {
t.Errorf("msg[0]: expected user, got %v", msgs[0].Role)
}
if msgs[1].Role != RoleAssistant {
t.Errorf("msg[1]: expected assistant, got %v", msgs[1].Role)
}
if len(msgs[1].ToolCalls) != 1 {
t.Errorf("msg[1]: expected 1 tool call, got %d", len(msgs[1].ToolCalls))
}
if msgs[2].Role != RoleTool {
t.Errorf("msg[2]: expected tool, got %v", msgs[2].Role)
}
if msgs[2].Content.Text != "hello!" {
t.Errorf("msg[2]: expected 'hello!', got %q", msgs[2].Content.Text)
}
if msgs[3].Role != RoleAssistant {
t.Errorf("msg[3]: expected assistant, got %v", msgs[3].Role)
}
}
func TestChat_ToolCallLoop_NoTools(t *testing.T) {
mp := newMockProvider(provider.Response{
ToolCalls: []provider.ToolCall{
{ID: "tc1", Name: "fake", Arguments: "{}"},
},
})
model := newMockModel(mp)
chat := NewChat(model)
_, _, err := chat.Send(context.Background(), "test")
if !errors.Is(err, ErrNoToolsConfigured) {
t.Errorf("expected ErrNoToolsConfigured, got %v", err)
}
}
func TestChat_SendRaw(t *testing.T) {
mp := newMockProvider(provider.Response{
Text: "raw response",
ToolCalls: []provider.ToolCall{
{ID: "tc1", Name: "tool1", Arguments: `{"x":1}`},
},
})
model := newMockModel(mp)
chat := NewChat(model)
resp, err := chat.SendRaw(context.Background(), UserMessage("test"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Text != "raw response" {
t.Errorf("expected 'raw response', got %q", resp.Text)
}
if !resp.HasToolCalls() {
t.Error("expected HasToolCalls() to be true")
}
if len(resp.ToolCalls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(resp.ToolCalls))
}
if resp.ToolCalls[0].Name != "tool1" {
t.Errorf("expected tool name 'tool1', got %q", resp.ToolCalls[0].Name)
}
}
func TestChat_SendRaw_ManualToolResults(t *testing.T) {
var callCount int32
mp := newMockProviderFunc(func(ctx context.Context, req provider.Request) (provider.Response, error) {
n := atomic.AddInt32(&callCount, 1)
if n == 1 {
return provider.Response{
ToolCalls: []provider.ToolCall{
{ID: "tc1", Name: "tool1", Arguments: "{}"},
},
}, nil
}
return provider.Response{Text: "final"}, nil
})
model := newMockModel(mp)
chat := NewChat(model)
// First call returns tool calls
resp, err := chat.SendRaw(context.Background(), UserMessage("test"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !resp.HasToolCalls() {
t.Fatal("expected tool calls")
}
// Manually add tool result
chat.AddToolResults(ToolResultMessage("tc1", "tool result"))
// Second call returns text
resp, err = chat.SendRaw(context.Background(), UserMessage("continue"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Text != "final" {
t.Errorf("expected 'final', got %q", resp.Text)
}
// Check the full history
msgs := chat.Messages()
// user, assistant(tool call), tool result, user, assistant(text)
if len(msgs) != 5 {
t.Fatalf("expected 5 messages, got %d", len(msgs))
}
if msgs[2].Role != RoleTool {
t.Errorf("expected msg[2] role=tool, got %v", msgs[2].Role)
}
if msgs[2].ToolCallID != "tc1" {
t.Errorf("expected msg[2] toolCallID=tc1, got %q", msgs[2].ToolCallID)
}
}
func TestChat_Messages(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "ok"})
model := newMockModel(mp)
chat := NewChat(model)
_, _, _ = chat.Send(context.Background(), "test")
msgs := chat.Messages()
// Verify it's a copy — modifying returned slice shouldn't affect chat
msgs[0] = Message{}
original := chat.Messages()
if original[0].Role != RoleUser {
t.Error("Messages() did not return a copy")
}
}
func TestChat_Reset(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "ok"})
model := newMockModel(mp)
chat := NewChat(model)
_, _, _ = chat.Send(context.Background(), "test")
if len(chat.Messages()) == 0 {
t.Fatal("expected messages before reset")
}
chat.Reset()
if len(chat.Messages()) != 0 {
t.Errorf("expected 0 messages after reset, got %d", len(chat.Messages()))
}
}
func TestChat_Fork(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "ok"})
model := newMockModel(mp)
chat := NewChat(model)
_, _, _ = chat.Send(context.Background(), "msg1")
fork := chat.Fork()
// Fork should have same history
if len(fork.Messages()) != len(chat.Messages()) {
t.Fatalf("fork should have same message count: got %d vs %d", len(fork.Messages()), len(chat.Messages()))
}
// Adding to fork should not affect original
_, _, _ = fork.Send(context.Background(), "msg2")
if len(fork.Messages()) == len(chat.Messages()) {
t.Error("fork messages should be independent of original")
}
// Adding to original should not affect fork
originalLen := len(chat.Messages())
_, _, _ = chat.Send(context.Background(), "msg3")
if len(chat.Messages()) == originalLen {
t.Error("original should have more messages after send")
}
}
func TestChat_SendWithImages(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "I see an image"})
model := newMockModel(mp)
chat := NewChat(model)
img := Image{URL: "https://example.com/image.png"}
text, _, err := chat.SendWithImages(context.Background(), "What's in this image?", img)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if text != "I see an image" {
t.Errorf("expected 'I see an image', got %q", text)
}
// Verify the image was passed through to the provider
req := mp.lastRequest()
if len(req.Messages) == 0 {
t.Fatal("expected messages in request")
}
lastUserMsg := req.Messages[0]
if len(lastUserMsg.Images) != 1 {
t.Fatalf("expected 1 image, got %d", len(lastUserMsg.Images))
}
if lastUserMsg.Images[0].URL != "https://example.com/image.png" {
t.Errorf("expected image URL, got %q", lastUserMsg.Images[0].URL)
}
}
func TestChat_MultipleToolCallRounds(t *testing.T) {
var callCount int32
mp := newMockProviderFunc(func(ctx context.Context, req provider.Request) (provider.Response, error) {
n := atomic.AddInt32(&callCount, 1)
if n <= 3 {
return provider.Response{
ToolCalls: []provider.ToolCall{
{ID: "tc" + string(rune('0'+n)), Name: "counter", Arguments: "{}"},
},
}, nil
}
return provider.Response{Text: "all done"}, nil
})
model := newMockModel(mp)
chat := NewChat(model)
var execCount int32
tool := DefineSimple("counter", "Counts", func(ctx context.Context) (string, error) {
atomic.AddInt32(&execCount, 1)
return "counted", nil
})
chat.SetTools(NewToolBox(tool))
text, _, err := chat.Send(context.Background(), "count three times")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if text != "all done" {
t.Errorf("expected 'all done', got %q", text)
}
if atomic.LoadInt32(&callCount) != 4 {
t.Errorf("expected 4 provider calls, got %d", callCount)
}
if atomic.LoadInt32(&execCount) != 3 {
t.Errorf("expected 3 tool executions, got %d", execCount)
}
}
func TestChat_SendError(t *testing.T) {
wantErr := errors.New("provider failed")
mp := newMockProviderFunc(func(ctx context.Context, req provider.Request) (provider.Response, error) {
return provider.Response{}, wantErr
})
model := newMockModel(mp)
chat := NewChat(model)
_, _, err := chat.Send(context.Background(), "test")
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, wantErr) {
t.Errorf("expected wrapped provider error, got %v", err)
}
}
func TestChat_WithRequestOptions(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "ok"})
model := newMockModel(mp)
chat := NewChat(model, WithTemperature(0.5), WithMaxTokens(200))
_, _, err := chat.Send(context.Background(), "test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
req := mp.lastRequest()
if req.Temperature == nil || *req.Temperature != 0.5 {
t.Errorf("expected temperature 0.5, got %v", req.Temperature)
}
if req.MaxTokens == nil || *req.MaxTokens != 200 {
t.Errorf("expected maxTokens 200, got %v", req.MaxTokens)
}
}
func TestChat_Send_UsageAccumulation(t *testing.T) {
var callCount int32
mp := newMockProviderFunc(func(ctx context.Context, req provider.Request) (provider.Response, error) {
n := atomic.AddInt32(&callCount, 1)
if n == 1 {
return provider.Response{
ToolCalls: []provider.ToolCall{
{ID: "tc1", Name: "greet", Arguments: "{}"},
},
Usage: &provider.Usage{InputTokens: 10, OutputTokens: 5, TotalTokens: 15},
}, nil
}
return provider.Response{
Text: "done",
Usage: &provider.Usage{InputTokens: 20, OutputTokens: 8, TotalTokens: 28},
}, nil
})
model := newMockModel(mp)
chat := NewChat(model)
tool := DefineSimple("greet", "Says hello", func(ctx context.Context) (string, error) {
return "hello!", nil
})
chat.SetTools(NewToolBox(tool))
text, usage, err := chat.Send(context.Background(), "test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if text != "done" {
t.Errorf("expected 'done', got %q", text)
}
if usage == nil {
t.Fatal("expected usage, got nil")
}
if usage.InputTokens != 30 {
t.Errorf("expected accumulated input 30, got %d", usage.InputTokens)
}
if usage.OutputTokens != 13 {
t.Errorf("expected accumulated output 13, got %d", usage.OutputTokens)
}
if usage.TotalTokens != 43 {
t.Errorf("expected accumulated total 43, got %d", usage.TotalTokens)
}
}
func TestChat_Send_UsageWithDetails(t *testing.T) {
var callCount int32
mp := newMockProviderFunc(func(ctx context.Context, req provider.Request) (provider.Response, error) {
n := atomic.AddInt32(&callCount, 1)
if n == 1 {
return provider.Response{
ToolCalls: []provider.ToolCall{
{ID: "tc1", Name: "greet", Arguments: "{}"},
},
Usage: &provider.Usage{
InputTokens: 10,
OutputTokens: 5,
TotalTokens: 15,
Details: map[string]int{
"cached_input_tokens": 3,
},
},
}, nil
}
return provider.Response{
Text: "done",
Usage: &provider.Usage{
InputTokens: 20,
OutputTokens: 8,
TotalTokens: 28,
Details: map[string]int{
"cached_input_tokens": 7,
"reasoning_tokens": 2,
},
},
}, nil
})
model := newMockModel(mp)
chat := NewChat(model)
tool := DefineSimple("greet", "Says hello", func(ctx context.Context) (string, error) {
return "hello!", nil
})
chat.SetTools(NewToolBox(tool))
_, usage, err := chat.Send(context.Background(), "test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if usage == nil {
t.Fatal("expected usage, got nil")
}
if usage.Details == nil {
t.Fatal("expected usage details, got nil")
}
if usage.Details["cached_input_tokens"] != 10 {
t.Errorf("expected cached_input_tokens=10, got %d", usage.Details["cached_input_tokens"])
}
if usage.Details["reasoning_tokens"] != 2 {
t.Errorf("expected reasoning_tokens=2, got %d", usage.Details["reasoning_tokens"])
}
}
// TestChatSend_WithPromptCaching_PopulatesCacheHints is an end-to-end check
// that WithPromptCaching() on a Chat causes CacheHints to be populated on the
// provider.Request that reaches the provider layer.
func TestChatSend_WithPromptCaching_PopulatesCacheHints(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "hello back"})
model := newMockModel(mp)
chat := NewChat(model, WithPromptCaching())
chat.SetSystem("you are helpful")
_, _, err := chat.Send(context.Background(), "hi")
if err != nil {
t.Fatalf("Send failed: %v", err)
}
if len(mp.Requests) != 1 {
t.Fatalf("expected 1 provider request, got %d", len(mp.Requests))
}
req := mp.Requests[0]
if req.CacheHints == nil {
t.Fatal("expected CacheHints to be set on provider.Request")
}
if !req.CacheHints.CacheSystem {
t.Error("expected CacheSystem=true")
}
// Last non-system message index should be the user "hi" at index 1
// (SetSystem prepended a system message at index 0).
if req.CacheHints.LastCacheableMessageIndex != 1 {
t.Errorf("expected LastCacheableMessageIndex=1, got %d", req.CacheHints.LastCacheableMessageIndex)
}
}
// TestChatSend_WithoutPromptCaching_NoCacheHints verifies that omitting
// WithPromptCaching() leaves CacheHints nil on the provider.Request, so
// existing callers see no behavior change.
func TestChatSend_WithoutPromptCaching_NoCacheHints(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "hello back"})
model := newMockModel(mp)
chat := NewChat(model)
chat.SetSystem("you are helpful")
_, _, err := chat.Send(context.Background(), "hi")
if err != nil {
t.Fatalf("Send failed: %v", err)
}
if len(mp.Requests) != 1 {
t.Fatalf("expected 1 provider request, got %d", len(mp.Requests))
}
if mp.Requests[0].CacheHints != nil {
t.Errorf("expected nil CacheHints when caching not requested, got %+v", mp.Requests[0].CacheHints)
}
}
+27
View File
@@ -0,0 +1,27 @@
# go-llm CLI environment variables
# Copy this file to .env and fill in the keys for providers you use.
# OpenAI API Key (https://platform.openai.com/api-keys)
OPENAI_API_KEY=
# Anthropic API Key (https://console.anthropic.com/settings/keys)
ANTHROPIC_API_KEY=
# Google AI API Key (https://aistudio.google.com/apikey)
GOOGLE_API_KEY=
# DeepSeek API Key (https://platform.deepseek.com)
DEEPSEEK_API_KEY=
# Moonshot / Kimi API Key (https://platform.moonshot.ai)
MOONSHOT_API_KEY=
# xAI / Grok API Key (https://x.ai/api)
XAI_API_KEY=
# Groq API Key (https://console.groq.com/keys)
GROQ_API_KEY=
# Ollama runs locally with no API key required.
# Override the endpoint if you're not using localhost:11434.
# OLLAMA_BASE_URL=http://localhost:11434/v1
+136
View File
@@ -0,0 +1,136 @@
package main
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// Message types for async operations.
// ChatResponseMsg contains the response from a chat completion.
type ChatResponseMsg struct {
Response llm.Response
Err error
}
// ToolExecutionMsg contains results from executing tool calls, one Message
// (RoleTool) per ToolCall, in the same order.
type ToolExecutionMsg struct {
Results []llm.Message
Err error
}
// ImageLoadedMsg contains a loaded image.
type ImageLoadedMsg struct {
Image llm.Image
Err error
}
// sendChatRequest sends a completion request with the current conversation,
// returning a ChatResponseMsg tea.Msg when the provider responds.
func sendChatRequest(model *llm.Model, messages []llm.Message, toolbox *llm.ToolBox, toolsEnabled bool, temperature *float64) tea.Cmd {
return func() tea.Msg {
opts := buildOpts(toolbox, toolsEnabled, temperature)
resp, err := model.Complete(context.Background(), messages, opts...)
return ChatResponseMsg{Response: resp, Err: err}
}
}
// executeTools runs each tool call via the toolbox and returns ToolExecutionMsg
// with one RoleTool Message per call, in the same order.
func executeTools(toolbox *llm.ToolBox, calls []llm.ToolCall) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
results, err := toolbox.ExecuteAll(ctx, calls)
return ToolExecutionMsg{Results: results, Err: err}
}
}
// buildOpts constructs RequestOptions from the current CLI state.
func buildOpts(toolbox *llm.ToolBox, toolsEnabled bool, temperature *float64) []llm.RequestOption {
var opts []llm.RequestOption
if toolsEnabled && toolbox != nil && len(toolbox.AllTools()) > 0 {
opts = append(opts, llm.WithTools(toolbox))
}
if temperature != nil {
opts = append(opts, llm.WithTemperature(*temperature))
}
return opts
}
// loadImageFromPath loads an image from a file path.
func loadImageFromPath(path string) tea.Cmd {
return func() tea.Msg {
path = strings.TrimSpace(path)
path = strings.Trim(path, "\"'")
data, err := os.ReadFile(path)
if err != nil {
return ImageLoadedMsg{Err: fmt.Errorf("failed to read image file: %w", err)}
}
contentType := http.DetectContentType(data)
if !strings.HasPrefix(contentType, "image/") {
return ImageLoadedMsg{Err: fmt.Errorf("file is not an image: %s", contentType)}
}
return ImageLoadedMsg{
Image: llm.Image{
Base64: base64.StdEncoding.EncodeToString(data),
ContentType: contentType,
},
}
}
}
// loadImageFromURL loads an image from a URL (kept as URL, not fetched).
func loadImageFromURL(url string) tea.Cmd {
return func() tea.Msg {
return ImageLoadedMsg{Image: llm.Image{URL: strings.TrimSpace(url)}}
}
}
// loadImageFromBase64 loads an image from base64 data (raw or data: URL).
func loadImageFromBase64(data string) tea.Cmd {
return func() tea.Msg {
data = strings.TrimSpace(data)
if strings.HasPrefix(data, "data:") {
parts := strings.SplitN(data, ",", 2)
if len(parts) != 2 {
return ImageLoadedMsg{Err: fmt.Errorf("invalid data URL format")}
}
mediaType := strings.TrimPrefix(parts[0], "data:")
mediaType = strings.TrimSuffix(mediaType, ";base64")
return ImageLoadedMsg{
Image: llm.Image{
Base64: parts[1],
ContentType: mediaType,
},
}
}
decoded, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return ImageLoadedMsg{Err: fmt.Errorf("invalid base64 data: %w", err)}
}
contentType := http.DetectContentType(decoded)
if !strings.HasPrefix(contentType, "image/") {
return ImageLoadedMsg{Err: fmt.Errorf("data is not an image: %s", contentType)}
}
return ImageLoadedMsg{
Image: llm.Image{
Base64: data,
ContentType: contentType,
},
}
}
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/joho/godotenv"
)
func main() {
// Load .env file if it exists (ignore error if not found)
_ = godotenv.Load()
p := tea.NewProgram(
InitialModel(),
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
)
if _, err := p.Run(); err != nil {
fmt.Printf("Error running program: %v\n", err)
os.Exit(1)
}
}
+245
View File
@@ -0,0 +1,245 @@
package main
import (
"os"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// State represents the current view/screen of the application.
type State int
const (
StateChat State = iota
StateProviderSelect
StateModelSelect
StateImageInput
StateToolsPanel
StateSettings
StateAPIKeyInput
)
// DisplayMessage represents a message for display in the UI.
type DisplayMessage struct {
Role llm.Role
Content string
Images int // number of images attached
}
// ProviderEntry is a CLI-local view of a registered provider, enriched with
// UI state (which model is currently chosen, whether we have a key, etc.).
type ProviderEntry struct {
Info llm.ProviderInfo
HasAPIKey bool
ModelIndex int
}
// Model is the main Bubble Tea model.
type Model struct {
// State
state State
previousState State
// Provider
client *llm.Client
chat *llm.Model
providerName string
modelName string
apiKeys map[string]string
providers []ProviderEntry
providerIndex int
// Conversation
conversation []llm.Message
messages []DisplayMessage
// Tools
toolbox *llm.ToolBox
toolsEnabled bool
// Settings
systemPrompt string
temperature *float64
// Pending images
pendingImages []llm.Image
// UI Components
input textinput.Model
viewport viewport.Model
viewportReady bool
// Selection state (for lists)
listIndex int
listItems []string
// Dimensions
width int
height int
// Loading state
loading bool
err error
// For API key input
apiKeyInput textinput.Model
}
// InitialModel creates and returns the initial model.
func InitialModel() Model {
ti := textinput.New()
ti.Placeholder = "Type your message..."
ti.Focus()
ti.CharLimit = 4096
ti.Width = 60
aki := textinput.New()
aki.Placeholder = "Enter API key..."
aki.CharLimit = 256
aki.Width = 60
aki.EchoMode = textinput.EchoPassword
// Build provider list from the go-llm registry.
registry := llm.Providers()
providers := make([]ProviderEntry, len(registry))
apiKeys := make(map[string]string)
for i, info := range registry {
entry := ProviderEntry{Info: info}
if info.EnvKey == "" {
// Key-less provider (e.g., Ollama).
entry.HasAPIKey = true
} else if key := os.Getenv(info.EnvKey); key != "" {
apiKeys[info.Name] = key
entry.HasAPIKey = true
}
providers[i] = entry
}
m := Model{
state: StateProviderSelect,
input: ti,
apiKeyInput: aki,
apiKeys: apiKeys,
providers: providers,
systemPrompt: "You are a helpful assistant.",
toolbox: createDemoToolbox(),
toolsEnabled: false,
messages: []DisplayMessage{},
conversation: []llm.Message{},
}
// Build list items for provider selection.
m.listItems = make([]string, len(providers))
for i, p := range providers {
status := " (no key)"
if p.HasAPIKey {
status = " (ready)"
if p.Info.EnvKey == "" {
status = " (local)"
}
}
m.listItems[i] = p.Info.DisplayName + status
}
return m
}
// Init initializes the model.
func (m Model) Init() tea.Cmd {
return textinput.Blink
}
// selectProvider sets up the selected provider.
func (m *Model) selectProvider(index int) error {
if index < 0 || index >= len(m.providers) {
return nil
}
p := m.providers[index]
key := m.apiKeys[p.Info.Name] // empty for key-less providers like Ollama
if p.Info.EnvKey != "" && key == "" {
return nil
}
m.providerName = p.Info.DisplayName
m.providerIndex = index
m.client = p.Info.New(key)
// Select default model.
if len(p.Info.Models) > 0 {
return m.selectModel(p.ModelIndex)
}
return nil
}
// selectModel sets the current model.
func (m *Model) selectModel(index int) error {
if m.client == nil {
return nil
}
p := m.providers[m.providerIndex]
if index < 0 || index >= len(p.Info.Models) {
return nil
}
modelName := p.Info.Models[index]
m.chat = m.client.Model(modelName)
m.modelName = modelName
m.providers[m.providerIndex].ModelIndex = index
return nil
}
// newConversation resets the conversation.
func (m *Model) newConversation() {
m.conversation = []llm.Message{}
m.messages = []DisplayMessage{}
m.pendingImages = []llm.Image{}
m.err = nil
}
// addUserMessage adds a user message to the conversation.
func (m *Model) addUserMessage(text string, images []llm.Image) {
msg := llm.Message{
Role: llm.RoleUser,
Content: llm.Content{Text: text, Images: images},
}
m.conversation = append(m.conversation, msg)
m.messages = append(m.messages, DisplayMessage{
Role: llm.RoleUser,
Content: text,
Images: len(images),
})
}
// addAssistantMessage adds an assistant message to the conversation display.
func (m *Model) addAssistantMessage(content string) {
m.messages = append(m.messages, DisplayMessage{
Role: llm.RoleAssistant,
Content: content,
})
}
// addToolCallMessage adds a tool call message to display.
func (m *Model) addToolCallMessage(name string, args string) {
m.messages = append(m.messages, DisplayMessage{
Role: llm.Role("tool_call"),
Content: name + ": " + args,
})
}
// addToolResultMessage adds a tool result message to display.
func (m *Model) addToolResultMessage(name string, result string) {
m.messages = append(m.messages, DisplayMessage{
Role: llm.Role("tool_result"),
Content: name + " -> " + result,
})
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"github.com/charmbracelet/lipgloss"
)
var (
// Colors
primaryColor = lipgloss.Color("205")
secondaryColor = lipgloss.Color("39")
accentColor = lipgloss.Color("212")
mutedColor = lipgloss.Color("241")
errorColor = lipgloss.Color("196")
successColor = lipgloss.Color("82")
// App styles
appStyle = lipgloss.NewStyle().Padding(1, 2)
// Header
headerStyle = lipgloss.NewStyle().
Bold(true).
Foreground(primaryColor).
BorderStyle(lipgloss.NormalBorder()).
BorderBottom(true).
BorderForeground(mutedColor).
Padding(0, 1)
// Provider badge
providerBadgeStyle = lipgloss.NewStyle().
Background(secondaryColor).
Foreground(lipgloss.Color("0")).
Padding(0, 1).
Bold(true)
// Messages
systemMsgStyle = lipgloss.NewStyle().
Foreground(mutedColor).
Italic(true).
Padding(0, 1)
userMsgStyle = lipgloss.NewStyle().
Foreground(secondaryColor).
Padding(0, 1)
assistantMsgStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Padding(0, 1)
roleLabelStyle = lipgloss.NewStyle().
Bold(true).
Width(12)
// Tool calls
toolCallStyle = lipgloss.NewStyle().
Foreground(accentColor).
Italic(true).
Padding(0, 1)
toolResultStyle = lipgloss.NewStyle().
Foreground(successColor).
Padding(0, 1)
// Input area
inputStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(primaryColor).
Padding(0, 1)
inputHelpStyle = lipgloss.NewStyle().
Foreground(mutedColor).
Italic(true)
// Error
errorStyle = lipgloss.NewStyle().
Foreground(errorColor).
Bold(true)
// Loading
loadingStyle = lipgloss.NewStyle().
Foreground(accentColor).
Italic(true)
// List selection
selectedItemStyle = lipgloss.NewStyle().
Foreground(primaryColor).
Bold(true)
normalItemStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255"))
// Settings panel
settingLabelStyle = lipgloss.NewStyle().
Foreground(secondaryColor).
Width(15)
settingValueStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255"))
// Help text
helpStyle = lipgloss.NewStyle().
Foreground(mutedColor).
Padding(1, 0)
// Image indicator
imageIndicatorStyle = lipgloss.NewStyle().
Foreground(accentColor).
Bold(true)
// Viewport
viewportStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(mutedColor)
)
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"context"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// TimeParams is the parameter struct for the GetTime function.
type TimeParams struct{}
// GetTime returns the current time.
func GetTime(_ context.Context, _ TimeParams) (string, error) {
return time.Now().Format("Monday, January 2, 2006 3:04:05 PM MST"), nil
}
// CalcParams is the parameter struct for the Calculate function.
type CalcParams struct {
A float64 `json:"a" description:"First number"`
B float64 `json:"b" description:"Second number"`
Op string `json:"op" description:"Operation: add, subtract, multiply, divide, power, sqrt, mod"`
}
// Calculate performs basic math operations.
func Calculate(_ context.Context, params CalcParams) (string, error) {
var result float64
switch strings.ToLower(params.Op) {
case "add", "+":
result = params.A + params.B
case "subtract", "sub", "-":
result = params.A - params.B
case "multiply", "mul", "*":
result = params.A * params.B
case "divide", "div", "/":
if params.B == 0 {
return "", fmt.Errorf("division by zero")
}
result = params.A / params.B
case "power", "pow", "^":
result = math.Pow(params.A, params.B)
case "sqrt":
if params.A < 0 {
return "", fmt.Errorf("cannot take square root of negative number")
}
result = math.Sqrt(params.A)
case "mod", "%":
result = math.Mod(params.A, params.B)
default:
return "", fmt.Errorf("unknown operation: %s", params.Op)
}
return strconv.FormatFloat(result, 'f', -1, 64), nil
}
// WeatherParams is the parameter struct for the GetWeather function.
type WeatherParams struct {
Location string `json:"location" description:"City name or location"`
}
// GetWeather returns mock weather data (for demo purposes).
func GetWeather(_ context.Context, params WeatherParams) (string, error) {
weathers := []string{"sunny", "cloudy", "rainy", "partly cloudy", "windy"}
temps := []int{65, 72, 58, 80, 45}
idx := len(params.Location) % len(weathers)
out := map[string]any{
"location": params.Location,
"temperature": strconv.Itoa(temps[idx]) + "F",
"condition": weathers[idx],
"humidity": "45%",
"note": "This is mock data for demonstration purposes",
}
b, err := json.Marshal(out)
if err != nil {
return "", err
}
return string(b), nil
}
// RandomNumberParams is the parameter struct for the RandomNumber function.
type RandomNumberParams struct {
Min int `json:"min" description:"Minimum value (inclusive)"`
Max int `json:"max" description:"Maximum value (inclusive)"`
}
// RandomNumber generates a pseudo-random number (using current time nanoseconds).
func RandomNumber(_ context.Context, params RandomNumberParams) (string, error) {
if params.Min > params.Max {
return "", fmt.Errorf("min cannot be greater than max")
}
n := time.Now().UnixNano()
rangeSize := params.Max - params.Min + 1
result := params.Min + int(n%int64(rangeSize))
return strconv.Itoa(result), nil
}
// createDemoToolbox creates a toolbox with demo tools for testing.
func createDemoToolbox() *llm.ToolBox {
return llm.NewToolBox(
llm.Define[TimeParams]("get_time", "Get the current date and time", GetTime),
llm.Define[CalcParams]("calculate",
"Perform basic math operations (add, subtract, multiply, divide, power, sqrt, mod)",
Calculate),
llm.Define[WeatherParams]("get_weather",
"Get weather information for a location (demo data)", GetWeather),
llm.Define[RandomNumberParams]("random_number",
"Generate a random number between min and max", RandomNumber),
)
}
+409
View File
@@ -0,0 +1,409 @@
package main
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// pendingToolCalls stores the last response's tool calls so we can pair them
// with tool execution results for display.
var pendingToolCalls []llm.ToolCall
// Update handles messages and updates the model.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKeyMsg(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
headerHeight := 3
footerHeight := 4
verticalMargins := headerHeight + footerHeight
if !m.viewportReady {
m.viewport = viewport.New(msg.Width-4, msg.Height-verticalMargins)
m.viewport.HighPerformanceRendering = false
m.viewportReady = true
} else {
m.viewport.Width = msg.Width - 4
m.viewport.Height = msg.Height - verticalMargins
}
m.input.Width = msg.Width - 6
m.apiKeyInput.Width = msg.Width - 6
m.viewport.SetContent(m.renderMessages())
case ChatResponseMsg:
m.loading = false
if msg.Err != nil {
m.err = msg.Err
return m, nil
}
resp := msg.Response
// Add the assistant message to the conversation history.
m.conversation = append(m.conversation, resp.Message())
// Show any text the assistant produced alongside tool calls.
if resp.Text != "" {
m.addAssistantMessage(resp.Text)
}
if resp.HasToolCalls() && m.toolsEnabled {
pendingToolCalls = resp.ToolCalls
for _, call := range resp.ToolCalls {
m.addToolCallMessage(call.Name, call.Arguments)
}
m.viewport.SetContent(m.renderMessages())
m.viewport.GotoBottom()
m.loading = true
return m, executeTools(m.toolbox, resp.ToolCalls)
}
m.viewport.SetContent(m.renderMessages())
m.viewport.GotoBottom()
case ToolExecutionMsg:
if msg.Err != nil {
m.loading = false
m.err = msg.Err
return m, nil
}
// Display results paired with the tool calls that produced them.
for i, result := range msg.Results {
name := ""
if i < len(pendingToolCalls) {
name = pendingToolCalls[i].Name
}
m.addToolResultMessage(name, result.Content.Text)
}
// Append the raw tool result messages to the conversation so the
// assistant can reference them on the next turn.
m.conversation = append(m.conversation, msg.Results...)
m.viewport.SetContent(m.renderMessages())
m.viewport.GotoBottom()
// Ask the model to continue given the tool results.
return m, sendChatRequest(m.chat, m.conversation, m.toolbox, m.toolsEnabled, m.temperature)
case ImageLoadedMsg:
if msg.Err != nil {
m.err = msg.Err
m.state = m.previousState
return m, nil
}
m.pendingImages = append(m.pendingImages, msg.Image)
m.state = m.previousState
m.err = nil
default:
// Update text input.
if m.state == StateChat {
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
} else if m.state == StateAPIKeyInput {
m.apiKeyInput, cmd = m.apiKeyInput.Update(msg)
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
}
// handleKeyMsg handles keyboard input.
func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
if m.state != StateChat {
m.state = StateChat
m.input.Focus()
return m, nil
}
return m, tea.Quit
}
switch m.state {
case StateChat:
return m.handleChatKeys(msg)
case StateProviderSelect:
return m.handleProviderSelectKeys(msg)
case StateModelSelect:
return m.handleModelSelectKeys(msg)
case StateImageInput:
return m.handleImageInputKeys(msg)
case StateToolsPanel:
return m.handleToolsPanelKeys(msg)
case StateSettings:
return m.handleSettingsKeys(msg)
case StateAPIKeyInput:
return m.handleAPIKeyInputKeys(msg)
}
return m, nil
}
// handleChatKeys handles keys in chat state.
func (m Model) handleChatKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
if m.loading {
return m, nil
}
text := strings.TrimSpace(m.input.Value())
if text == "" {
return m, nil
}
if m.chat == nil {
m.err = fmt.Errorf("no model selected - press Ctrl+P to select a provider")
return m, nil
}
// Ensure a system message is at the head of the conversation.
if len(m.conversation) == 0 && m.systemPrompt != "" {
m.conversation = append(m.conversation, llm.SystemMessage(m.systemPrompt))
}
m.addUserMessage(text, m.pendingImages)
m.input.Reset()
m.pendingImages = nil
m.err = nil
m.loading = true
m.viewport.SetContent(m.renderMessages())
m.viewport.GotoBottom()
return m, sendChatRequest(m.chat, m.conversation, m.toolbox, m.toolsEnabled, m.temperature)
case "ctrl+i":
m.previousState = StateChat
m.state = StateImageInput
m.input.SetValue("")
m.input.Placeholder = "Enter image path or URL..."
return m, nil
case "ctrl+t":
m.state = StateToolsPanel
return m, nil
case "ctrl+p":
m.state = StateProviderSelect
m.listIndex = m.providerIndex
return m, nil
case "ctrl+m":
if m.client == nil {
m.err = fmt.Errorf("select a provider first")
return m, nil
}
m.state = StateModelSelect
m.listItems = m.providers[m.providerIndex].Info.Models
m.listIndex = m.providers[m.providerIndex].ModelIndex
return m, nil
case "ctrl+s":
m.state = StateSettings
return m, nil
case "ctrl+n":
m.newConversation()
m.viewport.SetContent(m.renderMessages())
return m, nil
case "up", "down", "pgup", "pgdown":
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
default:
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
}
// handleProviderSelectKeys handles keys in provider selection state.
func (m Model) handleProviderSelectKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
if m.listIndex > 0 {
m.listIndex--
}
case "down", "j":
if m.listIndex < len(m.providers)-1 {
m.listIndex++
}
case "enter":
p := m.providers[m.listIndex]
if !p.HasAPIKey {
m.state = StateAPIKeyInput
m.apiKeyInput.Focus()
m.apiKeyInput.SetValue("")
return m, textinput.Blink
}
if err := m.selectProvider(m.listIndex); err != nil {
m.err = err
return m, nil
}
m.state = StateChat
m.input.Focus()
m.newConversation()
return m, nil
}
return m, nil
}
// handleAPIKeyInputKeys handles keys in API key input state.
func (m Model) handleAPIKeyInputKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
key := strings.TrimSpace(m.apiKeyInput.Value())
if key == "" {
return m, nil
}
p := m.providers[m.listIndex]
m.apiKeys[p.Info.Name] = key
m.providers[m.listIndex].HasAPIKey = true
for i, prov := range m.providers {
status := " (no key)"
if prov.HasAPIKey {
status = " (ready)"
if prov.Info.EnvKey == "" {
status = " (local)"
}
}
m.listItems[i] = prov.Info.DisplayName + status
}
if err := m.selectProvider(m.listIndex); err != nil {
m.err = err
return m, nil
}
m.state = StateChat
m.input.Focus()
m.newConversation()
return m, nil
default:
var cmd tea.Cmd
m.apiKeyInput, cmd = m.apiKeyInput.Update(msg)
return m, cmd
}
}
// handleModelSelectKeys handles keys in model selection state.
func (m Model) handleModelSelectKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
if m.listIndex > 0 {
m.listIndex--
}
case "down", "j":
if m.listIndex < len(m.listItems)-1 {
m.listIndex++
}
case "enter":
if err := m.selectModel(m.listIndex); err != nil {
m.err = err
return m, nil
}
m.state = StateChat
m.input.Focus()
}
return m, nil
}
// handleImageInputKeys handles keys in image input state.
func (m Model) handleImageInputKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
input := strings.TrimSpace(m.input.Value())
if input == "" {
m.state = m.previousState
m.input.Placeholder = "Type your message..."
return m, nil
}
m.input.Placeholder = "Type your message..."
switch {
case strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://"):
return m, loadImageFromURL(input)
case strings.HasPrefix(input, "data:") || (len(input) > 100 && !strings.Contains(input, "/") && !strings.Contains(input, "\\")):
return m, loadImageFromBase64(input)
default:
return m, loadImageFromPath(input)
}
default:
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
}
// handleToolsPanelKeys handles keys in tools panel state.
func (m Model) handleToolsPanelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "t":
m.toolsEnabled = !m.toolsEnabled
case "enter", "q":
m.state = StateChat
m.input.Focus()
}
return m, nil
}
// handleSettingsKeys handles keys in settings state.
func (m Model) handleSettingsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "1":
m.temperature = nil
case "2":
t := 0.0
m.temperature = &t
case "3":
t := 0.5
m.temperature = &t
case "4":
t := 0.7
m.temperature = &t
case "5":
t := 1.0
m.temperature = &t
case "enter", "q":
m.state = StateChat
m.input.Focus()
}
return m, nil
}
+291
View File
@@ -0,0 +1,291 @@
package main
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// View renders the current state.
func (m Model) View() string {
switch m.state {
case StateProviderSelect:
return m.renderProviderSelect()
case StateModelSelect:
return m.renderModelSelect()
case StateImageInput:
return m.renderImageInput()
case StateToolsPanel:
return m.renderToolsPanel()
case StateSettings:
return m.renderSettings()
case StateAPIKeyInput:
return m.renderAPIKeyInput()
default:
return m.renderChat()
}
}
// renderChat renders the main chat view.
func (m Model) renderChat() string {
var b strings.Builder
provider := m.providerName
if provider == "" {
provider = "None"
}
model := m.modelName
if model == "" {
model = "None"
}
header := headerStyle.Render(fmt.Sprintf("go-llm CLI %s",
providerBadgeStyle.Render(fmt.Sprintf("%s/%s", provider, model))))
b.WriteString(header)
b.WriteString("\n")
if m.viewportReady {
b.WriteString(m.viewport.View())
b.WriteString("\n")
}
if len(m.pendingImages) > 0 {
b.WriteString(imageIndicatorStyle.Render(fmt.Sprintf(" [%d image(s) attached]", len(m.pendingImages))))
b.WriteString("\n")
}
if m.err != nil {
b.WriteString(errorStyle.Render(" Error: " + m.err.Error()))
b.WriteString("\n")
}
if m.loading {
b.WriteString(loadingStyle.Render(" Thinking..."))
b.WriteString("\n")
}
inputBox := inputStyle.Render(m.input.View())
b.WriteString(inputBox)
b.WriteString("\n")
help := inputHelpStyle.Render("Enter: send | Ctrl+I: image | Ctrl+T: tools | Ctrl+P: provider | Ctrl+M: model | Ctrl+S: settings | Ctrl+N: new | Esc: quit")
b.WriteString(help)
return appStyle.Render(b.String())
}
// renderMessages renders all messages for the viewport.
func (m Model) renderMessages() string {
var b strings.Builder
if len(m.messages) == 0 {
b.WriteString(systemMsgStyle.Render("[System] " + m.systemPrompt))
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Start a conversation by typing a message below."))
return b.String()
}
b.WriteString(systemMsgStyle.Render("[System] " + m.systemPrompt))
b.WriteString("\n\n")
for _, msg := range m.messages {
var content string
var style lipgloss.Style
switch msg.Role {
case llm.RoleUser:
style = userMsgStyle
label := roleLabelStyle.Foreground(secondaryColor).Render("[User]")
content = label + " " + msg.Content
if msg.Images > 0 {
content += imageIndicatorStyle.Render(fmt.Sprintf(" [%d image(s)]", msg.Images))
}
case llm.RoleAssistant:
style = assistantMsgStyle
label := roleLabelStyle.Foreground(lipgloss.Color("255")).Render("[Assistant]")
content = label + " " + msg.Content
case llm.Role("tool_call"):
style = toolCallStyle
content = " -> Calling: " + msg.Content
case llm.Role("tool_result"):
style = toolResultStyle
content = " <- Result: " + msg.Content
default:
style = assistantMsgStyle
content = msg.Content
}
b.WriteString(style.Render(content))
b.WriteString("\n\n")
}
return b.String()
}
// renderProviderSelect renders the provider selection view.
func (m Model) renderProviderSelect() string {
var b strings.Builder
b.WriteString(headerStyle.Render("Select Provider"))
b.WriteString("\n\n")
for i, item := range m.listItems {
cursor := " "
style := normalItemStyle
if i == m.listIndex {
cursor = "> "
style = selectedItemStyle
}
b.WriteString(style.Render(cursor + item))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("Use arrow keys or j/k to navigate, Enter to select, Esc to cancel"))
return appStyle.Render(b.String())
}
// renderAPIKeyInput renders the API key input view.
func (m Model) renderAPIKeyInput() string {
var b strings.Builder
provider := m.providers[m.listIndex]
b.WriteString(headerStyle.Render(fmt.Sprintf("Enter API Key for %s", provider.Info.DisplayName)))
b.WriteString("\n\n")
if provider.Info.EnvKey != "" {
b.WriteString(fmt.Sprintf("Environment variable: %s\n\n", provider.Info.EnvKey))
}
b.WriteString("Enter your API key below (it will be hidden):\n\n")
inputBox := inputStyle.Render(m.apiKeyInput.View())
b.WriteString(inputBox)
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("Enter to confirm, Esc to cancel"))
return appStyle.Render(b.String())
}
// renderModelSelect renders the model selection view.
func (m Model) renderModelSelect() string {
var b strings.Builder
b.WriteString(headerStyle.Render(fmt.Sprintf("Select Model (%s)", m.providerName)))
b.WriteString("\n\n")
for i, item := range m.listItems {
cursor := " "
style := normalItemStyle
if i == m.listIndex {
cursor = "> "
style = selectedItemStyle
}
if item == m.modelName {
item += " (current)"
}
b.WriteString(style.Render(cursor + item))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("Use arrow keys or j/k to navigate, Enter to select, Esc to cancel"))
return appStyle.Render(b.String())
}
// renderImageInput renders the image input view.
func (m Model) renderImageInput() string {
var b strings.Builder
b.WriteString(headerStyle.Render("Add Image"))
b.WriteString("\n\n")
b.WriteString("Enter an image source:\n")
b.WriteString(" - File path (e.g., /path/to/image.png)\n")
b.WriteString(" - URL (e.g., https://example.com/image.jpg)\n")
b.WriteString(" - Base64 data or data URL\n\n")
if len(m.pendingImages) > 0 {
b.WriteString(imageIndicatorStyle.Render(fmt.Sprintf("Currently attached: %d image(s)\n\n", len(m.pendingImages))))
}
inputBox := inputStyle.Render(m.input.View())
b.WriteString(inputBox)
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("Enter to add image, Esc to cancel"))
return appStyle.Render(b.String())
}
// renderToolsPanel renders the tools panel.
func (m Model) renderToolsPanel() string {
var b strings.Builder
b.WriteString(headerStyle.Render("Tools / Function Calling"))
b.WriteString("\n\n")
status := "DISABLED"
statusStyle := errorStyle
if m.toolsEnabled {
status = "ENABLED"
statusStyle = lipgloss.NewStyle().Foreground(successColor).Bold(true)
}
b.WriteString(settingLabelStyle.Render("Tools Status:"))
b.WriteString(statusStyle.Render(status))
b.WriteString("\n\n")
b.WriteString("Available tools:\n")
if m.toolbox != nil {
for _, t := range m.toolbox.AllTools() {
b.WriteString(fmt.Sprintf(" - %s: %s\n", selectedItemStyle.Render(t.Name), t.Description))
}
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("Press 't' to toggle tools, Enter or 'q' to close"))
return appStyle.Render(b.String())
}
// renderSettings renders the settings view.
func (m Model) renderSettings() string {
var b strings.Builder
b.WriteString(headerStyle.Render("Settings"))
b.WriteString("\n\n")
tempStr := "default"
if m.temperature != nil {
tempStr = fmt.Sprintf("%.1f", *m.temperature)
}
b.WriteString(settingLabelStyle.Render("Temperature:"))
b.WriteString(settingValueStyle.Render(tempStr))
b.WriteString("\n\n")
b.WriteString("Press a key to set temperature:\n")
b.WriteString(" 1 - Default (model decides)\n")
b.WriteString(" 2 - 0.0 (deterministic)\n")
b.WriteString(" 3 - 0.5 (balanced)\n")
b.WriteString(" 4 - 0.7 (creative)\n")
b.WriteString(" 5 - 1.0 (very creative)\n")
b.WriteString("\n")
b.WriteString(settingLabelStyle.Render("System Prompt:"))
b.WriteString("\n")
b.WriteString(settingValueStyle.Render(" " + m.systemPrompt))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("Enter or 'q' to close"))
return appStyle.Render(b.String())
}
+139
View File
@@ -0,0 +1,139 @@
package llm
import (
anthProvider "gitea.stevedudenhoeffer.com/steve/go-llm/v2/anthropic"
deepseekProvider "gitea.stevedudenhoeffer.com/steve/go-llm/v2/deepseek"
googleProvider "gitea.stevedudenhoeffer.com/steve/go-llm/v2/google"
groqProvider "gitea.stevedudenhoeffer.com/steve/go-llm/v2/groq"
moonshotProvider "gitea.stevedudenhoeffer.com/steve/go-llm/v2/moonshot"
ollamaProvider "gitea.stevedudenhoeffer.com/steve/go-llm/v2/ollama"
openaiProvider "gitea.stevedudenhoeffer.com/steve/go-llm/v2/openai"
xaiProvider "gitea.stevedudenhoeffer.com/steve/go-llm/v2/xai"
)
// OpenAI creates an OpenAI client.
//
// Example:
//
// model := llm.OpenAI("sk-...").Model("gpt-4o")
func OpenAI(apiKey string, opts ...ClientOption) *Client {
cfg := &clientConfig{}
for _, opt := range opts {
opt(cfg)
}
return NewClient(openaiProvider.New(apiKey, cfg.baseURL))
}
// Anthropic creates an Anthropic client.
//
// Example:
//
// model := llm.Anthropic("sk-ant-...").Model("claude-sonnet-4-20250514")
func Anthropic(apiKey string, opts ...ClientOption) *Client {
cfg := &clientConfig{}
for _, opt := range opts {
opt(cfg)
}
_ = cfg // Anthropic doesn't support custom base URL in the SDK
return NewClient(anthProvider.New(apiKey))
}
// Google creates a Google (Gemini) client.
//
// Example:
//
// model := llm.Google("...").Model("gemini-2.0-flash")
func Google(apiKey string, opts ...ClientOption) *Client {
cfg := &clientConfig{}
for _, opt := range opts {
opt(cfg)
}
_ = cfg // Google doesn't support custom base URL in the SDK
return NewClient(googleProvider.New(apiKey))
}
// DeepSeek creates a DeepSeek client (OpenAI-compatible).
//
// Example:
//
// model := llm.DeepSeek("sk-...").Model("deepseek-chat")
func DeepSeek(apiKey string, opts ...ClientOption) *Client {
cfg := &clientConfig{}
for _, opt := range opts {
opt(cfg)
}
return NewClient(deepseekProvider.New(apiKey, cfg.baseURL))
}
// Moonshot creates a Moonshot AI (Kimi) client (OpenAI-compatible).
//
// Example:
//
// model := llm.Moonshot("sk-...").Model("kimi-k2-0711-preview")
func Moonshot(apiKey string, opts ...ClientOption) *Client {
cfg := &clientConfig{}
for _, opt := range opts {
opt(cfg)
}
return NewClient(moonshotProvider.New(apiKey, cfg.baseURL))
}
// XAI creates an xAI (Grok) client (OpenAI-compatible).
//
// Example:
//
// model := llm.XAI("xai-...").Model("grok-2")
func XAI(apiKey string, opts ...ClientOption) *Client {
cfg := &clientConfig{}
for _, opt := range opts {
opt(cfg)
}
return NewClient(xaiProvider.New(apiKey, cfg.baseURL))
}
// Groq creates a Groq client (OpenAI-compatible).
//
// Example:
//
// model := llm.Groq("gsk-...").Model("llama-3.3-70b-versatile")
func Groq(apiKey string, opts ...ClientOption) *Client {
cfg := &clientConfig{}
for _, opt := range opts {
opt(cfg)
}
return NewClient(groqProvider.New(apiKey, cfg.baseURL))
}
// Ollama creates a client for a local Ollama instance using the native
// /api/chat endpoint. No API key is required. Use WithBaseURL to point at a
// non-default host/port.
//
// Example:
//
// model := llm.Ollama().Model("llama3.2")
func Ollama(opts ...ClientOption) *Client {
cfg := &clientConfig{}
for _, opt := range opts {
opt(cfg)
}
return NewClient(ollamaProvider.New("", cfg.baseURL))
}
// OllamaCloud creates a client targeting Ollama Cloud (https://ollama.com).
// The apiKey is required and is sent as `Authorization: Bearer <key>`. Use
// WithBaseURL to point at a private Ollama deployment that requires auth.
//
// Example:
//
// model := llm.OllamaCloud(os.Getenv("OLLAMA_API_KEY")).Model("kimi-k2.5")
func OllamaCloud(apiKey string, opts ...ClientOption) *Client {
cfg := &clientConfig{}
for _, opt := range opts {
opt(cfg)
}
baseURL := cfg.baseURL
if baseURL == "" {
baseURL = ollamaProvider.DefaultCloudBaseURL
}
return NewClient(ollamaProvider.New(apiKey, baseURL))
}
+41
View File
@@ -0,0 +1,41 @@
// Package deepseek implements the go-llm v2 provider interface for DeepSeek
// (https://platform.deepseek.com). DeepSeek speaks the OpenAI Chat Completions
// protocol, so this package is a thin wrapper around openaicompat with its own
// defaults and per-model Rules.
package deepseek
import (
"strings"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/openaicompat"
)
// DefaultBaseURL is the public DeepSeek API endpoint.
const DefaultBaseURL = "https://api.deepseek.com/v1"
// Provider is a type alias over openaicompat.Provider.
type Provider = openaicompat.Provider
// New creates a new DeepSeek provider. An empty baseURL uses DefaultBaseURL.
func New(apiKey, baseURL string) *Provider {
if baseURL == "" {
baseURL = DefaultBaseURL
}
return openaicompat.New(apiKey, baseURL, openaicompat.Rules{
// DeepSeek's chat and reasoner models are text-only.
SupportsVision: func(string) bool { return false },
// Reasoner doesn't accept tool calls.
SupportsTools: func(m string) bool {
return !strings.Contains(m, "reasoner")
},
// Reasoner rejects user-supplied temperature.
RestrictTemperature: func(m string) bool {
return strings.Contains(m, "reasoner")
},
// DeepSeek's reasoner thinks unconditionally; the API rejects an
// explicit reasoning_effort parameter. The thinking trace is
// surfaced via openaicompat's reasoning_content extraction without
// any opt-in.
SupportsReasoning: func(string) bool { return false },
})
}
+49
View File
@@ -0,0 +1,49 @@
package deepseek_test
import (
"context"
"errors"
"testing"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/deepseek"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/openaicompat"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
func TestNew_DefaultBaseURL(t *testing.T) {
if p := deepseek.New("key", ""); p == nil {
t.Fatal("New returned nil")
}
}
func TestRules_ReasonerRejectsTools(t *testing.T) {
p := deepseek.New("key", "")
req := provider.Request{
Model: "deepseek-reasoner",
Messages: []provider.Message{{Role: "user", Content: "hi"}},
Tools: []provider.ToolDef{
{Name: "x", Schema: map[string]any{"type": "object"}},
},
}
_, err := p.Complete(context.Background(), req)
var fue *openaicompat.FeatureUnsupportedError
if !errors.As(err, &fue) || fue.Feature != "tools" {
t.Fatalf("want FeatureUnsupportedError(tools), got %v", err)
}
}
func TestRules_ChatRejectsImages(t *testing.T) {
p := deepseek.New("key", "")
req := provider.Request{
Model: "deepseek-chat",
Messages: []provider.Message{{
Role: "user",
Images: []provider.Image{{URL: "a"}},
}},
}
_, err := p.Complete(context.Background(), req)
var fue *openaicompat.FeatureUnsupportedError
if !errors.As(err, &fue) || fue.Feature != "vision" {
t.Fatalf("want FeatureUnsupportedError(vision), got %v", err)
}
}
+20
View File
@@ -0,0 +1,20 @@
package llm
import "errors"
var (
// ErrNoToolsConfigured is returned when the model requests tool calls but no tools are available.
ErrNoToolsConfigured = errors.New("model requested tool calls but no tools configured")
// ErrToolNotFound is returned when a requested tool is not in the toolbox.
ErrToolNotFound = errors.New("tool not found")
// ErrNotConnected is returned when trying to use an MCP server that isn't connected.
ErrNotConnected = errors.New("MCP server not connected")
// ErrStreamClosed is returned when trying to read from a closed stream.
ErrStreamClosed = errors.New("stream closed")
// ErrNoStructuredOutput is returned when the model did not return a structured output tool call.
ErrNoStructuredOutput = errors.New("model did not return structured output")
)
+56
View File
@@ -0,0 +1,56 @@
package llm
import (
"context"
"encoding/json"
"fmt"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/internal/schema"
)
const structuredOutputToolName = "structured_output"
// Generate sends a single user prompt to the model and parses the response into T.
// T must be a struct. The model is forced to return structured output matching T's schema
// by using a hidden tool call internally.
// Returns the parsed value, token usage, and any error.
func Generate[T any](ctx context.Context, model *Model, prompt string, opts ...RequestOption) (T, *Usage, error) {
return GenerateWith[T](ctx, model, []Message{UserMessage(prompt)}, opts...)
}
// GenerateWith sends the given messages to the model and parses the response into T.
// T must be a struct. The model is forced to return structured output matching T's schema
// by using a hidden tool call internally.
// Returns the parsed value, token usage, and any error.
func GenerateWith[T any](ctx context.Context, model *Model, messages []Message, opts ...RequestOption) (T, *Usage, error) {
var zero T
s := schema.FromStruct(zero)
tool := Tool{
Name: structuredOutputToolName,
Description: "Return your response as structured data using this function. You MUST call this function with your response.",
Schema: s,
}
// Append WithTools as the last option so it overrides any user-provided tools.
opts = append(opts, WithTools(NewToolBox(tool)))
resp, err := model.Complete(ctx, messages, opts...)
if err != nil {
return zero, nil, err
}
// Find the structured_output tool call in the response.
for _, tc := range resp.ToolCalls {
if tc.Name == structuredOutputToolName {
var result T
if err := json.Unmarshal([]byte(tc.Arguments), &result); err != nil {
return zero, resp.Usage, fmt.Errorf("failed to parse structured output: %w", err)
}
return result, resp.Usage, nil
}
}
return zero, resp.Usage, ErrNoStructuredOutput
}
+282
View File
@@ -0,0 +1,282 @@
package llm
import (
"context"
"errors"
"testing"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
type testPerson struct {
Name string `json:"name" description:"The person's name"`
Age int `json:"age" description:"The person's age"`
}
func TestGenerate(t *testing.T) {
mp := newMockProvider(provider.Response{
ToolCalls: []provider.ToolCall{
{
ID: "call_1",
Name: "structured_output",
Arguments: `{"name":"Alice","age":30}`,
},
},
})
model := newMockModel(mp)
result, _, err := Generate[testPerson](context.Background(), model, "Tell me about Alice")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Name != "Alice" {
t.Errorf("expected name 'Alice', got %q", result.Name)
}
if result.Age != 30 {
t.Errorf("expected age 30, got %d", result.Age)
}
// Verify the tool was sent in the request
req := mp.lastRequest()
if len(req.Tools) != 1 {
t.Fatalf("expected 1 tool, got %d", len(req.Tools))
}
if req.Tools[0].Name != "structured_output" {
t.Errorf("expected tool name 'structured_output', got %q", req.Tools[0].Name)
}
}
func TestGenerateWith(t *testing.T) {
mp := newMockProvider(provider.Response{
ToolCalls: []provider.ToolCall{
{
ID: "call_1",
Name: "structured_output",
Arguments: `{"name":"Bob","age":25}`,
},
},
})
model := newMockModel(mp)
messages := []Message{
SystemMessage("You are helpful."),
UserMessage("Tell me about Bob"),
}
result, _, err := GenerateWith[testPerson](context.Background(), model, messages)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Name != "Bob" {
t.Errorf("expected name 'Bob', got %q", result.Name)
}
if result.Age != 25 {
t.Errorf("expected age 25, got %d", result.Age)
}
// Verify messages were passed through
req := mp.lastRequest()
if len(req.Messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(req.Messages))
}
if req.Messages[0].Role != "system" {
t.Errorf("expected first message role 'system', got %q", req.Messages[0].Role)
}
}
func TestGenerate_NoToolCall(t *testing.T) {
mp := newMockProvider(provider.Response{
Text: "I can't use tools right now.",
})
model := newMockModel(mp)
_, _, err := Generate[testPerson](context.Background(), model, "Tell me about someone")
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrNoStructuredOutput) {
t.Errorf("expected ErrNoStructuredOutput, got %v", err)
}
}
func TestGenerate_InvalidJSON(t *testing.T) {
mp := newMockProvider(provider.Response{
ToolCalls: []provider.ToolCall{
{
ID: "call_1",
Name: "structured_output",
Arguments: `{not valid json}`,
},
},
})
model := newMockModel(mp)
_, _, err := Generate[testPerson](context.Background(), model, "Tell me about someone")
if err == nil {
t.Fatal("expected error, got nil")
}
if errors.Is(err, ErrNoStructuredOutput) {
t.Error("expected parse error, not ErrNoStructuredOutput")
}
}
type testAddress struct {
Street string `json:"street" description:"Street address"`
City string `json:"city" description:"City name"`
}
type testPersonWithAddress struct {
Name string `json:"name" description:"The person's name"`
Age int `json:"age" description:"The person's age"`
Address testAddress `json:"address" description:"The person's address"`
}
func TestGenerate_NestedStruct(t *testing.T) {
mp := newMockProvider(provider.Response{
ToolCalls: []provider.ToolCall{
{
ID: "call_1",
Name: "structured_output",
Arguments: `{"name":"Carol","age":40,"address":{"street":"123 Main St","city":"Springfield"}}`,
},
},
})
model := newMockModel(mp)
result, _, err := Generate[testPersonWithAddress](context.Background(), model, "Tell me about Carol")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Name != "Carol" {
t.Errorf("expected name 'Carol', got %q", result.Name)
}
if result.Address.Street != "123 Main St" {
t.Errorf("expected street '123 Main St', got %q", result.Address.Street)
}
if result.Address.City != "Springfield" {
t.Errorf("expected city 'Springfield', got %q", result.Address.City)
}
}
func TestGenerate_WithOptions(t *testing.T) {
mp := newMockProvider(provider.Response{
ToolCalls: []provider.ToolCall{
{
ID: "call_1",
Name: "structured_output",
Arguments: `{"name":"Dave","age":35}`,
},
},
})
model := newMockModel(mp)
_, _, err := Generate[testPerson](context.Background(), model, "Tell me about Dave",
WithTemperature(0.5),
WithMaxTokens(200),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
req := mp.lastRequest()
if req.Temperature == nil || *req.Temperature != 0.5 {
t.Errorf("expected temperature 0.5, got %v", req.Temperature)
}
if req.MaxTokens == nil || *req.MaxTokens != 200 {
t.Errorf("expected maxTokens 200, got %v", req.MaxTokens)
}
}
func TestGenerate_WithMiddleware(t *testing.T) {
var middlewareCalled bool
mw := func(next CompletionFunc) CompletionFunc {
return func(ctx context.Context, model string, messages []Message, cfg *requestConfig) (Response, error) {
middlewareCalled = true
return next(ctx, model, messages, cfg)
}
}
mp := newMockProvider(provider.Response{
ToolCalls: []provider.ToolCall{
{
ID: "call_1",
Name: "structured_output",
Arguments: `{"name":"Eve","age":28}`,
},
},
})
model := newMockModel(mp).WithMiddleware(mw)
result, _, err := Generate[testPerson](context.Background(), model, "Tell me about Eve")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !middlewareCalled {
t.Error("middleware was not called")
}
if result.Name != "Eve" {
t.Errorf("expected name 'Eve', got %q", result.Name)
}
}
func TestGenerate_WrongToolName(t *testing.T) {
mp := newMockProvider(provider.Response{
ToolCalls: []provider.ToolCall{
{
ID: "call_1",
Name: "some_other_tool",
Arguments: `{"name":"Frank","age":50}`,
},
},
})
model := newMockModel(mp)
_, _, err := Generate[testPerson](context.Background(), model, "Tell me about Frank")
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrNoStructuredOutput) {
t.Errorf("expected ErrNoStructuredOutput, got %v", err)
}
}
func TestGenerate_ReturnsUsage(t *testing.T) {
mp := newMockProvider(provider.Response{
ToolCalls: []provider.ToolCall{
{
ID: "call_1",
Name: "structured_output",
Arguments: `{"name":"Grace","age":22}`,
},
},
Usage: &provider.Usage{
InputTokens: 50,
OutputTokens: 20,
TotalTokens: 70,
Details: map[string]int{
"reasoning_tokens": 5,
},
},
})
model := newMockModel(mp)
result, usage, err := Generate[testPerson](context.Background(), model, "Tell me about Grace")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Name != "Grace" {
t.Errorf("expected name 'Grace', got %q", result.Name)
}
if usage == nil {
t.Fatal("expected usage, got nil")
}
if usage.InputTokens != 50 {
t.Errorf("expected input 50, got %d", usage.InputTokens)
}
if usage.OutputTokens != 20 {
t.Errorf("expected output 20, got %d", usage.OutputTokens)
}
if usage.Details["reasoning_tokens"] != 5 {
t.Errorf("expected reasoning_tokens=5, got %d", usage.Details["reasoning_tokens"])
}
}
+62
View File
@@ -0,0 +1,62 @@
module gitea.stevedudenhoeffer.com/steve/go-llm/v2
go 1.24.2
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/joho/godotenv v1.5.1
github.com/liushuangls/go-anthropic/v2 v2.17.0
github.com/modelcontextprotocol/go-sdk v1.2.0
github.com/openai/openai-go v1.12.0
github.com/pkg/sftp v1.13.10
golang.org/x/crypto v0.41.0
golang.org/x/image v0.35.0
google.golang.org/genai v1.45.0
)
require (
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/auth v0.9.3 // indirect
cloud.google.com/go/compute/metadata v0.5.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/grpc v1.66.2 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)
+215
View File
@@ -0,0 +1,215 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/liushuangls/go-anthropic/v2 v2.17.0 h1:iBA6h7aghi1q86owEQ95XE2R2MF/0dQ7bCxtwTxOg4c=
github.com/liushuangls/go-anthropic/v2 v2.17.0/go.mod h1:a550cJXPoTG2FL3DvfKG2zzD5O2vjgvo4tHtoGPzFLU=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genai v1.45.0 h1:s80ZpS42XW0zu/ogiOtenCio17nJ7reEFJjoCftukpA=
google.golang.org/genai v1.45.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+435
View File
@@ -0,0 +1,435 @@
// Package google implements the go-llm v2 provider interface for Google (Gemini).
package google
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
"google.golang.org/genai"
)
// Provider implements the provider.Provider interface for Google Gemini.
type Provider struct {
apiKey string
}
// New creates a new Google provider.
func New(apiKey string) *Provider {
return &Provider{apiKey: apiKey}
}
// Complete performs a non-streaming completion.
func (p *Provider) Complete(ctx context.Context, req provider.Request) (provider.Response, error) {
cl, err := genai.NewClient(ctx, &genai.ClientConfig{
APIKey: p.apiKey,
Backend: genai.BackendGeminiAPI,
})
if err != nil {
return provider.Response{}, fmt.Errorf("google client error: %w", err)
}
contents, cfg := p.buildRequest(req)
resp, err := cl.Models.GenerateContent(ctx, req.Model, contents, cfg)
if err != nil {
return provider.Response{}, fmt.Errorf("google completion error: %w", err)
}
return p.convertResponse(resp)
}
// Stream performs a streaming completion.
func (p *Provider) Stream(ctx context.Context, req provider.Request, events chan<- provider.StreamEvent) error {
cl, err := genai.NewClient(ctx, &genai.ClientConfig{
APIKey: p.apiKey,
Backend: genai.BackendGeminiAPI,
})
if err != nil {
return fmt.Errorf("google client error: %w", err)
}
contents, cfg := p.buildRequest(req)
var fullText strings.Builder
var fullThinking strings.Builder
var toolCalls []provider.ToolCall
var usage *provider.Usage
for resp, err := range cl.Models.GenerateContentStream(ctx, req.Model, contents, cfg) {
if err != nil {
return fmt.Errorf("google stream error: %w", err)
}
// Track usage from the last chunk (final chunk has cumulative counts)
if resp.UsageMetadata != nil {
usage = &provider.Usage{
InputTokens: int(resp.UsageMetadata.PromptTokenCount),
OutputTokens: int(resp.UsageMetadata.CandidatesTokenCount),
TotalTokens: int(resp.UsageMetadata.TotalTokenCount),
}
details := map[string]int{}
if resp.UsageMetadata.CachedContentTokenCount > 0 {
details[provider.UsageDetailCachedInputTokens] = int(resp.UsageMetadata.CachedContentTokenCount)
}
if resp.UsageMetadata.ThoughtsTokenCount > 0 {
details[provider.UsageDetailThoughtsTokens] = int(resp.UsageMetadata.ThoughtsTokenCount)
}
if len(details) > 0 {
usage.Details = details
}
}
for _, c := range resp.Candidates {
if c.Content == nil {
continue
}
for _, part := range c.Content.Parts {
if part.Text != "" {
if part.Thought {
fullThinking.WriteString(part.Text)
events <- provider.StreamEvent{
Type: provider.StreamEventThinking,
Text: part.Text,
}
} else {
fullText.WriteString(part.Text)
events <- provider.StreamEvent{
Type: provider.StreamEventText,
Text: part.Text,
}
}
}
if part.FunctionCall != nil {
args, _ := json.Marshal(part.FunctionCall.Args)
tc := provider.ToolCall{
ID: part.FunctionCall.Name,
Name: part.FunctionCall.Name,
Arguments: string(args),
}
toolCalls = append(toolCalls, tc)
events <- provider.StreamEvent{
Type: provider.StreamEventToolStart,
ToolCall: &tc,
ToolIndex: len(toolCalls) - 1,
}
events <- provider.StreamEvent{
Type: provider.StreamEventToolEnd,
ToolCall: &tc,
ToolIndex: len(toolCalls) - 1,
}
}
}
}
}
events <- provider.StreamEvent{
Type: provider.StreamEventDone,
Response: &provider.Response{
Text: fullText.String(),
Thinking: fullThinking.String(),
ToolCalls: toolCalls,
Usage: usage,
},
}
return nil
}
func (p *Provider) buildRequest(req provider.Request) ([]*genai.Content, *genai.GenerateContentConfig) {
var contents []*genai.Content
cfg := &genai.GenerateContentConfig{}
for _, tool := range req.Tools {
cfg.Tools = append(cfg.Tools, &genai.Tool{
FunctionDeclarations: []*genai.FunctionDeclaration{
{
Name: tool.Name,
Description: tool.Description,
Parameters: schemaToGenai(tool.Schema),
},
},
})
}
if req.Temperature != nil {
f := float32(*req.Temperature)
cfg.Temperature = &f
}
if req.MaxTokens != nil {
cfg.MaxOutputTokens = int32(*req.MaxTokens)
}
if req.TopP != nil {
f := float32(*req.TopP)
cfg.TopP = &f
}
if len(req.Stop) > 0 {
cfg.StopSequences = req.Stop
}
// Extended thinking via thinking_config. Models that don't support
// thinking ignore this field; budgets here mirror the Anthropic
// mapping so a single ReasoningLevel produces comparable behavior
// across providers.
if budget := thinkingBudget(req.Reasoning); budget > 0 {
b := int32(budget)
cfg.ThinkingConfig = &genai.ThinkingConfig{
ThinkingBudget: &b,
IncludeThoughts: true,
}
}
for _, msg := range req.Messages {
var role genai.Role
switch msg.Role {
case "system":
cfg.SystemInstruction = genai.NewContentFromText(msg.Content, genai.RoleUser)
continue
case "assistant":
role = genai.RoleModel
case "tool":
// Tool results go as function responses (Genai uses RoleUser for function responses)
contents = append(contents, &genai.Content{
Role: genai.RoleUser,
Parts: []*genai.Part{
{
FunctionResponse: &genai.FunctionResponse{
Name: msg.ToolCallID,
Response: map[string]any{
"result": msg.Content,
},
},
},
},
})
continue
default:
role = genai.RoleUser
}
var parts []*genai.Part
if msg.Content != "" {
parts = append(parts, genai.NewPartFromText(msg.Content))
}
// Handle tool calls in assistant messages
for _, tc := range msg.ToolCalls {
var args map[string]any
if tc.Arguments != "" {
_ = json.Unmarshal([]byte(tc.Arguments), &args)
}
parts = append(parts, &genai.Part{
FunctionCall: &genai.FunctionCall{
Name: tc.Name,
Args: args,
},
})
}
for _, img := range msg.Images {
if img.URL != "" {
// Gemini doesn't support URLs directly; download
resp, err := http.Get(img.URL)
if err != nil {
continue
}
data, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
continue
}
mimeType := http.DetectContentType(data)
parts = append(parts, genai.NewPartFromBytes(data, mimeType))
} else if img.Base64 != "" {
data, err := base64.StdEncoding.DecodeString(img.Base64)
if err != nil {
continue
}
parts = append(parts, genai.NewPartFromBytes(data, img.ContentType))
}
}
for _, aud := range msg.Audio {
if aud.URL != "" {
resp, err := http.Get(aud.URL)
if err != nil {
continue
}
data, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
continue
}
mimeType := resp.Header.Get("Content-Type")
if mimeType == "" {
mimeType = aud.ContentType
}
if mimeType == "" {
mimeType = "audio/wav"
}
parts = append(parts, genai.NewPartFromBytes(data, mimeType))
} else if aud.Base64 != "" {
data, err := base64.StdEncoding.DecodeString(aud.Base64)
if err != nil {
continue
}
ct := aud.ContentType
if ct == "" {
ct = "audio/wav"
}
parts = append(parts, genai.NewPartFromBytes(data, ct))
}
}
contents = append(contents, genai.NewContentFromParts(parts, role))
}
return contents, cfg
}
func (p *Provider) convertResponse(resp *genai.GenerateContentResponse) (provider.Response, error) {
var res provider.Response
for _, c := range resp.Candidates {
if c.Content == nil {
continue
}
for _, part := range c.Content.Parts {
if part.Text != "" {
if part.Thought {
res.Thinking += part.Text
} else {
res.Text += part.Text
}
}
if part.FunctionCall != nil {
args, _ := json.Marshal(part.FunctionCall.Args)
res.ToolCalls = append(res.ToolCalls, provider.ToolCall{
ID: part.FunctionCall.Name,
Name: part.FunctionCall.Name,
Arguments: string(args),
})
}
}
}
if resp.UsageMetadata != nil {
res.Usage = &provider.Usage{
InputTokens: int(resp.UsageMetadata.PromptTokenCount),
OutputTokens: int(resp.UsageMetadata.CandidatesTokenCount),
TotalTokens: int(resp.UsageMetadata.TotalTokenCount),
}
details := map[string]int{}
if resp.UsageMetadata.CachedContentTokenCount > 0 {
details[provider.UsageDetailCachedInputTokens] = int(resp.UsageMetadata.CachedContentTokenCount)
}
if resp.UsageMetadata.ThoughtsTokenCount > 0 {
details[provider.UsageDetailThoughtsTokens] = int(resp.UsageMetadata.ThoughtsTokenCount)
}
if len(details) > 0 {
res.Usage.Details = details
}
}
return res, nil
}
// Thinking budgets used by Google for low/medium/high reasoning levels.
// Mirrors the Anthropic mapping so a single go-llm ReasoningLevel produces
// comparable behavior across providers.
const (
thinkingBudgetLow = 1024
thinkingBudgetMedium = 8000
thinkingBudgetHigh = 24000
)
// thinkingBudget returns the genai thinking_budget for a go-llm
// ReasoningLevel, or 0 to disable thinking.
func thinkingBudget(level string) int {
switch level {
case "low":
return thinkingBudgetLow
case "medium":
return thinkingBudgetMedium
case "high":
return thinkingBudgetHigh
}
return 0
}
// schemaToGenai converts a JSON Schema map to a genai.Schema.
func schemaToGenai(s map[string]any) *genai.Schema {
if s == nil {
return nil
}
schema := &genai.Schema{}
if t, ok := s["type"].(string); ok {
switch t {
case "object":
schema.Type = genai.TypeObject
case "array":
schema.Type = genai.TypeArray
case "string":
schema.Type = genai.TypeString
case "integer":
schema.Type = genai.TypeInteger
case "number":
schema.Type = genai.TypeNumber
case "boolean":
schema.Type = genai.TypeBoolean
}
}
if desc, ok := s["description"].(string); ok {
schema.Description = desc
}
if props, ok := s["properties"].(map[string]any); ok {
schema.Properties = make(map[string]*genai.Schema)
for k, v := range props {
if vm, ok := v.(map[string]any); ok {
schema.Properties[k] = schemaToGenai(vm)
}
}
}
if req, ok := s["required"].([]string); ok {
schema.Required = req
} else if req, ok := s["required"].([]any); ok {
for _, r := range req {
if rs, ok := r.(string); ok {
schema.Required = append(schema.Required, rs)
}
}
}
if items, ok := s["items"].(map[string]any); ok {
schema.Items = schemaToGenai(items)
}
if enums, ok := s["enum"].([]string); ok {
schema.Enum = enums
} else if enums, ok := s["enum"].([]any); ok {
for _, e := range enums {
if es, ok := e.(string); ok {
schema.Enum = append(schema.Enum, es)
}
}
}
return schema
}
+41
View File
@@ -0,0 +1,41 @@
// Package groq implements the go-llm v2 provider interface for Groq
// (https://console.groq.com). Groq hosts open-source models behind an OpenAI
// Chat Completions-compatible endpoint, so this package is a thin wrapper over
// openaicompat with its own defaults and per-model Rules.
package groq
import (
"strings"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/openaicompat"
)
// DefaultBaseURL is the public Groq OpenAI-compatible endpoint.
const DefaultBaseURL = "https://api.groq.com/openai/v1"
// Provider is a type alias over openaicompat.Provider.
type Provider = openaicompat.Provider
// New creates a new Groq provider. An empty baseURL uses DefaultBaseURL.
func New(apiKey, baseURL string) *Provider {
if baseURL == "" {
baseURL = DefaultBaseURL
}
return openaicompat.New(apiKey, baseURL, openaicompat.Rules{
// Only Groq-hosted vision variants (e.g. *-vision-preview) accept images.
SupportsVision: func(m string) bool {
return strings.Contains(m, "vision")
},
// Chat completions endpoint does not accept audio input; audio is via
// dedicated transcription endpoints, which go-llm doesn't cover here.
SupportsAudio: func(string) bool { return false },
// Reasoning models hosted on Groq (DeepSeek R1 distill family, qwen
// reasoning variants, gpt-oss) accept reasoning_effort. Vanilla
// Llama / Mixtral don't.
SupportsReasoning: func(m string) bool {
return strings.Contains(m, "deepseek-r1") ||
strings.Contains(m, "qwen") ||
strings.Contains(m, "gpt-oss")
},
})
}
+33
View File
@@ -0,0 +1,33 @@
package groq_test
import (
"context"
"errors"
"testing"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/groq"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/openaicompat"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
func TestNew_Basic(t *testing.T) {
if p := groq.New("key", ""); p == nil {
t.Fatal("New returned nil")
}
}
func TestRules_AudioRejected(t *testing.T) {
p := groq.New("key", "")
req := provider.Request{
Model: "llama-3.3-70b-versatile",
Messages: []provider.Message{{
Role: "user",
Audio: []provider.Audio{{Base64: "AAA=", ContentType: "audio/wav"}},
}},
}
_, err := p.Complete(context.Background(), req)
var fue *openaicompat.FeatureUnsupportedError
if !errors.As(err, &fue) || fue.Feature != "audio" {
t.Fatalf("want FeatureUnsupportedError(audio), got %v", err)
}
}
+105
View File
@@ -0,0 +1,105 @@
// Package imageutil provides image compression utilities.
package imageutil
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/gif"
"image/jpeg"
_ "image/png" // register PNG decoder
"net/http"
"golang.org/x/image/draw"
)
// CompressImage takes a base-64-encoded image (JPEG, PNG or GIF) and returns
// a base-64-encoded version that is at most maxLength bytes, along with the MIME type.
func CompressImage(b64 string, maxLength int) (string, string, error) {
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", "", fmt.Errorf("base64 decode: %w", err)
}
mime := http.DetectContentType(raw)
if len(raw) <= maxLength {
return b64, mime, nil
}
switch mime {
case "image/gif":
return compressGIF(raw, maxLength)
default:
return compressRaster(raw, maxLength)
}
}
func compressRaster(src []byte, maxLength int) (string, string, error) {
img, _, err := image.Decode(bytes.NewReader(src))
if err != nil {
return "", "", fmt.Errorf("decode raster: %w", err)
}
quality := 95
for {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
return "", "", fmt.Errorf("jpeg encode: %w", err)
}
if buf.Len() <= maxLength {
return base64.StdEncoding.EncodeToString(buf.Bytes()), "image/jpeg", nil
}
if quality > 20 {
quality -= 5
continue
}
b := img.Bounds()
if b.Dx() < 100 || b.Dy() < 100 {
return "", "", fmt.Errorf("cannot compress below %.02fMiB without destroying image", float64(maxLength)/1048576.0)
}
dst := image.NewRGBA(image.Rect(0, 0, int(float64(b.Dx())*0.8), int(float64(b.Dy())*0.8)))
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
img = dst
quality = 95
}
}
func compressGIF(src []byte, maxLength int) (string, string, error) {
g, err := gif.DecodeAll(bytes.NewReader(src))
if err != nil {
return "", "", fmt.Errorf("gif decode: %w", err)
}
for {
var buf bytes.Buffer
if err := gif.EncodeAll(&buf, g); err != nil {
return "", "", fmt.Errorf("gif encode: %w", err)
}
if buf.Len() <= maxLength {
return base64.StdEncoding.EncodeToString(buf.Bytes()), "image/gif", nil
}
w, h := g.Config.Width, g.Config.Height
if w < 100 || h < 100 {
return "", "", fmt.Errorf("cannot compress animated GIF below %.02fMiB", float64(maxLength)/1048576.0)
}
nw, nh := int(float64(w)*0.8), int(float64(h)*0.8)
for i, frm := range g.Image {
rgba := image.NewRGBA(frm.Bounds())
draw.Draw(rgba, rgba.Bounds(), frm, frm.Bounds().Min, draw.Src)
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), rgba, rgba.Bounds(), draw.Over, nil)
paletted := image.NewPaletted(dst.Bounds(), nil)
draw.FloydSteinberg.Draw(paletted, paletted.Bounds(), dst, dst.Bounds().Min)
g.Image[i] = paletted
}
g.Config.Width, g.Config.Height = nw, nh
}
}
+188
View File
@@ -0,0 +1,188 @@
// Package schema provides JSON Schema generation from Go structs.
// It produces standard JSON Schema as map[string]any, with no provider-specific types.
package schema
import (
"reflect"
"strings"
)
// FromStruct generates a JSON Schema object from a Go struct.
// Struct tags used:
// - `json:"name"` — sets the property name (standard Go JSON convention)
// - `description:"..."` — sets the property description
// - `enum:"a,b,c"` — restricts string values to the given set
//
// Pointer fields are treated as optional; non-pointer fields are required.
// Anonymous (embedded) struct fields are flattened into the parent.
func FromStruct(v any) map[string]any {
t := reflect.TypeOf(v)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
panic("schema.FromStruct expects a struct or pointer to struct")
}
return objectSchema(t)
}
func objectSchema(t reflect.Type) map[string]any {
properties := map[string]any{}
var required []string
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
// Skip unexported fields
if !field.IsExported() {
continue
}
// Flatten anonymous (embedded) structs
if field.Anonymous {
ft := field.Type
if ft.Kind() == reflect.Ptr {
ft = ft.Elem()
}
if ft.Kind() == reflect.Struct {
embedded := objectSchema(ft)
if props, ok := embedded["properties"].(map[string]any); ok {
for k, v := range props {
properties[k] = v
}
}
if req, ok := embedded["required"].([]string); ok {
required = append(required, req...)
}
}
continue
}
name := fieldName(field)
isRequired := true
ft := field.Type
if ft.Kind() == reflect.Ptr {
ft = ft.Elem()
isRequired = false
}
prop := fieldSchema(field, ft)
properties[name] = prop
if isRequired {
required = append(required, name)
}
}
result := map[string]any{
"type": "object",
"properties": properties,
}
if len(required) > 0 {
result["required"] = required
}
return result
}
func fieldSchema(field reflect.StructField, ft reflect.Type) map[string]any {
prop := map[string]any{}
// Check for enum tag first
if enumTag, ok := field.Tag.Lookup("enum"); ok {
vals := parseEnum(enumTag)
prop["type"] = "string"
prop["enum"] = vals
if desc, ok := field.Tag.Lookup("description"); ok {
prop["description"] = desc
}
return prop
}
switch ft.Kind() {
case reflect.String:
prop["type"] = "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
prop["type"] = "integer"
case reflect.Float32, reflect.Float64:
prop["type"] = "number"
case reflect.Bool:
prop["type"] = "boolean"
case reflect.Struct:
return objectSchema(ft)
case reflect.Slice:
prop["type"] = "array"
elemType := ft.Elem()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
prop["items"] = typeSchema(elemType)
case reflect.Map:
prop["type"] = "object"
if ft.Key().Kind() == reflect.String {
valType := ft.Elem()
if valType.Kind() == reflect.Ptr {
valType = valType.Elem()
}
prop["additionalProperties"] = typeSchema(valType)
}
default:
prop["type"] = "string" // fallback
}
if desc, ok := field.Tag.Lookup("description"); ok {
prop["description"] = desc
}
return prop
}
func typeSchema(t reflect.Type) map[string]any {
switch t.Kind() {
case reflect.String:
return map[string]any{"type": "string"}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return map[string]any{"type": "integer"}
case reflect.Float32, reflect.Float64:
return map[string]any{"type": "number"}
case reflect.Bool:
return map[string]any{"type": "boolean"}
case reflect.Struct:
return objectSchema(t)
case reflect.Slice:
elemType := t.Elem()
if elemType.Kind() == reflect.Ptr {
elemType = elemType.Elem()
}
return map[string]any{
"type": "array",
"items": typeSchema(elemType),
}
default:
return map[string]any{"type": "string"}
}
}
func fieldName(f reflect.StructField) string {
if tag, ok := f.Tag.Lookup("json"); ok {
parts := strings.SplitN(tag, ",", 2)
if parts[0] != "" && parts[0] != "-" {
return parts[0]
}
}
return f.Name
}
func parseEnum(tag string) []string {
parts := strings.Split(tag, ",")
var vals []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
vals = append(vals, p)
}
}
return vals
}
+181
View File
@@ -0,0 +1,181 @@
package schema
import (
"encoding/json"
"testing"
)
type SimpleParams struct {
Name string `json:"name" description:"The name"`
Age int `json:"age" description:"The age"`
}
type OptionalParams struct {
Required string `json:"required" description:"A required field"`
Optional *string `json:"optional,omitempty" description:"An optional field"`
}
type EnumParams struct {
Color string `json:"color" description:"The color" enum:"red,green,blue"`
}
type NestedParams struct {
Inner SimpleParams `json:"inner" description:"Nested object"`
}
type ArrayParams struct {
Items []string `json:"items" description:"A list of items"`
}
type EmbeddedBase struct {
ID string `json:"id" description:"The ID"`
}
type EmbeddedParams struct {
EmbeddedBase
Name string `json:"name" description:"The name"`
}
func TestFromStruct_Simple(t *testing.T) {
s := FromStruct(SimpleParams{})
if s["type"] != "object" {
t.Errorf("expected type=object, got %v", s["type"])
}
props, ok := s["properties"].(map[string]any)
if !ok {
t.Fatal("expected properties to be map[string]any")
}
if len(props) != 2 {
t.Errorf("expected 2 properties, got %d", len(props))
}
nameSchema, ok := props["name"].(map[string]any)
if !ok {
t.Fatal("expected name property to be map[string]any")
}
if nameSchema["type"] != "string" {
t.Errorf("expected name type=string, got %v", nameSchema["type"])
}
if nameSchema["description"] != "The name" {
t.Errorf("expected name description='The name', got %v", nameSchema["description"])
}
ageSchema, ok := props["age"].(map[string]any)
if !ok {
t.Fatal("expected age property to be map[string]any")
}
if ageSchema["type"] != "integer" {
t.Errorf("expected age type=integer, got %v", ageSchema["type"])
}
required, ok := s["required"].([]string)
if !ok {
t.Fatal("expected required to be []string")
}
if len(required) != 2 {
t.Errorf("expected 2 required fields, got %d", len(required))
}
}
func TestFromStruct_Optional(t *testing.T) {
s := FromStruct(OptionalParams{})
required, ok := s["required"].([]string)
if !ok {
t.Fatal("expected required to be []string")
}
// Only "required" field should be required, not "optional"
if len(required) != 1 {
t.Errorf("expected 1 required field, got %d: %v", len(required), required)
}
if required[0] != "required" {
t.Errorf("expected required field 'required', got %v", required[0])
}
}
func TestFromStruct_Enum(t *testing.T) {
s := FromStruct(EnumParams{})
props := s["properties"].(map[string]any)
colorSchema := props["color"].(map[string]any)
if colorSchema["type"] != "string" {
t.Errorf("expected enum type=string, got %v", colorSchema["type"])
}
enums, ok := colorSchema["enum"].([]string)
if !ok {
t.Fatal("expected enum to be []string")
}
if len(enums) != 3 {
t.Errorf("expected 3 enum values, got %d", len(enums))
}
}
func TestFromStruct_Nested(t *testing.T) {
s := FromStruct(NestedParams{})
props := s["properties"].(map[string]any)
innerSchema := props["inner"].(map[string]any)
if innerSchema["type"] != "object" {
t.Errorf("expected nested type=object, got %v", innerSchema["type"])
}
innerProps := innerSchema["properties"].(map[string]any)
if len(innerProps) != 2 {
t.Errorf("expected 2 inner properties, got %d", len(innerProps))
}
}
func TestFromStruct_Array(t *testing.T) {
s := FromStruct(ArrayParams{})
props := s["properties"].(map[string]any)
itemsSchema := props["items"].(map[string]any)
if itemsSchema["type"] != "array" {
t.Errorf("expected array type=array, got %v", itemsSchema["type"])
}
items := itemsSchema["items"].(map[string]any)
if items["type"] != "string" {
t.Errorf("expected items type=string, got %v", items["type"])
}
}
func TestFromStruct_Embedded(t *testing.T) {
s := FromStruct(EmbeddedParams{})
props := s["properties"].(map[string]any)
// Should have both ID from embedded and Name
if len(props) != 2 {
t.Errorf("expected 2 properties (flattened), got %d", len(props))
}
if _, ok := props["id"]; !ok {
t.Error("expected 'id' property from embedded struct")
}
if _, ok := props["name"]; !ok {
t.Error("expected 'name' property")
}
}
func TestFromStruct_ValidJSON(t *testing.T) {
s := FromStruct(SimpleParams{})
data, err := json.Marshal(s)
if err != nil {
t.Fatalf("schema should be valid JSON: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("schema should round-trip through JSON: %v", err)
}
}
+248
View File
@@ -0,0 +1,248 @@
package llm
import (
"context"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
// Client represents an LLM provider. Create with OpenAI(), Anthropic(), Google().
type Client struct {
p provider.Provider
middleware []Middleware
}
// NewClient creates a Client backed by the given provider.
// Use this to integrate custom provider implementations or for testing.
func NewClient(p provider.Provider) *Client {
return &Client{p: p}
}
// Model returns a Model for the specified model version.
func (c *Client) Model(modelVersion string) *Model {
return &Model{
provider: c.p,
model: modelVersion,
middleware: c.middleware,
}
}
// WithMiddleware returns a new Client with additional middleware applied to all models.
func (c *Client) WithMiddleware(mw ...Middleware) *Client {
c2 := &Client{
p: c.p,
middleware: append(append([]Middleware{}, c.middleware...), mw...),
}
return c2
}
// Model represents a specific model from a provider, ready for completions.
type Model struct {
provider provider.Provider
model string
middleware []Middleware
defaultReasoning ReasoningLevel
}
// WithReasoning returns a copy of the Model that uses the given reasoning
// level by default on every Complete/Stream/Chat call. Per-request use of the
// WithReasoning request option still takes precedence.
func (m *Model) WithReasoning(level ReasoningLevel) *Model {
c := *m
c.defaultReasoning = level
return &c
}
// Complete sends a non-streaming completion request.
func (m *Model) Complete(ctx context.Context, messages []Message, opts ...RequestOption) (Response, error) {
cfg := m.newRequestConfig(opts)
chain := m.buildChain()
return chain(ctx, m.model, messages, cfg)
}
// Stream sends a streaming completion request, returning a StreamReader.
func (m *Model) Stream(ctx context.Context, messages []Message, opts ...RequestOption) (*StreamReader, error) {
cfg := m.newRequestConfig(opts)
req := buildProviderRequest(m.model, messages, cfg)
return newStreamReader(ctx, m.provider, req)
}
// newRequestConfig builds a requestConfig pre-populated with the Model's
// defaults, then applies per-call options on top.
func (m *Model) newRequestConfig(opts []RequestOption) *requestConfig {
cfg := &requestConfig{
reasoning: m.defaultReasoning,
}
for _, opt := range opts {
opt(cfg)
}
return cfg
}
// WithMiddleware returns a new Model with additional middleware applied.
func (m *Model) WithMiddleware(mw ...Middleware) *Model {
return &Model{
provider: m.provider,
model: m.model,
middleware: append(append([]Middleware{}, m.middleware...), mw...),
}
}
func (m *Model) buildChain() CompletionFunc {
// Base handler that calls the provider
base := func(ctx context.Context, model string, messages []Message, cfg *requestConfig) (Response, error) {
req := buildProviderRequest(model, messages, cfg)
resp, err := m.provider.Complete(ctx, req)
if err != nil {
return Response{}, err
}
return convertProviderResponse(resp), nil
}
// Apply middleware in reverse order (first middleware wraps outermost)
chain := base
for i := len(m.middleware) - 1; i >= 0; i-- {
chain = m.middleware[i](chain)
}
return chain
}
func buildProviderRequest(model string, messages []Message, cfg *requestConfig) provider.Request {
req := provider.Request{
Model: model,
Messages: convertMessages(messages),
}
if cfg.temperature != nil {
req.Temperature = cfg.temperature
}
if cfg.maxTokens != nil {
req.MaxTokens = cfg.maxTokens
}
if cfg.topP != nil {
req.TopP = cfg.topP
}
if len(cfg.stop) > 0 {
req.Stop = cfg.stop
}
if cfg.reasoning != "" {
req.Reasoning = string(cfg.reasoning)
}
if cfg.tools != nil {
for _, tool := range cfg.tools.AllTools() {
req.Tools = append(req.Tools, provider.ToolDef{
Name: tool.Name,
Description: tool.Description,
Schema: tool.Schema,
})
}
}
if cfg.cacheConfig != nil && cfg.cacheConfig.enabled {
hints := &provider.CacheHints{LastCacheableMessageIndex: -1}
if len(req.Tools) > 0 {
hints.CacheTools = true
}
for _, m := range messages {
if m.Role == RoleSystem {
hints.CacheSystem = true
break
}
}
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role != RoleSystem {
hints.LastCacheableMessageIndex = i
break
}
}
req.CacheHints = hints
}
return req
}
func convertMessages(msgs []Message) []provider.Message {
out := make([]provider.Message, len(msgs))
for i, m := range msgs {
pm := provider.Message{
Role: string(m.Role),
Content: m.Content.Text,
ToolCallID: m.ToolCallID,
}
for _, img := range m.Content.Images {
pm.Images = append(pm.Images, provider.Image{
URL: img.URL,
Base64: img.Base64,
ContentType: img.ContentType,
})
}
for _, aud := range m.Content.Audio {
pm.Audio = append(pm.Audio, provider.Audio{
URL: aud.URL,
Base64: aud.Base64,
ContentType: aud.ContentType,
})
}
for _, tc := range m.ToolCalls {
pm.ToolCalls = append(pm.ToolCalls, provider.ToolCall{
ID: tc.ID,
Name: tc.Name,
Arguments: tc.Arguments,
})
}
out[i] = pm
}
return out
}
func convertProviderResponse(resp provider.Response) Response {
r := Response{
Text: resp.Text,
Thinking: resp.Thinking,
}
for _, tc := range resp.ToolCalls {
r.ToolCalls = append(r.ToolCalls, ToolCall{
ID: tc.ID,
Name: tc.Name,
Arguments: tc.Arguments,
})
}
if resp.Usage != nil {
r.Usage = &Usage{
InputTokens: resp.Usage.InputTokens,
OutputTokens: resp.Usage.OutputTokens,
TotalTokens: resp.Usage.TotalTokens,
Details: resp.Usage.Details,
}
}
// Build the assistant message for conversation history
r.message = Message{
Role: RoleAssistant,
Content: Content{Text: resp.Text},
ToolCalls: r.ToolCalls,
}
return r
}
// --- Provider constructors ---
// These are defined here and delegate to provider-specific packages.
// They are set up via init() in the provider packages, or defined directly.
// ClientOption configures a client.
type ClientOption func(*clientConfig)
type clientConfig struct {
baseURL string
}
// WithBaseURL overrides the API base URL.
func WithBaseURL(url string) ClientOption {
return func(c *clientConfig) { c.baseURL = url }
}
+264
View File
@@ -0,0 +1,264 @@
package llm
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"sync"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// MCPTransport specifies how to connect to an MCP server.
type MCPTransport string
const (
MCPStdio MCPTransport = "stdio"
MCPSSE MCPTransport = "sse"
MCPHTTP MCPTransport = "http"
)
// MCPServer represents a connection to an MCP server.
type MCPServer struct {
name string
transport MCPTransport
// stdio fields
command string
args []string
env []string
// network fields
url string
// internal
client *mcp.Client
session *mcp.ClientSession
tools map[string]*mcp.Tool
mu sync.RWMutex
}
// MCPOption configures an MCP server.
type MCPOption func(*MCPServer)
// WithMCPEnv adds environment variables for the subprocess.
func WithMCPEnv(env ...string) MCPOption {
return func(s *MCPServer) { s.env = env }
}
// WithMCPName sets a friendly name for logging.
func WithMCPName(name string) MCPOption {
return func(s *MCPServer) { s.name = name }
}
// MCPStdioServer creates and connects to an MCP server via stdio transport.
//
// Example:
//
// server, err := llm.MCPStdioServer(ctx, "npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp")
func MCPStdioServer(ctx context.Context, command string, args ...string) (*MCPServer, error) {
s := &MCPServer{
name: command,
transport: MCPStdio,
command: command,
args: args,
}
if err := s.connect(ctx); err != nil {
return nil, err
}
return s, nil
}
// MCPHTTPServer creates and connects to an MCP server via streamable HTTP transport.
//
// Example:
//
// server, err := llm.MCPHTTPServer(ctx, "https://mcp.example.com")
func MCPHTTPServer(ctx context.Context, url string, opts ...MCPOption) (*MCPServer, error) {
s := &MCPServer{
name: url,
transport: MCPHTTP,
url: url,
}
for _, opt := range opts {
opt(s)
}
if err := s.connect(ctx); err != nil {
return nil, err
}
return s, nil
}
// MCPSSEServer creates and connects to an MCP server via SSE transport.
func MCPSSEServer(ctx context.Context, url string, opts ...MCPOption) (*MCPServer, error) {
s := &MCPServer{
name: url,
transport: MCPSSE,
url: url,
}
for _, opt := range opts {
opt(s)
}
if err := s.connect(ctx); err != nil {
return nil, err
}
return s, nil
}
func (s *MCPServer) connect(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.session != nil {
return nil
}
s.client = mcp.NewClient(&mcp.Implementation{
Name: "go-llm-v2",
Version: "2.0.0",
}, nil)
var transport mcp.Transport
switch s.transport {
case MCPSSE:
transport = &mcp.SSEClientTransport{
Endpoint: s.url,
}
case MCPHTTP:
transport = &mcp.StreamableClientTransport{
Endpoint: s.url,
}
default: // stdio
cmd := exec.Command(s.command, s.args...)
cmd.Env = append(os.Environ(), s.env...)
transport = &mcp.CommandTransport{
Command: cmd,
}
}
session, err := s.client.Connect(ctx, transport, nil)
if err != nil {
return fmt.Errorf("failed to connect to MCP server %s: %w", s.name, err)
}
s.session = session
// Load tools
s.tools = make(map[string]*mcp.Tool)
for tool, err := range session.Tools(ctx, nil) {
if err != nil {
s.session.Close()
s.session = nil
return fmt.Errorf("failed to list tools from %s: %w", s.name, err)
}
s.tools[tool.Name] = tool
}
return nil
}
// Close closes the connection to the MCP server.
func (s *MCPServer) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.session == nil {
return nil
}
err := s.session.Close()
s.session = nil
s.tools = nil
return err
}
// IsConnected returns true if the server is connected.
func (s *MCPServer) IsConnected() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.session != nil
}
// ListTools returns Tool definitions for all tools this server provides.
func (s *MCPServer) ListTools() []Tool {
s.mu.RLock()
defer s.mu.RUnlock()
var tools []Tool
for _, t := range s.tools {
tools = append(tools, s.toTool(t))
}
return tools
}
// CallTool invokes a tool on the server.
func (s *MCPServer) CallTool(ctx context.Context, name string, arguments map[string]any) (string, error) {
s.mu.RLock()
session := s.session
s.mu.RUnlock()
if session == nil {
return "", fmt.Errorf("%w: %s", ErrNotConnected, s.name)
}
result, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: name,
Arguments: arguments,
})
if err != nil {
return "", err
}
if len(result.Content) == 0 {
return "", nil
}
return contentToString(result.Content), nil
}
func (s *MCPServer) toTool(t *mcp.Tool) Tool {
var inputSchema map[string]any
if t.InputSchema != nil {
data, err := json.Marshal(t.InputSchema)
if err == nil {
_ = json.Unmarshal(data, &inputSchema)
}
}
if inputSchema == nil {
inputSchema = map[string]any{
"type": "object",
"properties": map[string]any{},
}
}
return Tool{
Name: t.Name,
Description: t.Description,
Schema: inputSchema,
isMCP: true,
mcpServer: s,
}
}
func contentToString(content []mcp.Content) string {
var parts []string
for _, c := range content {
switch tc := c.(type) {
case *mcp.TextContent:
parts = append(parts, tc.Text)
default:
if data, err := json.Marshal(c); err == nil {
parts = append(parts, string(data))
}
}
}
if len(parts) == 1 {
return parts[0]
}
data, _ := json.Marshal(parts)
return string(data)
}
+87
View File
@@ -0,0 +1,87 @@
package llm
// Role represents who authored a message.
type Role string
const (
RoleSystem Role = "system"
RoleUser Role = "user"
RoleAssistant Role = "assistant"
RoleTool Role = "tool"
)
// Image represents an image attachment.
type Image struct {
// Provide exactly one of URL or Base64.
URL string // HTTP(S) URL
Base64 string // Raw base64-encoded data
ContentType string // MIME type (e.g., "image/png"), required for Base64
}
// Audio represents an audio attachment.
type Audio struct {
// Provide exactly one of URL or Base64.
URL string // HTTP(S) URL to audio file
Base64 string // Raw base64-encoded audio data
ContentType string // MIME type (e.g., "audio/wav", "audio/mp3")
}
// Content represents message content with optional text, images, and audio.
type Content struct {
Text string
Images []Image
Audio []Audio
}
// ToolCall represents a tool invocation requested by the assistant.
type ToolCall struct {
ID string
Name string
Arguments string // raw JSON
}
// Message represents a single message in a conversation.
type Message struct {
Role Role
Content Content
// ToolCallID is set when Role == RoleTool, identifying which tool call this responds to.
ToolCallID string
// ToolCalls is set when the assistant requests tool invocations.
ToolCalls []ToolCall
}
// UserMessage creates a user message with text content.
func UserMessage(text string) Message {
return Message{Role: RoleUser, Content: Content{Text: text}}
}
// UserMessageWithImages creates a user message with text and images.
func UserMessageWithImages(text string, images ...Image) Message {
return Message{Role: RoleUser, Content: Content{Text: text, Images: images}}
}
// UserMessageWithAudio creates a user message with text and audio attachments.
func UserMessageWithAudio(text string, audio ...Audio) Message {
return Message{Role: RoleUser, Content: Content{Text: text, Audio: audio}}
}
// SystemMessage creates a system prompt message.
func SystemMessage(text string) Message {
return Message{Role: RoleSystem, Content: Content{Text: text}}
}
// AssistantMessage creates an assistant message with text content.
func AssistantMessage(text string) Message {
return Message{Role: RoleAssistant, Content: Content{Text: text}}
}
// ToolResultMessage creates a tool result message.
func ToolResultMessage(toolCallID string, result string) Message {
return Message{
Role: RoleTool,
Content: Content{Text: result},
ToolCallID: toolCallID,
}
}
+212
View File
@@ -0,0 +1,212 @@
package llm
import (
"testing"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
func TestUserMessage(t *testing.T) {
msg := UserMessage("hello")
if msg.Role != RoleUser {
t.Errorf("expected role=user, got %v", msg.Role)
}
if msg.Content.Text != "hello" {
t.Errorf("expected text='hello', got %q", msg.Content.Text)
}
if len(msg.Content.Images) != 0 {
t.Errorf("expected no images, got %d", len(msg.Content.Images))
}
}
func TestUserMessageWithImages(t *testing.T) {
img1 := Image{URL: "https://example.com/1.png"}
img2 := Image{Base64: "abc123", ContentType: "image/png"}
msg := UserMessageWithImages("describe", img1, img2)
if msg.Role != RoleUser {
t.Errorf("expected role=user, got %v", msg.Role)
}
if msg.Content.Text != "describe" {
t.Errorf("expected text='describe', got %q", msg.Content.Text)
}
if len(msg.Content.Images) != 2 {
t.Fatalf("expected 2 images, got %d", len(msg.Content.Images))
}
if msg.Content.Images[0].URL != "https://example.com/1.png" {
t.Errorf("expected image[0] URL, got %q", msg.Content.Images[0].URL)
}
if msg.Content.Images[1].Base64 != "abc123" {
t.Errorf("expected image[1] base64='abc123', got %q", msg.Content.Images[1].Base64)
}
if msg.Content.Images[1].ContentType != "image/png" {
t.Errorf("expected image[1] contentType='image/png', got %q", msg.Content.Images[1].ContentType)
}
}
func TestSystemMessage(t *testing.T) {
msg := SystemMessage("Be helpful")
if msg.Role != RoleSystem {
t.Errorf("expected role=system, got %v", msg.Role)
}
if msg.Content.Text != "Be helpful" {
t.Errorf("expected text='Be helpful', got %q", msg.Content.Text)
}
}
func TestAssistantMessage(t *testing.T) {
msg := AssistantMessage("Sure thing")
if msg.Role != RoleAssistant {
t.Errorf("expected role=assistant, got %v", msg.Role)
}
if msg.Content.Text != "Sure thing" {
t.Errorf("expected text='Sure thing', got %q", msg.Content.Text)
}
}
func TestToolResultMessage(t *testing.T) {
msg := ToolResultMessage("tc-123", "result data")
if msg.Role != RoleTool {
t.Errorf("expected role=tool, got %v", msg.Role)
}
if msg.ToolCallID != "tc-123" {
t.Errorf("expected toolCallID='tc-123', got %q", msg.ToolCallID)
}
if msg.Content.Text != "result data" {
t.Errorf("expected text='result data', got %q", msg.Content.Text)
}
}
func TestConvertMessages(t *testing.T) {
msgs := []Message{
SystemMessage("system prompt"),
UserMessageWithImages("look at this", Image{URL: "https://example.com/img.png"}),
{
Role: RoleAssistant,
Content: Content{Text: "I'll use a tool"},
ToolCalls: []ToolCall{
{ID: "tc1", Name: "search", Arguments: `{"q":"test"}`},
},
},
ToolResultMessage("tc1", "found it"),
}
converted := convertMessages(msgs)
if len(converted) != 4 {
t.Fatalf("expected 4 converted messages, got %d", len(converted))
}
// System message
if converted[0].Role != "system" {
t.Errorf("msg[0]: expected role='system', got %q", converted[0].Role)
}
if converted[0].Content != "system prompt" {
t.Errorf("msg[0]: expected content='system prompt', got %q", converted[0].Content)
}
// User message with images
if converted[1].Role != "user" {
t.Errorf("msg[1]: expected role='user', got %q", converted[1].Role)
}
if len(converted[1].Images) != 1 {
t.Fatalf("msg[1]: expected 1 image, got %d", len(converted[1].Images))
}
if converted[1].Images[0].URL != "https://example.com/img.png" {
t.Errorf("msg[1]: expected image URL, got %q", converted[1].Images[0].URL)
}
// Assistant message with tool calls
if converted[2].Role != "assistant" {
t.Errorf("msg[2]: expected role='assistant', got %q", converted[2].Role)
}
if len(converted[2].ToolCalls) != 1 {
t.Fatalf("msg[2]: expected 1 tool call, got %d", len(converted[2].ToolCalls))
}
if converted[2].ToolCalls[0].ID != "tc1" {
t.Errorf("msg[2]: expected tool call ID='tc1', got %q", converted[2].ToolCalls[0].ID)
}
if converted[2].ToolCalls[0].Name != "search" {
t.Errorf("msg[2]: expected tool call name='search', got %q", converted[2].ToolCalls[0].Name)
}
if converted[2].ToolCalls[0].Arguments != `{"q":"test"}` {
t.Errorf("msg[2]: expected tool call arguments, got %q", converted[2].ToolCalls[0].Arguments)
}
// Tool result message
if converted[3].Role != "tool" {
t.Errorf("msg[3]: expected role='tool', got %q", converted[3].Role)
}
if converted[3].ToolCallID != "tc1" {
t.Errorf("msg[3]: expected toolCallID='tc1', got %q", converted[3].ToolCallID)
}
if converted[3].Content != "found it" {
t.Errorf("msg[3]: expected content='found it', got %q", converted[3].Content)
}
}
func TestConvertProviderResponse(t *testing.T) {
t.Run("text only", func(t *testing.T) {
resp := convertProviderResponse(provider.Response{
Text: "hello",
Usage: &provider.Usage{
InputTokens: 10,
OutputTokens: 5,
TotalTokens: 15,
},
})
if resp.Text != "hello" {
t.Errorf("expected text='hello', got %q", resp.Text)
}
if resp.HasToolCalls() {
t.Error("expected no tool calls")
}
if resp.Usage == nil {
t.Fatal("expected usage")
}
if resp.Usage.InputTokens != 10 {
t.Errorf("expected 10 input tokens, got %d", resp.Usage.InputTokens)
}
msg := resp.Message()
if msg.Role != RoleAssistant {
t.Errorf("expected role=assistant, got %v", msg.Role)
}
if msg.Content.Text != "hello" {
t.Errorf("expected message text='hello', got %q", msg.Content.Text)
}
})
t.Run("with tool calls", func(t *testing.T) {
resp := convertProviderResponse(provider.Response{
ToolCalls: []provider.ToolCall{
{ID: "tc1", Name: "search", Arguments: `{"q":"go"}`},
{ID: "tc2", Name: "calc", Arguments: `{"a":1}`},
},
})
if !resp.HasToolCalls() {
t.Fatal("expected tool calls")
}
if len(resp.ToolCalls) != 2 {
t.Fatalf("expected 2 tool calls, got %d", len(resp.ToolCalls))
}
if resp.ToolCalls[0].ID != "tc1" || resp.ToolCalls[0].Name != "search" {
t.Errorf("unexpected tool call[0]: %+v", resp.ToolCalls[0])
}
if resp.ToolCalls[1].ID != "tc2" || resp.ToolCalls[1].Name != "calc" {
t.Errorf("unexpected tool call[1]: %+v", resp.ToolCalls[1])
}
msg := resp.Message()
if len(msg.ToolCalls) != 2 {
t.Errorf("expected 2 tool calls in message, got %d", len(msg.ToolCalls))
}
})
t.Run("nil usage", func(t *testing.T) {
resp := convertProviderResponse(provider.Response{Text: "ok"})
if resp.Usage != nil {
t.Errorf("expected nil usage, got %+v", resp.Usage)
}
})
}
+140
View File
@@ -0,0 +1,140 @@
package llm
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
)
// CompletionFunc is the signature for the completion call chain.
type CompletionFunc func(ctx context.Context, model string, messages []Message, cfg *requestConfig) (Response, error)
// Middleware wraps a completion call. It receives the next handler in the chain
// and returns a new handler that can inspect/modify the request and response.
type Middleware func(next CompletionFunc) CompletionFunc
// WithLogging returns middleware that logs requests and responses via slog.
func WithLogging(logger *slog.Logger) Middleware {
return func(next CompletionFunc) CompletionFunc {
return func(ctx context.Context, model string, messages []Message, cfg *requestConfig) (Response, error) {
logger.Info("llm request",
"model", model,
"message_count", len(messages),
)
start := time.Now()
resp, err := next(ctx, model, messages, cfg)
elapsed := time.Since(start)
if err != nil {
logger.Error("llm error", "model", model, "elapsed", elapsed, "error", err)
} else {
logger.Info("llm response",
"model", model,
"elapsed", elapsed,
"text_len", len(resp.Text),
"tool_calls", len(resp.ToolCalls),
)
}
return resp, err
}
}
}
// WithRetry returns middleware that retries failed requests with configurable backoff.
func WithRetry(maxRetries int, backoff func(attempt int) time.Duration) Middleware {
return func(next CompletionFunc) CompletionFunc {
return func(ctx context.Context, model string, messages []Message, cfg *requestConfig) (Response, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return Response{}, ctx.Err()
case <-time.After(backoff(attempt)):
}
}
resp, err := next(ctx, model, messages, cfg)
if err == nil {
return resp, nil
}
lastErr = err
}
return Response{}, fmt.Errorf("after %d retries: %w", maxRetries, lastErr)
}
}
}
// WithTimeout returns middleware that enforces a per-request timeout.
func WithTimeout(d time.Duration) Middleware {
return func(next CompletionFunc) CompletionFunc {
return func(ctx context.Context, model string, messages []Message, cfg *requestConfig) (Response, error) {
ctx, cancel := context.WithTimeout(ctx, d)
defer cancel()
return next(ctx, model, messages, cfg)
}
}
}
// UsageTracker accumulates token usage statistics across calls.
type UsageTracker struct {
mu sync.Mutex
TotalInput int64
TotalOutput int64
TotalRequests int64
TotalDetails map[string]int64
}
// Add records usage from a single request.
func (ut *UsageTracker) Add(u *Usage) {
if u == nil {
return
}
ut.mu.Lock()
defer ut.mu.Unlock()
ut.TotalInput += int64(u.InputTokens)
ut.TotalOutput += int64(u.OutputTokens)
ut.TotalRequests++
if len(u.Details) > 0 {
if ut.TotalDetails == nil {
ut.TotalDetails = make(map[string]int64)
}
for k, v := range u.Details {
ut.TotalDetails[k] += int64(v)
}
}
}
// Summary returns the accumulated totals.
func (ut *UsageTracker) Summary() (input, output, requests int64) {
ut.mu.Lock()
defer ut.mu.Unlock()
return ut.TotalInput, ut.TotalOutput, ut.TotalRequests
}
// Details returns a copy of the accumulated detail totals.
func (ut *UsageTracker) Details() map[string]int64 {
ut.mu.Lock()
defer ut.mu.Unlock()
if ut.TotalDetails == nil {
return nil
}
cp := make(map[string]int64, len(ut.TotalDetails))
for k, v := range ut.TotalDetails {
cp[k] = v
}
return cp
}
// WithUsageTracking returns middleware that accumulates token usage across calls.
func WithUsageTracking(tracker *UsageTracker) Middleware {
return func(next CompletionFunc) CompletionFunc {
return func(ctx context.Context, model string, messages []Message, cfg *requestConfig) (Response, error) {
resp, err := next(ctx, model, messages, cfg)
if err == nil {
tracker.Add(resp.Usage)
}
return resp, err
}
}
}
+359
View File
@@ -0,0 +1,359 @@
package llm
import (
"context"
"errors"
"log/slog"
"sync"
"sync/atomic"
"testing"
"time"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
func TestWithRetry_Success(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "ok"})
model := newMockModel(mp).WithMiddleware(
WithRetry(3, func(attempt int) time.Duration { return time.Millisecond }),
)
resp, err := model.Complete(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Text != "ok" {
t.Errorf("expected 'ok', got %q", resp.Text)
}
if len(mp.Requests) != 1 {
t.Errorf("expected 1 request (no retries needed), got %d", len(mp.Requests))
}
}
func TestWithRetry_EventualSuccess(t *testing.T) {
var callCount int32
mp := newMockProviderFunc(func(ctx context.Context, req provider.Request) (provider.Response, error) {
n := atomic.AddInt32(&callCount, 1)
if n <= 2 {
return provider.Response{}, errors.New("transient error")
}
return provider.Response{Text: "success"}, nil
})
model := newMockModel(mp).WithMiddleware(
WithRetry(3, func(attempt int) time.Duration { return time.Millisecond }),
)
resp, err := model.Complete(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Text != "success" {
t.Errorf("expected 'success', got %q", resp.Text)
}
if atomic.LoadInt32(&callCount) != 3 {
t.Errorf("expected 3 calls, got %d", callCount)
}
}
func TestWithRetry_AllFail(t *testing.T) {
providerErr := errors.New("persistent error")
mp := newMockProviderFunc(func(ctx context.Context, req provider.Request) (provider.Response, error) {
return provider.Response{}, providerErr
})
model := newMockModel(mp).WithMiddleware(
WithRetry(2, func(attempt int) time.Duration { return time.Millisecond }),
)
_, err := model.Complete(context.Background(), []Message{UserMessage("test")})
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, providerErr) {
t.Errorf("expected wrapped persistent error, got %v", err)
}
if len(mp.Requests) != 3 {
t.Errorf("expected 3 requests (1 initial + 2 retries), got %d", len(mp.Requests))
}
}
func TestWithRetry_ContextCancelled(t *testing.T) {
mp := newMockProviderFunc(func(ctx context.Context, req provider.Request) (provider.Response, error) {
return provider.Response{}, errors.New("fail")
})
model := newMockModel(mp).WithMiddleware(
WithRetry(10, func(attempt int) time.Duration { return 5 * time.Second }),
)
ctx, cancel := context.WithCancel(context.Background())
// Cancel after a short delay
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
_, err := model.Complete(ctx, []Message{UserMessage("test")})
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got %v", err)
}
}
func TestWithTimeout(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "fast"})
model := newMockModel(mp).WithMiddleware(WithTimeout(5 * time.Second))
resp, err := model.Complete(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Text != "fast" {
t.Errorf("expected 'fast', got %q", resp.Text)
}
}
func TestWithTimeout_Exceeded(t *testing.T) {
mp := newMockProviderFunc(func(ctx context.Context, req provider.Request) (provider.Response, error) {
select {
case <-ctx.Done():
return provider.Response{}, ctx.Err()
case <-time.After(5 * time.Second):
return provider.Response{Text: "slow"}, nil
}
})
model := newMockModel(mp).WithMiddleware(WithTimeout(50 * time.Millisecond))
_, err := model.Complete(context.Background(), []Message{UserMessage("test")})
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("expected DeadlineExceeded, got %v", err)
}
}
func TestWithUsageTracking(t *testing.T) {
mp := newMockProvider(provider.Response{
Text: "ok",
Usage: &provider.Usage{
InputTokens: 10,
OutputTokens: 5,
TotalTokens: 15,
},
})
tracker := &UsageTracker{}
model := newMockModel(mp).WithMiddleware(WithUsageTracking(tracker))
// Make two requests
for i := 0; i < 2; i++ {
_, err := model.Complete(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error on call %d: %v", i, err)
}
}
input, output, requests := tracker.Summary()
if input != 20 {
t.Errorf("expected total input 20, got %d", input)
}
if output != 10 {
t.Errorf("expected total output 10, got %d", output)
}
if requests != 2 {
t.Errorf("expected 2 requests, got %d", requests)
}
}
func TestWithUsageTracking_NilUsage(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "no usage"})
tracker := &UsageTracker{}
model := newMockModel(mp).WithMiddleware(WithUsageTracking(tracker))
_, err := model.Complete(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
input, output, requests := tracker.Summary()
if input != 0 || output != 0 {
t.Errorf("expected 0 tokens with nil usage, got input=%d output=%d", input, output)
}
// Add(nil) returns early without incrementing TotalRequests
if requests != 0 {
t.Errorf("expected 0 requests (nil usage skips Add), got %d", requests)
}
}
func TestUsageTracker_Concurrent(t *testing.T) {
tracker := &UsageTracker{}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
tracker.Add(&Usage{
InputTokens: 10,
OutputTokens: 5,
TotalTokens: 15,
})
}()
}
wg.Wait()
input, output, requests := tracker.Summary()
if input != 1000 {
t.Errorf("expected total input 1000, got %d", input)
}
if output != 500 {
t.Errorf("expected total output 500, got %d", output)
}
if requests != 100 {
t.Errorf("expected 100 requests, got %d", requests)
}
}
func TestMiddleware_Chaining(t *testing.T) {
var order []string
mw1 := func(next CompletionFunc) CompletionFunc {
return func(ctx context.Context, model string, messages []Message, cfg *requestConfig) (Response, error) {
order = append(order, "mw1-before")
resp, err := next(ctx, model, messages, cfg)
order = append(order, "mw1-after")
return resp, err
}
}
mw2 := func(next CompletionFunc) CompletionFunc {
return func(ctx context.Context, model string, messages []Message, cfg *requestConfig) (Response, error) {
order = append(order, "mw2-before")
resp, err := next(ctx, model, messages, cfg)
order = append(order, "mw2-after")
return resp, err
}
}
mp := newMockProvider(provider.Response{Text: "ok"})
model := newMockModel(mp).WithMiddleware(mw1, mw2)
_, err := model.Complete(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := []string{"mw1-before", "mw2-before", "mw2-after", "mw1-after"}
if len(order) != len(expected) {
t.Fatalf("expected %d middleware calls, got %d: %v", len(expected), len(order), order)
}
for i, v := range expected {
if order[i] != v {
t.Errorf("order[%d]: expected %q, got %q", i, v, order[i])
}
}
}
func TestWithLogging(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "logged"})
logger := slog.Default()
model := newMockModel(mp).WithMiddleware(WithLogging(logger))
resp, err := model.Complete(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Text != "logged" {
t.Errorf("expected 'logged', got %q", resp.Text)
}
}
func TestWithLogging_Error(t *testing.T) {
providerErr := errors.New("log this error")
mp := newMockProviderFunc(func(ctx context.Context, req provider.Request) (provider.Response, error) {
return provider.Response{}, providerErr
})
logger := slog.Default()
model := newMockModel(mp).WithMiddleware(WithLogging(logger))
_, err := model.Complete(context.Background(), []Message{UserMessage("test")})
if !errors.Is(err, providerErr) {
t.Errorf("expected provider error, got %v", err)
}
}
func TestUsageTracker_Details(t *testing.T) {
tracker := &UsageTracker{}
tracker.Add(&Usage{
InputTokens: 100,
OutputTokens: 50,
TotalTokens: 150,
Details: map[string]int{
"cached_input_tokens": 20,
"reasoning_tokens": 10,
},
})
tracker.Add(&Usage{
InputTokens: 80,
OutputTokens: 40,
TotalTokens: 120,
Details: map[string]int{
"cached_input_tokens": 15,
},
})
details := tracker.Details()
if details == nil {
t.Fatal("expected details, got nil")
}
if details["cached_input_tokens"] != 35 {
t.Errorf("expected cached_input_tokens=35, got %d", details["cached_input_tokens"])
}
if details["reasoning_tokens"] != 10 {
t.Errorf("expected reasoning_tokens=10, got %d", details["reasoning_tokens"])
}
// Verify returned map is a copy
details["cached_input_tokens"] = 999
fresh := tracker.Details()
if fresh["cached_input_tokens"] != 35 {
t.Error("Details() did not return a copy")
}
}
func TestUsageTracker_Details_Nil(t *testing.T) {
tracker := &UsageTracker{}
tracker.Add(&Usage{InputTokens: 10, OutputTokens: 5, TotalTokens: 15})
details := tracker.Details()
if details != nil {
t.Errorf("expected nil details for usage without details, got %v", details)
}
}
func TestWithUsageTracking_WithDetails(t *testing.T) {
mp := newMockProvider(provider.Response{
Text: "ok",
Usage: &provider.Usage{
InputTokens: 100,
OutputTokens: 50,
TotalTokens: 150,
Details: map[string]int{
"cached_input_tokens": 30,
},
},
})
tracker := &UsageTracker{}
model := newMockModel(mp).WithMiddleware(WithUsageTracking(tracker))
_, err := model.Complete(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
details := tracker.Details()
if details["cached_input_tokens"] != 30 {
t.Errorf("expected cached_input_tokens=30, got %d", details["cached_input_tokens"])
}
}
+87
View File
@@ -0,0 +1,87 @@
package llm
import (
"context"
"sync"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
// mockProvider is a configurable mock implementation of provider.Provider for testing.
type mockProvider struct {
CompleteFunc func(ctx context.Context, req provider.Request) (provider.Response, error)
StreamFunc func(ctx context.Context, req provider.Request, events chan<- provider.StreamEvent) error
// mu guards Requests
mu sync.Mutex
Requests []provider.Request
}
func (m *mockProvider) Complete(ctx context.Context, req provider.Request) (provider.Response, error) {
m.mu.Lock()
m.Requests = append(m.Requests, req)
m.mu.Unlock()
return m.CompleteFunc(ctx, req)
}
func (m *mockProvider) Stream(ctx context.Context, req provider.Request, events chan<- provider.StreamEvent) error {
m.mu.Lock()
m.Requests = append(m.Requests, req)
m.mu.Unlock()
if m.StreamFunc != nil {
return m.StreamFunc(ctx, req, events)
}
close(events)
return nil
}
// lastRequest returns the most recent request recorded by the mock.
func (m *mockProvider) lastRequest() provider.Request {
m.mu.Lock()
defer m.mu.Unlock()
if len(m.Requests) == 0 {
return provider.Request{}
}
return m.Requests[len(m.Requests)-1]
}
// newMockProvider creates a mock that always returns the given response.
func newMockProvider(resp provider.Response) *mockProvider {
return &mockProvider{
CompleteFunc: func(ctx context.Context, req provider.Request) (provider.Response, error) {
return resp, nil
},
}
}
// newMockProviderFunc creates a mock with a custom Complete function.
func newMockProviderFunc(fn func(ctx context.Context, req provider.Request) (provider.Response, error)) *mockProvider {
return &mockProvider{CompleteFunc: fn}
}
// newMockStreamProvider creates a mock that streams the given events.
func newMockStreamProvider(events []provider.StreamEvent) *mockProvider {
return &mockProvider{
CompleteFunc: func(ctx context.Context, req provider.Request) (provider.Response, error) {
return provider.Response{}, nil
},
StreamFunc: func(ctx context.Context, req provider.Request, ch chan<- provider.StreamEvent) error {
for _, ev := range events {
select {
case ch <- ev:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
},
}
}
// newMockModel creates a *Model backed by the given mock provider.
func newMockModel(p *mockProvider) *Model {
return &Model{
provider: p,
model: "mock-model",
}
}
+215
View File
@@ -0,0 +1,215 @@
package llm
import (
"context"
"errors"
"testing"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
func TestModel_Complete(t *testing.T) {
mp := newMockProvider(provider.Response{
Text: "Hello!",
Usage: &provider.Usage{
InputTokens: 10,
OutputTokens: 5,
TotalTokens: 15,
},
})
model := newMockModel(mp)
resp, err := model.Complete(context.Background(), []Message{UserMessage("Hi")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Text != "Hello!" {
t.Errorf("expected text 'Hello!', got %q", resp.Text)
}
if resp.Usage == nil {
t.Fatal("expected usage, got nil")
}
if resp.Usage.InputTokens != 10 {
t.Errorf("expected input tokens 10, got %d", resp.Usage.InputTokens)
}
if resp.Usage.OutputTokens != 5 {
t.Errorf("expected output tokens 5, got %d", resp.Usage.OutputTokens)
}
if resp.Usage.TotalTokens != 15 {
t.Errorf("expected total tokens 15, got %d", resp.Usage.TotalTokens)
}
}
func TestModel_Complete_WithOptions(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "ok"})
model := newMockModel(mp)
temp := 0.7
maxTok := 100
topP := 0.9
_, err := model.Complete(context.Background(), []Message{UserMessage("test")},
WithTemperature(temp),
WithMaxTokens(maxTok),
WithTopP(topP),
WithStop("STOP", "END"),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
req := mp.lastRequest()
if req.Temperature == nil || *req.Temperature != temp {
t.Errorf("expected temperature %v, got %v", temp, req.Temperature)
}
if req.MaxTokens == nil || *req.MaxTokens != maxTok {
t.Errorf("expected maxTokens %v, got %v", maxTok, req.MaxTokens)
}
if req.TopP == nil || *req.TopP != topP {
t.Errorf("expected topP %v, got %v", topP, req.TopP)
}
if len(req.Stop) != 2 || req.Stop[0] != "STOP" || req.Stop[1] != "END" {
t.Errorf("expected stop [STOP END], got %v", req.Stop)
}
}
func TestModel_Complete_Error(t *testing.T) {
wantErr := errors.New("provider error")
mp := newMockProviderFunc(func(ctx context.Context, req provider.Request) (provider.Response, error) {
return provider.Response{}, wantErr
})
model := newMockModel(mp)
_, err := model.Complete(context.Background(), []Message{UserMessage("Hi")})
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, wantErr) {
t.Errorf("expected error %v, got %v", wantErr, err)
}
}
func TestModel_Complete_WithTools(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "done"})
model := newMockModel(mp)
tool := DefineSimple("greet", "Says hello", func(ctx context.Context) (string, error) {
return "hello", nil
})
tb := NewToolBox(tool)
_, err := model.Complete(context.Background(), []Message{UserMessage("test")}, WithTools(tb))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
req := mp.lastRequest()
if len(req.Tools) != 1 {
t.Fatalf("expected 1 tool, got %d", len(req.Tools))
}
if req.Tools[0].Name != "greet" {
t.Errorf("expected tool name 'greet', got %q", req.Tools[0].Name)
}
if req.Tools[0].Description != "Says hello" {
t.Errorf("expected tool description 'Says hello', got %q", req.Tools[0].Description)
}
}
func TestClient_Model(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "hi"})
client := NewClient(mp)
model := client.Model("test-model")
resp, err := model.Complete(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Text != "hi" {
t.Errorf("expected 'hi', got %q", resp.Text)
}
req := mp.lastRequest()
if req.Model != "test-model" {
t.Errorf("expected model 'test-model', got %q", req.Model)
}
}
func TestClient_WithMiddleware(t *testing.T) {
var called bool
mw := func(next CompletionFunc) CompletionFunc {
return func(ctx context.Context, model string, messages []Message, cfg *requestConfig) (Response, error) {
called = true
return next(ctx, model, messages, cfg)
}
}
mp := newMockProvider(provider.Response{Text: "ok"})
client := NewClient(mp).WithMiddleware(mw)
model := client.Model("test-model")
_, err := model.Complete(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !called {
t.Error("middleware was not called")
}
}
func TestModel_WithMiddleware(t *testing.T) {
var order []string
mw1 := func(next CompletionFunc) CompletionFunc {
return func(ctx context.Context, model string, messages []Message, cfg *requestConfig) (Response, error) {
order = append(order, "mw1")
return next(ctx, model, messages, cfg)
}
}
mw2 := func(next CompletionFunc) CompletionFunc {
return func(ctx context.Context, model string, messages []Message, cfg *requestConfig) (Response, error) {
order = append(order, "mw2")
return next(ctx, model, messages, cfg)
}
}
mp := newMockProvider(provider.Response{Text: "ok"})
model := newMockModel(mp).WithMiddleware(mw1).WithMiddleware(mw2)
_, err := model.Complete(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(order) != 2 || order[0] != "mw1" || order[1] != "mw2" {
t.Errorf("expected middleware order [mw1 mw2], got %v", order)
}
}
func TestModel_Complete_NoUsage(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "no usage"})
model := newMockModel(mp)
resp, err := model.Complete(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Usage != nil {
t.Errorf("expected nil usage, got %+v", resp.Usage)
}
}
func TestModel_Complete_ResponseMessage(t *testing.T) {
mp := newMockProvider(provider.Response{Text: "response text"})
model := newMockModel(mp)
resp, err := model.Complete(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
msg := resp.Message()
if msg.Role != RoleAssistant {
t.Errorf("expected role assistant, got %v", msg.Role)
}
if msg.Content.Text != "response text" {
t.Errorf("expected text 'response text', got %q", msg.Content.Text)
}
}
+30
View File
@@ -0,0 +1,30 @@
// Package moonshot implements the go-llm v2 provider interface for Moonshot
// AI (Kimi, https://platform.moonshot.ai). Moonshot speaks OpenAI Chat
// Completions, so this package is a thin wrapper over openaicompat with its
// own defaults and per-model Rules.
package moonshot
import (
"strings"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/openaicompat"
)
// DefaultBaseURL is the public Moonshot API endpoint (international).
const DefaultBaseURL = "https://api.moonshot.ai/v1"
// Provider is a type alias over openaicompat.Provider.
type Provider = openaicompat.Provider
// New creates a new Moonshot provider. An empty baseURL uses DefaultBaseURL.
func New(apiKey, baseURL string) *Provider {
if baseURL == "" {
baseURL = DefaultBaseURL
}
return openaicompat.New(apiKey, baseURL, openaicompat.Rules{
// Only Moonshot models whose name contains "vision" accept images.
SupportsVision: func(m string) bool {
return strings.Contains(m, "vision")
},
})
}
+33
View File
@@ -0,0 +1,33 @@
package moonshot_test
import (
"context"
"errors"
"testing"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/moonshot"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/openaicompat"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
func TestNew_Basic(t *testing.T) {
if p := moonshot.New("key", ""); p == nil {
t.Fatal("New returned nil")
}
}
func TestRules_NonVisionModelRejectsImages(t *testing.T) {
p := moonshot.New("key", "")
req := provider.Request{
Model: "moonshot-v1-8k",
Messages: []provider.Message{{
Role: "user",
Images: []provider.Image{{URL: "a"}},
}},
}
_, err := p.Complete(context.Background(), req)
var fue *openaicompat.FeatureUnsupportedError
if !errors.As(err, &fue) || fue.Feature != "vision" {
t.Fatalf("want FeatureUnsupportedError(vision), got %v", err)
}
}
+535
View File
@@ -0,0 +1,535 @@
// Package ollama implements the go-llm v2 provider interface for Ollama,
// targeting Ollama's native /api/chat endpoint. Supports both local Ollama
// instances (no API key) and Ollama Cloud (https://ollama.com, requires an
// API key).
package ollama
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
// DefaultLocalBaseURL is the default base URL for a locally-running Ollama
// instance.
const DefaultLocalBaseURL = "http://localhost:11434"
// DefaultCloudBaseURL is the default base URL for Ollama Cloud.
const DefaultCloudBaseURL = "https://ollama.com"
// Provider implements provider.Provider over Ollama's native /api/chat
// endpoint. An empty apiKey means local-mode (no Authorization header sent);
// a non-empty apiKey is sent as a Bearer token (cloud-mode).
type Provider struct {
apiKey string
baseURL string
client *http.Client
}
// newNative constructs a native Ollama provider. Callers should use the
// package-level New() constructor or the v2 llm.Ollama() / llm.OllamaCloud()
// helpers.
func newNative(apiKey, baseURL string) *Provider {
return &Provider{
apiKey: apiKey,
baseURL: baseURL,
client: &http.Client{},
}
}
// nativeChatRequest is the JSON body POSTed to /api/chat.
type nativeChatRequest struct {
Model string `json:"model"`
Messages []nativeChatMessage `json:"messages"`
Tools []nativeToolDef `json:"tools,omitempty"`
Stream bool `json:"stream"`
// Think is polymorphic — Ollama accepts true/false or "low"/"medium"/"high".
Think json.RawMessage `json:"think,omitempty"`
Options map[string]any `json:"options,omitempty"`
}
// nativeChatMessage is one entry in the messages array on the wire. It also
// carries assistant tool calls and tool-role responses.
type nativeChatMessage struct {
Role string `json:"role"`
Content string `json:"content,omitempty"`
Images []string `json:"images,omitempty"`
ToolCalls []nativeToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Thinking string `json:"thinking,omitempty"`
}
// nativeToolCall mirrors Ollama's tool-call wire shape: a function with name
// and JSON-encoded arguments. Ollama's spec doesn't require an id, but some
// builds and some streaming chunks include one — we accept it on both wire and
// internal sides.
type nativeToolCall struct {
ID string `json:"id,omitempty"`
Function nativeFunctionCall `json:"function"`
}
type nativeFunctionCall struct {
Index *int `json:"index,omitempty"`
Name string `json:"name,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"`
}
// nativeChatResponse is the JSON body returned from a non-streaming /api/chat
// call (and is also the per-line shape during streaming).
type nativeChatResponse struct {
Model string `json:"model,omitempty"`
Message nativeChatMessage `json:"message"`
Done bool `json:"done"`
DoneReason string `json:"done_reason,omitempty"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
EvalCount int `json:"eval_count,omitempty"`
TotalDuration int64 `json:"total_duration,omitempty"`
}
// nativeToolDef is the wire shape of a tool definition sent to Ollama.
type nativeToolDef struct {
Type string `json:"type"`
Function nativeFunctionDef `json:"function"`
}
type nativeFunctionDef struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters map[string]any `json:"parameters,omitempty"`
}
// encodeThink converts a go-llm Reasoning string ("", "low", "medium",
// "high", or the literal strings "true"/"false") into Ollama's polymorphic
// `think` field. Returns nil for the empty string so the field is omitted.
func encodeThink(reasoning string) json.RawMessage {
switch reasoning {
case "":
return nil
case "true":
return json.RawMessage(`true`)
case "false":
return json.RawMessage(`false`)
default:
// "low" / "medium" / "high" — encode as a JSON string.
b, _ := json.Marshal(reasoning)
return b
}
}
// Complete performs a non-streaming chat completion via /api/chat.
func (p *Provider) Complete(ctx context.Context, req provider.Request) (provider.Response, error) {
body, err := p.buildChatRequest(req, false)
if err != nil {
return provider.Response{}, err
}
httpResp, err := p.doChatRequest(ctx, body)
if err != nil {
return provider.Response{}, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
b, _ := io.ReadAll(httpResp.Body)
return provider.Response{}, fmt.Errorf("ollama: HTTP %d: %s", httpResp.StatusCode, string(b))
}
var chat nativeChatResponse
if err := json.NewDecoder(httpResp.Body).Decode(&chat); err != nil {
return provider.Response{}, fmt.Errorf("ollama: decode response: %w", err)
}
resp := provider.Response{
Text: chat.Message.Content,
Thinking: chat.Message.Thinking,
}
for i, tc := range chat.Message.ToolCalls {
resp.ToolCalls = append(resp.ToolCalls, provider.ToolCall{
ID: toolCallID(tc, i),
Name: tc.Function.Name,
Arguments: rawMessageToArgString(tc.Function.Arguments),
})
}
if chat.PromptEvalCount > 0 || chat.EvalCount > 0 {
resp.Usage = &provider.Usage{
InputTokens: chat.PromptEvalCount,
OutputTokens: chat.EvalCount,
TotalTokens: chat.PromptEvalCount + chat.EvalCount,
}
}
return resp, nil
}
// Stream performs a streaming chat completion via /api/chat with
// `stream: true`, parsing NDJSON line-by-line. Tool-call argument deltas are
// accumulated across chunks keyed by id (or function index) and finalized
// when the upstream Done flag arrives.
func (p *Provider) Stream(ctx context.Context, req provider.Request, events chan<- provider.StreamEvent) error {
defer close(events)
body, err := p.buildChatRequest(req, true)
if err != nil {
return err
}
httpResp, err := p.doChatRequest(ctx, body)
if err != nil {
return err
}
defer httpResp.Body.Close()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
b, _ := io.ReadAll(httpResp.Body)
return fmt.Errorf("ollama: HTTP %d: %s", httpResp.StatusCode, string(b))
}
scanner := bufio.NewScanner(httpResp.Body)
// Ollama can emit multi-KB lines on tool-call deltas. Generous buffer.
const maxLineSize = 4 * 1024 * 1024
scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
type toolAcc struct {
id string
name string
args strings.Builder
index int // ToolIndex emitted on stream events
}
tools := map[string]*toolAcc{}
var toolOrder []*toolAcc
var (
fullText strings.Builder
fullThinking strings.Builder
usage *provider.Usage
streamErr error
)
for scanner.Scan() {
line := scanner.Bytes()
if len(bytes.TrimSpace(line)) == 0 {
continue
}
var chunk nativeChatResponse
if err := json.Unmarshal(line, &chunk); err != nil {
streamErr = fmt.Errorf("ollama: decode stream chunk: %w", err)
break
}
if chunk.Message.Thinking != "" {
fullThinking.WriteString(chunk.Message.Thinking)
events <- provider.StreamEvent{
Type: provider.StreamEventThinking,
Text: chunk.Message.Thinking,
}
}
if chunk.Message.Content != "" {
fullText.WriteString(chunk.Message.Content)
events <- provider.StreamEvent{
Type: provider.StreamEventText,
Text: chunk.Message.Content,
}
}
for pos, tc := range chunk.Message.ToolCalls {
key := streamToolKey(tc, pos)
acc, exists := tools[key]
if !exists {
acc = &toolAcc{
id: tc.ID,
name: tc.Function.Name,
index: len(toolOrder),
}
if acc.id == "" {
acc.id = fmt.Sprintf("tc_%d", acc.index)
}
tools[key] = acc
toolOrder = append(toolOrder, acc)
events <- provider.StreamEvent{
Type: provider.StreamEventToolStart,
ToolIndex: acc.index,
ToolCall: &provider.ToolCall{
ID: acc.id,
Name: acc.name,
},
}
} else {
// Continuation chunk may carry the tool's name late; capture it.
if tc.Function.Name != "" && acc.name == "" {
acc.name = tc.Function.Name
}
}
delta := decodeArgumentDelta(tc.Function.Arguments)
if delta != "" {
acc.args.WriteString(delta)
events <- provider.StreamEvent{
Type: provider.StreamEventToolDelta,
ToolIndex: acc.index,
ToolCall: &provider.ToolCall{
Arguments: delta,
},
}
}
}
if chunk.Done {
if chunk.PromptEvalCount > 0 || chunk.EvalCount > 0 {
usage = &provider.Usage{
InputTokens: chunk.PromptEvalCount,
OutputTokens: chunk.EvalCount,
TotalTokens: chunk.PromptEvalCount + chunk.EvalCount,
}
}
break
}
}
if err := scanner.Err(); err != nil && streamErr == nil {
streamErr = fmt.Errorf("ollama: stream read: %w", err)
}
if streamErr != nil {
events <- provider.StreamEvent{
Type: provider.StreamEventError,
Error: streamErr,
}
return streamErr
}
// Finalize accumulated tool calls.
finalCalls := make([]provider.ToolCall, 0, len(toolOrder))
for _, acc := range toolOrder {
args := acc.args.String()
if args == "" {
args = "{}"
}
final := provider.ToolCall{
ID: acc.id,
Name: acc.name,
Arguments: args,
}
finalCalls = append(finalCalls, final)
events <- provider.StreamEvent{
Type: provider.StreamEventToolEnd,
ToolIndex: acc.index,
ToolCall: &final,
}
}
events <- provider.StreamEvent{
Type: provider.StreamEventDone,
Response: &provider.Response{
Text: fullText.String(),
Thinking: fullThinking.String(),
ToolCalls: finalCalls,
Usage: usage,
},
}
return nil
}
// streamToolKey computes a stable map key correlating tool-call deltas
// across stream chunks. Prefer the wire id, fall back to function index,
// finally fall back to the tool's position in the chunk's tool_calls array
// (a single-tool stream collapses cleanly under any strategy).
func streamToolKey(tc nativeToolCall, position int) string {
if tc.ID != "" {
return "id:" + tc.ID
}
if tc.Function.Index != nil {
return fmt.Sprintf("idx:%d", *tc.Function.Index)
}
return fmt.Sprintf("pos:%d", position)
}
// decodeArgumentDelta returns the string fragment to append when a streamed
// tool-call chunk includes arguments. Ollama may emit arguments either as a
// JSON-encoded string fragment (chunk-by-chunk concatenation, openaicompat
// style) or as a complete object value (one-shot delivery). We accept both:
// strings are unwrapped, objects/arrays pass through verbatim.
func decodeArgumentDelta(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || string(trimmed) == "null" {
return ""
}
if trimmed[0] == '"' {
var s string
if err := json.Unmarshal(trimmed, &s); err == nil {
return s
}
}
return string(trimmed)
}
// buildChatRequest converts a provider.Request into the native wire body
// JSON. stream toggles the stream flag (true for /api/chat streaming).
func (p *Provider) buildChatRequest(req provider.Request, stream bool) ([]byte, error) {
wire := nativeChatRequest{
Model: req.Model,
Stream: stream,
Think: encodeThink(req.Reasoning),
}
for _, msg := range req.Messages {
m, err := convertMessage(msg)
if err != nil {
return nil, err
}
wire.Messages = append(wire.Messages, m)
}
for _, t := range req.Tools {
wire.Tools = append(wire.Tools, nativeToolDef{
Type: "function",
Function: nativeFunctionDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Schema,
},
})
}
if req.Temperature != nil || req.MaxTokens != nil || req.TopP != nil || len(req.Stop) > 0 {
wire.Options = map[string]any{}
if req.Temperature != nil {
wire.Options["temperature"] = *req.Temperature
}
if req.TopP != nil {
wire.Options["top_p"] = *req.TopP
}
if req.MaxTokens != nil {
wire.Options["num_predict"] = *req.MaxTokens
}
if len(req.Stop) > 0 {
wire.Options["stop"] = req.Stop
}
}
return json.Marshal(wire)
}
// doChatRequest POSTs the wire body to /api/chat and returns the raw HTTP
// response. The caller is responsible for closing the response body.
func (p *Provider) doChatRequest(ctx context.Context, body []byte) (*http.Response, error) {
url := strings.TrimRight(p.baseURL, "/") + "/api/chat"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("ollama: build request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if p.apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
}
resp, err := p.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("ollama: HTTP request: %w", err)
}
return resp, nil
}
// convertMessage maps a provider.Message into a native wire message.
func convertMessage(msg provider.Message) (nativeChatMessage, error) {
out := nativeChatMessage{
Role: msg.Role,
Content: msg.Content,
ToolCallID: msg.ToolCallID,
}
for _, img := range msg.Images {
b64, err := imageToBase64(img)
if err != nil {
return nativeChatMessage{}, err
}
if b64 != "" {
out.Images = append(out.Images, b64)
}
}
for i, tc := range msg.ToolCalls {
raw := json.RawMessage(strings.TrimSpace(tc.Arguments))
if len(raw) == 0 {
raw = json.RawMessage(`{}`)
}
// Preserve a stable index so streaming peers can correlate deltas.
idx := i
out.ToolCalls = append(out.ToolCalls, nativeToolCall{
ID: tc.ID,
Function: nativeFunctionCall{
Index: &idx,
Name: tc.Name,
Arguments: raw,
},
})
}
return out, nil
}
// imageToBase64 returns the base64-encoded payload of an image, fetching
// URL-only images over HTTP if no inline base64 is supplied.
func imageToBase64(img provider.Image) (string, error) {
if img.Base64 != "" {
return img.Base64, nil
}
if img.URL == "" {
return "", nil
}
resp, err := http.Get(img.URL)
if err != nil {
return "", fmt.Errorf("ollama: fetch image %q: %w", img.URL, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("ollama: fetch image %q: HTTP %d", img.URL, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("ollama: read image %q: %w", img.URL, err)
}
return base64.StdEncoding.EncodeToString(data), nil
}
// rawMessageToArgString converts a JSON-encoded arguments value into the
// string form the provider package uses for ToolCall.Arguments. Object/array
// values pass through verbatim; bare string values (some Ollama builds emit
// pre-stringified arguments) are unwrapped.
func rawMessageToArgString(raw json.RawMessage) string {
if len(raw) == 0 {
return "{}"
}
trimmed := strings.TrimSpace(string(raw))
if len(trimmed) == 0 {
return "{}"
}
if trimmed[0] == '"' {
var s string
if err := json.Unmarshal([]byte(trimmed), &s); err == nil {
return s
}
}
return trimmed
}
// toolCallID returns a stable identifier for a tool call. Ollama's native
// API typically does not include an id, so we synthesize one from the index
// when missing.
func toolCallID(tc nativeToolCall, index int) string {
if tc.ID != "" {
return tc.ID
}
if tc.Function.Index != nil {
return fmt.Sprintf("tc_%d", *tc.Function.Index)
}
return fmt.Sprintf("tc_%d", index)
}
+573
View File
@@ -0,0 +1,573 @@
package ollama
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
// captureRequest is a tiny helper that records the inbound HTTP request and
// returns a configurable response body.
type captureRequest struct {
method string
path string
authHeader string
contentType string
body []byte
parsedBody map[string]any
}
func newTestServer(t *testing.T, captured *captureRequest, status int, respBody string, respContentType string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured.method = r.Method
captured.path = r.URL.Path
captured.authHeader = r.Header.Get("Authorization")
captured.contentType = r.Header.Get("Content-Type")
body, _ := io.ReadAll(r.Body)
captured.body = body
_ = json.Unmarshal(body, &captured.parsedBody)
if respContentType == "" {
respContentType = "application/json"
}
w.Header().Set("Content-Type", respContentType)
w.WriteHeader(status)
_, _ = w.Write([]byte(respBody))
}))
t.Cleanup(srv.Close)
return srv
}
func TestCompleteBasic(t *testing.T) {
resp := `{
"model": "kimi-k2.5",
"message": {"role": "assistant", "content": "hello there"},
"done": true,
"done_reason": "stop",
"prompt_eval_count": 10,
"eval_count": 3
}`
cap := &captureRequest{}
srv := newTestServer(t, cap, 200, resp, "")
p := newNative("test-key", srv.URL)
got, err := p.Complete(context.Background(), provider.Request{
Model: "kimi-k2.5",
Messages: []provider.Message{{Role: "user", Content: "hi"}},
})
if err != nil {
t.Fatalf("Complete: %v", err)
}
if cap.method != "POST" {
t.Errorf("method: want POST, got %q", cap.method)
}
if cap.path != "/api/chat" {
t.Errorf("path: want /api/chat, got %q", cap.path)
}
if cap.authHeader != "Bearer test-key" {
t.Errorf("auth header: want %q, got %q", "Bearer test-key", cap.authHeader)
}
if cap.contentType != "application/json" {
t.Errorf("content-type: want application/json, got %q", cap.contentType)
}
if cap.parsedBody["model"] != "kimi-k2.5" {
t.Errorf("body.model: want kimi-k2.5, got %v", cap.parsedBody["model"])
}
if cap.parsedBody["stream"] != false {
t.Errorf("body.stream: want false, got %v", cap.parsedBody["stream"])
}
msgs, _ := cap.parsedBody["messages"].([]any)
if len(msgs) != 1 {
t.Fatalf("messages: want 1 entry, got %d", len(msgs))
}
m0, _ := msgs[0].(map[string]any)
if m0["role"] != "user" || m0["content"] != "hi" {
t.Errorf("first message: want role=user content=hi, got %v", m0)
}
if got.Text != "hello there" {
t.Errorf("Text: want %q, got %q", "hello there", got.Text)
}
if got.Usage == nil {
t.Fatal("Usage: want non-nil")
}
if got.Usage.InputTokens != 10 || got.Usage.OutputTokens != 3 {
t.Errorf("Usage: want input=10 output=3, got input=%d output=%d", got.Usage.InputTokens, got.Usage.OutputTokens)
}
if got.Usage.TotalTokens != 13 {
t.Errorf("Usage.TotalTokens: want 13, got %d", got.Usage.TotalTokens)
}
}
func TestCompleteNoAuthHeaderWhenLocal(t *testing.T) {
resp := `{"message":{"role":"assistant","content":"ok"},"done":true}`
cap := &captureRequest{}
srv := newTestServer(t, cap, 200, resp, "")
p := newNative("", srv.URL)
if _, err := p.Complete(context.Background(), provider.Request{
Model: "llama3.2",
Messages: []provider.Message{{Role: "user", Content: "hi"}},
}); err != nil {
t.Fatalf("Complete: %v", err)
}
if cap.authHeader != "" {
t.Errorf("auth header: want empty (local mode), got %q", cap.authHeader)
}
}
func TestVisionImagesEncoded(t *testing.T) {
resp := `{"message":{"role":"assistant","content":"a cat"},"done":true}`
cap := &captureRequest{}
srv := newTestServer(t, cap, 200, resp, "")
p := newNative("", srv.URL)
if _, err := p.Complete(context.Background(), provider.Request{
Model: "llava",
Messages: []provider.Message{{
Role: "user",
Content: "what's in this?",
Images: []provider.Image{
{Base64: "AAAA", ContentType: "image/png"},
},
}},
}); err != nil {
t.Fatalf("Complete: %v", err)
}
msgs, _ := cap.parsedBody["messages"].([]any)
if len(msgs) != 1 {
t.Fatalf("messages: want 1, got %d", len(msgs))
}
m0, _ := msgs[0].(map[string]any)
imgs, _ := m0["images"].([]any)
if len(imgs) != 1 {
t.Fatalf("images: want 1 entry, got %d (msg=%v)", len(imgs), m0)
}
if imgs[0] != "AAAA" {
t.Errorf("images[0]: want raw base64 AAAA, got %v", imgs[0])
}
}
func TestThinkingField(t *testing.T) {
cases := []struct {
name string
reasoning string
want any // expected value of "think" in body, or nil if absent
}{
{"absent", "", nil},
{"high", "high", "high"},
{"low", "low", "low"},
{"medium", "medium", "medium"},
{"true", "true", true},
{"false", "false", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
resp := `{"message":{"role":"assistant","content":"ok"},"done":true}`
cap := &captureRequest{}
srv := newTestServer(t, cap, 200, resp, "")
p := newNative("", srv.URL)
_, err := p.Complete(context.Background(), provider.Request{
Model: "kimi-k2.5",
Messages: []provider.Message{{Role: "user", Content: "hi"}},
Reasoning: c.reasoning,
})
if err != nil {
t.Fatalf("Complete: %v", err)
}
got, present := cap.parsedBody["think"]
if c.want == nil {
if present {
t.Errorf("think field should be absent, got %v", got)
}
return
}
if !present {
t.Fatalf("think field absent; want %v", c.want)
}
if got != c.want {
t.Errorf("think: want %v (%T), got %v (%T)", c.want, c.want, got, got)
}
})
}
}
func TestToolRoundTrip(t *testing.T) {
t.Run("response tool_calls convert to provider.Response", func(t *testing.T) {
resp := `{
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{"function": {"name": "search", "arguments": {"query": "foo"}}}
]
},
"done": true,
"prompt_eval_count": 5,
"eval_count": 2
}`
cap := &captureRequest{}
srv := newTestServer(t, cap, 200, resp, "")
p := newNative("", srv.URL)
got, err := p.Complete(context.Background(), provider.Request{
Model: "kimi-k2.5",
Messages: []provider.Message{{Role: "user", Content: "hi"}},
Tools: []provider.ToolDef{
{
Name: "search",
Description: "Run a search",
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string"},
},
},
},
},
})
if err != nil {
t.Fatalf("Complete: %v", err)
}
// Verify request shape: tools array present.
toolsArr, _ := cap.parsedBody["tools"].([]any)
if len(toolsArr) != 1 {
t.Fatalf("tools: want 1 entry, got %d", len(toolsArr))
}
t0, _ := toolsArr[0].(map[string]any)
if t0["type"] != "function" {
t.Errorf("tools[0].type: want function, got %v", t0["type"])
}
fn, _ := t0["function"].(map[string]any)
if fn["name"] != "search" {
t.Errorf("tools[0].function.name: want search, got %v", fn["name"])
}
// Verify response conversion.
if len(got.ToolCalls) != 1 {
t.Fatalf("ToolCalls: want 1, got %d", len(got.ToolCalls))
}
tc := got.ToolCalls[0]
if tc.Name != "search" {
t.Errorf("ToolCall.Name: want search, got %q", tc.Name)
}
// Arguments should be valid JSON containing query=foo
var args map[string]any
if err := json.Unmarshal([]byte(tc.Arguments), &args); err != nil {
t.Fatalf("ToolCall.Arguments not valid JSON: %v (got %q)", err, tc.Arguments)
}
if args["query"] != "foo" {
t.Errorf("ToolCall.Arguments.query: want foo, got %v", args["query"])
}
})
t.Run("subsequent request includes assistant tool_calls and tool-role response", func(t *testing.T) {
resp := `{"message":{"role":"assistant","content":"done"},"done":true}`
cap := &captureRequest{}
srv := newTestServer(t, cap, 200, resp, "")
p := newNative("", srv.URL)
_, err := p.Complete(context.Background(), provider.Request{
Model: "kimi-k2.5",
Messages: []provider.Message{
{Role: "user", Content: "search foo"},
{
Role: "assistant",
ToolCalls: []provider.ToolCall{{
ID: "tc1",
Name: "search",
Arguments: `{"query":"foo"}`,
}},
},
{
Role: "tool",
ToolCallID: "tc1",
Content: `{"result":"bar"}`,
},
},
})
if err != nil {
t.Fatalf("Complete: %v", err)
}
msgs, _ := cap.parsedBody["messages"].([]any)
if len(msgs) != 3 {
t.Fatalf("messages: want 3, got %d", len(msgs))
}
// Assistant message must carry tool_calls with the JSON-object arguments.
asst, _ := msgs[1].(map[string]any)
if asst["role"] != "assistant" {
t.Errorf("msgs[1].role: want assistant, got %v", asst["role"])
}
tc, _ := asst["tool_calls"].([]any)
if len(tc) != 1 {
t.Fatalf("assistant.tool_calls: want 1, got %d", len(tc))
}
fn, _ := tc[0].(map[string]any)["function"].(map[string]any)
if fn["name"] != "search" {
t.Errorf("assistant.tool_calls[0].function.name: want search, got %v", fn["name"])
}
args, _ := fn["arguments"].(map[string]any)
if args["query"] != "foo" {
t.Errorf("assistant.tool_calls[0].function.arguments.query: want foo, got %v", args["query"])
}
// Tool-role message must have role=tool, tool_call_id, and content.
tool, _ := msgs[2].(map[string]any)
if tool["role"] != "tool" {
t.Errorf("msgs[2].role: want tool, got %v", tool["role"])
}
if tool["tool_call_id"] != "tc1" {
t.Errorf("msgs[2].tool_call_id: want tc1, got %v", tool["tool_call_id"])
}
if !strings.Contains(toString(tool["content"]), "bar") {
t.Errorf("msgs[2].content: want to contain bar, got %v", tool["content"])
}
})
}
func toString(v any) string {
if s, ok := v.(string); ok {
return s
}
b, _ := json.Marshal(v)
return string(b)
}
// streamServer returns an httptest.Server that writes the given NDJSON lines
// (each terminated with \n) as the response body.
func streamServer(t *testing.T, captured *captureRequest, lines []string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured.method = r.Method
captured.path = r.URL.Path
captured.authHeader = r.Header.Get("Authorization")
captured.contentType = r.Header.Get("Content-Type")
body, _ := io.ReadAll(r.Body)
captured.body = body
_ = json.Unmarshal(body, &captured.parsedBody)
w.Header().Set("Content-Type", "application/x-ndjson")
w.WriteHeader(200)
flusher, _ := w.(http.Flusher)
for _, line := range lines {
_, _ = w.Write([]byte(line + "\n"))
if flusher != nil {
flusher.Flush()
}
}
}))
t.Cleanup(srv.Close)
return srv
}
func collectStream(t *testing.T, p *Provider, req provider.Request) []provider.StreamEvent {
t.Helper()
events := make(chan provider.StreamEvent, 64)
done := make(chan error, 1)
go func() {
done <- p.Stream(context.Background(), req, events)
}()
var out []provider.StreamEvent
timeout := time.After(5 * time.Second)
streamErrored := false
loop:
for {
select {
case ev, ok := <-events:
if !ok {
break loop
}
out = append(out, ev)
if ev.Type == provider.StreamEventError {
streamErrored = true
}
case err := <-done:
if err != nil && !streamErrored {
t.Fatalf("Stream returned error: %v", err)
}
// Drain any final events buffered in the channel.
for {
select {
case ev, ok := <-events:
if !ok {
return out
}
out = append(out, ev)
default:
return out
}
}
case <-timeout:
t.Fatal("Stream did not complete within 5s")
}
}
if err := <-done; err != nil && !streamErrored {
t.Fatalf("Stream returned error: %v", err)
}
return out
}
func TestStreamBasic(t *testing.T) {
lines := []string{
`{"message":{"role":"assistant","content":"hello"},"done":false}`,
`{"message":{"role":"assistant","content":" world","thinking":"reasoning"},"done":false}`,
`{"message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":12,"eval_count":2}`,
}
cap := &captureRequest{}
srv := streamServer(t, cap, lines)
p := newNative("", srv.URL)
events := collectStream(t, p, provider.Request{
Model: "kimi-k2.5",
Messages: []provider.Message{{Role: "user", Content: "hi"}},
})
// Verify request shape: stream:true.
if cap.parsedBody["stream"] != true {
t.Errorf("body.stream: want true, got %v", cap.parsedBody["stream"])
}
// Filter to relevant events (text, thinking, done) preserving order.
var kinds []string
var texts []string
var doneEvent *provider.StreamEvent
for i, ev := range events {
switch ev.Type {
case provider.StreamEventText:
kinds = append(kinds, "text")
texts = append(texts, ev.Text)
case provider.StreamEventThinking:
kinds = append(kinds, "thinking")
texts = append(texts, ev.Text)
case provider.StreamEventDone:
kinds = append(kinds, "done")
e := events[i]
doneEvent = &e
}
}
wantKinds := []string{"text", "thinking", "text", "done"}
if !equalStrings(kinds, wantKinds) {
t.Errorf("event kinds: want %v, got %v", wantKinds, kinds)
}
if len(texts) >= 3 {
if texts[0] != "hello" {
t.Errorf("first text: want hello, got %q", texts[0])
}
if texts[1] != "reasoning" {
t.Errorf("thinking: want reasoning, got %q", texts[1])
}
if texts[2] != " world" {
t.Errorf("second text: want \" world\", got %q", texts[2])
}
}
if doneEvent == nil || doneEvent.Response == nil {
t.Fatal("Done event missing Response")
}
if doneEvent.Response.Text != "hello world" {
t.Errorf("Response.Text: want %q, got %q", "hello world", doneEvent.Response.Text)
}
if doneEvent.Response.Thinking != "reasoning" {
t.Errorf("Response.Thinking: want %q, got %q", "reasoning", doneEvent.Response.Thinking)
}
if doneEvent.Response.Usage == nil {
t.Fatal("Response.Usage missing")
}
if doneEvent.Response.Usage.InputTokens != 12 || doneEvent.Response.Usage.OutputTokens != 2 {
t.Errorf("Usage: want input=12 output=2, got input=%d output=%d", doneEvent.Response.Usage.InputTokens, doneEvent.Response.Usage.OutputTokens)
}
}
func TestStreamToolDeltaAccumulation(t *testing.T) {
lines := []string{
`{"message":{"role":"assistant","content":"","tool_calls":[{"id":"tc1","function":{"name":"search","arguments":"{\"que"}}]},"done":false}`,
`{"message":{"role":"assistant","content":"","tool_calls":[{"id":"tc1","function":{"arguments":"ry\":\"foo\"}"}}]},"done":false}`,
`{"message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":4,"eval_count":1}`,
}
cap := &captureRequest{}
srv := streamServer(t, cap, lines)
p := newNative("", srv.URL)
events := collectStream(t, p, provider.Request{
Model: "kimi-k2.5",
Messages: []provider.Message{{Role: "user", Content: "search foo"}},
Tools: []provider.ToolDef{
{Name: "search", Schema: map[string]any{"type": "object"}},
},
})
// Build a slim trace of tool events.
type traceEntry struct {
kind string
args string
name string
id string
}
var trace []traceEntry
var doneEvent *provider.StreamEvent
for i, ev := range events {
switch ev.Type {
case provider.StreamEventToolStart:
trace = append(trace, traceEntry{kind: "start", name: ev.ToolCall.Name, id: ev.ToolCall.ID})
case provider.StreamEventToolDelta:
trace = append(trace, traceEntry{kind: "delta", args: ev.ToolCall.Arguments})
case provider.StreamEventToolEnd:
trace = append(trace, traceEntry{kind: "end", args: ev.ToolCall.Arguments, name: ev.ToolCall.Name, id: ev.ToolCall.ID})
case provider.StreamEventDone:
e := events[i]
doneEvent = &e
}
}
if len(trace) != 4 {
t.Fatalf("trace: want 4 entries (start, delta, delta, end), got %d: %+v", len(trace), trace)
}
if trace[0].kind != "start" || trace[0].name != "search" || trace[0].id != "tc1" {
t.Errorf("trace[0]: want start search tc1, got %+v", trace[0])
}
if trace[1].kind != "delta" || trace[1].args != `{"que` {
t.Errorf("trace[1]: want delta args=%q, got %+v", `{"que`, trace[1])
}
if trace[2].kind != "delta" || trace[2].args != `ry":"foo"}` {
t.Errorf("trace[2]: want delta args=%q, got %+v", `ry":"foo"}`, trace[2])
}
if trace[3].kind != "end" || trace[3].args != `{"query":"foo"}` {
t.Errorf("trace[3]: want end args=%q, got %+v", `{"query":"foo"}`, trace[3])
}
if doneEvent == nil || doneEvent.Response == nil {
t.Fatal("Done event missing Response")
}
if len(doneEvent.Response.ToolCalls) != 1 {
t.Fatalf("Done.Response.ToolCalls: want 1, got %d", len(doneEvent.Response.ToolCalls))
}
tc := doneEvent.Response.ToolCalls[0]
if tc.ID != "tc1" || tc.Name != "search" || tc.Arguments != `{"query":"foo"}` {
t.Errorf("Done.Response.ToolCalls[0]: want tc1/search/{...}, got %+v", tc)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+22
View File
@@ -0,0 +1,22 @@
// Package ollama implements the go-llm v2 provider interface for Ollama,
// targeting Ollama's native /api/chat endpoint. Supports both local Ollama
// instances (no API key) and Ollama Cloud (https://ollama.com, requires an
// API key sent as a Bearer token).
package ollama
// New creates a new Ollama provider over the native /api/chat API. An empty
// apiKey means local-mode (no Authorization header is sent). A non-empty
// apiKey is sent as `Authorization: Bearer <key>` for Ollama Cloud.
//
// An empty baseURL defaults to DefaultLocalBaseURL when apiKey is empty, or
// DefaultCloudBaseURL when apiKey is set.
func New(apiKey, baseURL string) *Provider {
if baseURL == "" {
if apiKey == "" {
baseURL = DefaultLocalBaseURL
} else {
baseURL = DefaultCloudBaseURL
}
}
return newNative(apiKey, baseURL)
}
+35
View File
@@ -0,0 +1,35 @@
package ollama
import "testing"
func TestNew(t *testing.T) {
t.Run("local mode picks default local URL", func(t *testing.T) {
p := New("", "")
if p == nil {
t.Fatal("New returned nil")
}
if p.baseURL != DefaultLocalBaseURL {
t.Errorf("baseURL: want %q, got %q", DefaultLocalBaseURL, p.baseURL)
}
if p.apiKey != "" {
t.Errorf("apiKey: want empty, got %q", p.apiKey)
}
})
t.Run("cloud mode (apiKey set) picks cloud URL", func(t *testing.T) {
p := New("test-key", "")
if p.baseURL != DefaultCloudBaseURL {
t.Errorf("baseURL: want %q, got %q", DefaultCloudBaseURL, p.baseURL)
}
if p.apiKey != "test-key" {
t.Errorf("apiKey: want %q, got %q", "test-key", p.apiKey)
}
})
t.Run("explicit baseURL is preserved", func(t *testing.T) {
p := New("k", "http://example.test:9999")
if p.baseURL != "http://example.test:9999" {
t.Errorf("baseURL not preserved, got %q", p.baseURL)
}
})
}
+38
View File
@@ -0,0 +1,38 @@
// Package openai implements the go-llm v2 provider interface for OpenAI.
//
// The actual wire-protocol logic lives in the shared openaicompat package;
// this file encodes OpenAI-specific Rules (temperature is rejected on o-series
// and gpt-5* models) and supplies the default base URL.
package openai
import (
"strings"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/openaicompat"
)
// DefaultBaseURL is the public OpenAI Chat Completions endpoint.
const DefaultBaseURL = "https://api.openai.com/v1"
// Provider is the OpenAI chat-completion provider. It's a type alias over
// openaicompat.Provider so existing callers using openai.Provider keep compiling.
type Provider = openaicompat.Provider
// New creates a new OpenAI provider. An empty baseURL uses DefaultBaseURL.
func New(apiKey string, baseURL string) *Provider {
if baseURL == "" {
baseURL = DefaultBaseURL
}
return openaicompat.New(apiKey, baseURL, openaicompat.Rules{
RestrictTemperature: isReasoningModel,
SupportsReasoning: isReasoningModel,
})
}
// isReasoningModel reports whether the named OpenAI model is a reasoning
// model (o-series or gpt-5*). Reasoning models reject a user-supplied
// temperature and accept a reasoning_effort parameter; everything else
// rejects reasoning_effort.
func isReasoningModel(model string) bool {
return strings.HasPrefix(model, "o") || strings.HasPrefix(model, "gpt-5")
}
+230
View File
@@ -0,0 +1,230 @@
package openai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
// Transcriber implements the provider.Transcriber interface using OpenAI's audio models.
type Transcriber struct {
key string
model string
baseURL string
}
var _ provider.Transcriber = (*Transcriber)(nil)
// NewTranscriber creates a transcriber backed by OpenAI's audio models.
// If model is empty, "whisper-1" is used by default.
func NewTranscriber(key string, model string) *Transcriber {
if strings.TrimSpace(model) == "" {
model = "whisper-1"
}
return &Transcriber{
key: key,
model: model,
}
}
// NewTranscriberWithBaseURL creates a transcriber with a custom API base URL.
func NewTranscriberWithBaseURL(key, model, baseURL string) *Transcriber {
t := NewTranscriber(key, model)
t.baseURL = baseURL
return t
}
// Transcribe performs speech-to-text transcription of WAV audio data.
func (t *Transcriber) Transcribe(ctx context.Context, wav []byte, opts provider.TranscriptionOptions) (provider.Transcription, error) {
if len(wav) == 0 {
return provider.Transcription{}, fmt.Errorf("wav data is empty")
}
format := opts.ResponseFormat
if format == "" {
if strings.HasPrefix(t.model, "gpt-4o") {
format = provider.TranscriptionResponseFormatJSON
} else {
format = provider.TranscriptionResponseFormatVerboseJSON
}
}
if format != provider.TranscriptionResponseFormatJSON && format != provider.TranscriptionResponseFormatVerboseJSON {
return provider.Transcription{}, fmt.Errorf("openai transcriber requires response_format json or verbose_json for structured output")
}
if len(opts.TimestampGranularities) > 0 && format != provider.TranscriptionResponseFormatVerboseJSON {
return provider.Transcription{}, fmt.Errorf("timestamp granularities require response_format=verbose_json")
}
params := openai.AudioTranscriptionNewParams{
File: openai.File(bytes.NewReader(wav), "audio.wav", "audio/wav"),
Model: openai.AudioModel(t.model),
}
if opts.Language != "" {
params.Language = openai.String(opts.Language)
}
if opts.Prompt != "" {
params.Prompt = openai.String(opts.Prompt)
}
if opts.Temperature != nil {
params.Temperature = openai.Float(*opts.Temperature)
}
params.ResponseFormat = openai.AudioResponseFormat(format)
if opts.IncludeLogprobs {
params.Include = []openai.TranscriptionInclude{openai.TranscriptionIncludeLogprobs}
}
if len(opts.TimestampGranularities) > 0 {
for _, granularity := range opts.TimestampGranularities {
params.TimestampGranularities = append(params.TimestampGranularities, string(granularity))
}
}
clientOptions := []option.RequestOption{
option.WithAPIKey(t.key),
}
if t.baseURL != "" {
clientOptions = append(clientOptions, option.WithBaseURL(t.baseURL))
}
client := openai.NewClient(clientOptions...)
resp, err := client.Audio.Transcriptions.New(ctx, params)
if err != nil {
return provider.Transcription{}, fmt.Errorf("openai transcription failed: %w", err)
}
return transcriptionToResult(t.model, resp), nil
}
type verboseTranscription struct {
Text string `json:"text"`
Language string `json:"language"`
Duration float64 `json:"duration"`
Segments []verboseSegment `json:"segments"`
Words []verboseWord `json:"words"`
}
type verboseSegment struct {
ID int `json:"id"`
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
Tokens []int `json:"tokens"`
AvgLogprob *float64 `json:"avg_logprob"`
CompressionRatio *float64 `json:"compression_ratio"`
NoSpeechProb *float64 `json:"no_speech_prob"`
Words []verboseWord `json:"words"`
}
type verboseWord struct {
Word string `json:"word"`
Start float64 `json:"start"`
End float64 `json:"end"`
}
func transcriptionToResult(model string, resp *openai.Transcription) provider.Transcription {
result := provider.Transcription{
Provider: "openai",
Model: model,
}
if resp == nil {
return result
}
result.Text = resp.Text
result.RawJSON = resp.RawJSON()
for _, logprob := range resp.Logprobs {
result.Logprobs = append(result.Logprobs, provider.TranscriptionTokenLogprob{
Token: logprob.Token,
Bytes: logprob.Bytes,
Logprob: logprob.Logprob,
})
}
if usage := usageToTranscriptionUsage(resp.Usage); usage.Type != "" {
result.Usage = usage
}
if result.RawJSON == "" {
return result
}
var verbose verboseTranscription
if err := json.Unmarshal([]byte(result.RawJSON), &verbose); err != nil {
return result
}
if verbose.Text != "" {
result.Text = verbose.Text
}
result.Language = verbose.Language
result.DurationSeconds = verbose.Duration
for _, seg := range verbose.Segments {
segment := provider.TranscriptionSegment{
ID: seg.ID,
Start: seg.Start,
End: seg.End,
Text: seg.Text,
Tokens: append([]int(nil), seg.Tokens...),
AvgLogprob: seg.AvgLogprob,
CompressionRatio: seg.CompressionRatio,
NoSpeechProb: seg.NoSpeechProb,
}
for _, word := range seg.Words {
segment.Words = append(segment.Words, provider.TranscriptionWord{
Word: word.Word,
Start: word.Start,
End: word.End,
})
}
result.Segments = append(result.Segments, segment)
}
for _, word := range verbose.Words {
result.Words = append(result.Words, provider.TranscriptionWord{
Word: word.Word,
Start: word.Start,
End: word.End,
})
}
return result
}
func usageToTranscriptionUsage(usage openai.TranscriptionUsageUnion) provider.TranscriptionUsage {
switch usage.Type {
case "tokens":
tokens := usage.AsTokens()
return provider.TranscriptionUsage{
Type: usage.Type,
InputTokens: tokens.InputTokens,
OutputTokens: tokens.OutputTokens,
TotalTokens: tokens.TotalTokens,
AudioTokens: tokens.InputTokenDetails.AudioTokens,
TextTokens: tokens.InputTokenDetails.TextTokens,
}
case "duration":
duration := usage.AsDuration()
return provider.TranscriptionUsage{
Type: usage.Type,
Seconds: duration.Seconds,
}
default:
return provider.TranscriptionUsage{}
}
}
+595
View File
@@ -0,0 +1,595 @@
// Package openaicompat implements a shared chat-completion Provider for any
// service that speaks the OpenAI Chat Completions API (OpenAI itself, DeepSeek,
// Moonshot, xAI, Groq, Ollama, and friends).
//
// Most providers differ from vanilla OpenAI only in endpoint URL and a handful
// of per-model quirks (e.g., "this model is text-only", "this model doesn't
// accept tools", "drop temperature on reasoning models"). Those quirks are
// captured declaratively via Rules, so a concrete provider package is usually
// a one-function wrapper that calls New with its own base URL and Rules.
package openaicompat
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"path"
"strings"
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"
"github.com/openai/openai-go/packages/param"
"github.com/openai/openai-go/shared"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
// Rules encodes provider-specific constraints on top of the OpenAI wire
// protocol. The zero value means "no restrictions" and behaves like vanilla
// OpenAI. Individual fields are documented inline.
type Rules struct {
// MaxImagesPerMessage rejects requests whose any single message carries
// more images than this cap. 0 means "no cap".
MaxImagesPerMessage int
// MaxAudioPerMessage rejects requests whose any single message carries
// more audio attachments than this cap. 0 means "no cap".
MaxAudioPerMessage int
// SupportsVision, when non-nil, is consulted for every request that
// includes any image attachments. If it returns false for the request's
// model, the call fails with a FeatureUnsupportedError before hitting
// the network.
SupportsVision func(model string) bool
// SupportsTools, when non-nil, is consulted for every request that
// includes any tool definitions. If it returns false for the model,
// the call fails with a FeatureUnsupportedError before hitting the
// network.
SupportsTools func(model string) bool
// SupportsAudio, when non-nil, is consulted for every request that
// includes any audio attachments. If it returns false for the model,
// the call fails with a FeatureUnsupportedError.
SupportsAudio func(model string) bool
// RestrictTemperature, when non-nil and returning true for the request's
// model, causes the Temperature field to be silently dropped from the
// outgoing request. Used by OpenAI o-series and gpt-5* which reject a
// user-provided temperature.
RestrictTemperature func(model string) bool
// CustomizeRequest is a last-mile hook invoked after buildRequest but
// before the call is sent. It receives the fully built OpenAI SDK
// parameters and may mutate them freely (add headers, flip flags, tweak
// response_format, etc.).
CustomizeRequest func(params *openai.ChatCompletionNewParams)
// SupportsReasoning, when non-nil and returning false for the request's
// model, causes the request's Reasoning field to be silently dropped
// from the outgoing request. Used by providers (e.g., OpenAI) where
// reasoning_effort is rejected on non-reasoning models. nil = always
// pass reasoning_effort through when set.
SupportsReasoning func(model string) bool
// MapReasoningEffort, when non-nil, maps the standardized go-llm
// ReasoningLevel ("low"|"medium"|"high") to the provider's wire-level
// effort string. Used by xAI which only accepts "low"|"high" (callers
// remap "medium" to "high"). nil = pass-through unchanged.
MapReasoningEffort func(level string) string
}
// FeatureUnsupportedError is returned when a Rules predicate rejects a request
// because the target model does not support a feature the caller included.
type FeatureUnsupportedError struct {
Feature string
Model string
}
func (e *FeatureUnsupportedError) Error() string {
return fmt.Sprintf("openaicompat: model %q does not support %s", e.Model, e.Feature)
}
// Provider implements provider.Provider for any OpenAI-compatible endpoint.
type Provider struct {
apiKey string
baseURL string
rules Rules
}
// New creates a Provider. baseURL may be empty to let the OpenAI SDK use its
// default; in practice concrete provider packages always pass a default.
func New(apiKey, baseURL string, rules Rules) *Provider {
return &Provider{apiKey: apiKey, baseURL: baseURL, rules: rules}
}
// Complete performs a non-streaming completion.
func (p *Provider) Complete(ctx context.Context, req provider.Request) (provider.Response, error) {
if err := p.checkRules(req); err != nil {
return provider.Response{}, err
}
cl := openai.NewClient(p.requestOptions()...)
oaiReq := p.buildRequest(req)
if p.rules.CustomizeRequest != nil {
p.rules.CustomizeRequest(&oaiReq)
}
resp, err := cl.Chat.Completions.New(ctx, oaiReq)
if err != nil {
return provider.Response{}, fmt.Errorf("openai completion error: %w", err)
}
return p.convertResponse(resp), nil
}
// Stream performs a streaming completion.
func (p *Provider) Stream(ctx context.Context, req provider.Request, events chan<- provider.StreamEvent) error {
if err := p.checkRules(req); err != nil {
return err
}
cl := openai.NewClient(p.requestOptions()...)
oaiReq := p.buildRequest(req)
oaiReq.StreamOptions = openai.ChatCompletionStreamOptionsParam{
IncludeUsage: openai.Bool(true),
}
if p.rules.CustomizeRequest != nil {
p.rules.CustomizeRequest(&oaiReq)
}
stream := cl.Chat.Completions.NewStreaming(ctx, oaiReq)
var fullText strings.Builder
var fullThinking strings.Builder
var toolCalls []provider.ToolCall
toolCallArgs := map[int]*strings.Builder{}
var usage *provider.Usage
for stream.Next() {
chunk := stream.Current()
// Capture usage from the final chunk (present when StreamOptions.IncludeUsage is true)
if chunk.Usage.TotalTokens > 0 {
usage = &provider.Usage{
InputTokens: int(chunk.Usage.PromptTokens),
OutputTokens: int(chunk.Usage.CompletionTokens),
TotalTokens: int(chunk.Usage.TotalTokens),
Details: extractUsageDetails(chunk.Usage),
}
}
for _, choice := range chunk.Choices {
// Text delta
if choice.Delta.Content != "" {
fullText.WriteString(choice.Delta.Content)
events <- provider.StreamEvent{
Type: provider.StreamEventText,
Text: choice.Delta.Content,
}
}
// Reasoning/thinking delta — DeepSeek and Groq use a non-standard
// "reasoning_content" field on the delta. Extract it from the
// raw JSON since the OpenAI SDK doesn't surface it as a typed
// field.
if rc := extractReasoningContent(choice.Delta.RawJSON()); rc != "" {
fullThinking.WriteString(rc)
events <- provider.StreamEvent{
Type: provider.StreamEventThinking,
Text: rc,
}
}
// Tool call deltas
for _, tc := range choice.Delta.ToolCalls {
idx := int(tc.Index)
if tc.ID != "" {
// New tool call starting
for len(toolCalls) <= idx {
toolCalls = append(toolCalls, provider.ToolCall{})
}
toolCalls[idx].ID = tc.ID
toolCalls[idx].Name = tc.Function.Name
toolCallArgs[idx] = &strings.Builder{}
events <- provider.StreamEvent{
Type: provider.StreamEventToolStart,
ToolCall: &provider.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
},
ToolIndex: idx,
}
}
if tc.Function.Arguments != "" {
if b, ok := toolCallArgs[idx]; ok {
b.WriteString(tc.Function.Arguments)
}
events <- provider.StreamEvent{
Type: provider.StreamEventToolDelta,
ToolIndex: idx,
ToolCall: &provider.ToolCall{
Arguments: tc.Function.Arguments,
},
}
}
}
}
}
if err := stream.Err(); err != nil {
return fmt.Errorf("openai stream error: %w", err)
}
// Finalize tool calls
for idx := range toolCalls {
if b, ok := toolCallArgs[idx]; ok {
toolCalls[idx].Arguments = b.String()
}
events <- provider.StreamEvent{
Type: provider.StreamEventToolEnd,
ToolIndex: idx,
ToolCall: &toolCalls[idx],
}
}
events <- provider.StreamEvent{
Type: provider.StreamEventDone,
Response: &provider.Response{
Text: fullText.String(),
Thinking: fullThinking.String(),
ToolCalls: toolCalls,
Usage: usage,
},
}
return nil
}
func (p *Provider) requestOptions() []option.RequestOption {
opts := []option.RequestOption{option.WithAPIKey(p.apiKey)}
if p.baseURL != "" {
opts = append(opts, option.WithBaseURL(p.baseURL))
}
return opts
}
// checkRules applies all Rules predicates against a request and returns an
// error if any constraint is violated. Runs before any network call.
func (p *Provider) checkRules(req provider.Request) error {
var hasImages, hasAudio bool
for _, msg := range req.Messages {
if len(msg.Images) > 0 {
hasImages = true
}
if len(msg.Audio) > 0 {
hasAudio = true
}
if p.rules.MaxImagesPerMessage > 0 && len(msg.Images) > p.rules.MaxImagesPerMessage {
return fmt.Errorf("openaicompat: message has %d images, max allowed is %d for model %q",
len(msg.Images), p.rules.MaxImagesPerMessage, req.Model)
}
if p.rules.MaxAudioPerMessage > 0 && len(msg.Audio) > p.rules.MaxAudioPerMessage {
return fmt.Errorf("openaicompat: message has %d audio attachments, max allowed is %d for model %q",
len(msg.Audio), p.rules.MaxAudioPerMessage, req.Model)
}
}
if hasImages && p.rules.SupportsVision != nil && !p.rules.SupportsVision(req.Model) {
return &FeatureUnsupportedError{Feature: "vision", Model: req.Model}
}
if hasAudio && p.rules.SupportsAudio != nil && !p.rules.SupportsAudio(req.Model) {
return &FeatureUnsupportedError{Feature: "audio", Model: req.Model}
}
if len(req.Tools) > 0 && p.rules.SupportsTools != nil && !p.rules.SupportsTools(req.Model) {
return &FeatureUnsupportedError{Feature: "tools", Model: req.Model}
}
return nil
}
func (p *Provider) buildRequest(req provider.Request) openai.ChatCompletionNewParams {
oaiReq := openai.ChatCompletionNewParams{
Model: req.Model,
}
for _, msg := range req.Messages {
oaiReq.Messages = append(oaiReq.Messages, convertMessage(msg, req.Model))
}
for _, tool := range req.Tools {
oaiReq.Tools = append(oaiReq.Tools, openai.ChatCompletionToolParam{
Type: "function",
Function: shared.FunctionDefinitionParam{
Name: tool.Name,
Description: openai.String(tool.Description),
Parameters: openai.FunctionParameters(tool.Schema),
},
})
}
if req.Temperature != nil {
if p.rules.RestrictTemperature == nil || !p.rules.RestrictTemperature(req.Model) {
oaiReq.Temperature = openai.Float(*req.Temperature)
}
}
if req.MaxTokens != nil {
oaiReq.MaxCompletionTokens = openai.Int(int64(*req.MaxTokens))
}
if req.TopP != nil {
oaiReq.TopP = openai.Float(*req.TopP)
}
if len(req.Stop) > 0 {
oaiReq.Stop = openai.ChatCompletionNewParamsStopUnion{OfString: openai.String(req.Stop[0])}
}
if req.Reasoning != "" {
if p.rules.SupportsReasoning == nil || p.rules.SupportsReasoning(req.Model) {
effort := req.Reasoning
if p.rules.MapReasoningEffort != nil {
effort = p.rules.MapReasoningEffort(effort)
}
oaiReq.ReasoningEffort = shared.ReasoningEffort(effort)
}
}
return oaiReq
}
func convertMessage(msg provider.Message, model string) openai.ChatCompletionMessageParamUnion {
var arrayOfContentParts []openai.ChatCompletionContentPartUnionParam
var textContent param.Opt[string]
for _, img := range msg.Images {
var url string
if img.Base64 != "" {
url = "data:" + img.ContentType + ";base64," + img.Base64
} else if img.URL != "" {
url = img.URL
}
if url != "" {
arrayOfContentParts = append(arrayOfContentParts,
openai.ChatCompletionContentPartUnionParam{
OfImageURL: &openai.ChatCompletionContentPartImageParam{
ImageURL: openai.ChatCompletionContentPartImageImageURLParam{
URL: url,
},
},
},
)
}
}
for _, aud := range msg.Audio {
var b64Data string
var format string
if aud.Base64 != "" {
b64Data = aud.Base64
format = audioFormat(aud.ContentType)
} else if aud.URL != "" {
resp, err := http.Get(aud.URL)
if err != nil {
continue
}
data, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
continue
}
b64Data = base64.StdEncoding.EncodeToString(data)
ct := resp.Header.Get("Content-Type")
if ct == "" {
ct = aud.ContentType
}
if ct == "" {
ct = audioFormatFromURL(aud.URL)
}
format = audioFormat(ct)
}
if b64Data != "" && format != "" {
arrayOfContentParts = append(arrayOfContentParts,
openai.ChatCompletionContentPartUnionParam{
OfInputAudio: &openai.ChatCompletionContentPartInputAudioParam{
InputAudio: openai.ChatCompletionContentPartInputAudioInputAudioParam{
Data: b64Data,
Format: format,
},
},
},
)
}
}
if msg.Content != "" {
if len(arrayOfContentParts) > 0 {
arrayOfContentParts = append(arrayOfContentParts,
openai.ChatCompletionContentPartUnionParam{
OfText: &openai.ChatCompletionContentPartTextParam{
Text: msg.Content,
},
},
)
} else {
textContent = openai.String(msg.Content)
}
}
// Determine if this model uses developer messages instead of system
useDeveloper := false
parts := strings.Split(model, "-")
if len(parts) > 1 && len(parts[0]) > 0 && parts[0][0] == 'o' {
useDeveloper = true
}
switch msg.Role {
case "system":
if useDeveloper {
return openai.ChatCompletionMessageParamUnion{
OfDeveloper: &openai.ChatCompletionDeveloperMessageParam{
Content: openai.ChatCompletionDeveloperMessageParamContentUnion{
OfString: textContent,
},
},
}
}
return openai.ChatCompletionMessageParamUnion{
OfSystem: &openai.ChatCompletionSystemMessageParam{
Content: openai.ChatCompletionSystemMessageParamContentUnion{
OfString: textContent,
},
},
}
case "user":
return openai.ChatCompletionMessageParamUnion{
OfUser: &openai.ChatCompletionUserMessageParam{
Content: openai.ChatCompletionUserMessageParamContentUnion{
OfString: textContent,
OfArrayOfContentParts: arrayOfContentParts,
},
},
}
case "assistant":
as := &openai.ChatCompletionAssistantMessageParam{}
if msg.Content != "" {
as.Content.OfString = openai.String(msg.Content)
}
for _, tc := range msg.ToolCalls {
as.ToolCalls = append(as.ToolCalls, openai.ChatCompletionMessageToolCallParam{
ID: tc.ID,
Function: openai.ChatCompletionMessageToolCallFunctionParam{
Name: tc.Name,
Arguments: tc.Arguments,
},
})
}
return openai.ChatCompletionMessageParamUnion{OfAssistant: as}
case "tool":
return openai.ChatCompletionMessageParamUnion{
OfTool: &openai.ChatCompletionToolMessageParam{
ToolCallID: msg.ToolCallID,
Content: openai.ChatCompletionToolMessageParamContentUnion{
OfString: openai.String(msg.Content),
},
},
}
}
// Fallback to user message
return openai.ChatCompletionMessageParamUnion{
OfUser: &openai.ChatCompletionUserMessageParam{
Content: openai.ChatCompletionUserMessageParamContentUnion{
OfString: textContent,
},
},
}
}
func (p *Provider) convertResponse(resp *openai.ChatCompletion) provider.Response {
var res provider.Response
if resp == nil || len(resp.Choices) == 0 {
return res
}
choice := resp.Choices[0]
res.Text = choice.Message.Content
res.Thinking = extractReasoningContent(choice.Message.RawJSON())
for _, tc := range choice.Message.ToolCalls {
res.ToolCalls = append(res.ToolCalls, provider.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: strings.TrimSpace(tc.Function.Arguments),
})
}
if resp.Usage.TotalTokens > 0 {
res.Usage = &provider.Usage{
InputTokens: int(resp.Usage.PromptTokens),
OutputTokens: int(resp.Usage.CompletionTokens),
TotalTokens: int(resp.Usage.TotalTokens),
}
res.Usage.Details = extractUsageDetails(resp.Usage)
}
return res
}
// audioFormat converts a MIME type to an OpenAI audio format string ("wav" or "mp3").
func audioFormat(contentType string) string {
ct := strings.ToLower(contentType)
switch {
case strings.Contains(ct, "wav"):
return "wav"
case strings.Contains(ct, "mp3"), strings.Contains(ct, "mpeg"):
return "mp3"
default:
return "wav"
}
}
// extractUsageDetails extracts provider-specific detail tokens from an OpenAI CompletionUsage.
func extractUsageDetails(usage openai.CompletionUsage) map[string]int {
details := map[string]int{}
if usage.CompletionTokensDetails.ReasoningTokens > 0 {
details[provider.UsageDetailReasoningTokens] = int(usage.CompletionTokensDetails.ReasoningTokens)
}
if usage.CompletionTokensDetails.AudioTokens > 0 {
details[provider.UsageDetailAudioOutputTokens] = int(usage.CompletionTokensDetails.AudioTokens)
}
if usage.PromptTokensDetails.CachedTokens > 0 {
details[provider.UsageDetailCachedInputTokens] = int(usage.PromptTokensDetails.CachedTokens)
}
if usage.PromptTokensDetails.AudioTokens > 0 {
details[provider.UsageDetailAudioInputTokens] = int(usage.PromptTokensDetails.AudioTokens)
}
if len(details) == 0 {
return nil
}
return details
}
// extractReasoningContent pulls the non-standard "reasoning_content" string
// from the raw JSON of a message or delta. DeepSeek's reasoner and several
// Groq-hosted reasoning models put their thinking trace in this field rather
// than in OpenAI's standard "reasoning_summary" blocks; the OpenAI Go SDK
// doesn't surface it as a typed field, so we re-parse the raw JSON. Returns
// empty string when the field is absent or unparseable.
func extractReasoningContent(rawJSON string) string {
if rawJSON == "" || !strings.Contains(rawJSON, "reasoning_content") {
return ""
}
var d struct {
ReasoningContent string `json:"reasoning_content"`
}
if err := json.Unmarshal([]byte(rawJSON), &d); err != nil {
return ""
}
return d.ReasoningContent
}
// audioFormatFromURL guesses the audio format from a URL's file extension.
func audioFormatFromURL(u string) string {
ext := strings.ToLower(path.Ext(u))
switch ext {
case ".mp3":
return "audio/mp3"
case ".wav":
return "audio/wav"
default:
return "audio/wav"
}
}
+469
View File
@@ -0,0 +1,469 @@
package openaicompat_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/openai/openai-go"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/openaicompat"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
// newTestServer returns a httptest server that captures the raw request body
// on POST /chat/completions and returns a canned OpenAI response so Complete()
// succeeds. Use `captured` to assert on what the provider would send.
func newTestServer(t *testing.T) (*httptest.Server, *[]byte) {
t.Helper()
var body []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
http.NotFound(w, r)
return
}
b, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
}
body = b
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{
"id": "cmpl-1",
"object": "chat.completion",
"choices": [{
"index": 0,
"message": {"role":"assistant","content":"ok"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}
}`)
}))
return srv, &body
}
func textReq(model, content string) provider.Request {
return provider.Request{
Model: model,
Messages: []provider.Message{{Role: "user", Content: content}},
}
}
func TestComplete_ZeroRulesPassesThrough(t *testing.T) {
srv, body := newTestServer(t)
defer srv.Close()
temp := 0.7
req := textReq("gpt-4o", "hi")
req.Temperature = &temp
p := openaicompat.New("test-key", srv.URL, openaicompat.Rules{})
resp, err := p.Complete(context.Background(), req)
if err != nil {
t.Fatalf("Complete: %v", err)
}
if resp.Text != "ok" {
t.Errorf("Text = %q, want %q", resp.Text, "ok")
}
// Temperature should be present since RestrictTemperature is nil.
var parsed map[string]any
if err := json.Unmarshal(*body, &parsed); err != nil {
t.Fatalf("unmarshal request body: %v", err)
}
if _, ok := parsed["temperature"]; !ok {
t.Errorf("expected temperature in request body, got: %s", *body)
}
}
func TestComplete_RestrictTemperatureDropsField(t *testing.T) {
srv, body := newTestServer(t)
defer srv.Close()
temp := 0.7
req := textReq("o1", "hi")
req.Temperature = &temp
p := openaicompat.New("test-key", srv.URL, openaicompat.Rules{
RestrictTemperature: func(m string) bool { return strings.HasPrefix(m, "o") },
})
if _, err := p.Complete(context.Background(), req); err != nil {
t.Fatalf("Complete: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(*body, &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, ok := parsed["temperature"]; ok {
t.Errorf("temperature should be dropped for o1, got: %s", *body)
}
}
func TestComplete_SupportsVisionRejectsWhenFalse(t *testing.T) {
srv, _ := newTestServer(t)
defer srv.Close()
req := provider.Request{
Model: "deepseek-chat",
Messages: []provider.Message{{
Role: "user",
Content: "describe",
Images: []provider.Image{{URL: "https://example.com/a.png"}},
}},
}
p := openaicompat.New("test-key", srv.URL, openaicompat.Rules{
SupportsVision: func(string) bool { return false },
})
_, err := p.Complete(context.Background(), req)
var fue *openaicompat.FeatureUnsupportedError
if !errors.As(err, &fue) {
t.Fatalf("want FeatureUnsupportedError, got %v", err)
}
if fue.Feature != "vision" || fue.Model != "deepseek-chat" {
t.Errorf("unexpected err: %+v", fue)
}
}
func TestComplete_SupportsToolsRejectsWhenFalse(t *testing.T) {
srv, _ := newTestServer(t)
defer srv.Close()
req := provider.Request{
Model: "deepseek-reasoner",
Messages: []provider.Message{{Role: "user", Content: "hi"}},
Tools: []provider.ToolDef{
{Name: "get_weather", Description: "weather", Schema: map[string]any{"type": "object"}},
},
}
p := openaicompat.New("test-key", srv.URL, openaicompat.Rules{
SupportsTools: func(m string) bool { return !strings.Contains(m, "reasoner") },
})
_, err := p.Complete(context.Background(), req)
var fue *openaicompat.FeatureUnsupportedError
if !errors.As(err, &fue) {
t.Fatalf("want FeatureUnsupportedError, got %v", err)
}
if fue.Feature != "tools" {
t.Errorf("feature = %q, want tools", fue.Feature)
}
}
func TestComplete_SupportsAudioRejectsWhenFalse(t *testing.T) {
srv, _ := newTestServer(t)
defer srv.Close()
req := provider.Request{
Model: "groq-llama",
Messages: []provider.Message{{
Role: "user",
Audio: []provider.Audio{{Base64: "AAA=", ContentType: "audio/wav"}},
}},
}
p := openaicompat.New("test-key", srv.URL, openaicompat.Rules{
SupportsAudio: func(string) bool { return false },
})
_, err := p.Complete(context.Background(), req)
var fue *openaicompat.FeatureUnsupportedError
if !errors.As(err, &fue) {
t.Fatalf("want FeatureUnsupportedError, got %v", err)
}
if fue.Feature != "audio" {
t.Errorf("feature = %q, want audio", fue.Feature)
}
}
func TestComplete_MaxImagesPerMessage(t *testing.T) {
srv, _ := newTestServer(t)
defer srv.Close()
req := provider.Request{
Model: "anything",
Messages: []provider.Message{{
Role: "user",
Images: []provider.Image{
{URL: "a"}, {URL: "b"}, {URL: "c"},
},
}},
}
p := openaicompat.New("test-key", srv.URL, openaicompat.Rules{MaxImagesPerMessage: 2})
_, err := p.Complete(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "max allowed is 2") {
t.Fatalf("want max-images error, got %v", err)
}
// Exactly at limit succeeds.
req.Messages[0].Images = req.Messages[0].Images[:2]
if _, err := p.Complete(context.Background(), req); err != nil {
t.Errorf("at-limit request should succeed, got %v", err)
}
}
func TestComplete_CustomizeRequestInvoked(t *testing.T) {
srv, body := newTestServer(t)
defer srv.Close()
called := false
p := openaicompat.New("test-key", srv.URL, openaicompat.Rules{
CustomizeRequest: func(params *openai.ChatCompletionNewParams) {
called = true
// Confirm we receive a non-empty built request.
if params.Model != "gpt-4o" {
t.Errorf("CustomizeRequest saw model %q, want gpt-4o", params.Model)
}
// Mutation here should end up on the wire.
params.User = openai.String("test-user")
},
})
if _, err := p.Complete(context.Background(), textReq("gpt-4o", "hi")); err != nil {
t.Fatalf("Complete: %v", err)
}
if !called {
t.Fatal("CustomizeRequest hook was not invoked")
}
if !strings.Contains(string(*body), `"user":"test-user"`) {
t.Errorf("mutation from CustomizeRequest not reflected on wire: %s", *body)
}
}
func TestStream_EmitsDoneAndText(t *testing.T) {
// SSE stream with one content chunk then [DONE].
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
flusher, _ := w.(http.Flusher)
for _, line := range []string{
`data: {"id":"1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"hel"}}]}`,
`data: {"id":"1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"lo"}}]}`,
`data: {"id":"1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
`data: [DONE]`,
} {
_, _ = io.WriteString(w, line+"\n\n")
if flusher != nil {
flusher.Flush()
}
}
}))
defer srv.Close()
p := openaicompat.New("test-key", srv.URL, openaicompat.Rules{})
events := make(chan provider.StreamEvent, 16)
go func() {
_ = p.Stream(context.Background(), textReq("gpt-4o", "hi"), events)
close(events)
}()
var text strings.Builder
var sawDone bool
var doneUsage *provider.Usage
for ev := range events {
switch ev.Type {
case provider.StreamEventText:
text.WriteString(ev.Text)
case provider.StreamEventDone:
sawDone = true
if ev.Response != nil {
doneUsage = ev.Response.Usage
}
}
}
if text.String() != "hello" {
t.Errorf("got text %q, want %q", text.String(), "hello")
}
if !sawDone {
t.Fatal("no Done event emitted")
}
if doneUsage == nil || doneUsage.TotalTokens != 3 {
t.Errorf("usage on Done = %+v, want TotalTokens=3", doneUsage)
}
}
func TestComplete_ReasoningEffortPassthrough(t *testing.T) {
srv, body := newTestServer(t)
defer srv.Close()
req := textReq("o3-mini", "hi")
req.Reasoning = "high"
p := openaicompat.New("test-key", srv.URL, openaicompat.Rules{})
if _, err := p.Complete(context.Background(), req); err != nil {
t.Fatalf("Complete: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(*body, &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if parsed["reasoning_effort"] != "high" {
t.Errorf("reasoning_effort = %v, want \"high\"; body: %s", parsed["reasoning_effort"], *body)
}
}
func TestComplete_SupportsReasoningGate(t *testing.T) {
srv, body := newTestServer(t)
defer srv.Close()
req := textReq("gpt-4o", "hi")
req.Reasoning = "high"
// SupportsReasoning returns false → reasoning_effort must NOT be sent.
p := openaicompat.New("test-key", srv.URL, openaicompat.Rules{
SupportsReasoning: func(string) bool { return false },
})
if _, err := p.Complete(context.Background(), req); err != nil {
t.Fatalf("Complete: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(*body, &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, ok := parsed["reasoning_effort"]; ok {
t.Errorf("reasoning_effort should be absent when SupportsReasoning=false; body: %s", *body)
}
}
func TestComplete_MapReasoningEffort(t *testing.T) {
srv, body := newTestServer(t)
defer srv.Close()
req := textReq("grok-3-mini", "hi")
req.Reasoning = "medium"
// xAI-style mapping: medium → high.
p := openaicompat.New("test-key", srv.URL, openaicompat.Rules{
MapReasoningEffort: func(level string) string {
if level == "medium" {
return "high"
}
return level
},
})
if _, err := p.Complete(context.Background(), req); err != nil {
t.Fatalf("Complete: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(*body, &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if parsed["reasoning_effort"] != "high" {
t.Errorf("reasoning_effort = %v, want \"high\" after medium→high remap; body: %s", parsed["reasoning_effort"], *body)
}
}
func TestComplete_ReasoningContentExtracted(t *testing.T) {
// Server returns a DeepSeek-style response with reasoning_content alongside content.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{
"id": "cmpl-1",
"object": "chat.completion",
"choices": [{
"index": 0,
"message": {
"role":"assistant",
"content":"42",
"reasoning_content":"the user asked for the answer..."
},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}
}`)
}))
defer srv.Close()
p := openaicompat.New("test-key", srv.URL, openaicompat.Rules{})
resp, err := p.Complete(context.Background(), textReq("deepseek-reasoner", "?"))
if err != nil {
t.Fatalf("Complete: %v", err)
}
if resp.Text != "42" {
t.Errorf("Text = %q, want %q", resp.Text, "42")
}
if !strings.Contains(resp.Thinking, "the user asked for") {
t.Errorf("Thinking = %q, want it to contain the reasoning trace", resp.Thinking)
}
}
func TestStream_ReasoningContentEmitsThinkingEvents(t *testing.T) {
// Two SSE chunks, each with a reasoning_content delta, then a final done chunk.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
flusher, _ := w.(http.Flusher)
for _, line := range []string{
`data: {"id":"1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"reasoning_content":"think "}}]}`,
`data: {"id":"1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"reasoning_content":"hard","content":"42"}}]}`,
`data: {"id":"1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
`data: [DONE]`,
} {
_, _ = io.WriteString(w, line+"\n\n")
if flusher != nil {
flusher.Flush()
}
}
}))
defer srv.Close()
p := openaicompat.New("test-key", srv.URL, openaicompat.Rules{})
events := make(chan provider.StreamEvent, 32)
go func() {
_ = p.Stream(context.Background(), textReq("deepseek-reasoner", "?"), events)
close(events)
}()
var thinking strings.Builder
var sawDone bool
var doneThinking string
for ev := range events {
switch ev.Type {
case provider.StreamEventThinking:
thinking.WriteString(ev.Text)
case provider.StreamEventDone:
sawDone = true
if ev.Response != nil {
doneThinking = ev.Response.Thinking
}
}
}
if thinking.String() != "think hard" {
t.Errorf("streamed thinking = %q, want %q", thinking.String(), "think hard")
}
if !sawDone {
t.Fatal("no Done event")
}
if doneThinking != "think hard" {
t.Errorf("Done.Response.Thinking = %q, want %q", doneThinking, "think hard")
}
}
func TestStream_RulesCheckedBeforeNetwork(t *testing.T) {
// Server should never be hit when rules reject up front.
hit := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hit = true
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
p := openaicompat.New("test-key", srv.URL, openaicompat.Rules{
SupportsVision: func(string) bool { return false },
})
req := provider.Request{
Model: "no-vision-model",
Messages: []provider.Message{{
Role: "user",
Images: []provider.Image{{URL: "a"}},
}},
}
events := make(chan provider.StreamEvent, 4)
err := p.Stream(context.Background(), req, events)
var fue *openaicompat.FeatureUnsupportedError
if !errors.As(err, &fue) {
t.Fatalf("want FeatureUnsupportedError, got %v", err)
}
if hit {
t.Error("server was contacted despite Rules violation")
}
}
+83
View File
@@ -0,0 +1,83 @@
package llm
import "sync"
// ModelPricing defines per-token pricing for a model.
type ModelPricing struct {
InputPricePerToken float64 // USD per input token
OutputPricePerToken float64 // USD per output token
CachedInputPricePerToken float64 // USD per cached input token (0 = same as input)
}
// Cost computes the total USD cost from a Usage.
// When CachedInputPricePerToken is set and the usage includes cached_input_tokens,
// those tokens are charged at the cached rate instead of the regular input rate.
func (mp ModelPricing) Cost(u *Usage) float64 {
if u == nil {
return 0
}
inputTokens := u.InputTokens
cachedTokens := 0
if u.Details != nil {
cachedTokens = u.Details[UsageDetailCachedInputTokens]
}
var cost float64
if mp.CachedInputPricePerToken > 0 && cachedTokens > 0 {
regularInput := inputTokens - cachedTokens
if regularInput < 0 {
regularInput = 0
}
cost += float64(regularInput) * mp.InputPricePerToken
cost += float64(cachedTokens) * mp.CachedInputPricePerToken
} else {
cost += float64(inputTokens) * mp.InputPricePerToken
}
cost += float64(u.OutputTokens) * mp.OutputPricePerToken
return cost
}
// PricingRegistry maps model names to their pricing.
// Callers populate it with the models and prices relevant to their use case.
type PricingRegistry struct {
mu sync.RWMutex
models map[string]ModelPricing
}
// NewPricingRegistry creates an empty pricing registry.
func NewPricingRegistry() *PricingRegistry {
return &PricingRegistry{
models: make(map[string]ModelPricing),
}
}
// Set registers pricing for a model.
func (pr *PricingRegistry) Set(model string, pricing ModelPricing) {
pr.mu.Lock()
defer pr.mu.Unlock()
pr.models[model] = pricing
}
// Has returns true if pricing is registered for the given model.
func (pr *PricingRegistry) Has(model string) bool {
pr.mu.RLock()
defer pr.mu.RUnlock()
_, ok := pr.models[model]
return ok
}
// Cost computes the USD cost for the given model and usage.
// Returns 0 if the model is not registered.
func (pr *PricingRegistry) Cost(model string, u *Usage) float64 {
pr.mu.RLock()
pricing, ok := pr.models[model]
pr.mu.RUnlock()
if !ok {
return 0
}
return pricing.Cost(u)
}
+128
View File
@@ -0,0 +1,128 @@
package llm
import (
"math"
"testing"
)
func TestModelPricing_Cost(t *testing.T) {
pricing := ModelPricing{
InputPricePerToken: 0.000003, // $3/MTok
OutputPricePerToken: 0.000015, // $15/MTok
}
usage := &Usage{
InputTokens: 1000,
OutputTokens: 500,
TotalTokens: 1500,
}
cost := pricing.Cost(usage)
expected := 1000*0.000003 + 500*0.000015
if math.Abs(cost-expected) > 1e-10 {
t.Errorf("expected cost %f, got %f", expected, cost)
}
}
func TestModelPricing_Cost_WithCachedTokens(t *testing.T) {
pricing := ModelPricing{
InputPricePerToken: 0.000003, // $3/MTok
OutputPricePerToken: 0.000015, // $15/MTok
CachedInputPricePerToken: 0.0000015, // $1.50/MTok (50% discount)
}
usage := &Usage{
InputTokens: 1000,
OutputTokens: 500,
TotalTokens: 1500,
Details: map[string]int{
UsageDetailCachedInputTokens: 400,
},
}
cost := pricing.Cost(usage)
// 600 regular input tokens + 400 cached tokens + 500 output tokens
expected := 600*0.000003 + 400*0.0000015 + 500*0.000015
if math.Abs(cost-expected) > 1e-10 {
t.Errorf("expected cost %f, got %f", expected, cost)
}
}
func TestModelPricing_Cost_NilUsage(t *testing.T) {
pricing := ModelPricing{
InputPricePerToken: 0.000003,
OutputPricePerToken: 0.000015,
}
cost := pricing.Cost(nil)
if cost != 0 {
t.Errorf("expected 0 for nil usage, got %f", cost)
}
}
func TestModelPricing_Cost_NoCachedPrice(t *testing.T) {
// When CachedInputPricePerToken is 0, all input tokens use InputPricePerToken
pricing := ModelPricing{
InputPricePerToken: 0.000003,
OutputPricePerToken: 0.000015,
}
usage := &Usage{
InputTokens: 1000,
OutputTokens: 500,
TotalTokens: 1500,
Details: map[string]int{
UsageDetailCachedInputTokens: 400,
},
}
cost := pricing.Cost(usage)
expected := 1000*0.000003 + 500*0.000015
if math.Abs(cost-expected) > 1e-10 {
t.Errorf("expected cost %f, got %f", expected, cost)
}
}
func TestPricingRegistry(t *testing.T) {
registry := NewPricingRegistry()
registry.Set("gpt-4o", ModelPricing{
InputPricePerToken: 0.0000025,
OutputPricePerToken: 0.00001,
})
if !registry.Has("gpt-4o") {
t.Error("expected Has('gpt-4o') to be true")
}
if registry.Has("gpt-3.5-turbo") {
t.Error("expected Has('gpt-3.5-turbo') to be false")
}
usage := &Usage{InputTokens: 1000, OutputTokens: 200, TotalTokens: 1200}
cost := registry.Cost("gpt-4o", usage)
expected := 1000*0.0000025 + 200*0.00001
if math.Abs(cost-expected) > 1e-10 {
t.Errorf("expected cost %f, got %f", expected, cost)
}
// Unknown model returns 0
cost = registry.Cost("unknown-model", usage)
if cost != 0 {
t.Errorf("expected 0 for unknown model, got %f", cost)
}
}
func TestPricingRegistry_Override(t *testing.T) {
registry := NewPricingRegistry()
registry.Set("model-a", ModelPricing{InputPricePerToken: 0.001, OutputPricePerToken: 0.002})
registry.Set("model-a", ModelPricing{InputPricePerToken: 0.003, OutputPricePerToken: 0.004})
usage := &Usage{InputTokens: 100, OutputTokens: 50, TotalTokens: 150}
cost := registry.Cost("model-a", usage)
expected := 100*0.003 + 50*0.004
if math.Abs(cost-expected) > 1e-10 {
t.Errorf("expected overridden cost %f, got %f", expected, cost)
}
}
+156
View File
@@ -0,0 +1,156 @@
// Package provider defines the interface that LLM backend implementations must satisfy.
package provider
import "context"
// Message is the provider-level message representation.
type Message struct {
Role string
Content string
Images []Image
Audio []Audio
ToolCalls []ToolCall
ToolCallID string
}
// Image represents an image attachment at the provider level.
type Image struct {
URL string
Base64 string
ContentType string
}
// Audio represents an audio attachment at the provider level.
type Audio struct {
URL string
Base64 string
ContentType string
}
// ToolCall represents a tool invocation requested by the model.
type ToolCall struct {
ID string
Name string
Arguments string // raw JSON
}
// ToolDef defines a tool available to the model.
type ToolDef struct {
Name string
Description string
Schema map[string]any // JSON Schema
}
// CacheHints describes where a provider should attach prompt-cache breakpoints
// when the model / provider supports prompt caching. The public `llm` package
// populates this from `WithPromptCaching()`. Providers without cache support
// ignore this field.
//
// Anthropic allows at most 4 cache_control markers per request; this struct
// represents at most 3 (tools, system, last non-system message) to leave one
// breakpoint slot for future use.
type CacheHints struct {
// CacheTools, when true, requests a cache breakpoint on the final tool
// definition in Request.Tools. Has no effect when Tools is empty.
CacheTools bool
// CacheSystem, when true, requests a cache breakpoint on the final
// system-role message in Request.Messages. Has no effect when no
// system message is present.
CacheSystem bool
// LastCacheableMessageIndex is the index into Request.Messages at which
// to place a message-level cache breakpoint. A value of -1 means "no
// message-level breakpoint". Points at the last non-system message in
// the conversation; providers that merge consecutive same-role messages
// must map this index to the correct merged output message.
LastCacheableMessageIndex int
}
// Request is a completion request at the provider level.
type Request struct {
Model string
Messages []Message
Tools []ToolDef
Temperature *float64
MaxTokens *int
TopP *float64
Stop []string
// CacheHints requests prompt-cache breakpoints at specified positions
// on providers that support it (currently Anthropic). nil = no caching.
CacheHints *CacheHints
// Reasoning, when non-empty, asks the model to spend extra inference
// budget reasoning before answering. Each provider translates this to
// its native parameter (Anthropic thinking.budget_tokens, OpenAI/xAI
// reasoning_effort, Google thinking_config, etc.). Models that do not
// support reasoning silently ignore it.
//
// Allowed values: "" (no reasoning, default), "low", "medium", "high".
Reasoning string
}
// Response is a completion response at the provider level.
type Response struct {
Text string
ToolCalls []ToolCall
Usage *Usage
// Thinking holds the model's reasoning/thinking trace, when one was
// requested and the provider exposed it. Empty for providers/models
// that do not surface a thinking trace.
Thinking string
}
// Usage captures token consumption.
type Usage struct {
InputTokens int
OutputTokens int
TotalTokens int
Details map[string]int // provider-specific breakdown (e.g., cached, reasoning tokens)
}
// Standardized detail keys for provider-specific token breakdowns.
const (
UsageDetailReasoningTokens = "reasoning_tokens"
UsageDetailCachedInputTokens = "cached_input_tokens"
UsageDetailCacheCreationTokens = "cache_creation_tokens"
UsageDetailAudioInputTokens = "audio_input_tokens"
UsageDetailAudioOutputTokens = "audio_output_tokens"
UsageDetailThoughtsTokens = "thoughts_tokens"
)
// StreamEventType identifies the kind of stream event.
type StreamEventType int
const (
StreamEventText StreamEventType = iota // Text content delta
StreamEventToolStart // Tool call begins
StreamEventToolDelta // Tool call argument delta
StreamEventToolEnd // Tool call complete
StreamEventDone // Stream complete
StreamEventError // Error occurred
StreamEventThinking // Reasoning/thinking content delta
)
// StreamEvent represents a single event in a streaming response.
type StreamEvent struct {
Type StreamEventType
Text string
ToolCall *ToolCall
ToolIndex int
Error error
Response *Response
}
// Provider is the interface that LLM backends implement.
type Provider interface {
// Complete performs a non-streaming completion.
Complete(ctx context.Context, req Request) (Response, error)
// Stream performs a streaming completion, sending events to the channel.
// The provider MUST close the channel when done.
// The provider MUST send exactly one StreamEventDone as the last non-error event.
Stream(ctx context.Context, req Request, events chan<- StreamEvent) error
}
+90
View File
@@ -0,0 +1,90 @@
package provider
import "context"
// Transcriber abstracts a speech-to-text model implementation.
type Transcriber interface {
Transcribe(ctx context.Context, wav []byte, opts TranscriptionOptions) (Transcription, error)
}
// TranscriptionResponseFormat controls the output format requested from a transcriber.
type TranscriptionResponseFormat string
const (
TranscriptionResponseFormatJSON TranscriptionResponseFormat = "json"
TranscriptionResponseFormatVerboseJSON TranscriptionResponseFormat = "verbose_json"
TranscriptionResponseFormatText TranscriptionResponseFormat = "text"
TranscriptionResponseFormatSRT TranscriptionResponseFormat = "srt"
TranscriptionResponseFormatVTT TranscriptionResponseFormat = "vtt"
)
// TranscriptionTimestampGranularity defines the requested timestamp detail.
type TranscriptionTimestampGranularity string
const (
TranscriptionTimestampGranularityWord TranscriptionTimestampGranularity = "word"
TranscriptionTimestampGranularitySegment TranscriptionTimestampGranularity = "segment"
)
// TranscriptionOptions configures transcription behavior.
type TranscriptionOptions struct {
Language string
Prompt string
Temperature *float64
ResponseFormat TranscriptionResponseFormat
TimestampGranularities []TranscriptionTimestampGranularity
IncludeLogprobs bool
}
// Transcription captures a normalized transcription result.
type Transcription struct {
Provider string
Model string
Text string
Language string
DurationSeconds float64
Segments []TranscriptionSegment
Words []TranscriptionWord
Logprobs []TranscriptionTokenLogprob
Usage TranscriptionUsage
RawJSON string
}
// TranscriptionSegment provides a coarse time-sliced transcription segment.
type TranscriptionSegment struct {
ID int
Start float64
End float64
Text string
Tokens []int
AvgLogprob *float64
CompressionRatio *float64
NoSpeechProb *float64
Words []TranscriptionWord
}
// TranscriptionWord provides a word-level timestamp.
type TranscriptionWord struct {
Word string
Start float64
End float64
Confidence *float64
}
// TranscriptionTokenLogprob captures token-level log probability details.
type TranscriptionTokenLogprob struct {
Token string
Bytes []float64
Logprob float64
}
// TranscriptionUsage captures token or duration usage details.
type TranscriptionUsage struct {
Type string
InputTokens int64
OutputTokens int64
TotalTokens int64
AudioTokens int64
TextTokens int64
Seconds float64
}
+174
View File
@@ -0,0 +1,174 @@
package llm
import (
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/deepseek"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/groq"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/moonshot"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/ollama"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/openai"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/xai"
)
// ProviderInfo describes a registered provider for discovery purposes (CLI
// pickers, wiring layers, admin tools). It is the single source of truth for
// "what providers exist and how do I instantiate one."
type ProviderInfo struct {
// Name is the short lowercase identifier used in provider/model strings
// (e.g., "openai", "deepseek", "moonshot").
Name string
// DisplayName is a human-readable label for UIs.
DisplayName string
// EnvKey is the conventional environment variable that holds the API key
// for this provider. Empty string means "no key needed" (e.g., Ollama).
EnvKey string
// DefaultURL is the default base URL used when no override is supplied.
DefaultURL string
// Models is a list of well-known model names, populated for CLI pickers
// and similar. It is not exhaustive and not validated against the API.
Models []string
// New returns a ready-to-use Client for this provider, given an API key
// (ignored for key-less providers like Ollama) and optional ClientOptions.
New func(apiKey string, opts ...ClientOption) *Client
}
// providerRegistry is the in-process list of known providers. Order is
// intentional: the three original providers first, then OpenAI-compatible
// additions in the order they were added.
var providerRegistry = []ProviderInfo{
{
Name: "openai",
DisplayName: "OpenAI",
EnvKey: "OPENAI_API_KEY",
DefaultURL: openai.DefaultBaseURL,
Models: []string{
"gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano",
"gpt-4o", "gpt-4o-mini",
"gpt-4-turbo", "gpt-3.5-turbo",
"o1", "o1-mini", "o1-preview", "o3-mini",
},
New: OpenAI,
},
{
Name: "anthropic",
DisplayName: "Anthropic",
EnvKey: "ANTHROPIC_API_KEY",
DefaultURL: "https://api.anthropic.com",
Models: []string{
"claude-opus-4-7",
"claude-sonnet-4-6",
"claude-haiku-4-5-20251001",
"claude-opus-4-20250514",
"claude-sonnet-4-20250514",
"claude-3-7-sonnet-20250219",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
},
New: Anthropic,
},
{
Name: "google",
DisplayName: "Google",
EnvKey: "GOOGLE_API_KEY",
DefaultURL: "https://generativelanguage.googleapis.com",
Models: []string{
"gemini-2.0-flash", "gemini-2.0-flash-lite",
"gemini-1.5-pro", "gemini-1.5-flash", "gemini-1.5-flash-8b",
},
New: Google,
},
{
Name: "deepseek",
DisplayName: "DeepSeek",
EnvKey: "DEEPSEEK_API_KEY",
DefaultURL: deepseek.DefaultBaseURL,
Models: []string{"deepseek-chat", "deepseek-reasoner"},
New: DeepSeek,
},
{
Name: "moonshot",
DisplayName: "Moonshot (Kimi)",
EnvKey: "MOONSHOT_API_KEY",
DefaultURL: moonshot.DefaultBaseURL,
Models: []string{
"kimi-k2-0711-preview",
"moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
"moonshot-v1-8k-vision-preview",
},
New: Moonshot,
},
{
Name: "xai",
DisplayName: "xAI (Grok)",
EnvKey: "XAI_API_KEY",
DefaultURL: xai.DefaultBaseURL,
Models: []string{
"grok-2", "grok-2-mini", "grok-2-vision", "grok-beta",
},
New: XAI,
},
{
Name: "groq",
DisplayName: "Groq",
EnvKey: "GROQ_API_KEY",
DefaultURL: groq.DefaultBaseURL,
Models: []string{
"llama-3.3-70b-versatile",
"llama-3.1-8b-instant",
"mixtral-8x7b-32768",
"gemma2-9b-it",
"llama-3.2-90b-vision-preview",
},
New: Groq,
},
{
Name: "ollama",
DisplayName: "Ollama (local)",
EnvKey: "", // no key needed
DefaultURL: ollama.DefaultLocalBaseURL,
Models: []string{
"llama3.2", "llama3.1", "qwen2.5", "mistral", "gemma2", "phi4",
},
New: func(_ string, opts ...ClientOption) *Client { return Ollama(opts...) },
},
{
Name: "ollama-cloud",
DisplayName: "Ollama Cloud",
EnvKey: "OLLAMA_API_KEY",
DefaultURL: ollama.DefaultCloudBaseURL,
Models: []string{
"ministral-3:14b",
"kimi-k2.5", "kimi-k2.6",
"qwen3.5:122b",
"gemma4:31b",
"deepseek-v4-flash", "deepseek-v4-pro",
"glm-5.1",
"gemini-3-flash-preview",
},
New: OllamaCloud,
},
}
// Providers returns a copy of the registered provider list so callers cannot
// mutate library state.
func Providers() []ProviderInfo {
out := make([]ProviderInfo, len(providerRegistry))
copy(out, providerRegistry)
return out
}
// ProviderByName returns the registered ProviderInfo with the given name, or
// nil if no such provider is registered. Name matching is exact.
func ProviderByName(name string) *ProviderInfo {
for i := range providerRegistry {
if providerRegistry[i].Name == name {
p := providerRegistry[i]
return &p
}
}
return nil
}
+107
View File
@@ -0,0 +1,107 @@
package llm
// RequestOption configures a single completion request.
type RequestOption func(*requestConfig)
type requestConfig struct {
tools *ToolBox
temperature *float64
maxTokens *int
topP *float64
stop []string
cacheConfig *cacheConfig
reasoning ReasoningLevel
}
// ReasoningLevel selects how much reasoning effort/budget the provider should
// spend before answering. Empty string is the default (no reasoning, identical
// to historical behavior). Each provider translates this to its native
// parameter; models that don't support reasoning silently ignore it.
type ReasoningLevel string
const (
// ReasoningLow asks for a small amount of extra reasoning. Maps to
// reasoning_effort="low" on OpenAI/xAI, ~1k thinking budget on
// Anthropic/Google.
ReasoningLow ReasoningLevel = "low"
// ReasoningMedium asks for a moderate amount. Maps to reasoning_effort
// ="medium" on OpenAI, ~8k thinking budget on Anthropic/Google. xAI
// remaps medium to its only-other-option, "high".
ReasoningMedium ReasoningLevel = "medium"
// ReasoningHigh asks for the most reasoning the provider exposes.
// Maps to reasoning_effort="high" on OpenAI/xAI, ~24k thinking budget
// on Anthropic/Google.
ReasoningHigh ReasoningLevel = "high"
)
// cacheConfig holds prompt-caching settings. nil = disabled.
type cacheConfig struct {
enabled bool
}
// WithTools attaches a toolbox to the request.
func WithTools(tb *ToolBox) RequestOption {
return func(c *requestConfig) { c.tools = tb }
}
// WithTemperature sets the sampling temperature.
func WithTemperature(t float64) RequestOption {
return func(c *requestConfig) { c.temperature = &t }
}
// WithMaxTokens sets the maximum number of tokens to generate.
func WithMaxTokens(n int) RequestOption {
return func(c *requestConfig) { c.maxTokens = &n }
}
// WithTopP sets the nucleus sampling parameter.
func WithTopP(p float64) RequestOption {
return func(c *requestConfig) { c.topP = &p }
}
// WithStop sets stop sequences.
func WithStop(sequences ...string) RequestOption {
return func(c *requestConfig) { c.stop = sequences }
}
// WithReasoning asks the model to spend extra reasoning budget on the
// response. Each provider maps the level to its native shape:
//
// - Anthropic: thinking.budget_tokens (low ~ 1024, medium ~ 8000, high ~ 24000)
// - OpenAI / xAI / Groq: reasoning_effort string (xAI remaps medium to high)
// - Google: thinking_config.thinking_budget (same budget as Anthropic)
// - DeepSeek (reasoner): always-on regardless; this option is a no-op
// - Models without reasoning support: silently ignored
//
// Reasoning content (when surfaced by the provider) appears on
// Response.Thinking, and is also streamed as StreamEventThinking events.
func WithReasoning(level ReasoningLevel) RequestOption {
return func(c *requestConfig) { c.reasoning = level }
}
// WithPromptCaching enables automatic prompt-caching markers on providers
// that support it (currently Anthropic). On providers that don't support
// explicit cache markers (OpenAI, Google), this is a no-op.
//
// When enabled, the library places cache breakpoints at natural seams:
// - the last tool definition (caches all tools)
// - the last system message (caches tools + system)
// - the last non-system message in the history (caches tools + system +
// conversation so far)
//
// Breakpoints are placed only when the corresponding section is non-empty.
// Up to 3 markers are emitted per request, leaving one of Anthropic's 4
// marker slots for future use.
//
// Cache hits give a 90% discount on cached input tokens (5-minute ephemeral
// tier). Cache writes cost 25% more than normal input tokens, so this option
// is only worth enabling for prompts whose cacheable prefix exceeds the
// minimum (1024 tokens on Opus/Sonnet, 2048 tokens on Haiku) AND is re-sent
// at least twice within the 5-minute TTL.
func WithPromptCaching() RequestOption {
return func(c *requestConfig) {
c.cacheConfig = &cacheConfig{enabled: true}
}
}
+265
View File
@@ -0,0 +1,265 @@
package llm
import (
"context"
"testing"
)
func TestWithTemperature(t *testing.T) {
cfg := &requestConfig{}
WithTemperature(0.7)(cfg)
if cfg.temperature == nil || *cfg.temperature != 0.7 {
t.Errorf("expected temperature 0.7, got %v", cfg.temperature)
}
}
func TestWithMaxTokens(t *testing.T) {
cfg := &requestConfig{}
WithMaxTokens(256)(cfg)
if cfg.maxTokens == nil || *cfg.maxTokens != 256 {
t.Errorf("expected maxTokens 256, got %v", cfg.maxTokens)
}
}
func TestWithTopP(t *testing.T) {
cfg := &requestConfig{}
WithTopP(0.95)(cfg)
if cfg.topP == nil || *cfg.topP != 0.95 {
t.Errorf("expected topP 0.95, got %v", cfg.topP)
}
}
func TestWithStop(t *testing.T) {
cfg := &requestConfig{}
WithStop("END", "STOP", "###")(cfg)
if len(cfg.stop) != 3 {
t.Fatalf("expected 3 stop sequences, got %d", len(cfg.stop))
}
if cfg.stop[0] != "END" || cfg.stop[1] != "STOP" || cfg.stop[2] != "###" {
t.Errorf("unexpected stop sequences: %v", cfg.stop)
}
}
func TestWithTools(t *testing.T) {
tool := DefineSimple("test", "A test tool", func(ctx context.Context) (string, error) {
return "ok", nil
})
tb := NewToolBox(tool)
cfg := &requestConfig{}
WithTools(tb)(cfg)
if cfg.tools == nil {
t.Fatal("expected tools to be set")
}
if len(cfg.tools.AllTools()) != 1 {
t.Errorf("expected 1 tool, got %d", len(cfg.tools.AllTools()))
}
}
func TestBuildProviderRequest(t *testing.T) {
tool := DefineSimple("greet", "Greets", func(ctx context.Context) (string, error) {
return "hi", nil
})
tb := NewToolBox(tool)
temp := 0.8
maxTok := 512
topP := 0.9
cfg := &requestConfig{
tools: tb,
temperature: &temp,
maxTokens: &maxTok,
topP: &topP,
stop: []string{"END"},
}
msgs := []Message{
SystemMessage("be nice"),
UserMessage("hello"),
}
req := buildProviderRequest("test-model", msgs, cfg)
if req.Model != "test-model" {
t.Errorf("expected model 'test-model', got %q", req.Model)
}
if len(req.Messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(req.Messages))
}
if req.Messages[0].Role != "system" {
t.Errorf("expected first message role='system', got %q", req.Messages[0].Role)
}
if req.Messages[1].Role != "user" {
t.Errorf("expected second message role='user', got %q", req.Messages[1].Role)
}
if req.Temperature == nil || *req.Temperature != 0.8 {
t.Errorf("expected temperature 0.8, got %v", req.Temperature)
}
if req.MaxTokens == nil || *req.MaxTokens != 512 {
t.Errorf("expected maxTokens 512, got %v", req.MaxTokens)
}
if req.TopP == nil || *req.TopP != 0.9 {
t.Errorf("expected topP 0.9, got %v", req.TopP)
}
if len(req.Stop) != 1 || req.Stop[0] != "END" {
t.Errorf("expected stop=[END], got %v", req.Stop)
}
if len(req.Tools) != 1 {
t.Fatalf("expected 1 tool, got %d", len(req.Tools))
}
if req.Tools[0].Name != "greet" {
t.Errorf("expected tool name 'greet', got %q", req.Tools[0].Name)
}
}
func TestBuildProviderRequest_EmptyConfig(t *testing.T) {
cfg := &requestConfig{}
msgs := []Message{UserMessage("hi")}
req := buildProviderRequest("model", msgs, cfg)
if req.Temperature != nil {
t.Errorf("expected nil temperature, got %v", req.Temperature)
}
if req.MaxTokens != nil {
t.Errorf("expected nil maxTokens, got %v", req.MaxTokens)
}
if req.TopP != nil {
t.Errorf("expected nil topP, got %v", req.TopP)
}
if len(req.Stop) != 0 {
t.Errorf("expected no stop sequences, got %v", req.Stop)
}
if len(req.Tools) != 0 {
t.Errorf("expected no tools, got %d", len(req.Tools))
}
}
func TestWithPromptCaching(t *testing.T) {
cfg := &requestConfig{}
WithPromptCaching()(cfg)
if cfg.cacheConfig == nil {
t.Fatal("expected cacheConfig to be set after WithPromptCaching()")
}
if !cfg.cacheConfig.enabled {
t.Error("expected cacheConfig.enabled to be true")
}
}
func TestWithoutPromptCaching(t *testing.T) {
cfg := &requestConfig{}
// No option applied
if cfg.cacheConfig != nil {
t.Error("expected cacheConfig to be nil when option not applied")
}
}
func TestBuildProviderRequest_CachingDisabled(t *testing.T) {
cfg := &requestConfig{}
msgs := []Message{SystemMessage("sys"), UserMessage("hi")}
req := buildProviderRequest("m", msgs, cfg)
if req.CacheHints != nil {
t.Errorf("expected nil CacheHints when caching disabled, got %+v", req.CacheHints)
}
}
func TestBuildProviderRequest_CachingEnabled_AllSections(t *testing.T) {
tool := DefineSimple("greet", "greet", func(ctx context.Context) (string, error) { return "ok", nil })
tb := NewToolBox(tool)
cfg := &requestConfig{
tools: tb,
cacheConfig: &cacheConfig{enabled: true},
}
msgs := []Message{
SystemMessage("you are helpful"),
UserMessage("hello"),
AssistantMessage("hi"),
UserMessage("thanks"),
}
req := buildProviderRequest("m", msgs, cfg)
if req.CacheHints == nil {
t.Fatal("expected CacheHints to be set")
}
if !req.CacheHints.CacheTools {
t.Error("expected CacheTools=true")
}
if !req.CacheHints.CacheSystem {
t.Error("expected CacheSystem=true")
}
// Last non-system message index = 3 ("thanks")
if req.CacheHints.LastCacheableMessageIndex != 3 {
t.Errorf("expected LastCacheableMessageIndex=3, got %d", req.CacheHints.LastCacheableMessageIndex)
}
}
func TestBuildProviderRequest_CachingEnabled_NoTools(t *testing.T) {
cfg := &requestConfig{cacheConfig: &cacheConfig{enabled: true}}
msgs := []Message{SystemMessage("sys"), UserMessage("hi")}
req := buildProviderRequest("m", msgs, cfg)
if req.CacheHints == nil {
t.Fatal("expected CacheHints")
}
if req.CacheHints.CacheTools {
t.Error("expected CacheTools=false when there are no tools")
}
if !req.CacheHints.CacheSystem {
t.Error("expected CacheSystem=true")
}
if req.CacheHints.LastCacheableMessageIndex != 1 {
t.Errorf("expected LastCacheableMessageIndex=1, got %d", req.CacheHints.LastCacheableMessageIndex)
}
}
func TestBuildProviderRequest_CachingEnabled_NoSystem(t *testing.T) {
cfg := &requestConfig{cacheConfig: &cacheConfig{enabled: true}}
msgs := []Message{UserMessage("hi")}
req := buildProviderRequest("m", msgs, cfg)
if req.CacheHints == nil {
t.Fatal("expected CacheHints")
}
if req.CacheHints.CacheSystem {
t.Error("expected CacheSystem=false when there is no system message")
}
if req.CacheHints.LastCacheableMessageIndex != 0 {
t.Errorf("expected LastCacheableMessageIndex=0, got %d", req.CacheHints.LastCacheableMessageIndex)
}
}
func TestBuildProviderRequest_CachingEnabled_OnlySystem(t *testing.T) {
cfg := &requestConfig{cacheConfig: &cacheConfig{enabled: true}}
msgs := []Message{SystemMessage("sys")}
req := buildProviderRequest("m", msgs, cfg)
if req.CacheHints == nil {
t.Fatal("expected CacheHints")
}
if !req.CacheHints.CacheSystem {
t.Error("expected CacheSystem=true")
}
if req.CacheHints.LastCacheableMessageIndex != -1 {
t.Errorf("expected LastCacheableMessageIndex=-1 when no non-system messages, got %d", req.CacheHints.LastCacheableMessageIndex)
}
}
func TestBuildProviderRequest_CachingEnabled_EmptyMessages(t *testing.T) {
cfg := &requestConfig{cacheConfig: &cacheConfig{enabled: true}}
req := buildProviderRequest("m", nil, cfg)
if req.CacheHints == nil {
t.Fatal("expected CacheHints to be set even with no messages")
}
if req.CacheHints.CacheSystem {
t.Error("expected CacheSystem=false with no messages")
}
if req.CacheHints.LastCacheableMessageIndex != -1 {
t.Errorf("expected LastCacheableMessageIndex=-1 with no messages, got %d", req.CacheHints.LastCacheableMessageIndex)
}
}
func TestBuildProviderRequest_CachingNonNilButDisabled(t *testing.T) {
cfg := &requestConfig{cacheConfig: &cacheConfig{enabled: false}}
msgs := []Message{SystemMessage("sys"), UserMessage("hi")}
req := buildProviderRequest("m", msgs, cfg)
if req.CacheHints != nil {
t.Errorf("expected nil CacheHints when cacheConfig.enabled=false, got %+v", req.CacheHints)
}
}
+81
View File
@@ -0,0 +1,81 @@
package llm
import "gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
// Response represents the result of a completion request.
type Response struct {
// Text is the assistant's text content. Empty if only tool calls.
Text string
// Thinking is the assistant's reasoning/thinking trace, when reasoning
// was requested and the provider exposed it. Empty otherwise.
Thinking string
// ToolCalls contains any tool invocations the assistant requested.
ToolCalls []ToolCall
// Usage contains token usage information (if available from provider).
Usage *Usage
// message is the full assistant message for this response.
message Message
}
// Message returns the full assistant Message for this response,
// suitable for appending to the conversation history.
func (r Response) Message() Message {
return r.message
}
// HasToolCalls returns true if the response contains tool call requests.
func (r Response) HasToolCalls() bool {
return len(r.ToolCalls) > 0
}
// Usage captures token consumption.
type Usage struct {
InputTokens int
OutputTokens int
TotalTokens int
Details map[string]int // provider-specific breakdown (e.g., cached, reasoning tokens)
}
// addUsage merges usage u into the receiver, accumulating token counts and details.
// If the receiver is nil, it returns a copy of u. If u is nil, it returns the receiver unchanged.
func addUsage(total *Usage, u *Usage) *Usage {
if u == nil {
return total
}
if total == nil {
cp := *u
if u.Details != nil {
cp.Details = make(map[string]int, len(u.Details))
for k, v := range u.Details {
cp.Details[k] = v
}
}
return &cp
}
total.InputTokens += u.InputTokens
total.OutputTokens += u.OutputTokens
total.TotalTokens += u.TotalTokens
if u.Details != nil {
if total.Details == nil {
total.Details = make(map[string]int, len(u.Details))
}
for k, v := range u.Details {
total.Details[k] += v
}
}
return total
}
// Re-export detail key constants from provider package for convenience.
const (
UsageDetailReasoningTokens = provider.UsageDetailReasoningTokens
UsageDetailCachedInputTokens = provider.UsageDetailCachedInputTokens
UsageDetailCacheCreationTokens = provider.UsageDetailCacheCreationTokens
UsageDetailAudioInputTokens = provider.UsageDetailAudioInputTokens
UsageDetailAudioOutputTokens = provider.UsageDetailAudioOutputTokens
UsageDetailThoughtsTokens = provider.UsageDetailThoughtsTokens
)
+78
View File
@@ -0,0 +1,78 @@
// Package sandbox provides isolated Linux container environments for LLM agents.
//
// It manages the full lifecycle of Proxmox LXC containers — cloning from a template,
// starting, connecting via SSH, executing commands, transferring files, and destroying
// the container when done. Each sandbox is an ephemeral, unprivileged container on an
// isolated network bridge with no LAN access.
//
// # Architecture
//
// The package has three layers:
//
// - ProxmoxClient: thin REST client for the Proxmox VE API (container CRUD, IP discovery)
// - SSHExecutor: persistent SSH/SFTP connection for command execution and file transfer
// - Manager/Sandbox: high-level orchestrator that ties Proxmox + SSH together
//
// # Usage
//
// // Load SSH key for container access.
// signer, err := sandbox.LoadSSHKey("/etc/mort/sandbox_key")
// if err != nil {
// log.Fatal(err)
// }
//
// // Create a manager.
// mgr, err := sandbox.NewManager(sandbox.Config{
// Proxmox: sandbox.ProxmoxConfig{
// BaseURL: "https://proxmox.local:8006",
// TokenID: "mort-sandbox@pve!sandbox-token",
// Secret: os.Getenv("SANDBOX_PROXMOX_SECRET"),
// Node: "pve",
// TemplateID: 9000,
// Pool: "sandbox-pool",
// Bridge: "vmbr1",
// },
// SSH: sandbox.SSHConfig{
// Signer: signer,
// },
// })
// if err != nil {
// log.Fatal(err)
// }
//
// // Create a sandbox.
// ctx := context.Background()
// sb, err := mgr.Create(ctx,
// sandbox.WithHostname("user-abc"),
// sandbox.WithInternet(true),
// )
// if err != nil {
// log.Fatal(err)
// }
// defer sb.Destroy(ctx)
//
// // Execute commands.
// result, err := sb.Exec(ctx, "apt-get update && apt-get install -y nginx")
// if err != nil {
// log.Fatal(err)
// }
// fmt.Printf("exit %d: %s\n", result.ExitCode, result.Output)
//
// // Write files.
// err = sb.WriteFile(ctx, "/var/www/html/index.html", "<h1>Hello</h1>")
//
// // Read files.
// content, err := sb.ReadFile(ctx, "/etc/nginx/nginx.conf")
//
// # Security
//
// Sandboxes are secured through defense in depth:
// - Unprivileged LXC containers (UID mapping to high host UIDs)
// - Isolated network bridge with nftables default-deny outbound
// - Per-container opt-in internet access (HTTP/HTTPS only)
// - Resource limits: CPU, memory, disk, PID count
// - AppArmor confinement (lxc-container-default-cgns)
// - Capability dropping (sys_admin, sys_rawio, sys_ptrace, etc.)
//
// See docs/sandbox-setup.md for the complete Proxmox setup and hardening guide.
package sandbox
+410
View File
@@ -0,0 +1,410 @@
package sandbox
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// ProxmoxConfig holds configuration for connecting to a Proxmox VE host.
type ProxmoxConfig struct {
// BaseURL is the Proxmox API base URL (e.g., "https://proxmox.local:8006").
BaseURL string
// TokenID is the API token identifier (e.g., "mort-sandbox@pve!sandbox-token").
TokenID string
// Secret is the API token secret.
Secret string
// Node is the Proxmox node name (e.g., "pve").
Node string
// TemplateID is the LXC template container ID to clone from (e.g., 9000).
TemplateID int
// Pool is the Proxmox resource pool for sandbox containers (e.g., "sandbox-pool").
Pool string
// Bridge is the network bridge for containers (e.g., "vmbr1").
Bridge string
// InsecureSkipVerify disables TLS certificate verification.
// Use only for self-signed Proxmox certificates.
InsecureSkipVerify bool
}
// ContainerStatus represents the current state of a Proxmox LXC container.
type ContainerStatus struct {
Status string `json:"status"` // "running", "stopped", etc.
CPU float64 `json:"cpu"` // CPU usage (0.01.0)
Mem int64 `json:"mem"` // Current memory usage in bytes
MaxMem int64 `json:"maxmem"` // Maximum memory in bytes
Disk int64 `json:"disk"` // Current disk usage in bytes
MaxDisk int64 `json:"maxdisk"` // Maximum disk in bytes
NetIn int64 `json:"netin"` // Network bytes received
NetOut int64 `json:"netout"` // Network bytes sent
Uptime int64 `json:"uptime"` // Uptime in seconds
}
// ContainerConfig holds settings for creating a new container.
type ContainerConfig struct {
// Hostname for the container.
Hostname string
// CPUs is the number of CPU cores (default 1).
CPUs int
// MemoryMB is the memory limit in megabytes (default 1024).
MemoryMB int
// DiskGB is the root filesystem size in gigabytes (default 8).
DiskGB int
// SSHPublicKey is an optional SSH public key to inject.
SSHPublicKey string
}
// ProxmoxClient is a thin REST API client for Proxmox VE container lifecycle management.
type ProxmoxClient struct {
config ProxmoxConfig
http *http.Client
}
// NewProxmoxClient creates a new Proxmox API client.
func NewProxmoxClient(config ProxmoxConfig) *ProxmoxClient {
transport := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: config.InsecureSkipVerify,
},
}
return &ProxmoxClient{
config: config,
http: &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
},
}
}
// NextAvailableID queries Proxmox for the next free VMID.
func (p *ProxmoxClient) NextAvailableID(ctx context.Context) (int, error) {
var result int
err := p.get(ctx, "/api2/json/cluster/nextid", &result)
if err != nil {
return 0, fmt.Errorf("get next VMID: %w", err)
}
return result, nil
}
// CloneTemplate clones the configured template into a new container with the given VMID.
func (p *ProxmoxClient) CloneTemplate(ctx context.Context, newID int, cfg ContainerConfig) error {
path := fmt.Sprintf("/api2/json/nodes/%s/lxc/%d/clone", p.config.Node, p.config.TemplateID)
hostname := cfg.Hostname
if hostname == "" {
hostname = fmt.Sprintf("sandbox-%d", newID)
}
params := url.Values{
"newid": {fmt.Sprintf("%d", newID)},
"hostname": {hostname},
"full": {"1"},
}
if p.config.Pool != "" {
params.Set("pool", p.config.Pool)
}
taskID, err := p.post(ctx, path, params)
if err != nil {
return fmt.Errorf("clone template %d → %d: %w", p.config.TemplateID, newID, err)
}
return p.waitForTask(ctx, taskID)
}
// ConfigureContainer sets CPU, memory, and network on an existing container.
func (p *ProxmoxClient) ConfigureContainer(ctx context.Context, id int, cfg ContainerConfig) error {
path := fmt.Sprintf("/api2/json/nodes/%s/lxc/%d/config", p.config.Node, id)
cpus := cfg.CPUs
if cpus <= 0 {
cpus = 1
}
mem := cfg.MemoryMB
if mem <= 0 {
mem = 1024
}
params := url.Values{
"cores": {fmt.Sprintf("%d", cpus)},
"memory": {fmt.Sprintf("%d", mem)},
"swap": {"0"},
"net0": {fmt.Sprintf("name=eth0,bridge=%s,ip=dhcp", p.config.Bridge)},
}
_, err := p.put(ctx, path, params)
if err != nil {
return fmt.Errorf("configure container %d: %w", id, err)
}
return nil
}
// StartContainer starts a stopped container.
func (p *ProxmoxClient) StartContainer(ctx context.Context, id int) error {
path := fmt.Sprintf("/api2/json/nodes/%s/lxc/%d/status/start", p.config.Node, id)
taskID, err := p.post(ctx, path, nil)
if err != nil {
return fmt.Errorf("start container %d: %w", id, err)
}
return p.waitForTask(ctx, taskID)
}
// StopContainer stops a running container.
func (p *ProxmoxClient) StopContainer(ctx context.Context, id int) error {
path := fmt.Sprintf("/api2/json/nodes/%s/lxc/%d/status/stop", p.config.Node, id)
taskID, err := p.post(ctx, path, nil)
if err != nil {
return fmt.Errorf("stop container %d: %w", id, err)
}
return p.waitForTask(ctx, taskID)
}
// DestroyContainer stops (if running) and permanently deletes a container.
func (p *ProxmoxClient) DestroyContainer(ctx context.Context, id int) error {
// Try to stop first; ignore errors (might already be stopped).
status, err := p.GetContainerStatus(ctx, id)
if err != nil {
return fmt.Errorf("get status before destroy: %w", err)
}
if status.Status == "running" {
_ = p.StopContainer(ctx, id)
}
path := fmt.Sprintf("/api2/json/nodes/%s/lxc/%d", p.config.Node, id)
params := url.Values{"force": {"1"}, "purge": {"1"}}
taskID, err := p.delete(ctx, path, params)
if err != nil {
return fmt.Errorf("destroy container %d: %w", id, err)
}
return p.waitForTask(ctx, taskID)
}
// GetContainerStatus returns the current status and resource usage of a container.
func (p *ProxmoxClient) GetContainerStatus(ctx context.Context, id int) (ContainerStatus, error) {
path := fmt.Sprintf("/api2/json/nodes/%s/lxc/%d/status/current", p.config.Node, id)
var status ContainerStatus
if err := p.get(ctx, path, &status); err != nil {
return ContainerStatus{}, fmt.Errorf("get container %d status: %w", id, err)
}
return status, nil
}
// GetContainerIP discovers the container's IP address by querying its network interfaces.
// It polls until an IP is found or the context is cancelled.
func (p *ProxmoxClient) GetContainerIP(ctx context.Context, id int) (string, error) {
path := fmt.Sprintf("/api2/json/nodes/%s/lxc/%d/interfaces", p.config.Node, id)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
var ifaces []struct {
Name string `json:"name"`
HWAddr string `json:"hwaddr"`
Inet string `json:"inet"`
Inet6 string `json:"inet6"`
}
if err := p.get(ctx, path, &ifaces); err == nil {
for _, iface := range ifaces {
if iface.Name == "lo" || iface.Inet == "" {
continue
}
// Inet is in CIDR format (e.g., "10.99.1.5/16")
ip := iface.Inet
if idx := strings.IndexByte(ip, '/'); idx > 0 {
ip = ip[:idx]
}
return ip, nil
}
}
select {
case <-ctx.Done():
return "", fmt.Errorf("get container %d IP: %w", id, ctx.Err())
case <-ticker.C:
}
}
}
// EnableInternet adds a container IP to the nftables internet_allowed set,
// granting outbound HTTP/HTTPS access.
func (p *ProxmoxClient) EnableInternet(ctx context.Context, containerIP string) error {
return p.execOnHost(ctx, fmt.Sprintf("nft add element inet sandbox internet_allowed { %s }", containerIP))
}
// DisableInternet removes a container IP from the nftables internet_allowed set,
// revoking outbound HTTP/HTTPS access.
func (p *ProxmoxClient) DisableInternet(ctx context.Context, containerIP string) error {
return p.execOnHost(ctx, fmt.Sprintf("nft delete element inet sandbox internet_allowed { %s }", containerIP))
}
// execOnHost runs a command on the Proxmox host via the API's node exec endpoint.
func (p *ProxmoxClient) execOnHost(ctx context.Context, command string) error {
path := fmt.Sprintf("/api2/json/nodes/%s/execute", p.config.Node)
params := url.Values{"commands": {command}}
_, err := p.post(ctx, path, params)
if err != nil {
return fmt.Errorf("exec on host: %w", err)
}
return nil
}
// --- HTTP helpers ---
// proxmoxResponse is the standard envelope for all Proxmox API responses.
type proxmoxResponse struct {
Data json.RawMessage `json:"data"`
}
func (p *ProxmoxClient) doRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
u := strings.TrimRight(p.config.BaseURL, "/") + path
req, err := http.NewRequestWithContext(ctx, method, u, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("PVEAPIToken=%s=%s", p.config.TokenID, p.config.Secret))
if body != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
resp, err := p.http.Do(req)
if err != nil {
return nil, err
}
return resp, nil
}
func (p *ProxmoxClient) get(ctx context.Context, path string, result any) error {
resp, err := p.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return err
}
defer resp.Body.Close()
return p.parseResponse(resp, result)
}
func (p *ProxmoxClient) post(ctx context.Context, path string, params url.Values) (string, error) {
var body io.Reader
if params != nil {
body = strings.NewReader(params.Encode())
}
resp, err := p.doRequest(ctx, http.MethodPost, path, body)
if err != nil {
return "", err
}
defer resp.Body.Close()
var taskID string
if err := p.parseResponse(resp, &taskID); err != nil {
return "", err
}
return taskID, nil
}
func (p *ProxmoxClient) put(ctx context.Context, path string, params url.Values) (string, error) {
var body io.Reader
if params != nil {
body = strings.NewReader(params.Encode())
}
resp, err := p.doRequest(ctx, http.MethodPut, path, body)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result string
if err := p.parseResponse(resp, &result); err != nil {
return "", err
}
return result, nil
}
func (p *ProxmoxClient) delete(ctx context.Context, path string, params url.Values) (string, error) {
path = path + "?" + params.Encode()
resp, err := p.doRequest(ctx, http.MethodDelete, path, nil)
if err != nil {
return "", err
}
defer resp.Body.Close()
var taskID string
if err := p.parseResponse(resp, &taskID); err != nil {
return "", err
}
return taskID, nil
}
func (p *ProxmoxClient) parseResponse(resp *http.Response, result any) error {
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("proxmox API error (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
}
var envelope proxmoxResponse
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
return fmt.Errorf("decode response: %w", err)
}
if result == nil {
return nil
}
if err := json.Unmarshal(envelope.Data, result); err != nil {
return fmt.Errorf("unmarshal data: %w", err)
}
return nil
}
// waitForTask polls a Proxmox task until it completes or the context is cancelled.
func (p *ProxmoxClient) waitForTask(ctx context.Context, taskID string) error {
if taskID == "" {
return nil
}
path := fmt.Sprintf("/api2/json/nodes/%s/tasks/%s/status", p.config.Node, url.PathEscape(taskID))
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
var status struct {
Status string `json:"status"` // "running", "stopped", etc.
ExitCode string `json:"exitstatus"`
}
if err := p.get(ctx, path, &status); err != nil {
return fmt.Errorf("poll task %s: %w", taskID, err)
}
if status.Status != "running" {
if status.ExitCode != "OK" && status.ExitCode != "" {
return fmt.Errorf("task %s failed: %s", taskID, status.ExitCode)
}
return nil
}
select {
case <-ctx.Done():
return fmt.Errorf("wait for task %s: %w", taskID, ctx.Err())
case <-ticker.C:
}
}
}
+310
View File
@@ -0,0 +1,310 @@
package sandbox
import (
"context"
"fmt"
"io"
"os"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
// Config holds all configuration for creating sandboxes.
type Config struct {
Proxmox ProxmoxConfig
SSH SSHConfig
Defaults ContainerConfig
}
// Option configures a Sandbox before creation.
type Option func(*createOpts)
type createOpts struct {
hostname string
cpus int
memoryMB int
diskGB int
internet bool
}
// WithHostname sets the container hostname.
func WithHostname(name string) Option {
return func(o *createOpts) { o.hostname = name }
}
// WithCPUs sets the number of CPU cores for the container.
func WithCPUs(n int) Option {
return func(o *createOpts) { o.cpus = n }
}
// WithMemoryMB sets the memory limit in megabytes.
func WithMemoryMB(mb int) Option {
return func(o *createOpts) { o.memoryMB = mb }
}
// WithDiskGB sets the root filesystem size in gigabytes.
func WithDiskGB(gb int) Option {
return func(o *createOpts) { o.diskGB = gb }
}
// WithInternet enables outbound HTTP/HTTPS access on creation.
func WithInternet(enabled bool) Option {
return func(o *createOpts) { o.internet = enabled }
}
// Sandbox represents an isolated Linux container environment with SSH access.
// It wraps a Proxmox LXC container and provides command execution and file operations.
type Sandbox struct {
// ID is the Proxmox VMID of this container.
ID int
// IP is the container's IP address on the isolated bridge.
IP string
// Internet indicates whether outbound HTTP/HTTPS is enabled.
Internet bool
proxmox *ProxmoxClient
ssh *SSHExecutor
}
// Manager creates and manages sandbox instances.
type Manager struct {
proxmox *ProxmoxClient
sshKey ssh.Signer
defaults ContainerConfig
sshCfg SSHConfig
}
// NewManager creates a new sandbox manager from the given configuration.
func NewManager(cfg Config) (*Manager, error) {
if cfg.SSH.Signer == nil {
return nil, fmt.Errorf("SSH signer is required")
}
return &Manager{
proxmox: NewProxmoxClient(cfg.Proxmox),
sshKey: cfg.SSH.Signer,
defaults: cfg.Defaults,
sshCfg: cfg.SSH,
}, nil
}
// Create provisions a new sandbox container: clones the template, starts it,
// waits for SSH, and optionally enables internet access.
// The returned Sandbox must be destroyed with Destroy when no longer needed.
func (m *Manager) Create(ctx context.Context, opts ...Option) (*Sandbox, error) {
o := &createOpts{
hostname: m.defaults.Hostname,
cpus: m.defaults.CPUs,
memoryMB: m.defaults.MemoryMB,
diskGB: m.defaults.DiskGB,
}
for _, opt := range opts {
opt(o)
}
// Apply defaults for zero values.
if o.cpus <= 0 {
o.cpus = 1
}
if o.memoryMB <= 0 {
o.memoryMB = 1024
}
if o.diskGB <= 0 {
o.diskGB = 8
}
// Get next VMID.
vmid, err := m.proxmox.NextAvailableID(ctx)
if err != nil {
return nil, fmt.Errorf("get next VMID: %w", err)
}
containerCfg := ContainerConfig{
Hostname: o.hostname,
CPUs: o.cpus,
MemoryMB: o.memoryMB,
DiskGB: o.diskGB,
}
// Clone template.
if err := m.proxmox.CloneTemplate(ctx, vmid, containerCfg); err != nil {
return nil, fmt.Errorf("clone template: %w", err)
}
// Configure container resources.
if err := m.proxmox.ConfigureContainer(ctx, vmid, containerCfg); err != nil {
// Clean up the cloned container on failure.
_ = m.proxmox.DestroyContainer(ctx, vmid)
return nil, fmt.Errorf("configure container: %w", err)
}
// Start container.
if err := m.proxmox.StartContainer(ctx, vmid); err != nil {
_ = m.proxmox.DestroyContainer(ctx, vmid)
return nil, fmt.Errorf("start container: %w", err)
}
// Discover IP address (with timeout).
ipCtx, ipCancel := context.WithTimeout(ctx, 30*time.Second)
defer ipCancel()
ip, err := m.proxmox.GetContainerIP(ipCtx, vmid)
if err != nil {
_ = m.proxmox.DestroyContainer(ctx, vmid)
return nil, fmt.Errorf("discover IP: %w", err)
}
// Connect SSH (with timeout).
sshExec := NewSSHExecutor(ip, m.sshCfg)
sshCtx, sshCancel := context.WithTimeout(ctx, 30*time.Second)
defer sshCancel()
if err := sshExec.Connect(sshCtx); err != nil {
_ = m.proxmox.DestroyContainer(ctx, vmid)
return nil, fmt.Errorf("ssh connect: %w", err)
}
sb := &Sandbox{
ID: vmid,
IP: ip,
proxmox: m.proxmox,
ssh: sshExec,
}
// Enable internet if requested.
if o.internet {
if err := sb.SetInternet(ctx, true); err != nil {
sb.Destroy(ctx)
return nil, fmt.Errorf("enable internet: %w", err)
}
}
return sb, nil
}
// Attach reconnects to an existing sandbox container by VMID.
// This is useful for recovering sessions after a restart.
func (m *Manager) Attach(ctx context.Context, vmid int) (*Sandbox, error) {
status, err := m.proxmox.GetContainerStatus(ctx, vmid)
if err != nil {
return nil, fmt.Errorf("get container status: %w", err)
}
if status.Status != "running" {
return nil, fmt.Errorf("container %d is not running (status: %s)", vmid, status.Status)
}
ip, err := m.proxmox.GetContainerIP(ctx, vmid)
if err != nil {
return nil, fmt.Errorf("get container IP: %w", err)
}
sshExec := NewSSHExecutor(ip, m.sshCfg)
if err := sshExec.Connect(ctx); err != nil {
return nil, fmt.Errorf("ssh connect: %w", err)
}
return &Sandbox{
ID: vmid,
IP: ip,
proxmox: m.proxmox,
ssh: sshExec,
}, nil
}
// Exec runs a shell command in the sandbox and returns the result.
func (s *Sandbox) Exec(ctx context.Context, command string) (ExecResult, error) {
return s.ssh.Exec(ctx, command)
}
// WriteFile creates or overwrites a file in the sandbox.
func (s *Sandbox) WriteFile(ctx context.Context, path, content string) error {
return s.ssh.Upload(ctx, strings.NewReader(content), path, 0644)
}
// ReadFile reads a file from the sandbox and returns its contents.
func (s *Sandbox) ReadFile(ctx context.Context, path string) (string, error) {
rc, err := s.ssh.Download(ctx, path)
if err != nil {
return "", err
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
return "", fmt.Errorf("read file %s: %w", path, err)
}
return string(data), nil
}
// Upload copies data from an io.Reader to a file in the sandbox.
func (s *Sandbox) Upload(ctx context.Context, reader io.Reader, remotePath string, mode os.FileMode) error {
return s.ssh.Upload(ctx, reader, remotePath, mode)
}
// Download returns an io.ReadCloser for a file in the sandbox.
// The caller must close the returned reader.
func (s *Sandbox) Download(ctx context.Context, remotePath string) (io.ReadCloser, error) {
return s.ssh.Download(ctx, remotePath)
}
// SetInternet enables or disables outbound HTTP/HTTPS access for the sandbox.
func (s *Sandbox) SetInternet(ctx context.Context, enabled bool) error {
if enabled {
if err := s.proxmox.EnableInternet(ctx, s.IP); err != nil {
return err
}
} else {
if err := s.proxmox.DisableInternet(ctx, s.IP); err != nil {
return err
}
}
s.Internet = enabled
return nil
}
// Status returns the current resource usage of the sandbox container.
func (s *Sandbox) Status(ctx context.Context) (ContainerStatus, error) {
return s.proxmox.GetContainerStatus(ctx, s.ID)
}
// IsConnected returns true if the SSH connection to the sandbox is active.
func (s *Sandbox) IsConnected() bool {
return s.ssh.IsConnected()
}
// Destroy stops the container, removes internet access, closes SSH connections,
// and permanently deletes the container from Proxmox.
func (s *Sandbox) Destroy(ctx context.Context) error {
var errs []error
// Remove internet access first (ignore errors — container is being destroyed).
if s.Internet {
_ = s.proxmox.DisableInternet(ctx, s.IP)
}
// Close SSH connections.
if err := s.ssh.Close(); err != nil {
errs = append(errs, fmt.Errorf("close ssh: %w", err))
}
// Destroy the container.
if err := s.proxmox.DestroyContainer(ctx, s.ID); err != nil {
errs = append(errs, fmt.Errorf("destroy container: %w", err))
}
if len(errs) > 0 {
return fmt.Errorf("destroy sandbox %d: %v", s.ID, errs)
}
return nil
}
// DestroyByID destroys a container by VMID without requiring an active SSH connection.
// This is useful for cleaning up orphaned containers after a restart.
func (m *Manager) DestroyByID(ctx context.Context, vmid int) error {
return m.proxmox.DestroyContainer(ctx, vmid)
}
File diff suppressed because it is too large Load Diff
+253
View File
@@ -0,0 +1,253 @@
package sandbox
import (
"bytes"
"context"
"fmt"
"io"
"net"
"os"
"sync"
"time"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
// SSHConfig holds configuration for SSH connections to sandbox containers.
type SSHConfig struct {
// User is the SSH username (default "sandbox").
User string
// Signer is the SSH private key signer for authentication.
Signer ssh.Signer
// ConnectTimeout is the maximum time to wait for an SSH connection (default 10s).
ConnectTimeout time.Duration
// CommandTimeout is the default maximum time for a single command execution (default 60s).
CommandTimeout time.Duration
}
// SSHExecutor manages SSH and SFTP connections to a sandbox container.
type SSHExecutor struct {
host string
config SSHConfig
mu sync.Mutex
sshClient *ssh.Client
sftpClient *sftp.Client
}
// NewSSHExecutor creates a new SSH executor for the given host.
func NewSSHExecutor(host string, config SSHConfig) *SSHExecutor {
if config.User == "" {
config.User = "sandbox"
}
if config.ConnectTimeout <= 0 {
config.ConnectTimeout = 10 * time.Second
}
if config.CommandTimeout <= 0 {
config.CommandTimeout = 60 * time.Second
}
return &SSHExecutor{
host: host,
config: config,
}
}
// Connect establishes SSH and SFTP connections to the container.
// It polls until the connection succeeds or the context is cancelled,
// which is useful when waiting for a freshly started container to boot.
func (s *SSHExecutor) Connect(ctx context.Context) error {
sshConfig := &ssh.ClientConfig{
User: s.config.User,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(s.config.Signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: s.config.ConnectTimeout,
}
addr := net.JoinHostPort(s.host, "22")
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
var lastErr error
for {
client, err := ssh.Dial("tcp", addr, sshConfig)
if err == nil {
sftpClient, err := sftp.NewClient(client)
if err != nil {
client.Close()
return fmt.Errorf("create SFTP client: %w", err)
}
s.mu.Lock()
s.sshClient = client
s.sftpClient = sftpClient
s.mu.Unlock()
return nil
}
lastErr = err
select {
case <-ctx.Done():
return fmt.Errorf("ssh connect to %s: %w (last error: %v)", addr, ctx.Err(), lastErr)
case <-ticker.C:
}
}
}
// ExecResult contains the output and exit status of a command execution.
type ExecResult struct {
Output string
ExitCode int
}
// Exec runs a shell command on the container and returns the combined stdout/stderr
// output and exit code.
func (s *SSHExecutor) Exec(ctx context.Context, command string) (ExecResult, error) {
s.mu.Lock()
client := s.sshClient
s.mu.Unlock()
if client == nil {
return ExecResult{}, fmt.Errorf("ssh not connected")
}
session, err := client.NewSession()
if err != nil {
return ExecResult{}, fmt.Errorf("create session: %w", err)
}
defer session.Close()
var buf bytes.Buffer
session.Stdout = &buf
session.Stderr = &buf
// Apply context timeout.
done := make(chan error, 1)
go func() {
done <- session.Run(command)
}()
select {
case <-ctx.Done():
_ = session.Signal(ssh.SIGKILL)
return ExecResult{}, fmt.Errorf("exec timed out: %w", ctx.Err())
case err := <-done:
output := buf.String()
if err != nil {
if exitErr, ok := err.(*ssh.ExitError); ok {
return ExecResult{
Output: output,
ExitCode: exitErr.ExitStatus(),
}, nil
}
return ExecResult{Output: output}, fmt.Errorf("exec: %w", err)
}
return ExecResult{Output: output, ExitCode: 0}, nil
}
}
// Upload writes data from an io.Reader to a file on the container.
func (s *SSHExecutor) Upload(ctx context.Context, reader io.Reader, remotePath string, mode os.FileMode) error {
s.mu.Lock()
client := s.sftpClient
s.mu.Unlock()
if client == nil {
return fmt.Errorf("sftp not connected")
}
f, err := client.OpenFile(remotePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
if err != nil {
return fmt.Errorf("open remote file %s: %w", remotePath, err)
}
defer f.Close()
if _, err := io.Copy(f, reader); err != nil {
return fmt.Errorf("write to %s: %w", remotePath, err)
}
if err := client.Chmod(remotePath, mode); err != nil {
return fmt.Errorf("chmod %s: %w", remotePath, err)
}
return nil
}
// Download reads a file from the container and returns its contents as an io.ReadCloser.
// The caller must close the returned reader.
func (s *SSHExecutor) Download(ctx context.Context, remotePath string) (io.ReadCloser, error) {
s.mu.Lock()
client := s.sftpClient
s.mu.Unlock()
if client == nil {
return nil, fmt.Errorf("sftp not connected")
}
f, err := client.Open(remotePath)
if err != nil {
return nil, fmt.Errorf("open remote file %s: %w", remotePath, err)
}
return f, nil
}
// Close tears down both SFTP and SSH connections.
func (s *SSHExecutor) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
var errs []error
if s.sftpClient != nil {
if err := s.sftpClient.Close(); err != nil {
errs = append(errs, fmt.Errorf("close SFTP: %w", err))
}
s.sftpClient = nil
}
if s.sshClient != nil {
if err := s.sshClient.Close(); err != nil {
errs = append(errs, fmt.Errorf("close SSH: %w", err))
}
s.sshClient = nil
}
if len(errs) > 0 {
return fmt.Errorf("close ssh executor: %v", errs)
}
return nil
}
// IsConnected returns true if the SSH connection is established.
func (s *SSHExecutor) IsConnected() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.sshClient != nil
}
// LoadSSHKey reads a PEM-encoded private key file and returns an ssh.Signer.
func LoadSSHKey(path string) (ssh.Signer, error) {
keyData, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read SSH key %s: %w", path, err)
}
signer, err := ssh.ParsePrivateKey(keyData)
if err != nil {
return nil, fmt.Errorf("parse SSH key: %w", err)
}
return signer, nil
}
// ParseSSHKey parses a PEM-encoded private key from bytes and returns an ssh.Signer.
func ParseSSHKey(pemBytes []byte) (ssh.Signer, error) {
signer, err := ssh.ParsePrivateKey(pemBytes)
if err != nil {
return nil, fmt.Errorf("parse SSH key: %w", err)
}
return signer, nil
}
+164
View File
@@ -0,0 +1,164 @@
package llm
import (
"context"
"fmt"
"io"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
// StreamEventType identifies the kind of stream event.
type StreamEventType = provider.StreamEventType
const (
StreamEventText = provider.StreamEventText
StreamEventToolStart = provider.StreamEventToolStart
StreamEventToolDelta = provider.StreamEventToolDelta
StreamEventToolEnd = provider.StreamEventToolEnd
StreamEventDone = provider.StreamEventDone
StreamEventError = provider.StreamEventError
StreamEventThinking = provider.StreamEventThinking
)
// StreamEvent represents a single event in a streaming response.
type StreamEvent struct {
Type StreamEventType
// Text is set for StreamEventText — the text delta.
Text string
// ToolCall is set for StreamEventToolStart/ToolDelta/ToolEnd.
ToolCall *ToolCall
// ToolIndex identifies which tool call is being updated.
ToolIndex int
// Error is set for StreamEventError.
Error error
// Response is set for StreamEventDone — the complete, aggregated response.
Response *Response
}
// StreamReader reads streaming events from an LLM response.
// Must be closed when done.
type StreamReader struct {
events <-chan StreamEvent
cancel context.CancelFunc
done bool
}
func newStreamReader(ctx context.Context, p provider.Provider, req provider.Request) (*StreamReader, error) {
ctx, cancel := context.WithCancel(ctx)
providerEvents := make(chan provider.StreamEvent, 32)
publicEvents := make(chan StreamEvent, 32)
go func() {
defer close(publicEvents)
for pev := range providerEvents {
ev := convertStreamEvent(pev)
select {
case publicEvents <- ev:
case <-ctx.Done():
return
}
}
}()
go func() {
defer close(providerEvents)
if err := p.Stream(ctx, req, providerEvents); err != nil {
select {
case providerEvents <- provider.StreamEvent{Type: provider.StreamEventError, Error: err}:
default:
}
}
}()
return &StreamReader{
events: publicEvents,
cancel: cancel,
}, nil
}
func convertStreamEvent(pev provider.StreamEvent) StreamEvent {
ev := StreamEvent{
Type: pev.Type,
Text: pev.Text,
ToolIndex: pev.ToolIndex,
}
if pev.Error != nil {
ev.Error = pev.Error
}
if pev.ToolCall != nil {
tc := ToolCall{
ID: pev.ToolCall.ID,
Name: pev.ToolCall.Name,
Arguments: pev.ToolCall.Arguments,
}
ev.ToolCall = &tc
}
if pev.Response != nil {
resp := convertProviderResponse(*pev.Response)
ev.Response = &resp
}
return ev
}
// Next returns the next event from the stream.
// Returns io.EOF when the stream is complete.
func (sr *StreamReader) Next() (StreamEvent, error) {
if sr.done {
return StreamEvent{}, io.EOF
}
ev, ok := <-sr.events
if !ok {
sr.done = true
return StreamEvent{}, io.EOF
}
if ev.Type == StreamEventError {
return ev, ev.Error
}
if ev.Type == StreamEventDone {
sr.done = true
}
return ev, nil
}
// Close closes the stream reader and releases resources.
func (sr *StreamReader) Close() error {
sr.cancel()
return nil
}
// Collect reads all events and returns the final aggregated Response.
func (sr *StreamReader) Collect() (Response, error) {
var lastResp *Response
for {
ev, err := sr.Next()
if err == io.EOF {
break
}
if err != nil {
return Response{}, err
}
if ev.Type == StreamEventDone && ev.Response != nil {
lastResp = ev.Response
}
}
if lastResp == nil {
return Response{}, fmt.Errorf("stream completed without final response")
}
return *lastResp, nil
}
// Text is a convenience that collects the stream and returns just the text.
func (sr *StreamReader) Text() (string, error) {
resp, err := sr.Collect()
if err != nil {
return "", err
}
return resp.Text, nil
}
+338
View File
@@ -0,0 +1,338 @@
package llm
import (
"context"
"errors"
"io"
"testing"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
func TestStreamReader_TextEvents(t *testing.T) {
events := []provider.StreamEvent{
{Type: provider.StreamEventText, Text: "Hello"},
{Type: provider.StreamEventText, Text: " world"},
{Type: provider.StreamEventDone, Response: &provider.Response{
Text: "Hello world",
Usage: &provider.Usage{
InputTokens: 5,
OutputTokens: 2,
TotalTokens: 7,
},
}},
}
mp := newMockStreamProvider(events)
model := newMockModel(mp)
reader, err := model.Stream(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer reader.Close()
// Read text events
ev, err := reader.Next()
if err != nil {
t.Fatalf("unexpected error on first event: %v", err)
}
if ev.Type != StreamEventText || ev.Text != "Hello" {
t.Errorf("expected text event 'Hello', got type=%d text=%q", ev.Type, ev.Text)
}
ev, err = reader.Next()
if err != nil {
t.Fatalf("unexpected error on second event: %v", err)
}
if ev.Type != StreamEventText || ev.Text != " world" {
t.Errorf("expected text event ' world', got type=%d text=%q", ev.Type, ev.Text)
}
// Read done event
ev, err = reader.Next()
if err != nil {
t.Fatalf("unexpected error on done event: %v", err)
}
if ev.Type != StreamEventDone {
t.Errorf("expected done event, got type=%d", ev.Type)
}
if ev.Response == nil {
t.Fatal("expected response in done event")
}
if ev.Response.Text != "Hello world" {
t.Errorf("expected final text 'Hello world', got %q", ev.Response.Text)
}
// Subsequent reads should return EOF
_, err = reader.Next()
if !errors.Is(err, io.EOF) {
t.Errorf("expected io.EOF after done, got %v", err)
}
}
func TestStreamReader_ToolCallEvents(t *testing.T) {
events := []provider.StreamEvent{
{
Type: provider.StreamEventToolStart,
ToolIndex: 0,
ToolCall: &provider.ToolCall{ID: "tc1", Name: "search"},
},
{
Type: provider.StreamEventToolDelta,
ToolIndex: 0,
ToolCall: &provider.ToolCall{Arguments: `{"query":`},
},
{
Type: provider.StreamEventToolDelta,
ToolIndex: 0,
ToolCall: &provider.ToolCall{Arguments: `"test"}`},
},
{
Type: provider.StreamEventToolEnd,
ToolIndex: 0,
ToolCall: &provider.ToolCall{ID: "tc1", Name: "search", Arguments: `{"query":"test"}`},
},
{
Type: provider.StreamEventDone,
Response: &provider.Response{
ToolCalls: []provider.ToolCall{
{ID: "tc1", Name: "search", Arguments: `{"query":"test"}`},
},
},
},
}
mp := newMockStreamProvider(events)
model := newMockModel(mp)
reader, err := model.Stream(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer reader.Close()
// Read tool start
ev, err := reader.Next()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ev.Type != StreamEventToolStart {
t.Errorf("expected tool start, got type=%d", ev.Type)
}
if ev.ToolCall == nil || ev.ToolCall.Name != "search" {
t.Errorf("expected tool call 'search', got %+v", ev.ToolCall)
}
// Read tool deltas
ev, _ = reader.Next()
if ev.Type != StreamEventToolDelta {
t.Errorf("expected tool delta, got type=%d", ev.Type)
}
ev, _ = reader.Next()
if ev.Type != StreamEventToolDelta {
t.Errorf("expected tool delta, got type=%d", ev.Type)
}
// Read tool end
ev, _ = reader.Next()
if ev.Type != StreamEventToolEnd {
t.Errorf("expected tool end, got type=%d", ev.Type)
}
if ev.ToolCall == nil || ev.ToolCall.Arguments != `{"query":"test"}` {
t.Errorf("expected complete arguments, got %+v", ev.ToolCall)
}
// Read done
ev, _ = reader.Next()
if ev.Type != StreamEventDone {
t.Errorf("expected done, got type=%d", ev.Type)
}
if ev.Response == nil || len(ev.Response.ToolCalls) != 1 {
t.Error("expected response with 1 tool call")
}
}
func TestStreamReader_Error(t *testing.T) {
streamErr := errors.New("stream failed")
mp := &mockProvider{
CompleteFunc: func(ctx context.Context, req provider.Request) (provider.Response, error) {
return provider.Response{}, nil
},
StreamFunc: func(ctx context.Context, req provider.Request, ch chan<- provider.StreamEvent) error {
ch <- provider.StreamEvent{Type: provider.StreamEventText, Text: "partial"}
ch <- provider.StreamEvent{Type: provider.StreamEventError, Error: streamErr}
return nil
},
}
model := newMockModel(mp)
reader, err := model.Stream(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer reader.Close()
// Read partial text
ev, err := reader.Next()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ev.Text != "partial" {
t.Errorf("expected 'partial', got %q", ev.Text)
}
// Read error
_, err = reader.Next()
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, streamErr) {
t.Errorf("expected stream error, got %v", err)
}
}
func TestStreamReader_Close(t *testing.T) {
// Create a stream that sends one event then blocks until context is cancelled
mp := &mockProvider{
CompleteFunc: func(ctx context.Context, req provider.Request) (provider.Response, error) {
return provider.Response{}, nil
},
StreamFunc: func(ctx context.Context, req provider.Request, ch chan<- provider.StreamEvent) error {
ch <- provider.StreamEvent{Type: provider.StreamEventText, Text: "start"}
<-ctx.Done()
return ctx.Err()
},
}
model := newMockModel(mp)
reader, err := model.Stream(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Read the first event
ev, err := reader.Next()
if err != nil {
t.Fatalf("unexpected error on first event: %v", err)
}
if ev.Text != "start" {
t.Errorf("expected 'start', got %q", ev.Text)
}
// Close should cancel context
if err := reader.Close(); err != nil {
t.Fatalf("close error: %v", err)
}
// After close, Next should eventually terminate with either EOF or context error.
// The exact behavior depends on goroutine scheduling: the channel may close (EOF)
// or the error event from the cancelled context may arrive first.
_, err = reader.Next()
if err == nil {
t.Error("expected error after close, got nil")
}
}
func TestStreamReader_Collect(t *testing.T) {
events := []provider.StreamEvent{
{Type: provider.StreamEventText, Text: "Hello"},
{Type: provider.StreamEventText, Text: " world"},
{Type: provider.StreamEventDone, Response: &provider.Response{
Text: "Hello world",
Usage: &provider.Usage{
InputTokens: 10,
OutputTokens: 2,
TotalTokens: 12,
},
}},
}
mp := newMockStreamProvider(events)
model := newMockModel(mp)
reader, err := model.Stream(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer reader.Close()
resp, err := reader.Collect()
if err != nil {
t.Fatalf("collect error: %v", err)
}
if resp.Text != "Hello world" {
t.Errorf("expected 'Hello world', got %q", resp.Text)
}
if resp.Usage == nil {
t.Fatal("expected usage")
}
if resp.Usage.InputTokens != 10 {
t.Errorf("expected 10 input tokens, got %d", resp.Usage.InputTokens)
}
}
func TestStreamReader_Text(t *testing.T) {
events := []provider.StreamEvent{
{Type: provider.StreamEventText, Text: "result"},
{Type: provider.StreamEventDone, Response: &provider.Response{Text: "result"}},
}
mp := newMockStreamProvider(events)
model := newMockModel(mp)
reader, err := model.Stream(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer reader.Close()
text, err := reader.Text()
if err != nil {
t.Fatalf("text error: %v", err)
}
if text != "result" {
t.Errorf("expected 'result', got %q", text)
}
}
func TestStreamReader_EmptyStream(t *testing.T) {
// Stream that completes without a done event (no response)
mp := newMockStreamProvider([]provider.StreamEvent{
{Type: provider.StreamEventText, Text: "hi"},
})
model := newMockModel(mp)
reader, err := model.Stream(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer reader.Close()
_, err = reader.Collect()
if err == nil {
t.Fatal("expected error for stream without done event")
}
}
func TestStreamReader_StreamFuncError(t *testing.T) {
// Stream function returns error directly
mp := &mockProvider{
CompleteFunc: func(ctx context.Context, req provider.Request) (provider.Response, error) {
return provider.Response{}, nil
},
StreamFunc: func(ctx context.Context, req provider.Request, ch chan<- provider.StreamEvent) error {
return errors.New("stream init failed")
},
}
model := newMockModel(mp)
reader, err := model.Stream(context.Background(), []Message{UserMessage("test")})
if err != nil {
t.Fatalf("unexpected error creating reader: %v", err)
}
defer reader.Close()
// The error should come through as an error event
_, err = reader.Collect()
if err == nil {
t.Fatal("expected error from stream function")
}
}
+201
View File
@@ -0,0 +1,201 @@
package llm
import (
"context"
"encoding/json"
"fmt"
"reflect"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/internal/schema"
)
// Tool defines a tool that the LLM can invoke.
type Tool struct {
// Name is the tool's unique identifier.
Name string
// Description tells the LLM what this tool does.
Description string
// Schema is the JSON Schema for the tool's parameters.
Schema map[string]any
// fn holds the implementation function (set via Define or DefineSimple).
fn reflect.Value
pTyp reflect.Type // nil for parameterless tools
// isMCP indicates this tool is provided by an MCP server.
isMCP bool
mcpServer *MCPServer
}
// Define creates a tool from a typed handler function.
// T must be a struct. Struct fields become the tool's parameters.
//
// Struct tags:
// - `json:"name"` — parameter name
// - `description:"..."` — parameter description
// - `enum:"a,b,c"` — enum constraint
//
// Pointer fields are optional; non-pointer fields are required.
//
// Example:
//
// type WeatherParams struct {
// City string `json:"city" description:"The city to query"`
// Unit string `json:"unit" description:"Temperature unit" enum:"celsius,fahrenheit"`
// }
//
// llm.Define[WeatherParams]("get_weather", "Get weather for a city",
// func(ctx context.Context, p WeatherParams) (string, error) {
// return fmt.Sprintf("72F in %s", p.City), nil
// },
// )
func Define[T any](name, description string, fn func(context.Context, T) (string, error)) Tool {
var zero T
return Tool{
Name: name,
Description: description,
Schema: schema.FromStruct(zero),
fn: reflect.ValueOf(fn),
pTyp: reflect.TypeOf(zero),
}
}
// DefineSimple creates a parameterless tool.
//
// Example:
//
// llm.DefineSimple("get_time", "Get the current time",
// func(ctx context.Context) (string, error) {
// return time.Now().Format(time.RFC3339), nil
// },
// )
func DefineSimple(name, description string, fn func(context.Context) (string, error)) Tool {
return Tool{
Name: name,
Description: description,
Schema: map[string]any{"type": "object", "properties": map[string]any{}},
fn: reflect.ValueOf(fn),
}
}
// Execute runs the tool with the given JSON arguments string.
func (t Tool) Execute(ctx context.Context, argsJSON string) (string, error) {
if t.isMCP {
var args map[string]any
if argsJSON != "" && argsJSON != "{}" {
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid MCP tool arguments: %w", err)
}
}
return t.mcpServer.CallTool(ctx, t.Name, args)
}
// Parameterless tool
if t.pTyp == nil {
out := t.fn.Call([]reflect.Value{reflect.ValueOf(ctx)})
if !out[1].IsNil() {
return "", out[1].Interface().(error)
}
return out[0].String(), nil
}
// Typed tool: unmarshal JSON into the struct, call the function
p := reflect.New(t.pTyp)
if argsJSON != "" && argsJSON != "{}" {
err := json.Unmarshal([]byte(argsJSON), p.Interface())
if err != nil {
// LLMs sometimes return numeric/boolean fields as JSON strings
// (e.g. "3" instead of 3). Retry with type coercion.
if coerced, cerr := coerceArgsToType([]byte(argsJSON), t.pTyp); cerr == nil {
err = json.Unmarshal(coerced, p.Interface())
}
if err != nil {
return "", fmt.Errorf("invalid tool arguments: %w", err)
}
}
}
out := t.fn.Call([]reflect.Value{reflect.ValueOf(ctx), p.Elem()})
if !out[1].IsNil() {
return "", out[1].Interface().(error)
}
return out[0].String(), nil
}
// ToolBox is a collection of tools available for use by an LLM.
type ToolBox struct {
tools map[string]Tool
mcpServers []*MCPServer
}
// NewToolBox creates a new ToolBox from the given tools.
func NewToolBox(tools ...Tool) *ToolBox {
tb := &ToolBox{tools: make(map[string]Tool)}
for _, t := range tools {
tb.tools[t.Name] = t
}
return tb
}
// Add adds tools to the toolbox and returns it for chaining.
func (tb *ToolBox) Add(tools ...Tool) *ToolBox {
if tb.tools == nil {
tb.tools = make(map[string]Tool)
}
for _, t := range tools {
tb.tools[t.Name] = t
}
return tb
}
// AddMCP adds an MCP server's tools to the toolbox. The server must be connected.
func (tb *ToolBox) AddMCP(server *MCPServer) *ToolBox {
if tb.tools == nil {
tb.tools = make(map[string]Tool)
}
tb.mcpServers = append(tb.mcpServers, server)
for _, tool := range server.ListTools() {
tb.tools[tool.Name] = tool
}
return tb
}
// AllTools returns all tools (local + MCP) as a slice.
func (tb *ToolBox) AllTools() []Tool {
if tb == nil {
return nil
}
tools := make([]Tool, 0, len(tb.tools))
for _, t := range tb.tools {
tools = append(tools, t)
}
return tools
}
// Execute executes a tool call by name.
func (tb *ToolBox) Execute(ctx context.Context, call ToolCall) (string, error) {
if tb == nil {
return "", ErrNoToolsConfigured
}
tool, ok := tb.tools[call.Name]
if !ok {
return "", fmt.Errorf("%w: %s", ErrToolNotFound, call.Name)
}
return tool.Execute(ctx, call.Arguments)
}
// ExecuteAll executes all tool calls and returns tool result messages.
func (tb *ToolBox) ExecuteAll(ctx context.Context, calls []ToolCall) ([]Message, error) {
var results []Message
for _, call := range calls {
result, err := tb.Execute(ctx, call)
text := result
if err != nil {
text = "Error: " + err.Error()
}
results = append(results, ToolResultMessage(call.ID, text))
}
return results, nil
}
+134
View File
@@ -0,0 +1,134 @@
package llm
import (
"encoding/json"
"reflect"
"strconv"
"strings"
)
// coerceArgsToType reparses argsJSON with leniency: where the target struct
// expects a numeric or boolean field but the JSON value is a string, it
// converts the string to the target kind. Recurses into nested structs,
// slices, maps, and pointer fields.
//
// Returns a freshly marshaled JSON byte slice that can be unmarshaled into
// the target type with strict json.Unmarshal.
func coerceArgsToType(argsJSON []byte, target reflect.Type) ([]byte, error) {
var raw any
if err := json.Unmarshal(argsJSON, &raw); err != nil {
return nil, err
}
raw = coerceValue(raw, target)
return json.Marshal(raw)
}
func coerceValue(v any, t reflect.Type) any {
if t == nil {
return v
}
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
switch t.Kind() {
case reflect.Struct:
m, ok := v.(map[string]any)
if !ok {
return v
}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if !f.IsExported() {
continue
}
name := jsonFieldName(f)
if name == "-" {
continue
}
if val, present := m[name]; present {
m[name] = coerceValue(val, f.Type)
}
}
return m
case reflect.Slice, reflect.Array:
arr, ok := v.([]any)
if !ok {
return v
}
elemType := t.Elem()
for i := range arr {
arr[i] = coerceValue(arr[i], elemType)
}
return arr
case reflect.Map:
m, ok := v.(map[string]any)
if !ok {
return v
}
valType := t.Elem()
for k := range m {
m[k] = coerceValue(m[k], valType)
}
return m
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if s, ok := v.(string); ok {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "+")
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
return n
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
return int64(f)
}
}
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
if s, ok := v.(string); ok {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "+")
if n, err := strconv.ParseUint(s, 10, 64); err == nil {
return n
}
if f, err := strconv.ParseFloat(s, 64); err == nil && f >= 0 {
return uint64(f)
}
}
case reflect.Float32, reflect.Float64:
if s, ok := v.(string); ok {
s = strings.TrimSpace(s)
if f, err := strconv.ParseFloat(s, 64); err == nil {
return f
}
}
case reflect.Bool:
if s, ok := v.(string); ok {
if b, err := strconv.ParseBool(strings.TrimSpace(s)); err == nil {
return b
}
}
}
return v
}
func jsonFieldName(f reflect.StructField) string {
tag := f.Tag.Get("json")
if tag == "" {
return f.Name
}
if idx := strings.Index(tag, ","); idx >= 0 {
tag = tag[:idx]
}
if tag == "-" {
return "-"
}
if tag == "" {
return f.Name
}
return tag
}
+130
View File
@@ -0,0 +1,130 @@
package llm
import (
"context"
"testing"
)
func TestExecuteCoercesStringNumbers(t *testing.T) {
type params struct {
Memory string `json:"memory"`
ReplaceMemoryID *uint `json:"replace_memory_id,omitempty"`
RelationshipChange int `json:"relationship_change"`
}
var got params
tool := Define("process", "test",
func(ctx context.Context, p params) (string, error) {
got = p
return "ok", nil
},
)
cases := []struct {
name string
args string
wantInt int
wantUint uint
}{
{"int as string", `{"memory":"x","relationship_change":"3"}`, 3, 0},
{"int as string with plus", `{"memory":"x","relationship_change":"+3"}`, 3, 0},
{"int as string negative", `{"memory":"x","relationship_change":"-2"}`, -2, 0},
{"int as string with whitespace", `{"memory":"x","relationship_change":" 4 "}`, 4, 0},
{"int as string with decimal", `{"memory":"x","relationship_change":"2.7"}`, 2, 0},
{"native int still works", `{"memory":"x","relationship_change":5}`, 5, 0},
{"pointer uint as string", `{"memory":"x","replace_memory_id":"42","relationship_change":0}`, 0, 42},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got = params{}
result, err := tool.Execute(context.Background(), tc.args)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
if result != "ok" {
t.Errorf("expected 'ok', got %q", result)
}
if got.RelationshipChange != tc.wantInt {
t.Errorf("RelationshipChange: want %d, got %d", tc.wantInt, got.RelationshipChange)
}
if tc.wantUint != 0 {
if got.ReplaceMemoryID == nil || *got.ReplaceMemoryID != tc.wantUint {
t.Errorf("ReplaceMemoryID: want %d, got %v", tc.wantUint, got.ReplaceMemoryID)
}
}
})
}
}
func TestExecuteCoercesStringBoolAndFloat(t *testing.T) {
type params struct {
Enabled bool `json:"enabled"`
Ratio float64 `json:"ratio"`
}
var got params
tool := Define("cfg", "test",
func(ctx context.Context, p params) (string, error) {
got = p
return "ok", nil
},
)
if _, err := tool.Execute(context.Background(), `{"enabled":"true","ratio":"0.5"}`); err != nil {
t.Fatalf("execute failed: %v", err)
}
if !got.Enabled {
t.Errorf("expected enabled=true, got false")
}
if got.Ratio != 0.5 {
t.Errorf("expected ratio=0.5, got %v", got.Ratio)
}
}
func TestExecuteCoercesNestedAndSlices(t *testing.T) {
type inner struct {
N int `json:"n"`
}
type params struct {
Items []inner `json:"items"`
Tags []int `json:"tags"`
}
var got params
tool := Define("nest", "test",
func(ctx context.Context, p params) (string, error) {
got = p
return "ok", nil
},
)
args := `{"items":[{"n":"1"},{"n":"2"}],"tags":["10","20"]}`
if _, err := tool.Execute(context.Background(), args); err != nil {
t.Fatalf("execute failed: %v", err)
}
if len(got.Items) != 2 || got.Items[0].N != 1 || got.Items[1].N != 2 {
t.Errorf("nested struct coercion failed: %+v", got.Items)
}
if len(got.Tags) != 2 || got.Tags[0] != 10 || got.Tags[1] != 20 {
t.Errorf("slice element coercion failed: %+v", got.Tags)
}
}
func TestExecuteUnrecoverableArgsErrors(t *testing.T) {
type params struct {
N int `json:"n"`
}
tool := Define("bad", "test",
func(ctx context.Context, p params) (string, error) {
return "ok", nil
},
)
if _, err := tool.Execute(context.Background(), `{"n":"not-a-number"}`); err == nil {
t.Errorf("expected error for unparseable string")
}
if _, err := tool.Execute(context.Background(), `{not json`); err == nil {
t.Errorf("expected error for malformed JSON")
}
}
+139
View File
@@ -0,0 +1,139 @@
package llm
import (
"context"
"encoding/json"
"testing"
)
type calcParams struct {
A float64 `json:"a" description:"First number"`
B float64 `json:"b" description:"Second number"`
Op string `json:"op" description:"Operation" enum:"add,sub,mul,div"`
}
func TestDefine(t *testing.T) {
tool := Define[calcParams]("calc", "Calculator",
func(ctx context.Context, p calcParams) (string, error) {
var result float64
switch p.Op {
case "add":
result = p.A + p.B
case "sub":
result = p.A - p.B
case "mul":
result = p.A * p.B
case "div":
result = p.A / p.B
}
b, err := json.Marshal(result)
return string(b), err
},
)
if tool.Name != "calc" {
t.Errorf("expected name 'calc', got %q", tool.Name)
}
if tool.Description != "Calculator" {
t.Errorf("expected description 'Calculator', got %q", tool.Description)
}
if tool.Schema["type"] != "object" {
t.Errorf("expected schema type=object, got %v", tool.Schema["type"])
}
// Test execution
result, err := tool.Execute(context.Background(), `{"a": 10, "b": 3, "op": "add"}`)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
if result != "13" {
t.Errorf("expected '13', got %q", result)
}
}
func TestDefineSimple(t *testing.T) {
tool := DefineSimple("hello", "Say hello",
func(ctx context.Context) (string, error) {
return "Hello, world!", nil
},
)
result, err := tool.Execute(context.Background(), "")
if err != nil {
t.Fatalf("execute failed: %v", err)
}
if result != "Hello, world!" {
t.Errorf("expected 'Hello, world!', got %q", result)
}
}
func TestToolBox(t *testing.T) {
tool1 := DefineSimple("tool1", "Tool 1", func(ctx context.Context) (string, error) {
return "result1", nil
})
tool2 := DefineSimple("tool2", "Tool 2", func(ctx context.Context) (string, error) {
return "result2", nil
})
tb := NewToolBox(tool1, tool2)
tools := tb.AllTools()
if len(tools) != 2 {
t.Errorf("expected 2 tools, got %d", len(tools))
}
result, err := tb.Execute(context.Background(), ToolCall{ID: "1", Name: "tool1"})
if err != nil {
t.Fatalf("execute failed: %v", err)
}
if result != "result1" {
t.Errorf("expected 'result1', got %q", result)
}
// Test not found
_, err = tb.Execute(context.Background(), ToolCall{ID: "x", Name: "nonexistent"})
if err == nil {
t.Error("expected error for nonexistent tool")
}
}
func TestToolBoxExecuteAll(t *testing.T) {
tb := NewToolBox(
DefineSimple("t1", "T1", func(ctx context.Context) (string, error) {
return "r1", nil
}),
DefineSimple("t2", "T2", func(ctx context.Context) (string, error) {
return "r2", nil
}),
)
calls := []ToolCall{
{ID: "c1", Name: "t1"},
{ID: "c2", Name: "t2"},
}
msgs, err := tb.ExecuteAll(context.Background(), calls)
if err != nil {
t.Fatalf("execute all failed: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgs))
}
if msgs[0].Role != RoleTool {
t.Errorf("expected role=tool, got %v", msgs[0].Role)
}
if msgs[0].ToolCallID != "c1" {
t.Errorf("expected toolCallID=c1, got %v", msgs[0].ToolCallID)
}
if msgs[0].Content.Text != "r1" {
t.Errorf("expected content=r1, got %v", msgs[0].Content.Text)
}
}
// jsonMarshal helper for calcParams test
func (p calcParams) jsonMarshal(result float64) (string, error) {
b, err := json.Marshal(result)
return string(b), err
}
+59
View File
@@ -0,0 +1,59 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// BrowserParams defines parameters for the browser tool.
type BrowserParams struct {
URL string `json:"url" description:"The URL to fetch and extract text from"`
}
// Browser creates a simple web content fetcher tool.
// It fetches a URL and returns the text content.
//
// For a full headless browser, consider using an MCP server like Playwright MCP.
//
// Example:
//
// tools := llm.NewToolBox(tools.Browser())
func Browser() llm.Tool {
return llm.Define[BrowserParams]("browser", "Fetch a web page and return its text content",
func(ctx context.Context, p BrowserParams) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
req.Header.Set("User-Agent", "go-llm/2.0 (Web Fetcher)")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("fetching URL: %w", err)
}
defer resp.Body.Close()
// Limit to 1MB
limited := io.LimitReader(resp.Body, 1<<20)
body, err := io.ReadAll(limited)
if err != nil {
return "", fmt.Errorf("reading body: %w", err)
}
result := map[string]any{
"url": p.URL,
"status": resp.StatusCode,
"content_type": resp.Header.Get("Content-Type"),
"body": string(body),
}
out, _ := json.MarshalIndent(result, "", " ")
return string(out), nil
},
)
}
+101
View File
@@ -0,0 +1,101 @@
package tools
import (
"context"
"fmt"
"os/exec"
"runtime"
"strings"
"time"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// ExecParams defines parameters for the exec tool.
type ExecParams struct {
Command string `json:"command" description:"The shell command to execute"`
}
// ExecOption configures the exec tool.
type ExecOption func(*execConfig)
type execConfig struct {
allowedCommands []string
workDir string
timeout time.Duration
}
// WithAllowedCommands restricts which commands can be executed.
// If empty, all commands are allowed.
func WithAllowedCommands(cmds []string) ExecOption {
return func(c *execConfig) { c.allowedCommands = cmds }
}
// WithWorkDir sets the working directory for command execution.
func WithWorkDir(dir string) ExecOption {
return func(c *execConfig) { c.workDir = dir }
}
// WithExecTimeout sets the maximum execution time.
func WithExecTimeout(d time.Duration) ExecOption {
return func(c *execConfig) { c.timeout = d }
}
// Exec creates a shell command execution tool.
//
// Example:
//
// tools := llm.NewToolBox(
// tools.Exec(tools.WithAllowedCommands([]string{"ls", "cat", "grep"})),
// )
func Exec(opts ...ExecOption) llm.Tool {
cfg := &execConfig{
timeout: 30 * time.Second,
}
for _, opt := range opts {
opt(cfg)
}
return llm.Define[ExecParams]("exec", "Execute a shell command and return its output",
func(ctx context.Context, p ExecParams) (string, error) {
// Check allowed commands
if len(cfg.allowedCommands) > 0 {
parts := strings.Fields(p.Command)
if len(parts) == 0 {
return "", fmt.Errorf("empty command")
}
allowed := false
for _, cmd := range cfg.allowedCommands {
if parts[0] == cmd {
allowed = true
break
}
}
if !allowed {
return "", fmt.Errorf("command %q is not in the allowed list", parts[0])
}
}
ctx, cancel := context.WithTimeout(ctx, cfg.timeout)
defer cancel()
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.CommandContext(ctx, "cmd", "/C", p.Command)
} else {
cmd = exec.CommandContext(ctx, "sh", "-c", p.Command)
}
if cfg.workDir != "" {
cmd.Dir = cfg.workDir
}
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Sprintf("Error: %s\nOutput: %s", err.Error(), string(output)), nil
}
return string(output), nil
},
)
}
+75
View File
@@ -0,0 +1,75 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// HTTPParams defines parameters for the HTTP request tool.
type HTTPParams struct {
Method string `json:"method" description:"HTTP method" enum:"GET,POST,PUT,DELETE,PATCH,HEAD"`
URL string `json:"url" description:"Request URL"`
Headers map[string]string `json:"headers,omitempty" description:"Request headers"`
Body *string `json:"body,omitempty" description:"Request body"`
}
// HTTP creates an HTTP request tool.
//
// Example:
//
// tools := llm.NewToolBox(tools.HTTP())
func HTTP() llm.Tool {
return llm.Define[HTTPParams]("http_request", "Make an HTTP request and return the response",
func(ctx context.Context, p HTTPParams) (string, error) {
var bodyReader io.Reader
if p.Body != nil {
bodyReader = bytes.NewBufferString(*p.Body)
}
req, err := http.NewRequestWithContext(ctx, p.Method, p.URL, bodyReader)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
for k, v := range p.Headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
// Limit to 1MB
limited := io.LimitReader(resp.Body, 1<<20)
body, err := io.ReadAll(limited)
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}
headers := map[string]string{}
for k, v := range resp.Header {
if len(v) > 0 {
headers[k] = v[0]
}
}
result := map[string]any{
"status": resp.StatusCode,
"status_text": resp.Status,
"headers": headers,
"body": string(body),
}
out, _ := json.MarshalIndent(result, "", " ")
return string(out), nil
},
)
}
+81
View File
@@ -0,0 +1,81 @@
package tools
import (
"bufio"
"context"
"fmt"
"os"
"strings"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// ReadFileParams defines parameters for the read file tool.
type ReadFileParams struct {
Path string `json:"path" description:"File path to read"`
Start *int `json:"start,omitempty" description:"Starting line number (1-based, inclusive)"`
End *int `json:"end,omitempty" description:"Ending line number (1-based, inclusive)"`
}
// ReadFile creates a file reading tool.
//
// Example:
//
// tools := llm.NewToolBox(tools.ReadFile())
func ReadFile() llm.Tool {
return llm.Define[ReadFileParams]("read_file", "Read the contents of a file",
func(ctx context.Context, p ReadFileParams) (string, error) {
f, err := os.Open(p.Path)
if err != nil {
return "", fmt.Errorf("opening file: %w", err)
}
defer f.Close()
// If no line range specified, read the whole file (limited to 1MB)
if p.Start == nil && p.End == nil {
info, err := f.Stat()
if err != nil {
return "", fmt.Errorf("stat file: %w", err)
}
if info.Size() > 1<<20 {
return "", fmt.Errorf("file too large (%d bytes), use start/end to read a range", info.Size())
}
data, err := os.ReadFile(p.Path)
if err != nil {
return "", fmt.Errorf("reading file: %w", err)
}
return string(data), nil
}
// Read specific line range
start := 1
end := -1
if p.Start != nil {
start = *p.Start
}
if p.End != nil {
end = *p.End
}
var lines []string
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
if lineNum < start {
continue
}
if end > 0 && lineNum > end {
break
}
lines = append(lines, fmt.Sprintf("%d: %s", lineNum, scanner.Text()))
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("scanning file: %w", err)
}
return strings.Join(lines, "\n"), nil
},
)
}
+101
View File
@@ -0,0 +1,101 @@
// Package tools provides ready-to-use tool implementations for common agent patterns.
package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// WebSearchParams defines parameters for the web search tool.
type WebSearchParams struct {
Query string `json:"query" description:"The search query"`
Count *int `json:"count,omitempty" description:"Number of results to return (default 5, max 20)"`
}
// WebSearch creates a web search tool using the Brave Search API.
//
// Get a free API key at https://brave.com/search/api/
//
// Example:
//
// tools := llm.NewToolBox(tools.WebSearch("your-brave-api-key"))
func WebSearch(apiKey string) llm.Tool {
return llm.Define[WebSearchParams]("web_search", "Search the web for information using Brave Search",
func(ctx context.Context, p WebSearchParams) (string, error) {
count := 5
if p.Count != nil && *p.Count > 0 {
count = *p.Count
if count > 20 {
count = 20
}
}
u := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
url.QueryEscape(p.Query), count)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Subscription-Token", apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("search request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("search API returned %d: %s", resp.StatusCode, string(body))
}
// Parse and simplify the response
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return string(body), nil
}
type result struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
}
var results []result
if web, ok := raw["web"].(map[string]any); ok {
if items, ok := web["results"].([]any); ok {
for _, item := range items {
if m, ok := item.(map[string]any); ok {
r := result{}
if t, ok := m["title"].(string); ok {
r.Title = t
}
if u, ok := m["url"].(string); ok {
r.URL = u
}
if d, ok := m["description"].(string); ok {
r.Description = d
}
results = append(results, r)
}
}
}
}
out, _ := json.MarshalIndent(results, "", " ")
return string(out), nil
},
)
}
+31
View File
@@ -0,0 +1,31 @@
package tools
import (
"context"
"fmt"
"os"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// WriteFileParams defines parameters for the write file tool.
type WriteFileParams struct {
Path string `json:"path" description:"File path to write"`
Content string `json:"content" description:"Content to write to the file"`
}
// WriteFile creates a file writing tool.
//
// Example:
//
// tools := llm.NewToolBox(tools.WriteFile())
func WriteFile() llm.Tool {
return llm.Define[WriteFileParams]("write_file", "Write content to a file (creates or overwrites)",
func(ctx context.Context, p WriteFileParams) (string, error) {
if err := os.WriteFile(p.Path, []byte(p.Content), 0644); err != nil {
return "", fmt.Errorf("writing file: %w", err)
}
return fmt.Sprintf("Successfully wrote %d bytes to %s", len(p.Content), p.Path), nil
},
)
}
+100
View File
@@ -0,0 +1,100 @@
package llm
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
)
// Transcriber abstracts a speech-to-text model implementation.
type Transcriber = provider.Transcriber
// TranscriptionResponseFormat controls the output format requested from a transcriber.
type TranscriptionResponseFormat = provider.TranscriptionResponseFormat
const (
TranscriptionResponseFormatJSON = provider.TranscriptionResponseFormatJSON
TranscriptionResponseFormatVerboseJSON = provider.TranscriptionResponseFormatVerboseJSON
TranscriptionResponseFormatText = provider.TranscriptionResponseFormatText
TranscriptionResponseFormatSRT = provider.TranscriptionResponseFormatSRT
TranscriptionResponseFormatVTT = provider.TranscriptionResponseFormatVTT
)
// TranscriptionTimestampGranularity defines the requested timestamp detail.
type TranscriptionTimestampGranularity = provider.TranscriptionTimestampGranularity
const (
TranscriptionTimestampGranularityWord = provider.TranscriptionTimestampGranularityWord
TranscriptionTimestampGranularitySegment = provider.TranscriptionTimestampGranularitySegment
)
// TranscriptionOptions configures transcription behavior.
type TranscriptionOptions = provider.TranscriptionOptions
// Transcription captures a normalized transcription result.
type Transcription = provider.Transcription
// TranscriptionSegment provides a coarse time-sliced transcription segment.
type TranscriptionSegment = provider.TranscriptionSegment
// TranscriptionWord provides a word-level timestamp.
type TranscriptionWord = provider.TranscriptionWord
// TranscriptionTokenLogprob captures token-level log probability details.
type TranscriptionTokenLogprob = provider.TranscriptionTokenLogprob
// TranscriptionUsage captures token or duration usage details.
type TranscriptionUsage = provider.TranscriptionUsage
// TranscribeFile converts an audio file to WAV (via ffmpeg) and transcribes it.
func TranscribeFile(ctx context.Context, filename string, transcriber Transcriber, opts TranscriptionOptions) (Transcription, error) {
if transcriber == nil {
return Transcription{}, fmt.Errorf("transcriber is nil")
}
wav, err := audioFileToWav(ctx, filename)
if err != nil {
return Transcription{}, err
}
return transcriber.Transcribe(ctx, wav, opts)
}
func audioFileToWav(ctx context.Context, filename string) ([]byte, error) {
if filename == "" {
return nil, fmt.Errorf("filename is empty")
}
if strings.EqualFold(filepath.Ext(filename), ".wav") {
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("read wav file: %w", err)
}
return data, nil
}
tempFile, err := os.CreateTemp("", "go-llm-audio-*.wav")
if err != nil {
return nil, fmt.Errorf("create temp wav file: %w", err)
}
tempPath := tempFile.Name()
_ = tempFile.Close()
defer os.Remove(tempPath)
cmd := exec.CommandContext(ctx, "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", filename, "-vn", "-f", "wav", tempPath)
if output, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("ffmpeg convert failed: %w (output: %s)", err, strings.TrimSpace(string(output)))
}
data, err := os.ReadFile(tempPath)
if err != nil {
return nil, fmt.Errorf("read converted wav file: %w", err)
}
return data, nil
}
+40
View File
@@ -0,0 +1,40 @@
// Package xai implements the go-llm v2 provider interface for xAI (Grok,
// https://x.ai/api). xAI speaks OpenAI Chat Completions, so this package is a
// thin wrapper over openaicompat with its own defaults and per-model Rules.
package xai
import (
"strings"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/openaicompat"
)
// DefaultBaseURL is the public xAI API endpoint.
const DefaultBaseURL = "https://api.x.ai/v1"
// Provider is a type alias over openaicompat.Provider.
type Provider = openaicompat.Provider
// New creates a new xAI provider. An empty baseURL uses DefaultBaseURL.
func New(apiKey, baseURL string) *Provider {
if baseURL == "" {
baseURL = DefaultBaseURL
}
return openaicompat.New(apiKey, baseURL, openaicompat.Rules{
// Grok models whose name contains "vision" accept images; others don't.
SupportsVision: func(m string) bool {
return strings.Contains(m, "vision")
},
// Reasoning is supported on grok-3-mini and grok-4 family. The xAI
// API only accepts low|high (no medium); we map medium up to high.
SupportsReasoning: func(m string) bool {
return strings.Contains(m, "grok-3-mini") || strings.Contains(m, "grok-4")
},
MapReasoningEffort: func(level string) string {
if level == "medium" {
return "high"
}
return level
},
})
}
+100
View File
@@ -0,0 +1,100 @@
package xai_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/openaicompat"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/xai"
)
// newReasoningServer is a httptest server that records the request body and
// returns a minimal valid completion. Used to assert the reasoning_effort
// field that lands on the wire.
func newReasoningServer(t *testing.T) (*httptest.Server, *[]byte) {
t.Helper()
var body []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
body = b
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"id":"x","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)
}))
return srv, &body
}
// readEffort returns the value of the "reasoning_effort" field in the JSON
// body, or "" if absent.
func readEffort(t *testing.T, body []byte) string {
t.Helper()
if len(body) == 0 {
return ""
}
var parsed map[string]any
if err := json.Unmarshal(body, &parsed); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
if v, ok := parsed["reasoning_effort"]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func TestNew_Basic(t *testing.T) {
if p := xai.New("key", ""); p == nil {
t.Fatal("New returned nil")
}
}
func TestRules_ReasoningGate(t *testing.T) {
srv, body := newReasoningServer(t)
defer srv.Close()
// grok-3-mini: reasoning supported, medium maps to high.
p := xai.New("k", srv.URL)
req := provider.Request{
Model: "grok-3-mini",
Messages: []provider.Message{{Role: "user", Content: "?"}},
Reasoning: "medium",
}
if _, err := p.Complete(context.Background(), req); err != nil {
t.Fatalf("Complete: %v", err)
}
if effort := readEffort(t, *body); effort != "high" {
t.Errorf("grok-3-mini medium → effort=%q, want \"high\"", effort)
}
// grok-2 (no reasoning): effort must NOT be sent.
req.Model = "grok-2"
*body = nil
if _, err := p.Complete(context.Background(), req); err != nil {
t.Fatalf("Complete: %v", err)
}
if effort := readEffort(t, *body); effort != "" {
t.Errorf("grok-2 → effort=%q, want absent", effort)
}
}
func TestRules_Grok2RejectsImages(t *testing.T) {
p := xai.New("key", "")
req := provider.Request{
Model: "grok-2",
Messages: []provider.Message{{
Role: "user",
Images: []provider.Image{{URL: "a"}},
}},
}
_, err := p.Complete(context.Background(), req)
var fue *openaicompat.FeatureUnsupportedError
if !errors.As(err, &fue) || fue.Feature != "vision" {
t.Fatalf("want FeatureUnsupportedError(vision), got %v", err)
}
}