chat

package
v0.2.15 Latest Latest
Warning

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

Go to latest
Published: Jun 26, 2026 License: MPL-2.0 Imports: 10 Imported by: 0

Documentation

Overview

Package chat is the chassis-owned `ai://chat` exec dispatch surface.

Like chassis/compute and chassis/egress, it is a thin registry: backends (OpenRouter, future OpenAI-direct, Anthropic-direct, local-vllm) self- register via init() in their subpackages; the chassis activates one with a blank import. Per-call selection is handled by Resolve.

**Boundary of trust.** The chassis owns three concerns that raw HTTP wouldn't enforce:

  • Secret materialization. RequiredSecrets() declares which standardized names (OPENROUTER_KEY, OPENAI_KEY, …) a backend needs; the ExecAI handler materializes them through the same per-tenant store + audit counter every other op uses (with an optional env-var fallback gated by AIChatEnvFallback for developer-machine convenience).
  • Cleartext containment. Cleartext rides only in the *secrets.SecretBag passed to Run; the bag panics on every standard encoder, so it cannot reach trace, log, mock, or continuation by construction.
  • Fuel + trace. Chat ops pay the standard 25-fuel EXEC + 100-fuel per-secret charge — chassis-owned infrastructure cost. Token counts are recorded in the trace event and `_txc.chat.tokens` envelope metadata, NOT charged to fuel: tokens are provider compute, and conflating them with the chassis's per-request meter would distort the bound. USD-denominated cost reporting (deferred) sources from token counts × per-model pricing, not from fuel.

**v1 is intentionally boring.** No tool-call loop (Backend has no Tools() method; Request has no Tools field; Response has no ToolCalls). No capability-matched routing across multiple backends (Resolve is provider-override-or-first-registered). No sophisticated retry policy (the OpenRouter backend retries once on 5xx + network). No raw `{{!@field}}` template insertion (rejected with a clear error so v1.1 can land it without silent semantic drift). Each escalation belongs to its own focused PR.

Index

Constants

View Source
const (
	CodeMissingSecret  = "txco_chat_missing_secret"
	CodeNoBackend      = "txco_chat_no_backend"
	CodeInvalidWith    = "txco_chat_invalid_with"
	CodeAuthFailed     = "txco_chat_auth_failed"
	CodeProviderHTTP   = "txco_chat_provider_http"
	CodeProviderNet    = "txco_chat_provider_net"
	CodeProviderParse  = "txco_chat_provider_parse"
	CodeSchemaFailed   = "txco_chat_schema_failed"
	CodeUnsupportedSub = "txco_chat_unsupported_sub_op"
)

Error codes surfaced to operators via response envelope's `chat.error` field and trace events. The `txco_chat_*` prefix mirrors the budget-exhaustion codes (`txco_fuel_exhausted`, `txcl_scope_ttl_exhausted`) so they all sort together in logs and dashboards.

Variables

View Source
var ErrAuthFailed = &authFailedError{}

ErrAuthFailed is the sanitized auth-failed error returned by backends after a 401. The provider's body — which may quote a prefix of the submitted API key — is discarded BEFORE this error is constructed. Surfaced to operators as a fixed generic; the real diagnosis happens from the trace event's `retries` and `latency_ms` fields plus the inbound `_txc.chat.error` projection.

Functions

func Register

func Register(name string, c Constructor)

Register adds a backend constructor. Called from a backend package's init(); the chassis activates a backend with a blank import.

Re-registering an existing name overwrites the constructor (test support); the registration order is preserved on first-registration only, so a re-registered backend keeps its original priority slot.

func Registered

func Registered() []string

Registered returns the names of currently registered backends, sorted for deterministic display. Used by error messages and observability.

func Render

func Render(template string, envelope []byte, logger *zap.Logger) (string, error)

Render substitutes `{{@field.path}}` markers in template with values from the envelope. The `@`-prefix follows the **chassis-wide txcl convention**: it addresses paths under the envelope's `_txc.*` namespace, matching what `WHEN @path`, `SET @path = ...`, and `EMIT @path = ...` already do (the txcl parser rewrites `@x.y` to `_txc.x.y` at parse time — see chassis/txcl/parser/parser.go:216,234). So `{{@web.req.body}}` reads `_txc.web.req.body`. For a scratch value the author supplies (e.g. `{{@body_text}}` -> `_txc.body_text`), set it with `SET PRE @body_text = …`: SET PRE decorates only this op's input, so the template sees it but it never propagates to the next scope. Note `_txc.*` is the chassis control namespace — reserved fields (tenant, computed.*, budget, …) are NOT author-writable on the propagating envelope (see chassis/processor authorMayWriteTxc); the op-local SET PRE scratch above is exempt because it doesn't merge.

The substitution is JSON-escaped by default: string values are JSON-string-escaped with the outer quotes stripped, so they splice safely into the surrounding prompt context (no terminator breaks; no injection of quotes or backslashes that would mangle the prompt). Non-string values (objects, numbers, arrays, booleans, nulls) are marshaled to compact JSON.

Unknown paths render as the empty string and emit one debug log per occurrence (prompts tolerate missing data gracefully — erroring would turn every type-ahead-incomplete envelope into a request failure). Logger may be nil.

The verbatim opt-out form `{{!@path}}` is **intentionally rejected** in v1: any template containing it returns InvalidWithError. Trusted- fields-only insertion is the kind of escalation that wants its own review, and silently accepting it without escaping would be the wrong failure mode when the feature lands. Failing loud here means authors using the syntax today get a clear error, not the wrong escaping.

Prompt-injection caveat: the chassis cannot tell trusted from hostile input. The escaping makes hostile JSON inside a string value safe against trivial prompt-context breaks; it does NOT defend against an adversary who tells the model to ignore prior instructions. Fence- and-instruct is the recommended pattern; v2 may add a sanitize knob.

Types

type Backend

type Backend interface {
	// Name returns the registered name (must match the Register key).
	Name() string

	// Capabilities returns descriptive labels recorded on the trace event
	// for observability. v1 does NOT match against these for routing;
	// capability-superset selection across multiple backends is its own
	// follow-up PR.
	Capabilities() []string

	// RequiredSecrets are the standardized secret names this backend
	// needs to function. ExecAI materializes them automatically from the
	// per-tenant secret store at backend-selection time (with optional
	// env-var fallback) and passes the cleartext via the SecretBag.
	// E.g., the OpenRouter backend returns []string{"OPENROUTER_KEY"};
	// a self-hosted backend that needs URL + auth might return both.
	RequiredSecrets() []string

	// Run executes one chat completion. The secrets bag carries
	// cleartext for every name in RequiredSecrets(); the implementation
	// reads via bag.Get(name) and uses the cleartext only in local
	// variables — never on the Backend struct, never on context, never
	// in trace fields, never in logs. See the package godoc for the
	// five leak-prevention guards.
	Run(ctx context.Context, req Request, bag *secrets.SecretBag) (Response, error)
}

Backend is the chassis-facing interface every chat backend implements.

Lifecycle: backends are registered by name via Register() in an init(); the chassis Opens one at startup per Config; the resolved Backend is then long-lived across requests (Run is called once per ai://chat EXEC).

Run must be safe for concurrent use — the chassis dispatches multiple chat ops in parallel when WHEN/EMIT fan-out hits the same backend.

func Open

func Open(name string, cfg Config) (Backend, error)

Open constructs the named backend. Unknown name is an error listing what is available; a backend may also return an error for invalid config so misconfiguration fails loudly at boot.

func Resolve

func Resolve(providerHint string, cfg Config) (Backend, string, error)

Resolve picks a backend for one ai://chat dispatch. v1 logic is intentionally minimal:

  • providerHint non-empty → look up by name in the registry; error with NoBackendError if unknown. Trace records routing_decision = "provider-override".
  • providerHint empty → return the first-registered backend; trace records routing_decision = "default". With a single v1 backend (OpenRouter), this is unambiguous.

Capability-superset matching across multiple registered backends is its own design exercise (ordering, ties, no-match fallback semantics) and lands alongside the second backend in a follow-up PR. Backend.Capabilities() is still captured in the trace today as descriptive metadata.

type CodedError

type CodedError interface {
	error
	Code() string
	AsJSON() string
}

CodedError lets callers extract a stable `txco_chat_*` code from any chat-package error without a type switch over every variant.

type Config

type Config struct {
	// HTTPClient is the chassis-owned http.Client (constructed in
	// processor.New with egress.DialControl + otelhttp wrapping). Every
	// outbound HTTPS call from a backend MUST use this client so the
	// configured egress Guard applies. Backends never construct their
	// own transport.
	HTTPClient *http.Client
}

Config carries chat-package construction options resolved from chassis config. Backends extend it with their own fields without breaking existing callers (same convention as compute.EngineConfig and egress.Config).

type Constructor

type Constructor func(Config) (Backend, error)

Constructor builds a Backend from resolved config.

type InvalidWithError

type InvalidWithError struct {
	Reason string            `json:"reason"`
	Detail map[string]string `json:"detail,omitempty"`
}

InvalidWithError signals a malformed WITH clause set (e.g. both `prompt` and `messages` were supplied, or neither). Reason is operator-facing English; Detail is structured for trace.

func (*InvalidWithError) AsJSON

func (e *InvalidWithError) AsJSON() string

func (*InvalidWithError) Code

func (e *InvalidWithError) Code() string

func (*InvalidWithError) Error

func (e *InvalidWithError) Error() string

type Limits

type Limits struct {
	TimeoutMs  int     `json:"timeout_ms,omitempty"`
	MaxCostUSD float64 `json:"max_cost_usd,omitempty"`
}

Limits caps per-call work. Zero values mean "backend default."

type Message

type Message struct {
	Role    string `json:"role"` // "system" | "user" | "assistant"
	Content string `json:"content"`
	Name    string `json:"name,omitempty"`
}

Message is one turn in the conversation. JSON shapes match the OpenAI-compatible API every v1 provider speaks.

type MissingSecretError

type MissingSecretError struct {
	Backend string `json:"backend"`
	Secret  string `json:"secret"`
}

MissingSecretError signals one of the backend's RequiredSecrets() could not be found in either the per-tenant store or (when enabled) the chassis-wide env-var fallback. Names are the secret NAME and the backend NAME — never any cleartext.

func (*MissingSecretError) AsJSON

func (e *MissingSecretError) AsJSON() string

func (*MissingSecretError) Code

func (e *MissingSecretError) Code() string

func (*MissingSecretError) Error

func (e *MissingSecretError) Error() string

type NoBackendError

type NoBackendError struct {
	ProviderHint string   `json:"provider_hint,omitempty"`
	Registered   []string `json:"registered"`
}

NoBackendError signals no backend matches the requested provider hint. Lists the registered backends so the operator can diagnose typos / missed blank imports.

func (*NoBackendError) AsJSON

func (e *NoBackendError) AsJSON() string

func (*NoBackendError) Code

func (e *NoBackendError) Code() string

func (*NoBackendError) Error

func (e *NoBackendError) Error() string

type ProviderHTTPError

type ProviderHTTPError struct {
	StatusCode int    `json:"status_code"`
	Body       string `json:"body,omitempty"`
}

ProviderHTTPError signals a non-401 HTTP error from the provider. Body is the (potentially-sanitized) response body for diagnosis.

func (*ProviderHTTPError) AsJSON

func (e *ProviderHTTPError) AsJSON() string

func (*ProviderHTTPError) Code

func (e *ProviderHTTPError) Code() string

func (*ProviderHTTPError) Error

func (e *ProviderHTTPError) Error() string

type ProviderNetError

type ProviderNetError struct {
	Reason string `json:"reason"`
}

ProviderNetError signals a network / DNS / timeout failure reaching the provider.

func (*ProviderNetError) AsJSON

func (e *ProviderNetError) AsJSON() string

func (*ProviderNetError) Code

func (e *ProviderNetError) Code() string

func (*ProviderNetError) Error

func (e *ProviderNetError) Error() string

type ProviderParseError

type ProviderParseError struct {
	Reason  string `json:"reason"`
	BodyLen int    `json:"body_len"` // 0 = empty body; positive = malformed
}

ProviderParseError signals the provider returned a 2xx with a body the backend could not decode (malformed JSON, empty body, missing required fields). Seen most often when a `:free`-tier provider hits a quota and returns an empty 200 instead of a clean 429 — upstream flake the chassis can't repair, but should surface with a specific code so rule authors can WHEN-handle (e.g., fall back to a paid model on @chat.error.code == "txco_chat_provider_parse").

func (*ProviderParseError) AsJSON

func (e *ProviderParseError) AsJSON() string

func (*ProviderParseError) Code

func (e *ProviderParseError) Code() string

func (*ProviderParseError) Error

func (e *ProviderParseError) Error() string

type Request

type Request struct {
	// Messages is the conversation. Either authored by hand via
	// WITH messages = [...], or built by the handler from
	// WITH system = "..." + WITH prompt = "..." after template render.
	Messages []Message `json:"messages"`

	// Schema, when non-nil, triggers structured-output mode. The handler
	// validates the model's response against this schema after the call;
	// failure populates `chat.error` in the response envelope. Repair
	// semantics (distinguishing "ok" / "repaired" / "failed") is deferred
	// to a follow-up; v1 is binary ok-or-failed.
	Schema json.RawMessage `json:"schema,omitempty"`

	// Model is the resolved model identifier handed to the provider.
	// v1: WITH model = "..." is passed through verbatim; if unset, the
	// backend picks its default.
	Model string `json:"model,omitempty"`

	// Limits scopes the per-call work the backend is allowed to do.
	Limits Limits `json:"limits"`

	// Intent is a trace-only label authors can stamp for diagnosability
	// (e.g. "classify_support_ticket"). Not sent on the wire.
	Intent string `json:"intent,omitempty"`
}

Request is the chassis-normalized chat completion request. The handler decodes op.Meta (WITH-clause materialization) into this shape and the backend translates to its on-wire format (OpenAI-compatible for the v1 backend).

type Response

type Response struct {
	// Text is the assistant's final message content.
	Text string `json:"text"`

	// SchemaValidated, when non-nil, is the schema-validated payload
	// (Text parsed as JSON against Request.Schema). Present when
	// Request.Schema was set AND validation succeeded.
	SchemaValidated json.RawMessage `json:"schema_validated_payload,omitempty"`

	// Provider, Model are observability fields surfaced in the trace
	// event and the response envelope.
	Provider string `json:"provider"`
	Model    string `json:"model"`

	// TokensIn, TokensOut are reported by the provider. Recorded in
	// trace + `_txc.chat.tokens` metadata; NEVER charged to the chassis
	// fuel meter (provider compute is a separate dimension; see the
	// package godoc).
	TokensIn  int64 `json:"tokens_in"`
	TokensOut int64 `json:"tokens_out"`

	// LatencyMS is wall-clock from request build to response parse.
	LatencyMS int64 `json:"latency_ms"`

	// Retries is the count of provider retries the backend performed
	// (0 if the first attempt succeeded).
	Retries int `json:"retries"`
}

Response is the chassis-normalized chat completion result. Backends translate the provider's on-wire shape into this struct.

Tool-loop fields (AssistantMessage, ToolCalls) are intentionally absent in v1 — the tool-loop PR adds them as a non-breaking extension along with a Tools() method on the Backend interface.

type SchemaFailedError

type SchemaFailedError struct {
	Reason string `json:"reason"`
}

SchemaFailedError signals the model's response failed JSON-schema validation. The request still completes (the raw text is preserved in the envelope); rule authors handle via `WHEN @chat.error EXEC ...`.

func (*SchemaFailedError) AsJSON

func (e *SchemaFailedError) AsJSON() string

func (*SchemaFailedError) Code

func (e *SchemaFailedError) Code() string

func (*SchemaFailedError) Error

func (e *SchemaFailedError) Error() string

type UnsupportedSubOpError

type UnsupportedSubOpError struct {
	SubOp string `json:"sub_op"`
}

UnsupportedSubOpError signals an ai://<op> the chassis doesn't dispatch in v1 (only "chat" is wired). Returned by ExecAI before backend selection.

func (*UnsupportedSubOpError) AsJSON

func (e *UnsupportedSubOpError) AsJSON() string

func (*UnsupportedSubOpError) Code

func (e *UnsupportedSubOpError) Code() string

func (*UnsupportedSubOpError) Error

func (e *UnsupportedSubOpError) Error() string

Directories

Path Synopsis
Package openrouter is the v1 ai://chat backend.
Package openrouter is the v1 ai://chat backend.

Jump to

Keyboard shortcuts

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