openai

package
v0.5.1 Latest Latest
Warning

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

Go to latest
Published: Jul 26, 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.Promptactor.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_schemajson_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