chat

package module
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 21, 2026 License: MIT Imports: 25 Imported by: 0

README

chat

Multi-provider AI chat client for Go — light, framework-free, opt-in per-SDK

Go Reference Pipeline Coverage phpboyscout Go toolkit

Part of the phpboyscout Go toolkit — small, framework-free Go modules extracted from go-tool-base. Docs: chat.go.phpboyscout.uk


gitlab.com/phpboyscout/go/chat — a unified multi-provider AI chat client (Anthropic Claude, Claude-local CLI, OpenAI / OpenAI-compatible, Google Gemini) with a ReAct tool-calling loop, streaming, cross-provider fallback, usage accounting, and encrypted conversation persistence — as a light, dependency-inverted Go library.

It is the same client behind gtb ai, extracted so any project can embed a production chat client without pulling in the go-tool-base framework, and without linking every vendor SDK — you opt into only the provider(s) you use.

Design

  • SDK-free core. The core depends only on cockroachdb/errors, invopop/jsonschema, google/uuid, and spf13/afero. No vendor AI SDK, no web framework, no go-tool-base. A depfootprint_test.go guard enforces it.
  • Opt-in providers. Each vendor SDK lives in its own module, registered via a blank import — so a Claude-only tool never compiles the OpenAI or Gemini SDKs. The SDK-free claude-local provider ships in the core as a zero-weight default.
  • Stdlib seams. *slog.Logger (logging), *http.Client (transport — inject a hardened one or take the plain bounded default), a KeychainLookup func (credential resolution), and afero.Fs (persistence) — inject your own; everything has a sane default.
  • Config-system-agnostic. The core owns provider mechanics, not config schema. A host maps its own config into the typed chat.Config; go-tool-base does this in its adapter.

Providers

Providers register via a blank import, so you link only the SDK(s) you use:

Provider Module SDK
claude-local gitlab.com/phpboyscout/go/chat (core) none (local claude CLI)
claude gitlab.com/phpboyscout/go/chat-anthropic anthropics/anthropic-sdk-go
openai, openai-compatible gitlab.com/phpboyscout/go/chat-openai openai/openai-go/v3 (+ tiktoken)
gemini gitlab.com/phpboyscout/go/chat-gemini google.golang.org/genai

The per-provider modules are thin adapters, too tightly coupled to the core to warrant their own docs sites — each ships a detailed README, and all provider documentation lives on the core docs site.

Quick start

import (
    "gitlab.com/phpboyscout/go/chat"
    _ "gitlab.com/phpboyscout/go/chat-anthropic" // activate the Claude provider
)

client, err := chat.New(ctx, chat.Settings{
    Config: chat.Config{Provider: chat.ProviderClaude, Token: apiKey},
})
if err != nil {
    return err
}
answer, err := client.Chat(ctx, "Summarise this changelog in one line.")

See the runnable Example tests on pkg.go.dev.

Documentation

Licence

MIT — see LICENSE.

Documentation

Overview

Package chat provides a unified multi-provider AI chat client supporting Claude, OpenAI, Gemini, Claude Local (via CLI binary), and OpenAI-compatible endpoints.

The ChatClient interface exposes five methods: Add (append a message), Chat (multi-turn conversation with tool use via a ReAct loop), Ask (structured output with JSON schema validation), SetTools (register callable tools), and Usage (token accounting). Streaming-capable providers also implement StreamingChatClient (StreamChat).

Tool calling follows a JSON Schema parameter definition, and the ReAct loop automatically dispatches tool calls and feeds results back until the model produces a final text response. Per-provider token limits and maximum agent steps are configurable via Config.

Providers other than claude-local live in separate per-SDK modules and are activated by a blank import, so a consumer links only the SDK(s) it uses:

import _ "gitlab.com/phpboyscout/go/chat-anthropic" // provider "claude"
import _ "gitlab.com/phpboyscout/go/chat-openai"    // "openai", "openai-compatible"
import _ "gitlab.com/phpboyscout/go/chat-gemini"    // "gemini"

Each provider module self-registers via RegisterProvider (and a failover HTTPStatusExtractor via RegisterStatusExtractor) in its init(). Custom providers register the same way. The SDK-free claude-local provider ships in this core module. Structured-output helpers such as GenerateSchema simplify schema generation for Ask calls.

New constructs clients from package-owned Settings; the package is config-system-agnostic and owns typed config shapes such as RuntimeConfig, FallbackConfig, and CredentialConfig. A host application maps its own configuration into Settings/Config (go-tool-base ships such a Props adapter, which lives in go-tool-base, not here). Credentials resolve through ResolveAPIKey; the HTTP transport and OS-keychain lookup are injected via Config so the core depends on no specific HTTP or secret-store stack. Existing chat clients do not mutate provider settings live on config reload; construct a new client to pick up changed settings.

Multimodal input

Add, Ask, Chat and StreamChat accept a trailing variadic of Media — images (and, on Gemini, PDF and A/V) sent alongside the text prompt. A text-only call passes no media and is unchanged. Each attachment's type is sniffed from its bytes (never a caller-supplied filename), cross-checked against any declared MIMEType, allowlisted, and checked against the selected provider's support before any network call; disguised or unsupported content is rejected with ErrMediaRejected or ErrMediaUnsupported. Media support is per provider: Gemini (images, PDF, A/V), Claude and OpenAI (images, PDF); ProviderClaudeLocal accepts no media.

Index

Examples

Constants

View Source
const (
	// DefaultModelGemini is the default model for the Gemini provider.
	DefaultModelGemini = "gemini-3.5-flash"

	// DefaultModelClaude is the default model for the Claude provider.
	DefaultModelClaude = "claude-opus-4-8"

	// DefaultModelOpenAI is the default model for the OpenAI provider.
	// Mirrors openai.ChatModelGPT5_4 from openai-go/v3; inlined as a string
	// literal so the SDK-free chat core carries no OpenAI dependency. The
	// chat-openai provider module owns the mapping back onto the SDK constant.
	DefaultModelOpenAI = "gpt-5.4"

	// DefaultMaxSteps is the default maximum number of ReAct loop iterations.
	DefaultMaxSteps = 20

	// DefaultMaxTokensOpenAI is the default maximum tokens per response for OpenAI.
	DefaultMaxTokensOpenAI = 4096

	// DefaultMaxTokensClaude is the default maximum tokens per response for Claude.
	DefaultMaxTokensClaude = 8192

	// DefaultMaxTokensGemini is the default maximum tokens per response for Gemini.
	DefaultMaxTokensGemini = 8192
)
View Source
const DefaultChatRequestTimeout = 5 * time.Minute

DefaultChatRequestTimeout bounds a single AI request. LLM generations — especially a large single-shot code conversion on a slower flagship model like Opus — run well past the shared 30s HTTP default, so chat clients use a more generous cap. It is deliberately bounded (not unlimited): a model stuck in a loop or never returning must still fail rather than hang forever. Override per-environment with the ai.request_timeout config key (e.g. "8m") or programmatically via Config.RequestTimeout.

View Source
const EnvAIProvider = "AI_PROVIDER"

EnvAIProvider is the environment variable for overriding the AI provider. The chat core honours it as an ecosystem default in applyDefaultProvider; the GTB adapter owns the ai.* / <provider>.api.* config-key schema.

View Source
const MaxBaseURLLength = 2048

MaxBaseURLLength caps the length, in bytes, of a provider BaseURL. Normal BaseURLs are well under 200 bytes; 2 KiB is generous for legitimate proxy configurations and far short of any pathological input.

View Source
const SnapshotVersion = 1

SnapshotVersion is the current version of the snapshot format. Increment when the format changes in a way that requires migration.

Variables

View Source
var ErrInvalidBaseURL = errors.New("invalid chat provider base URL")

ErrInvalidBaseURL is returned when Config.BaseURL fails validation. Callers can distinguish validation failures from other errors via errors.Is.

View Source
var ErrInvalidSnapshotID = errors.New("invalid snapshot identifier")

ErrInvalidSnapshotID is returned when a snapshot identifier fails validation — not a canonical UUID, contains path separators, or produces a filesystem path outside the store directory.

Callers can distinguish validation failures from I/O failures via errors.Is(err, ErrInvalidSnapshotID).

View Source
var ErrMediaRejected = errors.New("media rejected")

ErrMediaRejected is returned when an attachment fails the safety filter — empty, too large, an unidentifiable/disallowed content type, or a declared type that contradicts the sniffed bytes. Wrapped with detail; test with errors.Is.

View Source
var ErrMediaUnsupported = errors.New("media not supported by provider")

ErrMediaUnsupported is returned when the selected provider (or model) cannot accept media, or cannot accept a particular attachment's type.

Functions

func ChatHTTPClient

func ChatHTTPClient(cfg Config) *http.Client

ChatHTTPClient returns the HTTP client a provider should use: the host-injected Config.HTTPClient verbatim when set (go-tool-base injects its hardened transport), otherwise the module's own plain bounded default. This is the seam that keeps the chat core free of any specific HTTP stack.

func ExecuteTool

func ExecuteTool(ctx context.Context, l *slog.Logger, tools map[string]Tool, name string, input json.RawMessage) string

ExecuteTool looks up a tool by name from the provided registry, executes it, and returns the result as a string. If the result is not a string, it is JSON marshalled. Errors at any stage are returned as formatted error strings suitable for feeding back into the AI conversation (matching existing provider behaviour where tool errors become conversation content rather than aborting the ReAct loop).

A tool handler that panics — handlers run model-generated, adversarial input — is recovered and converted to a tool-error string rather than crashing the process. This holds for both the serial and the parallel dispatch paths, since both route through ExecuteTool.

func GenerateEncryptionKey

func GenerateEncryptionKey() ([]byte, error)

GenerateEncryptionKey returns a fresh 32-byte AES-256 key from crypto/rand, suitable for use with WithEncryption. Each snapshot store should use a distinct key obtained either from this helper or from an operator-controlled source such as a KMS or secret manager.

Closes L-2 from docs/development/reports/security-audit-2026-04-17.md — using this helper avoids the footgun of deriving keys from human-readable passphrases, which have insufficient entropy for the AES-GCM threat model.

func GenerateSchema

func GenerateSchema[T any]() any

GenerateSchema creates a JSON schema for a given type T. OpenAI's structured outputs feature uses a subset of JSON schema. The reflector is configured with flags to ensure the generated schema complies with this specific subset.

func RegisterProvider

func RegisterProvider(name Provider, factory ProviderFactory)

RegisterProvider registers a factory function for a provider name. Call this from an init() function in your provider file or external package.

func RegisterStatusExtractor

func RegisterStatusExtractor(extractor HTTPStatusExtractor)

RegisterStatusExtractor adds a provider HTTP-status extractor to the registry. Call it from a provider module's init(); a nil extractor is ignored.

func ResolveAPIKey

func ResolveAPIKey(
	ctx context.Context,
	direct string,
	credential CredentialConfig,
	envFallback string,
) string

ResolveAPIKey implements the five-step precedence above. Whitespace is trimmed at every step and empty values fall through so a half-configured key cannot mask a fully-configured one at a lower priority.

It is exported because the per-provider modules (chat-anthropic, chat-openai, chat-gemini) call it to resolve their API key, each passing its own well-known fallback env var.

direct is the caller-supplied value (typically Config.Token); pass "" to skip it. credential is the already-adapted provider config, owned by this package rather than the host application's config system. envFallback is the well-known unprefixed environment variable name for the provider (e.g. "ANTHROPIC_API_KEY").

func ValidateBaseURL

func ValidateBaseURL(baseURL string, allowInsecure bool) error

ValidateBaseURL returns nil if baseURL is acceptable for use as a chat provider endpoint, or an error wrapping ErrInvalidBaseURL otherwise.

An empty baseURL is always accepted — callers that require a value (e.g. ProviderOpenAICompatible) must enforce non-emptiness separately. Every non-empty URL is checked against the seven rejection rules documented at the top of this file.

Pass allowInsecure=true ONLY from tests that point at an net/http/httptest.Server (which serves HTTP). Production callers must leave it false; the Config.AllowInsecureBaseURL field that drives this is tagged `json:"-"` so config files cannot set it.

Downstream tool authors should call this at the boundary where they accept BaseURL input (their own setup wizard, CLI flag, env var) so misconfiguration surfaces early rather than at New time.

func ValidateSnapshotID

func ValidateSnapshotID(id string) error

ValidateSnapshotID returns nil if id is a canonical UUID that will be accepted by the [FileStore] methods, or an error wrapping ErrInvalidSnapshotID otherwise.

Use this at the boundary of your own system — e.g. in a CLI flag or HTTP handler that accepts a snapshot identifier from an external source — so validation happens before the value reaches Save, Load, or Delete.

Types

type ChatClient

type ChatClient interface {
	// Add appends a user message to the conversation history without
	// triggering a completion. The message persists for subsequent
	// Chat() or Ask() calls.
	Add(ctx context.Context, prompt string, media ...Media) error
	// Ask sends a question and unmarshals the structured response into
	// target. If Config.ResponseSchema was set during construction, the
	// provider enforces that schema. If no schema is set, the provider
	// returns the raw text content unmarshalled into target (which must
	// be a *string or implement json.Unmarshaler).
	Ask(ctx context.Context, question string, target any, media ...Media) error
	// SetTools configures the tools available to the AI. This replaces
	// (not appends to) any previously set tools.
	SetTools(tools []Tool) error
	// Chat sends a message and returns the response content. If tools
	// are configured, the provider handles tool calls internally via a
	// ReAct loop bounded by Config.MaxSteps (default 20).
	Chat(ctx context.Context, prompt string, media ...Media) (string, error)
	// Usage returns the cumulative token usage across every provider
	// round-trip made by this client instance since construction. A single
	// Chat/Ask/StreamChat call may make multiple round-trips (one per ReAct
	// step) and all are summed. Providers that do not report token counts
	// (e.g. ProviderClaudeLocal) contribute a zero, Known == false Usage.
	// Wire Config.UsageObserver to observe usage per round-trip instead.
	Usage() Usage
}

ChatClient defines the interface for interacting with a chat service.

Implementations are NOT safe for concurrent use by multiple goroutines. Each goroutine should use its own ChatClient instance.

Message history from Add() calls persists across Chat() and Ask() calls within the same client instance. To start a fresh conversation, create a new client via chat.New().

func New

func New(ctx context.Context, settings Settings) (ChatClient, error)

New creates a ChatClient for the configured provider.

func NewFallback

func NewFallback(clients []ChatClient, opts ...FallbackOption) (ChatClient, error)

NewFallback builds a composite ChatClient that tries clients in order, advancing to the next on a retryable failure. The first client is the primary; the rest are fallbacks. At least one client is required.

The returned client also satisfies StreamingChatClient iff every supplied client does.

func NewFallbackFromConfigs

func NewFallbackFromConfigs(ctx context.Context, cfgs []Config, opts ...FallbackOption) (ChatClient, error)

NewFallbackFromConfigs constructs each provider from Config values and wraps the result in a composite. Use NewFallbackFromSettings when each provider needs distinct construction dependencies.

func NewFallbackFromSettings

func NewFallbackFromSettings(ctx context.Context, settings []Settings, opts ...FallbackOption) (ChatClient, error)

NewFallbackFromSettings constructs each provider via New and wraps the result in a composite. The first Settings value is the primary. A construction failure for a non-primary provider (e.g. a missing credential) is downgraded to a logged WARN and that provider is dropped, so one missing fallback credential does not break the whole client; if the primary fails to construct, the error is returned.

func NewWithFallbackSettings

func NewWithFallbackSettings(ctx context.Context, settings Settings, fallback FallbackConfig, opts ...FallbackOption) (ChatClient, error)

NewWithFallbackSettings builds a single provider client or fallback composite from package-owned settings and fallback config. Per the resolved OQ-3, fallback.Providers[0] is the primary and overrides Settings.Config.Provider.

type ClaudeLocal

type ClaudeLocal struct {
	UsageTracker
	// contains filtered or unexported fields
}

ClaudeLocal implements the ChatClient interface using a locally installed claude CLI binary. This provider is useful in environments where direct API access to api.anthropic.com is blocked but the pre-authenticated claude binary is permitted.

func (*ClaudeLocal) Add

func (c *ClaudeLocal) Add(_ context.Context, prompt string, media ...Media) error

Add buffers a user message to be prepended to the next Chat or Ask call.

func (*ClaudeLocal) Ask

func (c *ClaudeLocal) Ask(ctx context.Context, question string, target any, media ...Media) error

Ask sends a question to the local claude binary and unmarshals the structured response into the target using --json-schema for schema-enforced output.

func (*ClaudeLocal) Chat

func (c *ClaudeLocal) Chat(ctx context.Context, prompt string, media ...Media) (string, error)

Chat sends a message to the local claude binary and returns the text response.

func (*ClaudeLocal) SetTools

func (c *ClaudeLocal) SetTools(_ []Tool) error

SetTools is not supported in Phase 1 of ProviderClaudeLocal. Tool integration via MCP server is planned for a future release.

type Config

type Config struct {
	// Provider is the AI service provider to use.
	Provider Provider
	// Model is the specific model to use (e.g., "gpt-4o", "claude-3-5-sonnet").
	Model string
	// Token is the API key or token for the service.
	Token string
	// Credentials carries provider-specific credential config resolved by the
	// host application. Token still wins when both are set.
	Credentials CredentialConfig `json:"-"`
	// BaseURL overrides the API endpoint. Required when using ProviderOpenAICompatible.
	// Example: "http://localhost:11434/v1" for Ollama, "https://api.groq.com/openai/v1" for Groq.
	BaseURL string
	// SystemPrompt is the initial system prompt to set the context for the AI.
	SystemPrompt string
	// ResponseSchema is the JSON schema used to force a structured output from the AI.
	ResponseSchema any
	// SchemaName is the name of the response schema (e.g., "error_analysis").
	SchemaName string
	// SchemaDescription is a description of the response schema.
	SchemaDescription string
	// MaxSteps limits the number of ReAct loop iterations in Chat().
	// Zero means use the default (DefaultMaxSteps = 20).
	MaxSteps int
	// MaxTokens sets the maximum tokens per response.
	// Zero means use the provider default (OpenAI: 4096, Claude: 8192, Gemini: 8192).
	MaxTokens int
	// RequestTimeout bounds a single HTTP request to the provider. Zero falls
	// back to the ai.request_timeout config key, then DefaultChatRequestTimeout.
	// Bounded on purpose so a stuck model fails rather than hanging forever.
	RequestTimeout time.Duration
	// HTTPClient, when non-nil, is used verbatim for provider API calls. The
	// host injects a hardened transport here (go-tool-base supplies pkg/http's);
	// nil ⇒ the module builds a plain bounded client from RequestTimeout.
	HTTPClient *http.Client `json:"-"`
	// ParallelTools enables concurrent execution of multiple tool calls
	// within a single ReAct step. Disabled by default.
	ParallelTools bool
	// MaxParallelTools limits the number of tools executing concurrently.
	// Zero means use the default (5). Only effective when ParallelTools is true.
	MaxParallelTools int

	// Seed, when non-nil, requests deterministic sampling from providers
	// that support a seed parameter (currently OpenAI / OpenAI-compatible).
	// Nil — the default — omits the seed entirely so the provider samples
	// normally. A previous build hardcoded seed=0, silently pinning every
	// OpenAI request to one sampling path; set this only when you need
	// reproducible-ish completions.
	Seed *int64

	// ExecLookPath overrides exec.LookPath for the ClaudeLocal provider.
	// Nil means use the real exec.LookPath.
	ExecLookPath func(string) (string, error) `json:"-"`
	// ExecCommand overrides exec.CommandContext for the ClaudeLocal provider
	// and the update command's config re-init.
	// Nil means use the real exec.CommandContext.
	ExecCommand func(context.Context, string, ...string) *exec.Cmd `json:"-"`
	// GenaiNewClient overrides the Gemini client constructor for testing.
	// Must be func(context.Context, *genai.ClientConfig) (*genai.Client, error).
	// Nil means use the real genai.NewClient.
	GenaiNewClient any `json:"-"`

	// UsageObserver, when non-nil, is invoked once per provider round-trip
	// with that round-trip's token usage. A ReAct tool-calling loop fires it
	// once per step. This is the opt-in hook for emitting a telemetry event,
	// metric, or log line; the chat client never depends on a telemetry
	// collector. Providers that do not report usage still fire the observer
	// with a Known == false Usage. The callback runs synchronously on the
	// calling goroutine, so keep it fast and non-blocking.
	UsageObserver func(Usage) `json:"-"`

	// AllowInsecureBaseURL permits HTTP (non-HTTPS) BaseURLs. This is
	// exclusively for tests that point at an httptest.Server. Production
	// callers must leave this false. The field is tagged json:"-" so
	// config files cannot enable it.
	AllowInsecureBaseURL bool `json:"-"`
}

Config holds configuration for a chat client.

type ConversationStore

type ConversationStore interface {
	// Save writes a snapshot to the store.
	Save(ctx context.Context, snapshot *Snapshot) error
	// Load retrieves a snapshot by ID.
	Load(ctx context.Context, id string) (*Snapshot, error)
	// List returns summaries of all stored snapshots.
	List(ctx context.Context) ([]SnapshotSummary, error)
	// Delete removes a snapshot by ID.
	Delete(ctx context.Context, id string) error
}

ConversationStore persists and retrieves conversation snapshots.

func NewFileStore

func NewFileStore(fs afero.Fs, dir string, opts ...FileStoreOption) (ConversationStore, error)

NewFileStore creates a ConversationStore that persists snapshots as JSON files. Files are stored in dir with 0600 permissions. The directory is created with 0700 permissions if it doesn't exist.

Example
package main

import (
	"github.com/spf13/afero"

	"gitlab.com/phpboyscout/go/chat"
)

func main() {
	// Create a FileStore for persisting chat conversation snapshots.
	store, err := chat.NewFileStore(afero.NewMemMapFs(), "/conversations")
	if err != nil {
		return
	}

	// Save, Load, List, Delete snapshots
	_ = store
}
Example (WithEncryption)
package main

import (
	"github.com/spf13/afero"

	"gitlab.com/phpboyscout/go/chat"
)

func main() {
	// Encrypt stored snapshots with AES-256-GCM (key must be 32 bytes).
	key := make([]byte, 32) // In real usage, use a secure key source

	store, err := chat.NewFileStore(afero.NewMemMapFs(), "/conversations",
		chat.WithEncryption(key),
	)
	if err != nil {
		return
	}

	_ = store
}

type CredentialConfig

type CredentialConfig struct {
	Env      string `mapstructure:"env"`
	Keychain string `mapstructure:"keychain"`
	Key      string `mapstructure:"key"`
	// Lookup resolves the Keychain reference. Injected by the host (nil ⇒ the
	// keychain step is skipped); never decoded from config.
	Lookup KeychainLookup `mapstructure:"-" json:"-"`
}

CredentialConfig is the package-owned shape for provider api credentials.

func (CredentialConfig) IsZero

func (c CredentialConfig) IsZero() bool

IsZero reports whether no credential config values were supplied.

type FailoverDecision

type FailoverDecision int

FailoverDecision is the outcome of classifying a provider error.

const (
	// FailoverFatal — do not advance; return the error to the caller.
	FailoverFatal FailoverDecision = iota
	// FailoverNext — the active provider failed transiently or is
	// unavailable; advance to the next provider.
	FailoverNext
)

type FailoverPolicy

type FailoverPolicy interface {
	Classify(err error) FailoverDecision
}

FailoverPolicy classifies a provider error into a FailoverDecision.

Implementations MUST NOT log the error's message directly. A policy only decides whether to advance; the composite logs a single coarse WARN per transition (provider names + a "status"/"network" reason, never the raw error), and reduces any endpoint detail to the host only.

var DefaultFailoverPolicy FailoverPolicy = defaultFailoverPolicy{}

DefaultFailoverPolicy advances on transient/unavailable conditions (HTTP 408, 429, 5xx, and network errors) and treats everything else — auth, bad request, unknown model, caller cancellation, and local-CLI (claude-local) failures — as fatal so an operator-fixable problem surfaces instead of being masked.

See the resolved open questions in docs/development/specs/2026-06-21-chat-provider-fallback.md (OQ-1: a claude-local non-zero exit is fatal).

type FallbackConfig

type FallbackConfig struct {
	Enabled   bool       `mapstructure:"enabled"`
	Providers []Provider `mapstructure:"providers"`
}

FallbackConfig is the package-owned shape for GTB's ai.fallback.* config.

type FallbackOption

type FallbackOption func(*fallbackConfig)

FallbackOption configures a composite fallback client.

func WithFailoverPolicy

func WithFailoverPolicy(policy FailoverPolicy) FallbackOption

WithFailoverPolicy overrides the error-classification policy (default DefaultFailoverPolicy).

func WithFallbackLogger

func WithFallbackLogger(log *slog.Logger) FallbackOption

WithFallbackLogger sets the logger used for the single WARN line per failover transition. Defaults to a no-op logger; NewFallbackFromConfigs wires the tool's logger automatically.

func WithOnFailover

func WithOnFailover(fn func(from, to Provider)) FallbackOption

WithOnFailover registers an observability hook invoked on each provider transition. It is called after the WARN log, before the new provider is used.

func WithStrictToolContext

func WithStrictToolContext() FallbackOption

WithStrictToolContext makes failover fail fast once a tool call has executed in the current conversation, instead of advancing with a lossy text-only transcript replay (resolved OQ-2).

type FileStoreOption

type FileStoreOption func(*fileStoreConfig)

FileStoreOption configures a FileStore.

func WithEncryption

func WithEncryption(key []byte) FileStoreOption

WithEncryption enables AES-256-GCM encryption for stored snapshots. The key must be exactly 32 bytes and must come from a cryptographically secure source. Use GenerateEncryptionKey to generate one.

func WithLogger

func WithLogger(log *slog.Logger) FileStoreOption

WithLogger attaches a logger used for diagnostic DEBUG-level events (e.g. when [FileStore.List] skips a file whose name is not a canonical snapshot identifier). Defaults to a noop logger.

type HTTPStatusExtractor

type HTTPStatusExtractor func(err error) (status int, ok bool)

HTTPStatusExtractor pulls an HTTP status code out of a provider SDK error, unwrapping the cockroachdb/errors layers the providers add. The second return is false when the error is not this provider's status-bearing type.

Each provider module registers one via RegisterStatusExtractor in its init() so the chat core can classify failover decisions without importing any vendor SDK. This mirrors the RegisterProvider registry.

type KeychainLookup

type KeychainLookup func(ctx context.Context, service, account string) (string, error)

KeychainLookup resolves an OS-keychain (or remote secret-store) reference of the form "service/account" to its secret value. The host application injects it — go-tool-base wires pkg/credentials.Retrieve — so the chat core needs no keychain backend of its own. A nil lookup means the keychain resolution step is skipped (the caller falls through to the next credential source).

type Media

type Media struct {
	MIMEType string
	Data     []byte
}

Media is one input attachment (an image, PDF, or supported A/V clip) sent alongside a text prompt to a multimodal model (spec 2026-07-05-chat-multimodal). Data holds the raw bytes. MIMEType is an OPTIONAL cross-check: the type is always sniffed from Data and the sniffed type is authoritative (and what is sent); when MIMEType is set it must match the sniffed family or the attachment is rejected, so a declared type can never smuggle disguised content past the safety filter.

type PersistentChatClient

type PersistentChatClient interface {
	ChatClient
	// Save captures the current conversation state as an immutable snapshot.
	// The snapshot includes provider-specific messages as opaque JSON, tool
	// metadata (without handlers), and configuration. Tokens are never saved.
	Save() (*Snapshot, error)
	// Restore replaces the current conversation state with a previously saved
	// snapshot. The snapshot's Provider must match the client's provider.
	// After restore, tools must be re-registered via SetTools with live handlers.
	Restore(snapshot *Snapshot) error
}

PersistentChatClient extends ChatClient with the ability to save and restore conversation state. Discover via type assertion (same pattern as StreamingChatClient):

if pc, ok := client.(chat.PersistentChatClient); ok {
    snapshot, err := pc.Save()
}

ClaudeLocal does not implement this interface — it delegates to an external subprocess and has no internal message state to persist.

Example
package main

import (
	"context"
)

func main() {
	// Discover persistence support via type assertion:
	//
	//   client, _ := chat.New(ctx, chat.Settings{Config: cfg, Logger: log})
	//   if pc, ok := client.(chat.PersistentChatClient); ok {
	//       snapshot, _ := pc.Save()
	//       // ... store snapshot ...
	//       pc.Restore(snapshot)
	//   }
	//
	// ClaudeLocal does not implement PersistentChatClient.

	_ = context.Background()
}

type Provider

type Provider string

Provider defines the AI service provider.

const (
	// ProviderOpenAI uses OpenAI's API.
	ProviderOpenAI Provider = "openai"
	// ProviderOpenAICompatible uses any OpenAI-compatible API endpoint (e.g. Ollama, Groq).
	ProviderOpenAICompatible Provider = "openai-compatible"
	// ProviderClaude uses Anthropic's Claude API.
	ProviderClaude Provider = "claude"
	// ProviderClaudeLocal uses a locally installed claude CLI binary.
	ProviderClaudeLocal Provider = "claude-local"
	// ProviderGemini uses Google's Gemini API.
	ProviderGemini Provider = "gemini"
)

type ProviderFactory

type ProviderFactory func(ctx context.Context, settings Settings) (ChatClient, error)

ProviderFactory creates a ChatClient for a named provider. Register implementations via RegisterProvider in an init() function to allow external packages to add providers without modifying this file.

type ResolvedMedia

type ResolvedMedia struct {
	MIMEType string
	Data     []byte
}

ResolvedMedia is an attachment that has passed the safety filter: its type is known, allowlisted, and provider-accepted, and it is ready to map to a provider request. Providers consume []ResolvedMedia, never raw Media.

func ValidateMediaSet

func ValidateMediaSet(provider Provider, media []Media) ([]ResolvedMedia, error)

ValidateMediaSet runs the safety choke point over a request's attachments before any network call (spec §5): it enforces the provider's media capability, the per-attachment sniff + declared cross-check + allowlist, the provider's type support, and the size/count caps. It returns the resolved, ready-to-send attachments or a typed error.

type RuntimeConfig

type RuntimeConfig struct {
	Provider       Provider       `mapstructure:"provider"`
	RequestTimeout time.Duration  `mapstructure:"request_timeout"`
	Fallback       FallbackConfig `mapstructure:"fallback"`
}

RuntimeConfig is the package-owned shape for GTB's ai.* config section.

type Settings

type Settings struct {
	Config Config
	Logger *slog.Logger
}

Settings contains the package-owned construction dependencies for a chat client. Config carries provider behaviour; Logger is optional and defaults to a no-op logger.

type Snapshot

type Snapshot struct {
	// ID uniquely identifies this snapshot.
	ID string `json:"id"`
	// Provider identifies which chat provider created this snapshot.
	Provider Provider `json:"provider"`
	// Model is the AI model used in the conversation.
	Model string `json:"model"`
	// SystemPrompt is the system instruction active at snapshot time.
	SystemPrompt string `json:"system_prompt,omitempty"`
	// Messages contains provider-specific message history as opaque JSON.
	// The format varies by provider — do not parse or modify directly.
	Messages json.RawMessage `json:"messages"`
	// Tools captures tool metadata (name, description, parameters) without
	// handlers. After restoring, call SetTools to re-register live handlers.
	Tools []ToolSnapshot `json:"tools,omitempty"`
	// Metadata holds arbitrary key-value pairs for consumer use.
	Metadata map[string]string `json:"metadata,omitempty"`
	// CreatedAt is when this snapshot was taken.
	CreatedAt time.Time `json:"created_at"`
	// Version is the snapshot format version for forward compatibility.
	Version int `json:"version"`
}

Snapshot is an immutable point-in-time capture of a conversation.

func NewSnapshot

func NewSnapshot(provider Provider, model, systemPrompt string, messages json.RawMessage, tools map[string]Tool, metadata map[string]string) *Snapshot

NewSnapshot creates a Snapshot with a new UUID and the current timestamp.

The ID is generated via uuid.New so that it always satisfies the canonical-UUID contract enforced by [FileStore.Save], [FileStore.Load], and [FileStore.Delete]. Constructing a Snapshot struct directly and populating ID by hand is supported but discouraged — any value that fails ValidateSnapshotID will be rejected by the store. If you do need to accept a caller-supplied ID (for example, reconstructing a snapshot parsed from an external payload), call ValidateSnapshotID at the boundary rather than relying on Save/Load/Delete to reject it later.

Example
package main

import (
	"encoding/json"

	"gitlab.com/phpboyscout/go/chat"
)

func main() {
	snap := chat.NewSnapshot(
		chat.ProviderClaude,
		"claude-3-5-sonnet",
		"You are a helpful assistant.",
		json.RawMessage(`[{"role":"user","content":"hello"}]`),
		nil,
		map[string]string{"session": "demo"},
	)

	_ = snap.ID        // UUID
	_ = snap.CreatedAt // timestamp
}

type SnapshotSummary

type SnapshotSummary struct {
	ID           string    `json:"id"`
	Provider     Provider  `json:"provider"`
	Model        string    `json:"model"`
	CreatedAt    time.Time `json:"created_at"`
	MessageCount int       `json:"message_count"`
}

SnapshotSummary is a lightweight view of a snapshot for listing without loading the full message history.

type StreamCallback

type StreamCallback func(event StreamEvent) error

StreamCallback receives streaming events. Return a non-nil error to cancel the stream.

type StreamEvent

type StreamEvent struct {
	// Type indicates the kind of event.
	Type StreamEventType

	// Delta contains the text fragment for EventTextDelta events.
	Delta string

	// ToolCall contains tool call information for EventToolCallStart/EventToolCallEnd events.
	ToolCall *StreamToolCall

	// Error contains error information for EventError events.
	Error error
}

StreamEvent represents a single event in a streaming response.

type StreamEventType

type StreamEventType int

StreamEventType identifies the kind of stream event.

const (
	// EventTextDelta is a partial text response fragment.
	EventTextDelta StreamEventType = iota
	// EventToolCallStart indicates a tool call has begun execution.
	EventToolCallStart
	// EventToolCallEnd indicates a tool call has completed execution.
	EventToolCallEnd
	// EventComplete indicates the stream has finished successfully.
	EventComplete
	// EventError indicates an error occurred during streaming.
	EventError
)

type StreamToolCall

type StreamToolCall struct {
	// ID is the provider-assigned identifier for the tool call.
	ID string
	// Name is the tool name.
	Name string
	// Arguments is the complete JSON argument payload (only populated on EventToolCallEnd).
	Arguments string
	// Result is the tool execution result (only populated on EventToolCallEnd).
	Result string
}

StreamToolCall contains information about a tool call within a stream.

type StreamingChatClient

type StreamingChatClient interface {
	ChatClient

	// StreamChat sends a message and streams the response via callback.
	// The callback is invoked for each event in the stream. If the callback
	// returns a non-nil error, the stream is cancelled and that error is returned.
	// The return value is the complete assembled response text (concatenation of
	// all EventTextDelta fragments) or an error if streaming failed.
	// Tool calls are handled internally via the same ReAct loop as Chat(). If
	// Config.ParallelTools is enabled, multiple tool calls are executed concurrently.
	StreamChat(ctx context.Context, prompt string, callback StreamCallback, media ...Media) (string, error)
}

StreamingChatClient extends ChatClient with streaming support. Implementations that support streaming implement this interface in addition to ChatClient. Discover support via type assertion:

if streamer, ok := client.(chat.StreamingChatClient); ok {
    result, err := streamer.StreamChat(ctx, "prompt", callback)
}

type Tool

type Tool struct {
	Name        string                                                       `json:"name"`
	Description string                                                       `json:"description"`
	Parameters  *jsonschema.Schema                                           `json:"parameters"`
	Handler     func(ctx context.Context, args json.RawMessage) (any, error) `json:"-"`
}

Tool represents a function that the AI can call.

type ToolCall

type ToolCall struct {
	Name  string
	Input json.RawMessage
}

ToolCall represents a single tool invocation request.

type ToolResult

type ToolResult struct {
	Name   string
	Result string
}

ToolResult holds the result of a single tool execution.

func DispatchToolExecution

func DispatchToolExecution(ctx context.Context, l *slog.Logger, tools map[string]Tool, calls []ToolCall, parallelTools bool, maxParallelTools int) []ToolResult

DispatchToolExecution runs tool calls sequentially or in parallel depending on parallelTools and the number of calls. It is the shared dispatch entry point for all provider ReAct loops.

func ExecuteToolsParallel

func ExecuteToolsParallel(ctx context.Context, l *slog.Logger, tools map[string]Tool, calls []ToolCall, maxConcurrency int) []ToolResult

ExecuteToolsParallel executes multiple tool calls concurrently, bounded by maxConcurrency. Results are returned in the same order as the input calls. If maxConcurrency is zero or negative, it defaults to 5.

type ToolSnapshot

type ToolSnapshot struct {
	Name        string             `json:"name"`
	Description string             `json:"description"`
	Parameters  *jsonschema.Schema `json:"parameters,omitempty"`
}

ToolSnapshot captures tool metadata without the handler function.

type Usage

type Usage struct {
	// InputTokens is the number of prompt/input tokens consumed.
	InputTokens int
	// OutputTokens is the number of completion/output tokens produced.
	OutputTokens int
	// TotalTokens is the sum of input and output tokens. When a provider
	// supplies its own total it is preserved; otherwise it is computed as
	// InputTokens + OutputTokens.
	TotalTokens int
	// CachedTokens is the number of input tokens served from a provider-side
	// prompt cache, when reported. Zero when the provider does not expose it.
	CachedTokens int
	// ReasoningTokens is the number of tokens spent on internal reasoning
	// ("thinking") output, when reported. Zero when not exposed.
	ReasoningTokens int
	// Known reports whether the provider supplied token counts for this call.
	// False indicates the counts are not authoritative (e.g. ProviderClaudeLocal).
	Known bool
}

Usage reports the token consumption of one or more provider round-trips in a provider-neutral shape. Tools built on GTB use it to observe and cost their LLM calls without coupling to any one provider's SDK.

Token counts are summed across every provider round-trip made within a single Chat, Ask, or StreamChat call — a ReAct tool-calling loop makes one round-trip per step, and the usage of every step is accumulated. The Usage() accessor on a client returns the cumulative total across the lifetime of that client instance; see ChatClient.Usage for details.

Known reports whether the provider supplied token counts. Providers that do not expose usage (notably ProviderClaudeLocal, which wraps the claude CLI and returns no token data) report a zero-valued Usage with Known == false. Always check Known before treating the counts as authoritative.

func NewUsage

func NewUsage(input, output, total, cached, reasoning int) Usage

NewUsage builds a Usage from raw provider counts, computing TotalTokens when the provider did not supply one (total <= 0) and marking the result Known.

func (Usage) Add

func (u Usage) Add(other Usage) Usage

Add returns the element-wise sum of two Usage values. A result is Known if either operand is Known. TotalTokens is summed directly so a provider-supplied total (which may differ from input+output) is preserved across accumulation.

type UsageTracker

type UsageTracker struct {
	// contains filtered or unexported fields
}

UsageTracker accumulates per-round-trip usage for a single client instance and fans each round-trip out to an optional observer. It is embedded by every provider implementation. The zero value is ready to use.

It is safe for concurrent use, but note that ChatClient implementations are not themselves safe for concurrent use; the mutex guards only against an observer reading Usage() from another goroutine while a call is in flight.

func (*UsageTracker) RecordUsage

func (t *UsageTracker) RecordUsage(u Usage)

RecordUsage accumulates one round-trip's usage into the lifetime total and, if an observer is configured, invokes it with that round-trip's usage. Passing a usage with Known == false (e.g. a provider that reports nothing) still fires the observer so callers can distinguish "no usage" from "no call".

func (*UsageTracker) SetObserver

func (t *UsageTracker) SetObserver(observer func(Usage))

SetObserver wires a per-round-trip usage observer onto the tracker. Provider modules call it during construction to forward Config.UsageObserver; the observer field stays unexported so it is only settable through this method.

func (*UsageTracker) Usage

func (t *UsageTracker) Usage() Usage

Usage returns the cumulative token usage across every provider round-trip made by this client instance since construction. It is promoted onto each provider type via embedding to satisfy ChatClient.Usage.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL