LLM Client Layer
- Client
- ToolClient
- Message
- ToolDef
- ToolCall
- Usage
- OpenAIClient
- MockClient
The internal/llm package is astrolabe's thin abstraction over upstream inference providers. It exists so the rest of the pipeline — chiefly the agent loop in Agent Core — can talk to any OpenAI-compatible chat completions API (OpenAI, OpenRouter, Kimi, Qwen, Mistral, …) through two small interfaces, while tests run the exact same call sites against a scripted in-process fake. The package deliberately owns only the wire protocol: prompt construction, tool dispatch, and budget accounting live elsewhere package doc.
How it works
The two interfaces
Everything starts from Client, a one-method interface whose Chat sends a message list and returns the assistant's reply text Client. On top of it sits ToolClient, which adds ChatTools: the provider's native function-calling format, where tools go out as JSON-Schema function declarations and calls come back as structured entries on the assistant message ToolClient. This split exists because not every backend supports native tools — the agent pipeline requires ToolClient, and transports that only have plain chat (such as ACP agents) must adapt Chat to it rather than fake tool calling ToolClient doc.
ChatTools returns a full Message, not a string, and has a deliberate failure mode: a reply that carries neither text nor tool calls comes back together with ErrNoResponse, so the caller can inspect FinishReason and see why — for example "length", meaning the output budget was consumed by reasoning before any answer was produced ToolClient doc. ErrNoResponse is the package's single sentinel error, returned whenever a model response cannot be used ErrNoResponse.
The data model
The message and tool types mirror the OpenAI wire format closely but stay provider-neutral:
| Type | Role |
|---|---|
Message |
One chat message; ToolCalls on assistant turns requesting calls, ToolCallID on tool replies. Reasoning, FinishReason and Usage describe the reply as the provider returned it, for logs only — they are never sent back Message |
ToolDef |
A function the model may call: name, description, and a JSON-Schema Parameters ToolDef |
ToolCall |
One requested call; Arguments is the raw JSON text the model produced, not necessarily valid JSON ToolCall |
Usage |
Token accounting per provider report: requests, prompt/completion tokens, cached and cache-write tokens, reasoning tokens Usage |
Usage values accumulate with Add, which is how a client's running total is kept across requests Usage::Add.
OpenAIClient: one request, end to end
OpenAIClient is the production implementation OpenAIClient. Its configuration fields cover the usual knobs — BaseURL, APIKey, Model, an injectable HTTPClient, optional MaxTokens/Temperature, a ReasoningEffort passthrough (which OpenRouter-style gateways map onto their own settings), and an Attempts retry bound defaulting to 3 OpenAIClient fields.
Both Chat and ChatTools funnel into one private method, complete, which does the real work OpenAIClient::complete. Chat simply calls it without tools and errors with ErrNoResponse if the reply is empty text OpenAIClient::Chat; ChatTools additionally serialises the ToolDef catalogue into the request and explicitly asks for parallel tool calls, so one turn can carry several lookups OpenAIClient::ChatTools.
Inside complete, the flow is:
- Span setup. The request runs as a
chat {model}client span following GenAI conventions, with attributes for model, tool choice, reasoning effort, max tokens, temperature, and — when content capture is enabled — truncated input messages span setup. - Retry loop. Up to
Attemptstries; failures classified as transient (network errors, timeouts, HTTP 429/5xx) are retried with a linear backoff ofattempt × 2s, and each retry is recorded as a span event retry loop. - HTTP.
postsends one attempt toBaseURL + "/chat/completions"with a customUser-Agent(some providers behind bot protection reject Go's default header) and Bearer auth OpenAIClient::post. Network errors, timeouts, 429 and 5xx come back wrapped in an internalerrTransientmarker, which is what the retry loop checks errTransient. - Accounting and response shaping. On success the provider-reported usage is added to the client's running total (readable via
Usage()) and stamped onto the span as GenAI token metrics usage accounting. The first choice is decoded into aMessage, with reasoning text normalised across the two gateway spellings (reasoning_contentvsreasoning) and tool calls unpacked intoToolCallvalues response shaping.
An empty Choices array also yields ErrNoResponse empty choices.
MockClient: scripted tests without API keys
MockClient stands in for the model in tests. Its Handler receives the exact message list a real provider would and returns reply text; an optional ToolHandler does the same for ChatTools and returns a full assistant Message, so tests can script tool-call rounds MockClient. When ToolHandler is nil, ChatTools falls back to wrapping Handler's text in a plain assistant message MockClient::ChatTools. Every call is recorded behind a mutex and inspectable via Calls, and the NewScriptedMock helper produces a client that hands back responses in order, then fails with a caller-chosen exhaustion error (defaulting to ErrNoResponse) NewScriptedMock.
Relationships
graph TD
subgraph internal/llm
Client[`Client` interface] --> ToolClient[`ToolClient` interface]
OpenAIClient[`OpenAIClient`] --> Client
OpenAIClient --> ToolClient
MockClient[`MockClient`] --> Client
MockClient --> ToolClient
Message[`Message`] --> ToolClient
ToolDef[`ToolDef`] --> ToolClient
Usage[`Usage`] --> OpenAIClient
end
Agent["internal/agent loop"] -->|"ChatTools rounds"| ToolClient
OpenAIClient --> Telemetry["internal/telemetry spans & metrics"]The agent's ReAct loop is the primary consumer: each reasoning round is a ChatTools call carrying the tool catalogue from Agent Tools & Budget-style tool registrations, and the returned Message drives whether the agent answers or dispatches tools — see Agent Core. Telemetry-wise, every real request emits a GenAI-convention span and token metrics through Telemetry.
Reference
| Symbol | Kind | What it does | Location |
|---|---|---|---|
Client |
interface | Chat(ctx, messages) (string, error) — plain chat completion |
internal/llm/llm.go#L64-L67 |
ToolClient |
interface | Adds ChatTools with a JSON-schema tool catalogue and structured replies |
internal/llm/llm.go#L75-L83 |
Message |
struct | One chat message, plus reply-only reasoning/finish/usage fields | internal/llm/llm.go#L19-L28 |
ToolDef |
struct | Tool declaration: name, description, JSON-Schema parameters | internal/llm/llm.go#L40-L44 |
ToolCall |
struct | A model-requested function call with raw JSON arguments | internal/llm/llm.go#L48-L52 |
Usage |
struct | Token accounting; Add accumulates totals |
internal/llm/llm.go#L86-L93 |
ErrNoResponse |
var | Sentinel error for unusable model responses | internal/llm/llm.go#L61-L61 |
OpenAIClient |
struct | Production client for OpenAI-compatible /chat/completions, with retries and spans |
internal/llm/openai.go#L28-L46 |
OpenAIClient::complete |
method | One request: span, retry loop, usage accounting, response shaping | internal/llm/openai.go#L205-L359 |
OpenAIClient::post |
method | Single HTTP attempt; classifies transient failures | internal/llm/openai.go#L363-L405 |
MockClient |
struct | Scripted test double with recorded calls | internal/llm/mock.go#L15-L21 |
NewScriptedMock |
function | Builds a mock that replays responses in order | internal/llm/mock.go#L25-L38 |
Generated by astrolabe from commit ecc180b3 (tag 0.4.0).