Ir para o conteúdo
Trilha
Capítulos

Referência

ai

Cliente OpenAI-compatível, ferramentas, agentes, handoffs e composição.

import "github.com/emersonjoe/trilha/ai" — sem dependências externas.

Client#

Campo / funçãoPapel
NewFromEnv() *Clientlê OPENAI_API_KEY, OPENAI_BASE_URL (padrão ai.DefaultBaseURL, https://api.openai.com/v1) e TRILHA_AI_MODEL (ou OPENAI_MODEL; padrão gpt-4o-mini)
BaseURL, APIKey, Model stringconfiguração direta
Headers map[string]stringcabeçalhos extras (OpenRouter, Azure...)
HTTPClient *http.Clientcliente HTTP (padrão com timeout de 2 min)
Chat(ctx, Request) (*Response, error)uma chamada; Response.Text() e Response.ToolCalls()
Stream(ctx, Request, func(Delta) error) errorresposta em pedaços; Delta.Content, Delta.ToolCalls, Delta.Usage no fim

Respostas não-2xx viram *ai.Error{Status, Code, Message}.

Request e mensagens#

Request{Model, Messages, Tools, ToolChoice, Temperature, MaxTokens, ResponseFormat, Extra}. Extra map[string]any é mesclado no JSON enviado, para parâmetros específicos do provedor. ResponseFormat{Type: "json_schema", JSONSchema: ...} pede saída estruturada.

Construtores: ai.System(s), ai.User(s), ai.Assistant(s), ai.ToolResult(callID, s).

A forma que vai no fio#

Os tipos do protocolo são exportados, porque provedor que difere em um campo é o caso normal e ler a resposta na mão tem de continuar possível: Response.Choices é um []ai.Choice, cada um com sua Message e seu FinishReason; uma chamada que o modelo pede é um ai.ToolCall{ID, Type, Function} cujo Function é um ai.FunctionCall{Name, Arguments} — os argumentos chegam como o texto JSON que o modelo escreveu, não como mapa, porque é esse texto que o schema valida. O que sai descrevendo uma ferramenta é um ai.ToolDef{Type, Function} sobre um ai.FunctionDef{Name, Description, Parameters, Strict}, que é o que o Tool.Def() constrói a partir de um *ai.Tool. O Request tem um MarshalJSON próprio para o Extra mesclar no mesmo objeto em vez de aninhar sob uma chave que provedor nenhum lê.

Tool#

func NewTool(name, description string, schema json.RawMessage, fn ToolFunc) *Tool
type ToolFunc func(ctx context.Context, args json.RawMessage) (string, error)
func Schema(s string) json.RawMessage           // valida o JSON; pânico no início se inválido
func Typed[T any](fn func(ctx, in T) (string, error)) ToolFunc

schema == nil significa "sem argumentos". Erros e pânicos da função viram texto para o modelo (error: ...) e aparecem em Step.Err.

Agent#

CampoPapel
Nameidentifica o agente em Step.Agent e nos handoffs (transfer_to_<slug>)
Instructionsmensagem system
Modelsubstitui o modelo do cliente
Tools []*Toolferramentas
Handoffs []*Agentagentes para os quais este pode transferir a conversa
MaxTurnslimite de chamadas ao modelo por Run (padrão 10; excedido → ErrMaxTurns)
Temperature *float64, ResponseFormatpassados em cada requisição
func Run(ctx, cli *Client, agent *Agent, input string, history ...Message) (*Result, error)
func RunStream(ctx, cli *Client, agent *Agent, input string, fn func(Event), history ...Message) (*Result, error)

Result{Output, Agent, Messages, Steps, Usage, Turns}. Messages serve de histórico para a próxima chamada (mensagens system do histórico são ignoradas; valem as do agente atual).

Event.Type: text (Text), tool_call e tool_result (Step), handoff (Step.HandoffTo, Agent = novo agente), done (Result), error (Err).

Ferramentas de uma mesma rodada rodam em paralelo; a ordem dos resultados no histórico é a ordem em que o modelo as pediu. Um handoff troca a mensagem system, mantém o histórico e continua o laço com o agente alvo.

Composição#

func (a *Agent) AsTool(cli *Client, description string) *Tool   // {"input": "..."} → texto
func Parallel(ctx, cli, input string, agents ...*Agent) ([]*Result, error)
func Chain(ctx, cli, input string, agents ...*Agent) (*Result, error)

Parallel devolve na ordem dos agentes e propaga o primeiro erro; Chain passa Output de um como input do próximo.

Chat por HTTP#

func Serve(c *trilha.Ctx, cli *Client, agent *Agent) error
func (o ServeOpts) Serve(c *trilha.Ctx, cli *Client, agent *Agent) error

Serve é a metade de rota do ui.Chat: lê a mensagem, roda o agente e responde ao pedido.

// app/api/chat/route.go
func POST(c *trilha.Ctx) error {
	return ai.ServeOpts{HTML: ui.ChatHTML}.Serve(c, cliente, assistente)
}

O pedido traz {"message": "…", "history": [...]} em JSON, ou um campo message de formulário. Uma lista {"messages": [...]} também é lida: o último turno user é a mensagem e o que vem antes é o histórico.

Quando o cliente pede Accept: text/event-stream, a resposta é um fluxo de eventos com nome — o Stream do framework, com o contrato fixo:

EventoDado
texto pedaço de texto, como está
tool_call, tool_result, handoff{agent, tool, call_id, arguments, output, to, error}
done{agent, output, html, history, usage}
error{message}

Sem o cabeçalho a resposta inteira vem de uma vez — o pedido que chega quando o JavaScript não está lá. É o mesmo conteúdo do done como corpo JSON, ou o que o ServeOpts.Page renderizar.

Campo de ServeOptsO que faz
MaxHistoryquantas mensagens antigas voltam para o modelo (padrão ai.DefaultServeHistory, 40; negativo não guarda nenhuma). O histórico vem do navegador, então o teto é do servidor
MaxInputmaior corpo de pedido aceito (padrão ai.DefaultServeInput, 256 KB)
HTMLrenderiza a resposta pronta para o navegador; ui.ChatHTML é o que combina com ui.Chat. Sem ele a resposta fica em texto
Pageresponde ao pedido que não pediu fluxo, para o app desenhar a página com a mensagem dentro
Contextrecebe os campos ctx.* que a página mandou junto com a mensagem — o id do registro aberto, a rota em que a pessoa está — e o que devolve entra na frente do histórico

O Context é onde o ui.ChatOpts.Context chega, pelos dois caminhos: o JSON que o script manda e os campos escondidos de um formulário que submeteu sozinho.

ai.ServeOpts{Context: func(c *trilha.Ctx, campos map[string]string) []ai.Message {
	return []ai.Message{{Role: "user", Content: "A folha aberta é a " + campos["folha"] + "."}}
}}

Os campos vêm do pedido, então são o que a pessoa mandou e não o que o servidor sabe: confira ali o que um id dá acesso, do mesmo jeito que se confere um parâmetro de query.

Falha antes do primeiro byte é erro comum (um problema com o status dele). Depois dele a linha de status já foi: a falha viaja como evento error e o fluxo fecha.

O histórico é do app — o framework não guarda sessão de conversa.

Voz#

O mesmo protocolo transcreve e fala. Dois métodos, o mesmo Client, o mesmo *Error quando o provedor recusa.

func (c *Client) Transcribe(ctx context.Context, r io.Reader, o TranscribeOpts) (Transcript, error)
func (c *Client) Speak(ctx context.Context, text string, o SpeakOpts) (io.ReadCloser, error)

O Transcribe posta o r como multipart/form-data em /audio/transcriptions e responde um Transcript (Text, mais Language e Duration quando o formato é "verbose_json").

Campo de TranscribeOptsO que faz
Modelo modelo de transcrição: TRILHA_AI_TRANSCRIBE_MODEL, depois "whisper-1"
LanguageISO-639-1 do que foi dito; vazio deixa a detecção com o provedor
Promptsoletra os nomes e siglas que o modelo erraria
Formato response_format: "json" (padrão), "verbose_json", "text"
Filenameo nome que o provedor vê; padrão "audio.webm"
MaxByteso máximo lido de r; padrão 25 MB. Gravação maior é um *Error com Status 413, e nenhum pedido sai

O Speak posta {model, input, voice, response_format, speed} em /audio/speech e devolve o áudio conforme ele chega; quem chamou fecha o corpo. O SpeakOpts.ContentType() é o tipo de mídia do Format — "audio/mpeg" para o "mp3" padrão —, que é como a rota serve, para o ui.Audio tocar.

Campo de SpeakOptsO que faz
Modelo modelo de fala: TRILHA_AI_SPEECH_MODEL, depois "tts-1"
Voiceo nome da voz no provedor; padrão "alloy"
Format"mp3" (padrão), "opus", "aac", "flac", "wav", "pcm"
Speedmultiplica o ritmo (0,25 a 4); zero deixa o 1 do provedor
// app/api/voz/route.go
up, err := c.File("audio", trilha.FileRules{Accept: []string{"audio/*", "video/webm"}})
if err != nil {
	return err
}
defer up.Close()
t, err := cliente.Transcribe(c.Context(), up, ai.TranscribeOpts{Language: "pt", Filename: up.Name})

// app/api/voz/fala/route.go
body, err := cliente.Speak(c.Context(), c.Query("texto"), ai.SpeakOpts{Voice: "nova"})
if err != nil {
	return err
}
defer body.Close()
c.Header("Cache-Control", "private, no-store")
return c.Inline("resposta.mp3", body, ai.SpeakOpts{}.ContentType())

A gravação vem do ui.Recorder, e a volta inteira — gravar, transcrever, responder, falar — é a seção de voz do capítulo.