| title | LLM provider plugin contract | |||
|---|---|---|---|---|
| description | The Provider interface — chat completion backends with optional streaming and tool support. | |||
| tags |
|
An LLM provider answers a chat completion request. The
interface is deliberately small — Chat takes a list of
messages and returns a stream of chunks. Tool-calling is
layered on top via SupportsTools(); the orchestrator in
internal/llm/chat handles prompt construction, message
history, and tool dispatch.
v1.0 ships exactly two providers in-tree:
mock— for tests; canned responses.openai— speaks the OpenAI Chat Completions wire format and routes every backend through that one shape:api.openai.com(default), Azure OpenAI (withapi_key_header=api-key), Ollama (http://127.0.0.1:11434/v1), vLLM, OpenRouter, Together, Groq, LM Studio, llama.cpp server.
Older Anthropic-native and Ollama-native providers were removed before v1.0 (audit-driven simplification): one wire format, one set of tests, one deployment story including the air-gapped path.
!!! note "Reference implementations"
- internal/plugin/llmprovider/openai.go — the
production provider (every operator-visible
backend).
- internal/plugin/llmprovider/mock.go — for tests
and as a minimal example of the interface.
Both are short and self-contained.
// internal/plugin/llmprovider/llmprovider.go
package llmprovider
type Provider interface {
Name() string
Open(ctx context.Context, cfg ProviderConfig) error
Chat(ctx context.Context, msgs []Message, tools []ToolDef) iter.Seq2[Chunk, error]
SupportsTools() bool
SupportsStreaming() bool
Close() error
}Stable identifier — "mock", "openai". Used for
telemetry labels (pg_hardstorage_llm_request_total{provider="openai"}).
Stable across versions; goes into audit-log
subject.llm_provider.
Initialise. Idempotent on repeat calls; a config change
requires a fresh Provider instance (re-construct via the
registry's builder).
type ProviderConfig struct {
Endpoint string // base URL or DSN
Model string // provider-specific model id
APIKey string // already-resolved (kms-secret expansion is upstream)
Extra map[string]any // provider-specific overrides
}APIKey is already resolved — kms-secret://
indirection happens in the chat orchestrator's config
resolver, before this method is called. Providers
should treat APIKey as opaque and never log it.
Extra is the operator's pg_hardstorage.yaml
llm.config.extra: block — a free-form map for
provider-specific options the canonical fields don't
cover (Azure deployment names, Ollama keep-alive, OpenAI
organization IDs).
Issue a chat completion. Returns a Go-1.23 range-over-
function iterator that yields Chunk values until
Done == true (success) or an error (terminal failure).
The orchestrator iterates:
for chunk, err := range provider.Chat(ctx, msgs, tools) {
if err != nil { return err }
// ... stream to renderer, accumulate text, dispatch tools ...
if chunk.Done {
// record Usage if present
break
}
}ctx cancellation MUST abort the underlying HTTP request
promptly. A long-tail Chat blocking the orchestrator
on shutdown is the most common provider bug; use
http.NewRequestWithContext and propagate the context
through every layer.
type Message struct {
Role string `json:"role"` // "system", "user", "assistant"
Content string `json:"content,omitempty"` // text content (mutually exclusive with ToolCall / ToolResult)
// assistant turn that invoked a tool:
ToolCall *ToolCallChunk `json:"tool_call,omitempty"`
// user turn carrying a tool's return value back:
ToolUseID string `json:"tool_use_id,omitempty"` // pairs with assistant's ToolCall.ID
ToolResult string `json:"tool_result,omitempty"` // tool's output (typically JSON)
Name string `json:"name,omitempty"` // tool name, for backends that key on it
}A message has either Content OR a ToolCall OR a
ToolResult. The chat orchestrator persists all three
shapes in conversation history so a provider re-rendering
sees the full round-trip.
type ToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
Schema map[string]any `json:"schema,omitempty"` // JSON Schema for arguments
}The orchestrator builds these from the active skill's
available_tools list; providers that report
SupportsTools() == false see tools as an empty slice
they can ignore.
type Chunk struct {
Text string `json:"text,omitempty"` // delta text
ToolCall *ToolCallChunk `json:"tool_call,omitempty"` // model wants to invoke a tool
Done bool `json:"done,omitempty"` // terminal chunk
Usage *Usage `json:"usage,omitempty"` // token counts on final chunk
}
type ToolCallChunk struct {
ID string `json:"id,omitempty"` // provider-issued correlation token
Name string `json:"name"`
Args map[string]any `json:"args"`
}
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}ToolCallChunk.ID is the provider's correlation token
(Anthropic-native: tool_use_id; OpenAI: the function
call ID). The orchestrator includes the same ID on the
matching ToolResult message so providers that need to
pair them up can. Empty string for providers that don't
issue IDs.
Usage MAY appear only on the final chunk (Done = true)
or interleaved. The orchestrator forwards it to
pg_hardstorage_llm_tokens_total.
Reports whether the provider can invoke ToolDefs. The
orchestrator gates skill-specific tool routing on this
(skills that require tool-calling refuse to start
against a SupportsTools()==false provider).
Reports whether Chat actually streams chunks vs.
buffering and emitting one final chunk. The mock and
openai providers stream; some Bedrock InvokeAPI shapes
don't. Used by the orchestrator's UX layer to decide
whether to render incremental text.
Release HTTP connections, idle clients, refresh tokens. Idempotent.
For SupportsStreaming() == true providers:
- Each
Chunkrepresents an INCREMENTAL delta, not the cumulative response. Concatenatechunk.Textacross iterations to assemble the full response. Done = truearrives exactly once, on the final chunk.- A chunk MAY carry
TextANDToolCallANDUsageANDDonesimultaneously; the orchestrator handles each field independently.
For non-streaming providers:
- A single chunk with the full
Text,Done = true, and (if available)Usage.
1. Orchestrator builds messages + tools, calls Chat.
2. Provider streams chunks; eventually emits a chunk with ToolCall set.
3. Orchestrator receives the ToolCall, executes the named tool,
captures the result string.
4. Orchestrator appends two messages to history:
a. assistant message with .ToolCall set (the call)
b. user message with .ToolUseID + .ToolResult set (the result)
5. Orchestrator re-invokes Chat with the new history.
6. Provider sees both messages and continues the conversation.
The provider's job is shape-translation: incoming
canonical Message slice → wire format on the way out;
wire response → Chunk stream on the way back. The
orchestrator owns history, retry logic, tool dispatch,
and skill orchestration.
func init() {
llmprovider.DefaultRegistry.Register("openai", func() llmprovider.Provider {
return New() // returns a fresh, unopened Provider
})
}The Builder returns a fresh provider; the orchestrator
calls Open(ctx, cfg) after retrieval. Re-registration
overwrites — the idiom for operator-supplied overrides
via Tier-2 plugins.
Register panics on a nil builder or empty name; both
are programmer errors.
var ErrUnknownProvider = errors.New("llmprovider: unknown provider")Returned by Registry.Get(name) when no builder is
registered. The orchestrator's startup wiring catches
this and surfaces a useful "registered providers: …"
message.
A Provider instance MAY be shared across goroutines for
concurrent Chat calls — the orchestrator currently
serializes calls per session, but that's not contractual.
HTTP-based providers naturally handle this via shared
*http.Client.
Open and Close are serial; the host serializes
against in-flight Chat.
The OpenAI-shaped Endpoint MAY point at a local model
runtime (Ollama, vLLM, LM Studio, llama.cpp). Operators
in air-gap deployments (PG_HARDSTORAGE_AIRGAPPED=1)
point at a private-IP endpoint; the
airgap.Default().EndpointAllowed(url) check happens in
the chat orchestrator's config resolver, before
Provider.Open is called.
Provider implementations don't need to consult the air-gap policy directly — the orchestrator gates the endpoint up-front.
- Context cancellation aborts the HTTP round-trip.
No
Chatblocks pastctx.Done(). - Usage reported when available. The
pg_hardstorage_llm_tokens_totalmetric depends on this; absent Usage means absent telemetry. - Tool-call IDs round-trip. If the provider issues IDs, propagate them; the orchestrator correlates them.
APIKeynever logs. Treat as opaque.
The Tier-2 gRPC contract for LLM providers
(PluginTier.PLUGIN_TIER_LLM_PROVIDER) is forward-
looking; no proto service is defined for it in
proto/plugin/v1/plugin.proto v1. v1.1 will add one.
Tier-1 is the only path today.
- Skill schema:
reference/skill-schema.md. - Chat orchestrator:
internal/llm/chat/. - LLM telemetry catalogue:
reference/metric-catalogue.md(filter byllm).