Skip to content

Errors and sentinels

Every exported error value in the module, what raises it, and what fixes it. All of them are matched with the standard library's errors.Is; none requires phpboyscout/go/errors at the call site, even though the module builds its errors with it.

Construction errors

Raised by chat.New, chat.NewFallback* and chat.NewFileStore, before any request is made.

Sentinel Raised when What to do
chat.ErrUnableToConstruct construction failed outright and no client was returned fix the named cause; this is the one check every caller should make, because it separates "no client" from "a client missing a setting"
chat.ErrProviderNotRegistered no factory is registered under Config.Provider; the message reads unsupported provider: <name> add the provider module's blank import, or fix the name; chat.ProviderRegistered answers the same question before construction
chat.ErrInvalidBaseURL Config.BaseURL fails any endpoint rule (too long, control characters, unparseable, carries userinfo, not https, no host, or a placeholder host) or is empty for ProviderOpenAICompatible correct the URL; put credentials in Config.Token, never in the URL
chat.ErrInvalidSnapshotID a snapshot identifier reaching FileStore.Save, Load or Delete is not a canonical lowercase-hex UUID, or resolves outside the store directory validate at your own boundary with chat.ValidateSnapshotID; snapshots from chat.NewSnapshot always pass

errors.Is(err, chat.ErrUnableToConstruct) is true whenever chat.New returned a nil client, including for every fatal cause listed on this page. The aggregate makes the sentinel a member so no type assertion is needed.

Request-time errors

Raised by a provider during Chat, Ask, StreamChat, ChatWithLogprobs or ProposeToolCalls. Each wraps the vendor's own error, which stays reachable by unwrapping.

Sentinel Raised when What to do
chat.ErrThrottled the provider rate-limited the request and its own retries were exhausted back off, or configure a fallback composite; do not read the vendor's wording, which is often misleading (Gemini reports a per-minute burst limit as a quota and billing problem)
chat.ErrModelRejectedParameter the selected model refused a generation parameter that its provider structurally supports change Config.Model, or drop the parameter and use Config.Effort, which every provider accepts
chat.ErrLogprobsUnavailable ChatWithLogprobs was accepted but the reply carried no log-probabilities treat it as unsupported for this model or server; a model that refuses the request outright is chat.ErrModelRejectedParameter instead
chat.ErrMediaRejected an attachment is empty, over 20 MiB, of a type the sniffer cannot identify or the allowlist excludes, or declares a MIME family contradicting its bytes send a supported format, or downscale; the sniffed type is authoritative and a declared type can never override it
chat.ErrMediaUnsupported the selected provider accepts no media at all (claude-local, agy-local), or not this attachment's type choose a provider that accepts the type (the table is in Defaults and limits)
chat.ErrCommandUnavailable a slash command needs a contract this client does not implement: /clear without PersistentChatClient, or /compact and /undo without TranscriptEditor upgrade the provider module, or drop the command from your CommandSet; /help lists it either way, so a user is told rather than left guessing
chat.ErrTranscriptMoved an edit wrapped with chat.WhenBusy(..., chat.EditRefuses) met a busy client, or the conversation changed while a policy was being applied, so the edit was computed against turns that no longer exist retry; it is not a failure. An edit that would rather wait for a running call than be refused needs no wrapper at all: waiting is the default
chat.ErrRefused the provider's safety system declined or withheld the reply: the model's own refusal, output a content filter blocked, or a prompt rejected before generation. The *chat.RefusedError in the chain says which (see Refusals), and the call takes back the refused question treat it as an answer you will not get from this model; the default failover policy does not advance on it, so write a FailoverPolicy that returns FailoverNext for it if another provider should try
chat.ErrTruncated the reply stopped at Config.MaxTokens raise Config.MaxTokens; on models that think before answering, thinking shares that budget. The text that arrived before the cut is on the *chat.TruncatedError in the chain, read with errors.As. chat-anthropic raises it from v0.15.0, and takes back the question
chat.ErrStatelessRestore Restore is called on a client built with Config.Stateless restore into a conversational client; a stateless client retains no conversation for a snapshot to land in. Save still works and returns a snapshot with no messages

Media errors are raised before any network call, because the attachment is validated at the boundary, so a rejected attachment costs nothing.

Refusals

A refusal is a *chat.RefusedError, which matches chat.ErrRefused. Read it with errors.As when it matters which kind it was (spec 0029):

Field Holds
Reason the token the API sent, such as refusal, content_filter, SAFETY or cyber_policy; prompt_blocked for a Gemini prompt rejected before generation
Category the provider's finer classification, when it gives one: Claude's refusal category, Gemini's block reason
Prompt true when the prompt was rejected before anything was generated
Message the provider's own text about the refusal, when it sends any. Gemini's safety stops carry it on Vertex only: genai drops a candidate's finishMessage on the Gemini API backend
Err the provider's error, when the refusal arrived as one; it stays in the chain, so chat.ProviderHTTPStatus still reads its status

What each provider reports as a refusal:

Provider Refusal Reason
claude stop_reason: refusal refusal, with the category in Category
openai a refusal on the reply, which only a schema-bound Ask meets; finish_reason: content_filter; an HTTP 400 from the input filter with code cyber_policy or Azure's content_filter refusal, content_filter, or the error's code with Prompt set
gemini finish reason SAFETY, PROHIBITED_CONTENT, BLOCKLIST, SPII, IMAGE_SAFETY or IMAGE_PROHIBITED_CONTENT; a blocked prompt the finish reason, or prompt_blocked with the block reason in Category
bedrock stopReason: content_filtered; a block by a guardrail attached with chatbedrock.WithGuardrail content_filtered, or guardrail_intervened with the blocking policies in Category

A decline written in prose is not a refusal any API marks, so it comes back as an ordinary reply. See the limitations.

The ConfigErrors aggregate

chat.New returns problems as one *chat.ConfigErrors rather than the first one it hit, so a caller with three mistakes learns all three from one call.

Member Returns Use it to
Error() string one line naming every problem, prefixed by unable to construct client when fatal and configuration could not be fully applied when not log it
Unwrap() []error the individual problems, plus ErrUnableToConstruct when fatal let errors.Is and errors.As reach every member
Dropped() []DroppedSetting the settings that were not applied, each with Fields, Capability and Reason decide whether the shortfall matters to this job
Fatal() bool whether construction failed outright the cheap equivalent of errors.Is(err, ErrUnableToConstruct) when you already hold the type
var cfgErr *chat.ConfigErrors
if errors.As(err, &cfgErr) {
    for _, d := range cfgErr.Dropped() {
        log.Info("not applied", "fields", d.Fields, "capability", d.Capability, "why", d.Reason)
    }
}

Reading hints

Errors from this module carry hints, the half that says what to do rather than what is wrong. Read them with the standard readers from phpboyscout/go/errors:

import "gitlab.com/phpboyscout/go/errors"

for _, h := range errors.GetAllHints(err) {
    log.Info("hint", "advice", h)
}

Both GetAllHints and FlattenHints descend into an aggregate, so they work on anything chat.New returns.

chat.Hints was removed in v0.9.0

It existed to work around an error package that walked single unwraps only, so hints below an aggregate vanished silently. go/errors has one chain traversal that descends into a multi-error, which removed the reason for the helper rather than fixing it. Consumers reading hints should move to the readers above.

Tagging a provider error: chat.MarkAs

Provider modules tag their vendor errors with a sentinel using chat.MarkAs(err, chat.ErrThrottled). Both of these then hold under the standard library:

errors.Is(marked, chat.ErrThrottled) // true
errors.Is(marked, providerErr)       // true

A nil error returns nil, so a call site can wrap unconditionally.

The helper exists because of a trap the module walked into once: annotation libraries have historically offered a Mark whose markers only their own Is recognises, so a consumer writing the idiomatic standard-library check gets false. MarkAs wraps with %w: %w instead, which the standard library understands. phpboyscout/go/errors offers no Mark, so the trap is not reachable today. The helper stays because the guarantee it makes is the one callers depend on.

Failures that carry no sentinel

These are returned as plain messages. There is nothing to match on beyond the class the aggregate gives them, so treat the text as diagnostic, not as an API.

Message Raised when What it usually means
claude binary not found in PATH: … ProviderClaudeLocal cannot find the claude CLI install Claude Code and sign it in once
codex binary not found in PATH: … ProviderCodexLocal cannot find the codex CLI install the Codex CLI and sign it in once
agy binary not found in PATH: … ProviderAgyLocal cannot find the agy CLI install the agy CLI and sign it in once
chat.ProviderAgyLocal cannot carry tools: … SetTools on agy-local, which has no per-invocation MCP configuration use chat.ProviderGemini for tool calling against the same models; Capabilities reports this as CapTools: SupportNo before you build
Model is required for ProviderOpenAICompatible: … ProviderOpenAICompatible was configured without Config.Model name the backend's model, e.g. llama3.2 for Ollama
Anthropic API key is required but not provided, OpenAI token is required but not provided, Gemini API key is required but not provided the whole credential cascade resolved to nothing export the provider's well-known variable, or set Config.Token or Config.Credentials

| provider <name> does not support Config.Stateless | the provider module predates chat.StatelessCapable | upgrade the provider module; do not unset the flag | | provider <name> cannot carry Config.Temperature or Config.TopP | the provider has no sampling concept at any model (claude-local is the case) | use Config.Effort, or change provider | | provider <name> cannot carry Config.Effort | the provider module predates chat.EffortCapable | upgrade the provider module | | provider <name> cannot carry Config.CacheTTL | the provider module does not implement chat.CachingChatClient | upgrade the provider module, or unset Config.CacheTTL | | encryption key must be 32 bytes, got <n> | chat.WithEncryption was given the wrong key length | generate one with chat.GenerateEncryptionKey | | fallback: at least one provider config is required | a fallback constructor was handed an empty slice | supply at least the primary | | fallback: no provider could be constructed | every member of a composite failed to build; marked ErrUnableToConstruct | fix the credentials or imports the error names | | fallback refused: a tool call has executed in this conversation | WithStrictToolContext is set and a tool ran before the failure | accept lossy text-only failover by removing the option, or handle the error | | <Provider> reached maximum ReAct steps (<n>) without a final answer | the tool loop ran Config.MaxSteps times and the model never produced a final text answer | raise Config.MaxSteps, or narrow the task (the call returns no partial text) |

The rows that describe a construction failure (the missing claude binary, the four "cannot carry" messages, and fallback: no provider could be constructed) all satisfy errors.Is(err, chat.ErrUnableToConstruct), so that check stays reliable even though the specific cause has no sentinel of its own. The remaining rows do not: SetTools on agy-local and fallback refused are returned from a call rather than from construction, and the chat.NewFileStore key-length error and the empty-slice fallback error are returned bare.