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

exp

package
v1.14.0 Latest Latest
Warning

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

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

README

System One decision models for Genkit Go

Adds decision models to Genkit Go: models that speak System One, the protocol TypeSafe AI introduced with jev and that Liquid AI's d1, Cloudflare's Clef, and the gateways serving them also speak. A decision model does not generate text. It evaluates a state against typed questions and returns one typed answer per question, with calibrated probabilities, in a few hundred milliseconds. That makes it a fit for the decisions inside an application: routing, classification, scoring, guardrails, and verification.

Status: in preview. The package lives under go/plugins/systemone/exp and its APIs may change in any minor version release. Import it as systemonex.

One plugin type, SystemOne, serves any server that speaks the protocol. Constructors set it up for the known ones, and any other server takes a few fields; see Servers.

Design principle: the output type is the question set

A decision model is served as a model that speaks only constrained JSON, which is the subset of the generate API it fits exactly. The questions are the fields of the output type, declared with Choice, Score, and Noul. Each field's description is the question's instructions, and each field's JSON is the wire answer, so a decision is one typed generate call and the answer lands in typed fields.

The question types belong to the protocol, not to a server, so one decision type serves every model that speaks it, and a fallback from one to another needs no second type.

import systemonex "github.com/firebase/genkit/go/plugins/systemone/exp"

// The options of a choice belong to their type, with their criteria.
type Dept string

func (Dept) Criteria() map[Dept]string {
	return map[Dept]string{
		"billing":   "Payments, invoicing, refunds",
		"technical": "Bugs, outages, integrations",
		"other":     "None of the above",
	}
}

// The levels of a score belong to their rubric, lowest first.
type Anger int

func (Anger) Levels() []string { return []string{"Calm", "Concerned but civil", "Very angry"} }

// The decision: one question per field.
type Triage struct {
	Department  systemonex.Choice[Dept] `json:"department"  jsonschema_description:"Which team should handle this?"`
	IsUrgent    systemonex.Noul         `json:"is_urgent"   jsonschema_description:"Does the ticket explicitly communicate time pressure?"`
	Frustration systemonex.Score[Anger] `json:"frustration" jsonschema_description:"How frustrated is the customer?"`
}

g := genkit.Init(ctx, genkit.WithPlugins(systemonex.TypeSafe())) // TYPESAFE_API_KEY

out, resp, err := genkit.GenerateData[Triage](ctx, g,
	ai.WithModelName("typesafe/jev-1.13.0"),
	ai.WithPrompt(ticket))
if err != nil {
	return err
}
if out.Department.Confidence < 0.6 { // the caller owns thresholds
	return escalate(ticket)
}
switch out.Department.Choice { // typed
case "billing":
	// ...
}

That is the whole call: the model, the state, and the type. The questions ride on the output schema, which the model reads back, so no output format needs naming.

Everything else is the generate API as it already is: prompt files and the Dev UI prompts page, model middleware such as fallback across servers, the trace with the question set on the request and the distributions on the response, and the token counters.

Question types

Field type Question Answer fields
Choice[T] pick one of T.Criteria() Choice, Probabilities per option, Confidence
Score[L] rate on L.Levels() Score (expected level, fractional), Probabilities, Confidence, Legend
Noul is this true? Probability; near 0.5 means "could not tell"
NoulOf[C] is this true, where C.Criteria() says what yes and no mean as Noul, which is NoulOf with no criteria

A Choice also answers Ranked(), the options from most to least likely, and Margin(), the probability gap between the top two, which is how decisive the choice is. A Score answers Level(), the nearest whole level, and Label(), that level's text from the rubric type, with or without a legend on the answer.

The criteria of every question belong to a type, so a pair of yes and no criteria is a type too, and it is reused wherever the question is asked:

type Urgent struct{}

func (Urgent) Criteria() (yes, no string) {
	return "Names a deadline, or says now or today", "No time pressure is expressed"
}

IsUrgent systemonex.NoulOf[Urgent] `json:"is_urgent" jsonschema_description:"Does the ticket explicitly communicate time pressure?"`

Criteria and levels are strings, and the schema and the answers show those strings. The wire format also takes a structured description, most often an object with labeled parts. A type adds one with Guidance(), the optional companion of its interface: GuidedOption keyed by option, GuidedRubric by level index, and GuidedYesNo by side.

func (Dept) Guidance() map[Dept]any {
	return map[Dept]any{
		"billing": map[string]any{
			"not_for":  "Where an order is, or when it arrives",
			"examples": []string{"I was charged twice for one order."},
		},
	}
}

An object with no what gets the string as its what, so guidance adds to the description. Any other value goes out as it is. A score's legend keeps the rubric's strings, and the guidance the API echoes back is on systemonex.ResponseInfo(resp).Answers.

The built-in enum output format also works, with no decision type: the enum values are the options of one choice question, the system message is the question, and resp.Text() is the option.

resp, err := genkit.Generate(ctx, g,
	ai.WithModelName("typesafe/jev-1.13.0"),
	ai.WithSystem("Which team should handle this ticket?"),
	ai.WithOutputEnums(Billing, Technical, Sales),
	ai.WithPrompt(ticket))
team := Dept(resp.Text())

What the enum format does not give is criteria per option or a probability per option; a Choice field in a decision type gives both.

Questions built at run time

A question whose options come from data, such as the tools on hand, a tenant's categories, or the nodes of a taxonomy, has no type to declare it with. systemonex.Schema builds the output schema from values instead, and the answers come back as a map of systemonex.Answer:

options := make([]systemonex.ChoiceOption, 0, len(tools))
for _, tool := range tools {
	options = append(options, systemonex.ChoiceOption{Name: tool.Name, Criteria: tool.Description})
}
answers, _, err := genkit.GenerateData[map[string]systemonex.Answer](ctx, g,
	ai.WithModelName("typesafe/jev-1.13.0"),
	ai.WithOutputSchema(systemonex.Schema(map[string]systemonex.Question{
		"tool": systemonex.ChoiceQuestion{Instructions: "Which tool serves the request?", Options: options},
		"personal": systemonex.NoulQuestion{
			Instructions: "Does the request involve the user's own data?",
			Yes:          "Names the user's files, mail, or calendar",
			No:           "Asks about the world at large",
		},
	})),
	ai.WithPrompt(request))
if err != nil {
	return err
}
if a := (*answers)["tool"]; a.Confidence >= 0.8 {
	return run(a.Choice)
}

ChoiceQuestion, ScoreQuestion, and NoulQuestion take what the question types take from their type parameters: options with criteria and guidance, levels with guidance by index, and yes and no criteria. A choice's options go out in the order given. Instructions can be a string or any JSON value, such as an object that describes the field of the state a question is about; a system message goes beside structured instructions as the first element of an array.

State

The state is built from the user and model messages, with no instruction text mixed in. A system message is never state: it goes in front of every question's instructions, and for the enum format it is the question. That is the place for shared context, such as what the state is and what its fields mean. A description on the output schema itself, such as one a prompt file's schema carries, follows the system message in every question's instructions. To judge a transcript that has its own system prompt, leave that prompt out of the messages.

  • One message is sent as its value: the string of a text part, or the JSON of a data part. ai.WithPromptParts(ai.NewDataPart(v)) sends any value as an object state, which the vendors recommend so that a question can name a field.
  • Several messages are sent as an array of {role, content} records, roles included, so a question can refer to what the user said and what the model said.
  • With documents attached, the state is {messages, context}, each document as its content, read as a message's is, or as {content, metadata} when it has metadata.

A prompt template renders text, so a template that renders JSON needs stateJSON: true in its config to produce an object state.

Tools are rejected: the model never calls anything.

Media

Media parts become the request's media, by kind: images go in images, audio in audio, and video in videos, each in the order its parts appear, messages first and documents after. The servers place them before the state, and the text of the same messages stays the state; a message of media alone is a turn with empty text. A request may carry media and no text, except to Ollama. Media goes out as a base64 data URL, or as raw base64 to Ollama; a part given as a URL is refused rather than fetched, so download it first, for example with the ai.DownloadRequestMedia middleware. Any other media type is refused.

damage, _, err := genkit.GenerateData[Damage](ctx, g,
	ai.WithModelName("liquid/d1"),
	ai.WithMessages(ai.NewUserMessage(
		ai.NewTextPart("Customer's note: arrived like this."),
		ai.NewMediaPart("image/jpeg", photoDataURL))))

Media is an extension to the protocol, and a server that does not know a field can drop it and answer as if nothing had been sent. A plugin therefore refuses media before the request is sent, kind by kind, unless its Images, Audio, or Video is systemonex.MediaSupported. Ollama() and WorkersAI(accountID) set Images, and so does a server of your own that takes images, such as Liquid's in Any other server. Then a model is sent that kind, and the server refuses it for a model that cannot read it, so a new model needs no setup. A model's ModelSpec overrides the plugin's setting, kind by kind, either way: Clef-omni on Workers AI also reads audio and video. Its zero value keeps the plugin's, so an entry that only sets a Label changes nothing else. How much media a request takes, and how large, is the server's to say.

Prompt files

The decision type is a registered schema, so a prompt file can name it:

genkit.DefineSchemasFor(g, TicketInput{}, Triage{})
---
model: typesafe/jev-1.13.0
input:
  schema: TicketInput
output:
  schema: Triage
---
{{ticket}}

Servers

Models are named by the server they are reached through: the plugin's Provider, then the ID the server uses, so a model a server adds later works on the day it ships.

Constructor Models Key Notes
TypeSafe() typesafe/jev-1.13.0 TYPESAFE_API_KEY TYPESAFE_BASE_URL is read too; lists models; text only
OpenRouter() openrouter-decisions/liquid/d1, openrouter-decisions/typesafe/jev-1.13, openrouter-decisions/~typesafe/jev-latest OPENROUTER_API_KEY alpha Decisions API; lists OpenRouter's decision models; cost in resp.Usage.Custom["cost"]; text only
Ollama() ollama-decisions/clef none http://localhost:11434 unless BaseURL is set; images as raw base64; a request needs text
WorkersAI(accountID) cloudflare-decisions/@cf/cloudflare/clef, cloudflare-decisions/@cf/cloudflare/clef-flash, cloudflare-decisions/@cf/cloudflare/clef-omni, cloudflare-decisions/typesafe/jev CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID when accountID is empty; Clef models read images, Clef-omni audio and video too; a long state is truncated; no versions to pin

A gateway serves several vendors' models under one key, so one plugin reaches all of them. OpenRouter's is named openrouter-decisions because the plugin for its chat models already has openrouter, and an app often uses both; Ollama's is ollama-decisions for the same reason.

A constructor returns a *SystemOne set up for its server, and its fields can still be changed before genkit.Init, such as to pass a key from a secret manager rather than the environment.

TypeSafe

TypeSafe's own API serves jev, by version and as jev-latest, which follows each release. Pin a version in production.

ts := systemonex.TypeSafe()
ts.APIKey = key // when not in TYPESAFE_API_KEY
g := genkit.Init(ctx, genkit.WithPlugins(ts))

out, resp, err := genkit.GenerateData[Triage](ctx, g,
	ai.WithModelName("typesafe/jev-1.13.0"),
	ai.WithPrompt(ticket))
OpenRouter

Models go by OpenRouter's IDs, and each response reports what the request cost. An alias that follows the latest release starts with a tilde, as ~typesafe/jev-latest does; typesafe/jev-latest is not an ID.

g := genkit.Init(ctx, genkit.WithPlugins(systemonex.OpenRouter())) // OPENROUTER_API_KEY

out, resp, err := genkit.GenerateData[Triage](ctx, g,
	ai.WithModelName("openrouter-decisions/liquid/d1"),
	ai.WithPrompt(ticket))
if err != nil {
	return err
}
cost := resp.Usage.Custom["cost"] // in OpenRouter credits

The API is in alpha, and its models are text only here. It takes images inside the state, and can route them to a provider that drops them and answers anyway, so the plugin refuses media before the request is sent.

Ollama

A local Ollama serves the decision models it has pulled, such as Clef, with no key, and they read images. The Dev UI lists Clef, and Models adds the other models pulled.

ollama := systemonex.Ollama()
ollama.BaseURL = "http://gpu-box:11434" // when not on localhost
g := genkit.Init(ctx, genkit.WithPlugins(ollama))

damage, _, err := genkit.GenerateData[Damage](ctx, g,
	ai.WithModelName("ollama-decisions/clef"),
	ai.WithMessages(ai.NewUserMessage(
		ai.NewTextPart("Customer's note: arrived like this."), // Ollama needs text beside images
		ai.NewMediaPart("image/jpeg", photoDataURL))))
Cloudflare Workers AI

Workers AI serves Cloudflare's Clef and Clef-flash, which read images, Clef-omni, which reads images, audio, and video, and partners' models such as jev, which reads text only. A model is named by its Workers AI ID, @cf/ included. The account ID is the argument, or CLOUDFLARE_ACCOUNT_ID when it is empty.

g := genkit.Init(ctx, genkit.WithPlugins(systemonex.WorkersAI(""))) // CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID

out, _, err := genkit.GenerateData[Triage](ctx, g,
	ai.WithModelName("cloudflare-decisions/@cf/cloudflare/clef-flash"),
	ai.WithPrompt(ticket))

// Clef-omni hears and sees: audio and video go beside the text.
check, _, err := genkit.GenerateData[FanCheck](ctx, g,
	ai.WithModelName("cloudflare-decisions/@cf/cloudflare/clef-omni"),
	ai.WithMessages(ai.NewUserMessage(
		ai.NewTextPart("Clip from the server room."),
		ai.NewMediaPart("audio/wav", fanAudioDataURL),
		ai.NewMediaPart("video/mp4", fanVideoDataURL))))

Clef-omni takes up to 4 images, 4 audio clips of up to 5 minutes, and 2 videos of up to a minute, sampled at 2 frames a second; a video's soundtrack is heard only when every video in the request has one. Clef-flash takes 24k tokens of state, media, and questions together, and Clef and Clef-omni 64k.

The base URL has an {account} segment, which the account ID fills. A proxy's BaseURL with no such segment needs no account ID:

cf := systemonex.WorkersAI("")
cf.BaseURL = "https://ai-proxy.example.com/cloudflare" // takes /ai/run/<model>

Workers AI names one model per release, with no versions, so a threshold tuned against a model can move when Cloudflare updates it.

Several servers

Each server is its own plugin, so one app can use several, and the fallback middleware moves a request to another server when one is down. Each model's answers are calibrated on their own, so check which model answered before applying a threshold tuned for one.

cf, or := systemonex.WorkersAI(""), systemonex.OpenRouter()
g := genkit.Init(ctx, genkit.WithPlugins(cf, or))

out, resp, err := genkit.GenerateData[Triage](ctx, g,
	ai.WithModelName("cloudflare-decisions/typesafe/jev"),
	ai.WithUse(&middleware.Fallback{Models: []ai.ModelRef{
		or.ModelRef("typesafe/jev-1.13", nil),
	}}),
	ai.WithPrompt(ticket))
if err != nil {
	return err
}
threshold := thresholds[systemonex.ResponseInfo(resp).Model]

A fallback takes each model's own config, so give every reference the StateJSON it needs, or send the state as a data part.

Any other server

Any other server that speaks the protocol takes a few fields. Provider names the plugin and prefixes its models, and must differ from every other plugin's name; BaseURL is the root that Path (default /v1/systemone, or / for BaseURL itself) and ModelsPath hang under; APIKey is optional for a server that takes none.

// Liquid AI's d1, from Liquid's own API.
liquid := &systemonex.SystemOne{
	Provider:   "liquid",
	BaseURL:    "https://api.liquid.ai/decisions",
	ModelsPath: "/v1/models",
	APIKey:     os.Getenv("LIQUID_API_KEY"),
	Images:     systemonex.MediaSupported, // d1 reads images
}

g := genkit.Init(ctx, genkit.WithPlugins(liquid)) // liquid/d1

A proxy that forwards the native protocol, such as LiteLLM, is reached the same way. A server whose wire is not the native one takes two hooks over the native body, so it needs no Genkit release:

wrapped := &systemonex.SystemOne{
	Provider: "wrapped",
	BaseURL:  "https://decisions.example.com",
	Path:     "/run",
	// The request: the native body under input, the model in the path.
	Route: func(model string, body map[string]any) (string, any, error) {
		delete(body, "model")
		return "/" + model, map[string]any{"input": body}, nil
	},
	// The response: the native body out of the server's envelope.
	Unwrap: func(body []byte) ([]byte, error) {
		var envelope struct{ Result json.RawMessage }
		err := json.Unmarshal(body, &envelope)
		return envelope.Result, err
	},
}
Every server

Models describes the models known ahead, keyed by the server's ID or by the full model name; they are listed in the Dev UI with whatever the server's listing adds, which is kept for five minutes. The fields are read once, when Genkit initializes the plugin. HTTPClient and Headers are the escape hatches to the transport, and Config.Extra merges fields into the request body that the plugin does not model, such as a gateway's session_id or trace; it cannot replace a field the request builds itself.

Requests that fail to connect, time out, are rate limited, or hit a server error are retried twice, with Retry-After honored.

Pin a version in production where the server offers one. Confidence thresholds tuned against one release do not carry over to the next, nor from one model to another, and systemonex.ResponseInfo(resp).Model is the version that answered, on every call. A gateway's cost is resp.Usage.Custom["cost"]. Where a reference with a config is needed, such as a fallback list, the plugin's ModelRef("jev-1.13.0", &cfg) builds one.

Limits

  • No per-item questions. Score a list of passages with one call per passage.
  • No nested decision types: a question is a top-level field.
  • The instructions of a field in a decision type are a string, since a field's description is a tag. Structured instructions need a runtime question.
  • Each server sets its own limits on questions and tokens; jev takes up to 64k tokens of state and questions together, and up to 32k of state and its longest question.
  • A server can cut a long state to fit its limit and answer from what is left, with no error. Check a long document against the model's limit, or split it.
  • No streaming; the answer arrives whole.

Tests

go test ./plugins/systemone/... ./plugins/internal/systemone/... runs against a fake endpoint. With OPENROUTER_API_KEY set, TestOpenRouterLive runs the decision, guidance, enum, history, runtime-question, document, listing, and model-version paths against jev and d1 through OpenRouter. With CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID set, TestWorkersAILive runs a decision on Clef-flash, images on Clef, Clef-flash, and Clef-omni, and audio and video on Clef-omni.

Documentation

Overview

Package exp serves decision models, the models that speak the System One protocol, and holds the question types a decision is declared with. A decision model does not generate text. It evaluates a state against typed questions and returns one typed answer per question, with calibrated probabilities.

The plugin serves such a model as one that speaks only constrained JSON, which is the subset of the generate API it fits exactly. The questions are the fields of the output type, declared with Choice, Score, Noul, and NoulOf, so a decision is one typed generate call with nothing more than the model and the state:

type Dept string

func (Dept) Criteria() map[Dept]string {
	return map[Dept]string{
		"billing":   "Payments, invoicing, refunds",
		"technical": "Bugs, outages, integrations",
		"other":     "None of the above",
	}
}

type Triage struct {
	Department systemonex.Choice[Dept] `json:"department" jsonschema_description:"Which team should handle this?"`
	IsUrgent   systemonex.Noul         `json:"is_urgent"  jsonschema_description:"Does the ticket explicitly communicate time pressure?"`
}

g := genkit.Init(ctx, genkit.WithPlugins(systemonex.TypeSafe()))

out, resp, err := genkit.GenerateData[Triage](ctx, g,
	ai.WithModelName("typesafe/jev-1.13.0"),
	ai.WithPrompt(ticket))
if out.Department.Confidence < 0.6 {
	// route to a human
}

Criteria and levels are strings on their types. GuidedOption, GuidedRubric, and GuidedYesNo add the structured form the wire format also takes, such as examples per option. Questions whose options or instructions come from data are built at run time with Schema and answered as a map of Answer. ResponseInfo reads the model version that answered and the raw answers off a response.

The types are the same for every model that speaks the protocol, so one decision type works with each of them. SystemOne is the plugin that serves the models, from any server that speaks the protocol; TypeSafe and the other constructors set it up for the known ones.

This package is a preview: its API may change in any minor release.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Schema

func Schema(questions map[string]Question) map[string]any

Schema is the output schema that asks the given questions, each answered under its key. Pass it with ai.WithOutputSchema and read the answers as a map of Answer:

options := make([]systemonex.ChoiceOption, 0, len(tools))
for _, tool := range tools {
	options = append(options, systemonex.ChoiceOption{Name: tool.Name, Criteria: tool.Description})
}
answers, _, err := genkit.GenerateData[map[string]systemonex.Answer](ctx, g,
	ai.WithModelName("typesafe/jev-1.13.0"),
	ai.WithOutputSchema(systemonex.Schema(map[string]systemonex.Question{
		"tool": systemonex.ChoiceQuestion{Instructions: "Which tool serves the request?", Options: options},
	})),
	ai.WithPrompt(request))
if err != nil {
	return err
}
tool := (*answers)["tool"].Choice

Types

type Answer

type Answer struct {
	Type          string             `json:"type,omitempty"`
	Choice        string             `json:"choice,omitempty"`
	Score         float64            `json:"score,omitzero"`
	Probability   float64            `json:"noul,omitzero"`
	Probabilities map[string]float64 `json:"probabilities,omitempty"`
	Confidence    float64            `json:"confidence,omitzero"`
	Legend        map[string]string  `json:"legend,omitempty"`
}

Answer is the answer to a question of a Schema, with the fields of its kind set. Type names the kind: "choice" sets Choice, Probabilities over the options, and Confidence; "score" sets Score, Probabilities keyed by level number, Confidence, and Legend; "noul" sets Probability alone. They mean what the same fields of Choice, Score, and NoulOf mean. Decoding an answer sets Type from the field that names the kind, so a response's answers need no discriminator.

func (Answer) MarshalJSON

func (a Answer) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler. It writes the fields of the answer's kind, zeros included, so a score of 0 or a flat distribution's confidence of 0 survives a round trip, and none of another kind's. An answer with no Type writes its non-zero fields.

func (*Answer) UnmarshalJSON

func (a *Answer) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler. A missing type is read from the field every answer of a kind carries: choice, score, or noul.

type Choice

type Choice[T Option[T]] struct {
	Choice        T             `json:"choice"`
	Probabilities map[T]float64 `json:"probabilities,omitempty"`
	Confidence    float64       `json:"confidence"`
}

Choice is the answer to a choice question: one option from T's criteria, the probability of every option, and how concentrated that distribution is. Its JSON is the wire answer, so an output type built from these fields is filled straight from the response.

Probabilities is the model's distribution over the options and sums to 1; Choice is the option with the most of it; Confidence, from 0 to 1, is how concentrated the distribution is.

func (Choice[T]) JSONSchema

func (Choice[T]) JSONSchema() *jsonschema.Schema

JSONSchema encodes the question: the options and their criteria as a oneOf of constants, marked as a choice question. The options go out sorted by name, since a map has no order of its own.

func (Choice[T]) Margin

func (c Choice[T]) Margin() float64

Margin is the probability gap between the two most likely options: how decisive the choice is, where Confidence reads the whole distribution. It is zero with fewer than two options in the distribution.

func (Choice[T]) Ranked

func (c Choice[T]) Ranked() []T

Ranked is the options from most to least likely, ties broken by name. It is empty when the answer carries no distribution.

type ChoiceOption

type ChoiceOption struct {
	Name     string
	Criteria string
	Guidance any
}

ChoiceOption is one option of a ChoiceQuestion: the name that is the answer when it is chosen, what it means, and optionally the structured form of that meaning, as GuidedOption gives it for a Choice. An option with neither criteria nor guidance is described by its name.

type ChoiceQuestion

type ChoiceQuestion struct {
	// Instructions is the question: a string, or any JSON value, such as
	// an object that names the field of the state the question is about.
	Instructions any
	Options      []ChoiceOption
}

ChoiceQuestion asks for one of its options, which go out in the order given.

type Config

type Config = systemone.Config

Config is the per-request configuration of a decision model: StateJSON parses each text part of the state as JSON, so a prompt template that renders a JSON document produces an object state, and Extra merges fields into the request body that this package does not model, such as a gateway's session_id or trace.

type GuidedOption

type GuidedOption[T ~string] interface {
	Guidance() map[T]any
}

GuidedOption is an optional companion to Option for the structured form of a description the wire format takes: any JSON value, most often an object with labeled parts, such as what, not_for, and examples for an option. The strings of Criteria stay required, and the schema and the answers show them; guidance replaces a string on the wire only.

A value that encodes as a JSON object with no "what" key gets the string as its "what", so guidance adds to the description rather than replacing it. Any other value is sent as it is. An option that is absent, or whose value is nil, keeps its string.

func (Dept) Guidance() map[Dept]any {
	return map[Dept]any{
		"billing": map[string]any{
			"not_for":  "Progress of a refund already issued",
			"examples": []string{"I was charged twice for one order."},
		},
	}
}

type GuidedRubric

type GuidedRubric interface {
	Guidance() map[int]any
}

GuidedRubric is the GuidedOption of a Rubric: guidance keyed by level index, lowest level 0, such as summary and signals for a level. Score.Legend and Score.Label keep the strings of Levels.

type GuidedYesNo

type GuidedYesNo interface {
	Guidance() (yes, no any)
}

GuidedYesNo is the GuidedOption of a YesNo: guidance for the yes side and the no side, such as what and examples. The pair rule holds across both: each side needs a string or guidance, or neither does.

type Info

type Info = systemone.Info

Info is what a decision model's response carries beside the answers in the message: the model version that answered, which is what confidence thresholds are tuned against, a gateway's provider and generation ID, and the answers as the API sent them, guidance echoes and fields the answer types do not declare included. ResponseInfo reads it.

func ResponseInfo

func ResponseInfo(resp *ai.ModelResponse) Info

ResponseInfo reads the Info of a decision model's response, from the model or from JSON that carried one, such as a flow's output or a stored trace. It is the zero Info for a response from any other model.

type MediaSupport

type MediaSupport int

MediaSupport says whether models read one kind of media.

const (
	// MediaDefault keeps the setting it overrides: for a model, the
	// plugin's; for the plugin, refusing the media.
	MediaDefault MediaSupport = iota
	// MediaSupported sends the media.
	MediaSupported
	// MediaUnsupported refuses a request with the media before it is sent.
	MediaUnsupported
)

type ModelSpec

type ModelSpec struct {
	// Label names the model in the Dev UI. Empty means one built from the
	// ID.
	Label string

	// Images, Audio, and Video override the plugin's for this model. The
	// zero value keeps the plugin's, so an entry that only sets a Label
	// changes nothing else.
	Images, Audio, Video MediaSupport
}

ModelSpec describes one decision model.

type NoCriteria

type NoCriteria struct{}

NoCriteria is the YesNo of a plain Noul: the question is its description alone.

func (NoCriteria) Criteria

func (NoCriteria) Criteria() (yes, no string)

Criteria implements YesNo with no criteria.

type Noul

type Noul = NoulOf[NoCriteria]

Noul is a NoulOf with no criteria: the question is its description alone, which is the usual yes/no question.

type NoulOf

type NoulOf[C YesNo] struct {
	Probability float64 `json:"noul"`
}

NoulOf is the answer to a yes/no question whose criteria come from C: the probability that the statement is true. A value near 0.5 means the model could not tell, not that the answer is "somewhat". There is no separate confidence; the probability is the whole answer.

func (NoulOf[C]) JSONSchema

func (NoulOf[C]) JSONSchema() *jsonschema.Schema

JSONSchema encodes the question, marked as a noul question, with C's criteria on the x-true and x-false keywords when it has any.

type NoulQuestion

type NoulQuestion struct {
	// Instructions is the question: a string, or any JSON value.
	Instructions            any
	Yes, No                 string
	YesGuidance, NoGuidance any
}

NoulQuestion asks whether a statement is true, with what yes and no mean as YesNo and GuidedYesNo give them for a NoulOf: each side needs a string or guidance, or neither does.

type Option

type Option[T ~string] interface {
	~string
	Criteria() map[T]string
}

Option is the key type of a Choice: a string type that lists its own options and what each one means. The criteria are what the model chooses among, so they belong to the type rather than to any one call. A description is a string; GuidedOption adds the structured form the wire format also takes. Options known only at run time are asked with a ChoiceQuestion instead.

type Dept string

func (Dept) Criteria() map[Dept]string {
	return map[Dept]string{
		"billing":   "Payments, invoicing, refunds",
		"technical": "Bugs, outages, integrations",
		"other":     "None of the above",
	}
}

type Question

type Question interface {
	// contains filtered or unexported methods
}

Question is one question of a set built at run time, for questions whose options, levels, or instructions come from data: the tools or products on hand, the nodes of a taxonomy, a tenant's own categories. ChoiceQuestion, ScoreQuestion, and NoulQuestion implement it, and Schema turns a set of them into an output schema. A question known when the code is written reads better as a field of an output type.

type Rubric

type Rubric interface {
	Levels() []string
}

Rubric supplies the ordered levels of a Score, lowest first. The level's index is its number on the wire, so a three-level rubric scores 0 to 2.

type Score

type Score[L Rubric] struct {
	Score         float64            `json:"score"`
	Probabilities map[string]float64 `json:"probabilities,omitempty"`
	Confidence    float64            `json:"confidence"`
	Legend        map[string]string  `json:"legend,omitempty"`
}

Score is the answer to a rubric question: the expected level, computed from the probability of each level, so it falls between levels when the model is split. Probabilities is that distribution, keyed by level number; Confidence, from 0 to 1, is how concentrated it is; Legend maps each level number back to its description, the string from L even when the level was sent with guidance.

func (Score[L]) JSONSchema

func (Score[L]) JSONSchema() *jsonschema.Schema

JSONSchema encodes the question: the level descriptions ride on the x-levels keyword, since a fractional score cannot be a oneOf of levels.

func (Score[L]) Label

func (s Score[L]) Label() string

Label is the rubric's description of Score.Level. It comes from L, so it does not depend on the answer carrying a legend.

func (Score[L]) Level

func (s Score[L]) Level() int

Level is the nearest whole level, clamped to the rubric.

type ScoreQuestion

type ScoreQuestion struct {
	// Instructions is the question: a string, or any JSON value.
	Instructions any
	Levels       []string
	Guidance     map[int]any
}

ScoreQuestion rates the state on its levels, lowest first, as a Rubric gives them, with guidance keyed by level index as GuidedRubric gives it.

type SystemOne

type SystemOne struct {
	// Provider names the plugin and prefixes its model names. It is
	// required, and must differ from every other plugin's name, so a host
	// that serves chat models through another plugin needs a name of its
	// own here, such as openrouter-decisions.
	Provider string

	// BaseURL is the server's root, which Path and ModelsPath are joined
	// to. It is required, unless a constructor gives a default.
	BaseURL string

	// Path is the request path. Empty means /v1/systemone, and "/" means
	// BaseURL itself, for a BaseURL that is the whole endpoint.
	Path string

	// ModelsPath is the path of the server's model listing. When empty, no
	// listing is asked for, and the Dev UI shows the models in Models.
	ModelsPath string

	// APIKey is sent as a bearer token. A constructor reads it from the
	// environment variable it names when this is empty; a server that takes
	// no key, such as a local one, is reached with none.
	APIKey string

	// Models describes the models known ahead, keyed by the ID the server
	// uses or by the full model name, Provider/ID; a server ID that itself
	// starts with Provider/ takes the full name. They are listed in the Dev
	// UI whether or not the server has a listing. Any other ID resolves
	// too, as a model with no description.
	Models map[string]ModelSpec

	// Images, Audio, and Video say whether the models read each kind of
	// media. The zero value refuses a request with media of that kind
	// before it is sent, since media is an extension to the protocol, and
	// a server that does not know a field can drop it and answer without
	// the media, as OpenRouter's does for a model that reads none.
	// [MediaSupported] sends it, for a server that takes it, and leaves a
	// model that cannot read it to the server to refuse. A model's
	// ModelSpec overrides each.
	Images, Audio, Video MediaSupport

	// HTTPClient sends the requests. It is the escape hatch to transport
	// settings: timeouts, proxies, and client middleware. When nil, a
	// client with a 30-second timeout is used.
	HTTPClient *http.Client

	// Headers are sent on every request, after the authorization header.
	Headers http.Header

	// Route, for a server whose wire is not the native one, builds the
	// request it takes. It gets the model ID and the native body, which it
	// may change, and returns the path to append to Path, which is joined
	// with a slash when it does not start with one, and the body to send.
	// When nil, the native body goes to Path.
	Route func(model string, body map[string]any) (path string, out any, err error)

	// Unwrap, for a server that wraps its responses, returns the native
	// response from the body the server sent, such as by taking it out of
	// an envelope. An error it returns is reported under Provider, as
	// UNKNOWN unless it carries a Genkit status. When nil, the body is read
	// as the native response.
	Unwrap func(body []byte) ([]byte, error)
	// contains filtered or unexported fields
}

SystemOne is a plugin for any server that speaks System One. It serves the server's decision models under Provider, by the IDs the server uses: a model the server calls d1 is Provider/d1, and one a gateway calls liquid/d1 is Provider/liquid/d1. Any ID resolves, so a model the server adds later works without a change here.

For a server with no constructor of its own, set the fields:

&systemonex.SystemOne{
	Provider:   "liquid",
	BaseURL:    "https://api.liquid.ai/decisions",
	ModelsPath: "/v1/models",
	APIKey:     os.Getenv("LIQUID_API_KEY"),
	Images:     systemonex.MediaSupported,
}

The constructors, such as TypeSafe, return one set up for a known server; its fields can still be changed before the plugin is used. The fields are read once, by Init: a change after it has no effect.

func Ollama

func Ollama() *SystemOne

Ollama is a local Ollama server, which serves the decision models it has pulled, such as Clef, and takes no key. Its models are ollama-decisions/<name> by Ollama's name, such as ollama-decisions/clef. The name is not ollama, which the plugin for Ollama's chat models has, so both serve one app. The server is http://localhost:11434 unless BaseURL says otherwise. The Dev UI lists Clef, and Models adds the other models pulled.

Ollama takes images as raw base64 rather than data URLs, which this server converts, and requires text in the state, so a request with no text, such as one of images alone, is refused before it is sent.

func OpenRouter

func OpenRouter() *SystemOne

OpenRouter is OpenRouter's Decisions API, which serves several vendors' decision models under one key. Its models are openrouter-decisions/<ID> by OpenRouter's ID, such as openrouter-decisions/liquid/d1 and openrouter-decisions/typesafe/jev-1.13. The name is not openrouter, which the plugin for OpenRouter's chat models has, so both serve one app. The API key comes from OPENROUTER_API_KEY. The Dev UI lists the models OpenRouter lists as decision models, and the best known of them when the listing fails, and a request's cost is reported in the response's usage.

Its models are text only here. OpenRouter's Decisions API takes images in the state, not in the images field, and it can route a request to a model or a provider that drops them and answers without them, so a request with media is refused before it is sent.

func TypeSafe

func TypeSafe() *SystemOne

TypeSafe is TypeSafe AI's own API, which serves jev, the first System One model. Its models are typesafe/<id>, such as typesafe/jev-1.13.0, and read text and JSON only. The API key comes from TYPESAFE_API_KEY and the base URL from TYPESAFE_BASE_URL, the variables TypeSafe's SDKs read. Pin a version in production: confidence thresholds tuned against one release do not carry over to the next.

func WorkersAI

func WorkersAI(accountID string) *SystemOne

WorkersAI is Cloudflare Workers AI for one account. It serves Cloudflare's own decision models, Clef and Clef-flash, which read images, and Clef-omni, which reads images, audio, and video, and partners' models such as jev. Its models are cloudflare-decisions/<ID> by the Workers AI ID: cloudflare-decisions/@cf/cloudflare/clef, cloudflare-decisions/@cf/cloudflare/clef-flash, cloudflare-decisions/@cf/cloudflare/clef-omni, and cloudflare-decisions/typesafe/jev. The API token comes from CLOUDFLARE_API_TOKEN, and the account ID from CLOUDFLARE_ACCOUNT_ID when accountID is empty. A BaseURL with no {account} segment, such as a proxy's, needs no account ID.

Workers AI names one model per release, with no versions, so a threshold tuned against a model can move when Cloudflare updates it.

func (*SystemOne) Init

func (s *SystemOne) Init(ctx context.Context) []api.Action

Init implements api.Plugin. It builds the client, and panics without a provider name or a base URL, or without a key or an account ID for a server that requires one, since every request would fail, and when Models names one model twice. No actions are registered up front: models resolve by name.

func (*SystemOne) ListActions

func (s *SystemOne) ListActions(ctx context.Context) []api.ActionDesc

ListActions implements api.DynamicPlugin. It lists the models in Models together with the ones the server's listing names. A listing that fails or takes too long leaves the models in Models, so the Dev UI still shows them while offline. A listing is kept for a few minutes, and a failed one is not tried again for a minute, so the Dev UI does not wait on the server each time it lists actions.

func (*SystemOne) ModelRef

func (s *SystemOne) ModelRef(id string, config *Config) ai.ModelRef

ModelRef returns a reference to one of the plugin's models with a config, for the places that take a reference rather than a name, such as a fallback list. The ID is the server's or the full model name, as a key of Models is: ModelRef("jev-1.13.0", cfg) and ModelRef("typesafe/jev-1.13.0", cfg) on TypeSafe are both typesafe/jev-1.13.0. With no config to attach, ai.WithModelName with the full name is the usual way to pick the model.

func (*SystemOne) Name

func (s *SystemOne) Name() string

Name implements api.Plugin.

func (*SystemOne) ResolveAction

func (s *SystemOne) ResolveAction(atype api.ActionType, id string) api.Action

ResolveAction implements api.DynamicPlugin. Models are the only action type served. The ID is forwarded as given; whether it exists is for the server to say when the first request is made.

type YesNo

type YesNo interface {
	Criteria() (yes, no string)
}

YesNo supplies what yes and no mean for a NoulOf question, as the criteria of a Choice and the levels of a Score come from their types. Both sides are given: the API takes the pair or nothing.

type Urgent struct{}

func (Urgent) Criteria() (yes, no string) {
	return "Names a deadline, or says now or today", "No time pressure is expressed"
}

Jump to

Keyboard shortcuts

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