Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,27 @@

All notable changes to GopherAgent are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [Semantic Versioning](https://semver.org/) — pre-1.0, breaking API changes only require a minor bump.

## [v0.40.0] — 2026-08-09

### Added

- **`agent.CapabilityProvider` and `agent.LLMCapabilities` — an adapter can now say what it can actually put on the wire.** `LLMProvider` is a single method, and nothing on it reported whether the adapter honours `history.MediaPart` values of type `PartImage`. That is fine for a text agent and a hazard for a multimodal one: handed a text-only provider, a call that attaches images does not fail — it returns a schema-valid, confident answer formed without the model ever seeing them, and no log line distinguishes that from a working call. Consumers now assert the optional interface and reject at construction. Three rules make the signal worth trusting, and all three are enforced in-tree rather than merely documented. **Absence means unknown, not false**, so a provider that makes no claim stays unclassified and the caller decides how strict to be — which is why `llmfake.ScriptedProvider`, the one in-tree provider that honours nothing, declares the zero value explicitly instead of staying silent. **Decorators must not lose or invent the claim**: `otelllm.NewProvider` picks its concrete type at construction, forwarding the wrapped report when there is one and declining to implement the interface when there is not, so enabling tracing never downgrades a known capability to unknown. **A multiplexer answers conservatively**: `llm.RouterProvider` reports the intersection over its fallback and every route, because the route is chosen from the conversation and is unknown until the call runs, and one undeclared member collapses the report — under-reporting costs a spurious rejection at construction, over-reporting costs a wrong answer at run time. The report describes the *adapter*, not the model: a gateway fronting both text-only and multimodal catalogues answers for the transport it speaks, and selecting a model that honours it stays the caller's job. (`pkg/agent/loop_stream.go`, `pkg/llm/anthropic`, `pkg/llm/openai`, `pkg/llm/gemini`, `pkg/llm/llmfake`, `pkg/llm/router.go`, `pkg/telemetry/otelllm`)
- **`agent.TokenUsage.CostUSD` — a provider that knows what it charged can now say so, and no `PriceTable` is required.** Cost was estimated in exactly one way: multiply rolled-up tokens by a static `PriceTable`. Backends that route across vendors return the real per-request charge, which a table cannot reproduce — it cannot see which model the gateway picked, nor which cache discounts applied — and the rollup was gated on the table being non-nil, so the exact figure was discarded for precisely the adopters who configure no table *because* their provider is already exact. Providers now set `CostUSD` on the `TokenUsage` they return, and dollars resolve **per call before being summed**: a call that reported a charge contributes it verbatim, any other call is estimated from the table. Resolving per call rather than once over the rollup is what keeps a Run that mixes backends honest — summing table rates over the aggregate token count would bill a gateway's tokens a second time. `RunCostEvent.Usage.CostUSD` carries the reported portion alone, so comparing it against `USD` says how much of the total was billed rather than estimated. A negative charge is treated as *not reported* rather than accumulated, matching `PriceTable.Compute`'s existing clamp, so a buggy provider cannot surface a credit through either the run rollup or `BudgetTracker`. (`pkg/agent/cost.go`, `pkg/agent/llm_call.go`, `pkg/agent/budget.go`, `pkg/agent/event_types.go`)
- **`openai.WithBaseURL` and `openai.WithHTTPHeader` — transport options that work on every client in the package.** `WithBaseURL` targets any OpenAI-compatible endpoint from `New` itself, making `NewCompat` the same call with `baseURL` required rather than optional. `WithHTTPHeader` adds attribution or routing headers that gateways accept; headers are applied after the SDK builds the request, so a name the SDK already sets is overwritten — passing `Authorization` deliberately replaces the bearer token. Base URLs are validated at construction: absolute, HTTP(S), no embedded credentials, query, or fragment, with trailing slashes normalized because the SDK appends its own path segment. Every constructor routes through one client builder, so validation and header injection cannot drift apart between them. (`pkg/llm/openai/client.go`)

### Changed (breaking)

- **`openai.Option` is an interface, not `func(*Provider)`.** It has to be, for one call to accept both sampling and transport settings — `New(key, model, WithBaseURL(u), WithTemperature(0.2))`. The constructors that return options are unchanged, so `openai.WithTemperature(0)` and friends still compile; only code that declared or converted the bare func type is affected.
- **`openai.WithHTTPHeader` returns `ClientOption` rather than `Option`.** `ClientOption` is a strict subset of `Option`, so anywhere it was already passed keeps working, and it now also reaches the embedder, vision analyzer, and summary provider.
- **`openai.NewEmbedder`, `NewVisionAnalyzer`, and `NewSummaryProvider` take `...ClientOption`.** Existing two-argument calls are source-compatible; only code that assigned one of these to a function-typed variable is affected. `ClientOption` deliberately excludes the sampling options, so passing `WithTemperature` to an embedder is a compile error rather than a setting that silently does nothing.
- **`RunCostEvent` now fires without a `PriceTable` when a provider reported a charge.** It previously required a table. An adopter with neither a table nor a cost-reporting provider still sees no event, so the silent case is unchanged; hosts that render every event should expect this one on provider-priced runs. `USD` is the per-call resolved total rather than a single table computation over the rollup.
- **`openai` constructor errors now carry a `openai: <Constructor>: ` prefix.** Messages such as `OPENAI_API_KEY is not set in environment` became `openai: New: OPENAI_API_KEY is not set in environment`, matching the convention used elsewhere. Code matching on the old strings needs updating; `errors.Is` users are unaffected.

### Fixed

- **The embedder, vision analyzer, and summary provider no longer force a call to `api.openai.com`.** All three built their client with no configuration, so neither a base URL nor headers could reach them, and `NewCompat` configured the chat provider alone. Someone wiring a deliberately local or gateway-only stack got chat from their chosen endpoint while these three silently called OpenAI directly — an unexpected egress of user text to a third party, and a demand for a key the operator may not hold. The failure was invisible: each call succeeded against the wrong host. All three now accept `WithBaseURL`. (`pkg/llm/openai/embedder_openai.go`, `pkg/llm/openai/vision.go`, `pkg/llm/openai/summary_provider.go`)
- **`history.Message` states that the `Content` fallback is for an empty `Parts` only.** The precedence between the two fields was documented, but not the obligation it implies: an adapter that cannot render a *populated* `Parts` must fail rather than quietly answer from `Content`, because a caller that sent an image and got back a fluent reply has no way to tell the model never saw it. (`pkg/history/types.go`)

## [v0.39.0] — 2026-08-08

### Added
Expand Down Expand Up @@ -599,6 +620,7 @@ Multi-user, long-running, audit-friendly chat surface — the foundation for sid
- README section on the permission flow — documents `RequiresConfirmation` × `ConfirmHITL` × `Permissions` interaction.
- Enum struct tag support in `tools.SchemaFor[T]()` — emit values into JSON-Schema's `enum` array so providers reject invalid values upstream.

[v0.40.0]: https://github.com/hung12ct/gopheragent/releases/tag/v0.40.0
[v0.39.0]: https://github.com/hung12ct/gopheragent/releases/tag/v0.39.0
[v0.38.0]: https://github.com/hung12ct/gopheragent/releases/tag/v0.38.0
[v0.37.0]: https://github.com/hung12ct/gopheragent/releases/tag/v0.37.0
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ go get github.com/hung12ct/gopheragent
the bookkeeping did not" instead of forcing a turn into success or failure.
- **Custom tools** — one interface, schema derived from a Go struct; a middleware
chain for logging, timing, rate limiting, and tracing.
- **Multi-provider** — OpenAI, Anthropic, Gemini, Vertex, and OpenAI-compatible
- **Multi-provider** — OpenAI, Anthropic, Gemini, Vertex, OpenRouter, and OpenAI-compatible
backends, each in its own subpackage; multi-model routing; sampling controls.
- **Sub-agents & async** — sub-agent streaming, conversation forking, background
workers, first-class task tracking.
Expand Down
29 changes: 29 additions & 0 deletions docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,35 @@ loop.OnEvent(bt.Handler())
http.Handle("/metrics", agentmetrics.Handler(bt))
```

## Per-Run cost

`RunCostEvent` fires once per Run — on every terminal path, not just the
success one — with rolled-up tokens and a dollar total. There are two ways it
gets a figure, and they compose:

```go
// Estimate from your own rates, for providers that bill silently.
loop := agent.New(sm, reg, provider,
agent.WithPriceTable(agent.PriceTable{
"claude-sonnet-4-6": {InputPerMTokens: 3, OutputPerMTokens: 15},
}, "claude-sonnet-4-6"))
```

A provider that knows what it charged reports it directly instead, by setting
`CostUSD` on the `TokenUsage` it returns from `GenerateStream`. Gateways that
route across vendors typically do. No `PriceTable` is needed in that case — a
static table could not price them correctly anyway, since it cannot see which
model the gateway picked or which cache discounts applied.

Dollars resolve **per call**, then sum: a call that reported `CostUSD`
contributes that exact charge, and every other call is estimated from the
table. A Run that mixes both bills each call the best way available. Read
`RunCostEvent.Usage.CostUSD` to see how much of `USD` was billed rather than
estimated — equal means the total is exact, zero means it is all estimate.

With neither a table nor a reporting provider, no event fires; raw counts are
still on the wire as `UsageEvent`.

## Notes

- **Zero-cost when off:** no tracer/meter → decorators return the wrapped
Expand Down
68 changes: 67 additions & 1 deletion docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,76 @@ not statically link the other vendors' SDKs:
| Anthropic | `pkg/llm/anthropic` | `anthropic.New(key, model)` | claude-sonnet, claude-opus, ... |
| Google Gemini | `pkg/llm/gemini` | `gemini.New(key, model)` | gemini-2.5-flash, gemini-2.5-pro, ... |
| Vertex AI (Gemini) | `pkg/llm/gemini` | `gemini.NewVertex(project, location, model)` | Vertex-hosted Gemini via ADC |
| OpenAI-compatible | `pkg/llm/openai` | `openai.NewCompat(key, model, baseURL)` | Ollama, Groq, vLLM, Together, ... |
| OpenAI-compatible | `pkg/llm/openai` | `openai.NewCompat(key, model, baseURL)` | OpenRouter, Ollama, Groq, vLLM, Together, ... |

All providers auto-discover API keys from environment variables when key is `""`.

OpenRouter uses the existing OpenAI-compatible provider; it does not need a
separate adapter:

```go
p, err := openai.NewCompat(key, "openai/gpt-4o", "https://openrouter.ai/api/v1",
openai.WithHTTPHeader("HTTP-Referer", "https://example.com"),
openai.WithHTTPHeader("X-Title", "my-agent"),
)
```

Compatible base URLs must be absolute HTTP(S) URLs without embedded
credentials, query parameters, or fragments. Use HTTPS for remote gateways;
plain HTTP remains available for local Ollama/vLLM development.

### Point the non-chat clients at the same endpoint

`NewCompat` configures the **chat provider only**. The embedder, vision
analyzer, and summary provider take the same transport options, and without
them they call `api.openai.com` — on a deliberately local deployment that is an
unexpected egress and a key you may not hold. Pass `WithBaseURL` to each:

```go
base := openai.WithBaseURL("http://localhost:11434/v1")

chat, _ := openai.New(key, "llama3", base, openai.WithTemperature(0.2))
emb, _ := openai.NewEmbedder(key, "nomic-embed-text", base)
vis, _ := openai.NewVisionAnalyzer(key, "llava", base)
sum, _ := openai.NewSummaryProvider(key, "llama3", base)
```

`WithBaseURL` and `WithHTTPHeader` work on all four. The sampling options
(`WithTemperature`, `WithTopP`, `WithSeed`) apply to the chat provider only,
and passing one to `NewEmbedder` is a compile error rather than a setting that
silently does nothing.

## Provider capabilities

`agent.CapabilityProvider` lets a consumer that requires image input or
structured output reject an unsuitable provider at construction, instead of
discovering the gap from a confident, wrong answer. The OpenAI, Anthropic, and
Gemini adapters report both; `llmfake.ScriptedProvider` reports neither, since
it replays a script without ever reading a message's media parts.

```go
if c, ok := provider.(agent.CapabilityProvider); ok && !c.Capabilities().ImageInput {
return fmt.Errorf("judge requires a multimodal provider")
}
```

Two rules keep that check meaningful:

- **Absence means unknown, not false.** A provider that does not implement the
interface makes no claim; the caller decides how strict to be.
- **Decorators forward.** `otelllm.NewProvider` passes the wrapped provider's
report through, and does not implement the interface when the wrapped
provider doesn't — so enabling tracing never erases the signal.
`llm.RouterProvider` reports the intersection over its fallback and every
route, because the route is chosen from the conversation and is unknown
until the call runs; one undeclared member collapses the report to "supports
nothing".

It does not replace model discovery: gateways such as OpenRouter expose
text-only and multimodal models through the same adapter, so applications must
still verify the selected model's live metadata when correctness or spend
depends on it.

## Multi-model routing

`llm.RouterProvider` dispatches calls across several backing providers behind a
Expand Down
20 changes: 20 additions & 0 deletions pkg/agent/budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,19 @@ func (bt *BudgetTracker) Handler() EventHandler {
return
}
delta := p.Usage
// Negative charges are dropped rather than accumulated, so a
// buggy provider cannot refund a session's spend through the
// usage stream. Same clamp as runCostAcc.add.
cost := delta.CostUSD
if cost < 0 {
cost = 0
}
bt.mu.Lock()
cur := bt.usage[sessionKey]
cur.PromptTokens += delta.PromptTokens
cur.CompletionTokens += delta.CompletionTokens
cur.TotalTokens += delta.TotalTokens
cur.CostUSD += cost
bt.usage[sessionKey] = cur
bt.mu.Unlock()
}
Expand Down Expand Up @@ -144,6 +152,7 @@ func (bt *BudgetTracker) Rewind(sessionKey string, refund TokenUsage) {
cur.PromptTokens = subFloorZero(cur.PromptTokens, refund.PromptTokens)
cur.CompletionTokens = subFloorZero(cur.CompletionTokens, refund.CompletionTokens)
cur.TotalTokens = subFloorZero(cur.TotalTokens, refund.TotalTokens)
cur.CostUSD = subFloorZeroFloat(cur.CostUSD, refund.CostUSD)
bt.usage[sessionKey] = cur
}

Expand All @@ -155,3 +164,14 @@ func subFloorZero(a, b int) int {
}
return a - b
}

// subFloorZeroFloat is subFloorZero for the dollar field. A separate
// function rather than a generic over cmp.Ordered: the int and float
// counters are refunded under the same rule but a shared signature would
// invite callers to floor arbitrary numeric state through here.
func subFloorZeroFloat(a, b float64) float64 {
if b >= a {
return 0
}
return a - b
}
55 changes: 43 additions & 12 deletions pkg/agent/cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,23 +53,49 @@ type runCostKey struct{}
// RunCostEvent right before DoneEvent. Concurrency: callLLM is single-
// threaded per Run today, but the mutex keeps the contract safe in
// case a future speculation path emits Usage from a worker goroutine.
//
// pt and model are captured at install time and never mutated, so add
// can resolve dollars per call without reaching back into the loop.
type runCostAcc struct {
mu sync.Mutex
usage TokenUsage
usd float64

pt PriceTable
model string
}

func (a *runCostAcc) add(u TokenUsage) {
// A negative charge is nonsense and is treated as "not reported",
// matching PriceTable.Compute's clamp: neither should let a buggy
// provider surface a credit.
cost := u.CostUSD
if cost < 0 {
cost = 0
}
a.mu.Lock()
a.usage.PromptTokens += u.PromptTokens
a.usage.CompletionTokens += u.CompletionTokens
a.usage.TotalTokens += u.TotalTokens
a.usage.CostUSD += cost
// Resolve dollars per call rather than once over the rollup. A Run
// that mixes backends — a router fanning out to a gateway that
// prices its own calls and a vendor that does not — would otherwise
// charge table rates over tokens the provider already billed, or
// drop the estimate for the calls that reported nothing.
if cost > 0 {
a.usd += cost
} else {
a.usd += a.pt.Compute(a.model, u)
}
a.mu.Unlock()
}

func (a *runCostAcc) snapshot() TokenUsage {
// snapshot returns the accumulated usage and the resolved dollar total.
func (a *runCostAcc) snapshot() (TokenUsage, float64) {
a.mu.Lock()
defer a.mu.Unlock()
return a.usage
return a.usage, a.usd
}

func withRunCostAcc(ctx context.Context, acc *runCostAcc) context.Context {
Expand All @@ -88,17 +114,22 @@ func runCostAccFromContext(ctx context.Context) *runCostAcc {
// ctx, emitCost := al.installRunCostAccumulator(ctx, sessionKey, streamChan)
// defer emitCost()
//
// When PriceTable is nil, the returned ctx is unchanged and emitCost
// is a no-op closure — zero allocation in the hot path beyond the
// branch on PriceTable. Every Run entry point that drives
// iterateMessages (runLogicLoop, continueLogicLoop) must call this so
// MaxIters / MaxToolCallsPerSession / fatal-error terminal paths all
// emit the cost rollup, not just the final-answer success path.
// The accumulator is installed unconditionally, including when
// PriceTable is nil. It used to be gated on the table, which silently
// dropped the exact figure reported by providers that bill per call —
// precisely the adopters who configure no table because they do not
// need to estimate. Whether a provider reports a cost is not knowable
// before the first call, and there is no config knob to gate on, so
// the cost is one small struct and one ctx value per Run against a Run
// that makes at least one network call. Deliberate, same reasoning as
// installDegradationAccumulator.
//
// Every Run entry point that drives iterateMessages (runLogicLoop,
// continueLogicLoop) must call this so MaxIters /
// MaxToolCallsPerSession / fatal-error terminal paths all emit the
// cost rollup, not just the final-answer success path.
func (al *AgentLoop) installRunCostAccumulator(ctx context.Context, sessionKey string, streamChan chan<- StreamEvent) (context.Context, func()) {
if al.PriceTable == nil {
return ctx, func() {}
}
ctx = withRunCostAcc(ctx, &runCostAcc{})
ctx = withRunCostAcc(ctx, &runCostAcc{pt: al.PriceTable, model: al.PriceModel})
return ctx, func() {
al.emitRunCostIfConfigured(ctx, sessionKey, streamChan)
}
Expand Down
Loading
Loading