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ção | Papel |
|---|---|
NewFromEnv() *Client | lê 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 string | configuração direta |
Headers map[string]string | cabeçalhos extras (OpenRouter, Azure...) |
HTTPClient *http.Client | cliente 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) error | resposta 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)) ToolFuncschema == nil significa "sem argumentos". Erros e pânicos da função viram texto para o modelo (error: ...) e aparecem em Step.Err.
Agent#
| Campo | Papel |
|---|---|
Name | identifica o agente em Step.Agent e nos handoffs (transfer_to_<slug>) |
Instructions | mensagem system |
Model | substitui o modelo do cliente |
Tools []*Tool | ferramentas |
Handoffs []*Agent | agentes para os quais este pode transferir a conversa |
MaxTurns | limite de chamadas ao modelo por Run (padrão 10; excedido → ErrMaxTurns) |
Temperature *float64, ResponseFormat | passados 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) errorServe é 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:
| Evento | Dado |
|---|---|
text | o 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 ServeOpts | O que faz |
|---|---|
MaxHistory | quantas 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 |
MaxInput | maior corpo de pedido aceito (padrão ai.DefaultServeInput, 256 KB) |
HTML | renderiza a resposta pronta para o navegador; ui.ChatHTML é o que combina com ui.Chat. Sem ele a resposta fica em texto |
Page | responde ao pedido que não pediu fluxo, para o app desenhar a página com a mensagem dentro |
Context | recebe 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 TranscribeOpts | O que faz |
|---|---|
Model | o modelo de transcrição: TRILHA_AI_TRANSCRIBE_MODEL, depois "whisper-1" |
Language | ISO-639-1 do que foi dito; vazio deixa a detecção com o provedor |
Prompt | soletra os nomes e siglas que o modelo erraria |
Format | o response_format: "json" (padrão), "verbose_json", "text" |
Filename | o nome que o provedor vê; padrão "audio.webm" |
MaxBytes | o 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 SpeakOpts | O que faz |
|---|---|
Model | o modelo de fala: TRILHA_AI_SPEECH_MODEL, depois "tts-1" |
Voice | o nome da voz no provedor; padrão "alloy" |
Format | "mp3" (padrão), "opus", "aac", "flac", "wav", "pcm" |
Speed | multiplica 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.