Help us shape the future of Go! Take the 2026 Go Developer Survey

openai

package
v0.10.0 Latest Latest
Warning

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

Go to latest
Published: Oct 23, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

README

actor/openai

An actor.Provider for any OpenAI-compatible /chat/completions server: Ollama, LM Studio, OpenRouter, vLLM, or OpenAI itself. See ../anthropic/README.md for the sibling provider this package deliberately mirrors, and ../provider.go for the actor.Provider interface both implement.

This package exists for the local-development story: point a campaign at a model already running on your laptop — no API key, no cost, no network call beyond localhost. See spec/ideas/openai-compatible-provider.md.

Quick start

import "chatwright.dev/runtime/actor/openai"

// Ollama
provider, err := openai.New(openai.Config{
    BaseURL: "http://localhost:11434/v1",
    Model:   "qwen3.6:latest",
})

// LM Studio
provider, err := openai.New(openai.Config{
    BaseURL: "http://localhost:1234/v1",
    Model:   "your-loaded-model-id",
})

Provider satisfies actor.Provider, so it plugs directly into actor.Loop, run.NewAIGoalPart, or, for CI, into an actor.CassetteProvider — see Cassette workflow below.

Config

Field Required Notes
BaseURL Yes e.g. http://localhost:11434/v1 (Ollama), http://localhost:1234/v1 (LM Studio). No package default — unlike a single hosted vendor, every OpenAI-compatible server lives at its own address. New returns ErrMissingBaseURL if empty.
Model Yes e.g. qwen3.6:latest. No package default either — each server's model catalogue is whatever the developer pulled/loaded locally. New returns ErrMissingModel if empty.
APIKey No Sent as Authorization: Bearer <APIKey> only when non-empty. Local servers (Ollama, LM Studio) do not need one.
MaxTokens No Defaults to openai.DefaultMaxTokens (2048 — see its doc comment for the arena evidence behind the value).
HTTPClient No Defaults to http.DefaultClient. Tests point this at a fake http.RoundTripper, or drive a real httptest.Server and set BaseURL to it.
Now No Defaults to time.Now; only used to measure Usage.Latency.

Structured output and graceful degradation

Propose first asks for response_format: {"type":"json_schema", ...} with strict:true — OpenAI's structured-output contract, which newer Ollama/LM Studio builds also implement, enforcing the JSON shape server-side. The schema is the same proposal contract actor/anthropic enforces: kind (send-text | click | task-done | give-up), text, action_id, rationale, all four always present (an empty string for whichever field the chosen kind does not need) — a campaign's actor.Prompt → actor.Proposal contract does not vary by provider.

Some OpenAI-compatible servers reject an unrecognised response_format with an HTTP error instead of ignoring it. When that happens — any failure status except 401/403 (authentication) or 429 (rate limit), which no change of response_format can fix — Propose retries the same prompt exactly once with response_format: {"type":"json_object"} plus the response schema restated in the system prompt (there is no server-side enforcement in this mode). The reply is still parsed strictly, through the same one-JSON-repair-attempt path response.go uses either way. If the fallback attempt also fails, Propose returns a typed *openai.FallbackFailedError naming both underlying failures — never a third attempt, never a fabricated Proposal.

Call Provider.LastResponseFormatMode() after Propose to see which mode actually served the last call (openai.ModeJSONSchema or openai.ModeJSONObjectFallback) — a test/diagnostic hook, never required for correct use.

Reasoning models and reasoning_content

Some reasoning models served through an OpenAI-compatible endpoint 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 the server still bills output tokens for it. (qwen/qwen3.6-27b via LM Studio did this on 4/4 calls in the first actor-model arena run — see chatwright/runtime-go#3.)

Propose reads the first non-empty field in this order — the first hit wins outright and later fields are never even inspected:

  1. message.content — the normal path; unchanged behaviour whenever this is non-empty.
  2. message.reasoning_content — the LM Studio/DeepSeek-style field name.
  3. message.reasoning — an alternate name a minority of other servers use.

Text recovered from either reasoning field goes through the exact same strict, one-repair-attempt parse and contract validation as content does (see response.go's responseText/parseWireProposal) — this package still never fabricates a Proposal out of reasoning prose that merely looks plausible. A reasoning field that does not hold a valid proposal surfaces as the same typed *openai.InvalidResponseError, with its Source field naming which field the text came from.

Error taxonomy

Go type When Notes
*openai.AuthenticationError HTTP 401/403 Bad, missing or under-scoped APIKey. Not retryable without fixing the key; no json_object fallback attempted (a schema rejection this is not).
*openai.RateLimitError HTTP 429 Retryable after backoff — this package does not retry internally. No fallback attempted either.
*openai.FallbackFailedError Both the json_schema and the json_object attempts failed at the HTTP/transport level Wraps both underlying errors (Unwrap() []error).
*openai.InvalidResponseError Unparseable/contract-violating reply, or a response with no usable text in content/reasoning_content/reasoning, from either attempt Carries Raw (truncated), FinishReason (called out explicitly, with a truncation hint, when "length"), and Source (which field Raw came from — named in the message only for a reasoning field, never for the normal content path). Never a fabricated Proposal.
wrapped generic error A connection-level failure (DNS, connection refused, cancelled context) before any HTTP response at all No fallback attempted — there is nothing to fall back from.

Usage and cost

Usage.Model, InputTokens, OutputTokens and Latency are read from the response and the call's wall-clock duration. Usage.Model falls back to Config.Model when the response omits "model". InputTokens/ OutputTokens stay zero — never guessed — when the response carries no "usage" block at all (some OpenAI-compatible servers omit it).

Usage.Cost is always left nil. Unlike actor/anthropic's dated pricing snapshot (a single hosted vendor with a stable published price list — see ../anthropic/pricing.go), this package fronts arbitrary local/self-hosted servers with no shared pricing source of truth, and most of them are free to run locally anyway — the whole point of this package. A caller that wants Usage.Cost populated for a hosted OpenAI-compatible endpoint with a known rate prices it themselves from InputTokens/ OutputTokens after Propose returns; the pricing-snapshot mechanism is deliberately not replicated here.

Cassette workflow (record once, replay free)

Identical to actor/anthropic's — Provider is a plain actor.Provider:

provider, err := openai.New(openai.Config{BaseURL: "http://localhost:11434/v1", Model: "qwen3.6:latest"})
cassette := actor.NewCassette("actor/openai model=qwen3.6:latest")
recorder, err := actor.NewCassetteProvider(actor.ModeRecord, provider, cassette)

// ... run the campaign/loop against recorder ...

err = recorder.Cassette().Save("testdata/cassettes/my-campaign.json")

CI replays with actor.ModeReplay and no local server running at all — see ../cassette.go and ../anthropic/README.md's own "Cassette workflow" section for the full record/commit/replay contract, which this package follows unchanged.

Testing

  • go test ./actor/openai/... runs the full suite — request shape, all four proposal kinds, the JSON-repair path, the reasoning_content/ reasoning fallback fields (valid JSON, garbage, and content-wins precedence — see TestPropose_ReasoningContentFallback_ValidJSON and neighbours in provider_test.go), the finish_reason=length truncation hint (TestPropose_FinishReasonLength_SurfacesInError), the json_schema→json_object fallback (and its own failure path), the full error taxonomy, missing-usage/missing-model degradation, cassette record/replay, and a greetbot campaign end-to-end proof whose bundle validates against the chatwright.dev/sdk module's own formats/run-bundle/v1/schema.json (resolved via go list -m, see e2e_test.go's sdkSchemaPath) — entirely against a fake httptest.Server (see helpers_test.go) or a fake http.RoundTripper for the two transport-failure edge cases, never the real network.

  • One optional live local-LLM smoke test, gated behind CHATWRIGHT_LIVE_LOCAL_LLM=1 and a set CHATWRIGHT_LOCAL_LLM_BASE_URL — skipped with a clear message otherwise, so go test ./... never depends on a local server being up:

    CHATWRIGHT_LIVE_LOCAL_LLM=1 \
    CHATWRIGHT_LOCAL_LLM_BASE_URL=http://localhost:11434/v1 \
    CHATWRIGHT_LOCAL_LLM_MODEL=qwen3.6:latest \
      go test ./actor/openai/ -run TestLiveLocal -v
    

    CHATWRIGHT_LOCAL_LLM_MODEL is optional — when unset, the test asks the server's own GET {baseURL}/models for its catalogue and uses whichever model comes first. See live_test.go's own doc comment for the LM Studio invocation too.

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

View Source
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

View Source
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.

View Source
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

func New(cfg Config) (*Provider, error)

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.

Jump to

Keyboard shortcuts

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