Documentation
¶
Overview ¶
Package openai is an actor.Provider that speaks the OpenAI-compatible chat-completions wire format: the same request/response shape Ollama, LM Studio, OpenRouter, vLLM and OpenAI itself expose at POST {BaseURL}/chat/completions. One provider covers all of them because they share this wire shape — only Config.BaseURL (and, for hosted services, Config.APIKey) changes.
It composes with the frozen actor seam exactly like actor/anthropic does — see that package for the seam's own doc comment and README for the design this mirrors. Providers are dumb transports: this one renders a Prompt to a system+user message pair (see prompt.go), asks for exactly one JSON object via response_format (structured output where the server supports it, with a graceful one-shot fallback otherwise — see below), and maps the reply to a Proposal. It never fabricates a Proposal: an unparseable or malformed reply is a typed error, not a guess — see response.go.
Structured output and graceful degradation ¶
Propose first asks for response_format: {"type":"json_schema", ...} with strict:true — OpenAI's, and increasingly Ollama/LM Studio's, structured-output contract, enforced server-side (see prompt.go's responseJSONSchema — the SAME proposal JSON contract actor/anthropic's own response schema enforces, so a campaign's actor.Prompt→actor.Proposal contract does not vary by provider). Some OpenAI-compatible servers (older Ollama/LM Studio builds, third-party servers with partial compatibility) reject an unrecognised response_format with an HTTP error instead. On any such rejection — see wire.go's retryable classification — Propose retries the SAME prompt exactly once with response_format: {"type":"json_object"} plus the response schema restated in the system prompt (jsonObjectFallbackInstructions), and still parses the reply strictly through the same one-repair-attempt path. A caller can see which mode actually served the last call via Provider.LastResponseFormatMode — useful for tests and diagnostics, never required for correct use.
Reasoning models and the reasoning_content field ¶
Some reasoning models served through an OpenAI-compatible endpoint (LM Studio and DeepSeek-style servers observed so far) route their entire reply — including the proposal JSON the response contract asks for — into message.reasoning_content instead of message.content, leaving content empty while still billing output tokens for it (see chatwright/runtime-go#3: qwen/qwen3.6-27b via LM Studio did this on 4/4 calls in the first actor-model arena run). Propose reads that text rather than treating an empty content as "no reply": see response.go's responseText for the exact field precedence (content, then reasoning_content, then the alternate name reasoning — each checked only when every earlier one is empty; content winning outright whenever it is non-empty leaves existing behaviour unchanged). Text recovered from a reasoning field is parsed and validated through the exact same strict, one-repair-attempt path as content — see response.go — so this package still never fabricates a Proposal from reasoning prose that merely looks plausible; a reasoning field that does not hold a valid proposal surfaces as the same typed *InvalidResponseError, naming which field (Source) it came from.
Usage and cost ¶
Usage.Model, InputTokens and OutputTokens are read from the response; InputTokens/OutputTokens are left zero when the server's response carries no "usage" block at all (some OpenAI-compatible servers omit it), never guessed. Usage.Cost is always left nil: unlike actor/anthropic's dated pricing snapshot (a single hosted vendor with a stable published price list), this package fronts arbitrary local/self-hosted servers with no shared pricing source of truth at all — most of them are free to run locally anyway, which is the whole point of this package. A caller that wants Usage.Cost populated (e.g. a hosted OpenAI-compatible endpoint with a known rate) prices it themselves from InputTokens/OutputTokens after Propose returns; the pricing-snapshot mechanism actor/anthropic/pricing.go implements is deliberately NOT replicated here.
Index ¶
Constants ¶
const DefaultMaxTokens = 2048
DefaultMaxTokens is the default Config.MaxTokens: generous headroom for a short rationale plus the fixed JSON scaffolding. Originally 1024, matching actor/anthropic's own DefaultMaxTokens; raised to 2048 after the first actor-model arena run (chatwright/backstage research/model-arena-2026-07-23) observed finish_reason=length truncating replies mid-JSON at 1024 across multiple cells — the arena reran every cell at 2048 as a fairness fix, and that value is now this package's own default too. Config.MaxTokens still overrides it per Provider; see InvalidResponseError, which now names finish_reason=length explicitly when it is the culprit.
Variables ¶
var ErrMissingBaseURL = errors.New("actor/openai: no base URL: set Config.BaseURL (e.g. http://localhost:11434/v1 for Ollama)")
ErrMissingBaseURL means New was called with an empty Config.BaseURL. Unlike Anthropic's single hosted API, an OpenAI-compatible provider has no universal default endpoint — every server (Ollama, LM Studio, OpenRouter, ...) lives at its own address, so BaseURL is always required.
var ErrMissingModel = errors.New("actor/openai: no model: set Config.Model")
ErrMissingModel means New was called with an empty Config.Model. There is no package-level default model: OpenAI-compatible servers each expose their own catalogue (Ollama and LM Studio's model ids are whatever the developer pulled/loaded locally), so callers always name one explicitly.
Functions ¶
This section is empty.
Types ¶
type AuthenticationError ¶
type AuthenticationError struct{ Err error }
AuthenticationError wraps an OpenAI-compatible server's 401/403 response: the API key is missing, invalid, revoked, or lacks access to the requested model. Never retryable without fixing the key — Propose does not attempt the json_object fallback for this status (a rejected key is not a response_format rejection; see the package doc comment).
func (*AuthenticationError) Error ¶
func (e *AuthenticationError) Error() string
func (*AuthenticationError) Unwrap ¶
func (e *AuthenticationError) Unwrap() error
type Config ¶
type Config struct {
// BaseURL is the OpenAI-compatible server's base URL, e.g.
// "http://localhost:11434/v1" for Ollama or "http://localhost:1234/v1"
// for LM Studio. Required: New returns ErrMissingBaseURL if empty.
// Propose POSTs to {BaseURL}/chat/completions; a trailing slash on
// BaseURL is tolerated.
BaseURL string
// Model is the model id the server should use, e.g. "qwen3.6:latest".
// Required: New returns ErrMissingModel if empty — see ErrMissingModel.
Model string
// APIKey authenticates every request via "Authorization: Bearer
// <APIKey>". Optional: local servers (Ollama, LM Studio) do not need
// one; when empty, Propose sends no Authorization header at all,
// rather than an empty or placeholder one.
APIKey string
// MaxTokens bounds the model's reply, sent as the request's
// "max_tokens". <= 0 uses DefaultMaxTokens.
MaxTokens int
// HTTPClient issues every HTTP request. Nil uses http.DefaultClient.
// Tests point this at a fake http.RoundTripper, or run.a real
// httptest.Server and pass its BaseURL instead, so Propose never
// touches the network in CI — see provider_test.go.
HTTPClient *http.Client
// Now supplies the provider's notion of the current time, used only to
// measure Usage.Latency around the API call. Nil uses time.Now. Inject
// a fake clock for deterministic latency assertions in tests.
Now func() time.Time
}
Config configures a Provider.
type FallbackFailedError ¶
type FallbackFailedError struct {
// JSONSchemaErr is the primary (response_format: json_schema)
// attempt's error.
JSONSchemaErr error
// JSONObjectErr is the fallback (response_format: json_object)
// attempt's error.
JSONObjectErr error
}
FallbackFailedError means the primary json_schema request failed in a way this package classified as retryable (see wire.go's retryable), and the one-shot json_object fallback attempt also failed at the HTTP/ transport level (a malformed-but-200 reply from the fallback attempt instead surfaces as *InvalidResponseError, not this type — see Propose). Both underlying errors are retained so a developer can see what the server said both times; neither attempt is retried further.
func (*FallbackFailedError) Error ¶
func (e *FallbackFailedError) Error() string
func (*FallbackFailedError) Unwrap ¶
func (e *FallbackFailedError) Unwrap() []error
Unwrap supports errors.Is/errors.As against either underlying error, per the multi-error Unwrap() []error convention (Go 1.20+).
type InvalidResponseError ¶
type InvalidResponseError struct {
// Raw is the model's raw reply text that failed to parse, or empty if
// the response carried no usable text in any field at all.
Raw string
// FinishReason is the response's first choice's finish_reason, when
// known — e.g. "length" or "content_filter" can explain why Raw is
// empty or truncated. "length" is called out explicitly in Error()
// (see the arena evidence in DefaultMaxTokens's doc comment).
FinishReason string
// Source names which response field Raw was read from — one of
// "content" (the normal path), "reasoning_content" or "reasoning" (a
// reasoning model routed its reply there instead — see response.go's
// responseText) — or empty when the response carried no usable text in
// any field (Raw is "" in that case too). Called out in Error() only
// when it names a reasoning field, since that is the unusual case a
// developer needs to know about; the normal "content" path is silent
// exactly as it always was.
Source string
Err error
}
InvalidResponseError means the model's reply — from either the json_schema request or, after a fallback, the json_object request — could not be turned into a valid actor.Proposal: malformed JSON even after the one repair attempt (see response.go), a JSON object that does not match the response contract (missing/invalid "kind", or a kind whose required field is empty), or a response with no usable text in any field at all. Raw carries the model's raw reply text (truncated for the error message) so a developer can see what went wrong; Propose never fabricates a Proposal in its place.
func (*InvalidResponseError) Error ¶
func (e *InvalidResponseError) Error() string
func (*InvalidResponseError) Unwrap ¶
func (e *InvalidResponseError) Unwrap() error
type Provider ¶
type Provider struct {
// contains filtered or unexported fields
}
Provider is an actor.Provider backed by an OpenAI-compatible /chat/completions endpoint. Build one with New. The zero value is not usable.
func New ¶
New builds a Provider from cfg. It returns ErrMissingBaseURL if cfg.BaseURL is empty, or ErrMissingModel if cfg.Model is empty.
func (*Provider) LastResponseFormatMode ¶
func (p *Provider) LastResponseFormatMode() ResponseFormatMode
LastResponseFormatMode reports which response_format mode served the most recently completed Propose call: ModeJSONSchema (the default, structured-output request) or ModeJSONObjectFallback (the server rejected json_schema and Propose fell back — see the package doc comment). The zero value ("") means Propose has not been called yet. Safe for concurrent use; a test/diagnostic hook, never required for correct use of Provider.
func (*Provider) Propose ¶
func (p *Provider) Propose(ctx context.Context, prompt actor.Prompt) (actor.Proposal, actor.Usage, error)
Propose implements actor.Provider: it renders prompt (see prompt.go), POSTs it to {BaseURL}/chat/completions for exactly one structured-output JSON reply (with the json_object fallback on rejection — see the package doc comment), and maps that reply to a Proposal (see response.go). It never returns a fabricated Proposal: any failure to obtain and parse a valid reply is a typed error (see errors.go), leaving the caller's zero-value Proposal untouched. Usage.Cost is always left nil — see the package doc comment's "Usage and cost" section.
type RateLimitError ¶
type RateLimitError struct{ Err error }
RateLimitError wraps an OpenAI-compatible server's 429 response. Retryable after backoff; this package does not retry internally — the loop/caller decides whether and when to retry a failed Propose call. Not eligible for the json_object fallback either: a rate limit is not a response_format rejection.
func (*RateLimitError) Error ¶
func (e *RateLimitError) Error() string
func (*RateLimitError) Unwrap ¶
func (e *RateLimitError) Unwrap() error
type ResponseFormatMode ¶
type ResponseFormatMode string
ResponseFormatMode names which response_format mode served a Propose call — see Provider.LastResponseFormatMode. It is a string type, not an int enum, per AGENTS.md's JSON-artefact convention, even though it never itself reaches a JSON artefact — kept consistent with actor.ProposalKind and friends.
const ( // ModeJSONSchema: the server accepted the primary, structured-output // request (response_format: {"type":"json_schema", ...}). ModeJSONSchema ResponseFormatMode = "json_schema" // ModeJSONObjectFallback: the server rejected ModeJSONSchema and // Propose fell back to response_format: {"type":"json_object"} with // the schema restated in the system prompt — see the package doc // comment's "Structured output and graceful degradation" section. ModeJSONObjectFallback ResponseFormatMode = "json_object_fallback" )
Response-format modes. See ResponseFormatMode.
func (ResponseFormatMode) String ¶
func (m ResponseFormatMode) String() string
String renders m for diagnostics and test failure messages.