Phase 2: the loop and the model gateway. The composer in the console has
said "Not connected yet" since it was built, because there was no
assistant endpoint anywhere. There is one now.
- utils/chat.go the gateway, a sibling of embedding.go: one small
interface, a provider switch, the shared postJSON, no
framework. Agents name a TIER (fast/balanced/deep) and
config maps tier to model, so changing provider does not
touch an agent.
- services/assistantService.go one loop for every agent. An agent is a
name, a tier, a prompt and an allow-list — data, not a
class — so a sixth is config rather than a subclass.
- the endpoint under /v1/web, inheriting middleware.WebAuth along with
every other console route. The assistant reads the same data the console
does and must read it as the same person.
What the model does not get to decide:
whose data the caller is built from the verified session in the
controller, never from the request body — there is no
tenant field to fill in. A test scripts the model calling
a tool with {"tenantid": 916} and asserts it ran for 1147.
which tools the registry enforces the agent's allow-list; a test
scripts a call to a tool the agent lacks and asserts the
handler never ran.
when to stop steps and tool calls are counted here. A model that keeps
calling tools is stopped by arithmetic, not by being
asked nicely.
Two quiet failures have tests of their own. A finish_reason of "length"
means the provider cut the reply off mid-sentence, which reads exactly
like a complete answer unless it is flagged. And a truncated tool result
reaches the model in words it will repeat — otherwise it describes a
capped list and an empty one identically.
A refused tool goes back as a message, not an error: a model told "that
tool needs a tenant" can explain it, where a model handed nothing says
"something went wrong".
Optional, like the embedder. Without ASSISTANT_PROVIDER the endpoint
answers "not switched on here", the composer stays disabled, and the tools
still work — they are ordinary Go functions, and only turning a sentence
into a tool call needs a model.
14 tests, against a scripted model rather than a live provider: these are
about what the loop refuses to let a model do, and that has to hold for
any model, including one behaving badly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
286 lines
9.2 KiB
Go
286 lines
9.2 KiB
Go
package utils
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"nearle/config"
|
|
)
|
|
|
|
// The model gateway.
|
|
//
|
|
// A sibling of `embedding.go`, and deliberately the same shape: one small
|
|
// interface, a provider switch behind a factory, the shared `postJSON`, and a
|
|
// `Model()` so what produced an answer can be recorded. No framework — the
|
|
// backend talks to models over plain HTTP already, and the OpenAI chat
|
|
// completions format is what Groq, Ollama, vLLM, LM Studio, Together and Azure
|
|
// all accept, so one client reaches all of them.
|
|
//
|
|
// ── Agents name a tier, never a model ───────────────────────────────────────
|
|
//
|
|
// A tier is a promise about how much thinking a question deserves. Wiring a
|
|
// model name into an agent means editing every agent to change provider, and
|
|
// means an agent's YAML going stale the day a model is retired. The mapping
|
|
// lives in config and nowhere else.
|
|
//
|
|
// ── Degrades rather than fails ──────────────────────────────────────────────
|
|
//
|
|
// `ErrChatNotConfigured` is what a deployment with no provider gets, and the
|
|
// assistant answers "typed questions are not switched on here" instead of a
|
|
// 500. The tools keep working either way: they are ordinary Go functions, and
|
|
// only the part that turns a sentence into a tool call is missing.
|
|
|
|
// Tier is how much thinking a question deserves.
|
|
const (
|
|
// TierFast is a chip answer: one tool, no reasoning.
|
|
TierFast = "fast"
|
|
// TierBalanced is the default, and what most typed questions get.
|
|
TierBalanced = "balanced"
|
|
// TierDeep is multi-step reasoning across several tools.
|
|
TierDeep = "deep"
|
|
)
|
|
|
|
// Roles in a conversation, as the wire format spells them.
|
|
const (
|
|
RoleSystem = "system"
|
|
RoleUser = "user"
|
|
RoleAssistant = "assistant"
|
|
RoleTool = "tool"
|
|
)
|
|
|
|
// ErrChatNotConfigured is what callers see when no provider is set.
|
|
var ErrChatNotConfigured = errors.New("no assistant model is configured")
|
|
|
|
// ToolCall is the model asking for a tool to be run.
|
|
//
|
|
// `Arguments` is decoded here rather than passed along as the raw string the
|
|
// wire carries, so a model that emits malformed JSON is caught at the edge —
|
|
// one error, in one place, instead of every caller parsing it again.
|
|
type ToolCall struct {
|
|
ID string
|
|
Name string
|
|
Arguments map[string]any
|
|
}
|
|
|
|
// Message is one turn.
|
|
type Message struct {
|
|
Role string
|
|
Content string
|
|
// Set on an assistant turn that asked for tools.
|
|
ToolCalls []ToolCall
|
|
// Set on a tool turn, naming the call it answers. Without it the model
|
|
// cannot tell which result belongs to which request when it asked for two.
|
|
ToolCallID string
|
|
// The tool's name, which some providers want on the tool turn as well.
|
|
Name string
|
|
}
|
|
|
|
// ChatRequest is one round trip.
|
|
type ChatRequest struct {
|
|
Tier string
|
|
Messages []Message
|
|
// Tool definitions as the registry describes them: name, description,
|
|
// input_schema. Converted to the provider's shape here so nothing above
|
|
// this file knows what that shape is.
|
|
Tools []map[string]any
|
|
MaxTokens int
|
|
}
|
|
|
|
// ChatReply is what came back.
|
|
type ChatReply struct {
|
|
Content string
|
|
ToolCalls []ToolCall
|
|
// Which model actually answered. Recorded on every audit row: an answer
|
|
// nobody can attribute to a model cannot be reproduced when it is wrong.
|
|
Model string
|
|
// The provider's own word for why it stopped — `stop`, `tool_calls`,
|
|
// `length`. `length` is the one that matters: a truncated answer reads as a
|
|
// complete one unless somebody looks.
|
|
StopReason string
|
|
}
|
|
|
|
// Chat is the whole interface. One method, like the embedder.
|
|
type Chat interface {
|
|
Complete(ctx context.Context, req ChatRequest) (ChatReply, error)
|
|
// ModelFor names the model a tier resolves to, for the audit trail.
|
|
ModelFor(tier string) string
|
|
}
|
|
|
|
const chatTimeout = 60 * time.Second
|
|
|
|
// NewChat builds the gateway, or nil when none is configured.
|
|
//
|
|
// Nil rather than an error for the unconfigured case, matching `NewEmbedder`:
|
|
// "no model here" is a deployment choice, not a fault, and the caller checks
|
|
// for nil exactly as it does for the embedder.
|
|
func NewChat(cfg config.AssistantConfig) (Chat, error) {
|
|
if !cfg.Enabled() {
|
|
return nil, nil
|
|
}
|
|
switch cfg.Provider {
|
|
case "openai", "groq", "ollama", "together", "compatible":
|
|
base := strings.TrimRight(cfg.BaseURL, "/")
|
|
if base == "" {
|
|
base = "https://api.openai.com/v1"
|
|
}
|
|
return &openAIChat{cfg: cfg, base: base, client: &http.Client{Timeout: chatTimeout}}, nil
|
|
}
|
|
return nil, fmt.Errorf("assistant provider %q is not supported", cfg.Provider)
|
|
}
|
|
|
|
// ── OpenAI-compatible chat completions ──────────────────────────────────────
|
|
|
|
type openAIChat struct {
|
|
cfg config.AssistantConfig
|
|
base string
|
|
client *http.Client
|
|
}
|
|
|
|
func (c *openAIChat) ModelFor(tier string) string { return c.cfg.ModelFor(tier) }
|
|
|
|
// wire types, kept unexported: nothing above this file should know that a tool
|
|
// call arrives with its arguments as a string.
|
|
type wireToolCall struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Function struct {
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
} `json:"function"`
|
|
}
|
|
|
|
type wireMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content,omitempty"`
|
|
ToolCalls []wireToolCall `json:"tool_calls,omitempty"`
|
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
|
|
func (c *openAIChat) Complete(ctx context.Context, req ChatRequest) (ChatReply, error) {
|
|
model := c.cfg.ModelFor(req.Tier)
|
|
if model == "" {
|
|
return ChatReply{}, ErrChatNotConfigured
|
|
}
|
|
|
|
messages := make([]wireMessage, 0, len(req.Messages))
|
|
for _, m := range req.Messages {
|
|
wire := wireMessage{Role: m.Role, Content: m.Content, ToolCallID: m.ToolCallID, Name: m.Name}
|
|
for _, call := range m.ToolCalls {
|
|
raw, err := json.Marshal(call.Arguments)
|
|
if err != nil {
|
|
return ChatReply{}, fmt.Errorf("assistant: encoding a tool call: %w", err)
|
|
}
|
|
wt := wireToolCall{ID: call.ID, Type: "function"}
|
|
wt.Function.Name = call.Name
|
|
wt.Function.Arguments = string(raw)
|
|
wire.ToolCalls = append(wire.ToolCalls, wt)
|
|
}
|
|
messages = append(messages, wire)
|
|
}
|
|
|
|
body := map[string]any{
|
|
"model": model,
|
|
"messages": messages,
|
|
}
|
|
if req.MaxTokens > 0 {
|
|
body["max_tokens"] = req.MaxTokens
|
|
}
|
|
if len(req.Tools) > 0 {
|
|
body["tools"] = toolsForWire(req.Tools)
|
|
// "auto", never "required": some questions are answered from what is
|
|
// already in the conversation, and forcing a call makes the model
|
|
// invent one to satisfy the demand.
|
|
body["tool_choice"] = "auto"
|
|
}
|
|
|
|
var out struct {
|
|
Choices []struct {
|
|
Message wireMessage `json:"message"`
|
|
FinishReason string `json:"finish_reason"`
|
|
} `json:"choices"`
|
|
Model string `json:"model"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
auth := ""
|
|
if c.cfg.APIKey != "" {
|
|
auth = "Bearer " + c.cfg.APIKey
|
|
}
|
|
if err := postJSON(ctx, c.client, c.base+"/chat/completions", auth, body, &out); err != nil {
|
|
return ChatReply{}, err
|
|
}
|
|
if out.Error != nil {
|
|
return ChatReply{}, fmt.Errorf("assistant: %s", out.Error.Message)
|
|
}
|
|
if len(out.Choices) == 0 {
|
|
return ChatReply{}, errors.New("assistant: the model returned no choices")
|
|
}
|
|
|
|
choice := out.Choices[0]
|
|
reply := ChatReply{
|
|
Content: choice.Message.Content,
|
|
Model: firstNonEmpty(out.Model, model),
|
|
StopReason: choice.FinishReason,
|
|
}
|
|
|
|
for _, call := range choice.Message.ToolCalls {
|
|
args := map[string]any{}
|
|
// An empty argument string is a call with no arguments, which is
|
|
// ordinary — `{}` and `""` both mean the same thing here.
|
|
if trimmed := strings.TrimSpace(call.Function.Arguments); trimmed != "" {
|
|
if err := json.Unmarshal([]byte(trimmed), &args); err != nil {
|
|
// Caught at the edge rather than passed on. A model emitting
|
|
// malformed JSON is a fact about this round trip, and the loop
|
|
// can retry or give up — but nothing downstream should have to
|
|
// parse it a second time.
|
|
return ChatReply{}, fmt.Errorf("assistant: the model sent unreadable arguments for %s: %w", call.Function.Name, err)
|
|
}
|
|
}
|
|
reply.ToolCalls = append(reply.ToolCalls, ToolCall{
|
|
ID: call.ID,
|
|
Name: call.Function.Name,
|
|
Arguments: args,
|
|
})
|
|
}
|
|
|
|
return reply, nil
|
|
}
|
|
|
|
// toolsForWire converts the registry's description into the provider's shape.
|
|
//
|
|
// The registry speaks `{name, description, input_schema}` because that is what
|
|
// MCP uses and what reads clearly. This is the one place that knows OpenAI
|
|
// wants it wrapped in a `function` object — so a second provider with a
|
|
// different shape is a change here and nowhere else.
|
|
func toolsForWire(defs []map[string]any) []map[string]any {
|
|
out := make([]map[string]any, 0, len(defs))
|
|
for _, def := range defs {
|
|
out = append(out, map[string]any{
|
|
"type": "function",
|
|
"function": map[string]any{
|
|
"name": def["name"],
|
|
"description": def["description"],
|
|
"parameters": def["input_schema"],
|
|
},
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|