// Package reply drafts short replies with an LLM (FR-8). // // The model is reached through OmniRoute, the house AI gateway (AGENTS.md §8, // §10): every non-JEV model goes through it. OmniRoute exposes an // OpenAI-compatible /chat/completions endpoint, so this is a tiny client rather // than a vendor SDK. package reply import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "math/rand" "net/http" "strings" "time" ) // DefaultBaseURL is the OmniRoute API on the host that runs this service (.13). // It matches the URL the other house apps use (dsh, worldmonitor). The // OMNIROUTE_BASE_URL environment variable overrides it. const DefaultBaseURL = "http://192.168.20.13:20129/v1" // DefaultModel is the OmniRoute router alias for a good general chat model. const DefaultModel = "auto/best-chat" // ErrNotConfigured means no OmniRoute credentials are available, so no draft can // be generated. var ErrNotConfigured = errors.New("OmniRoute is not configured: set OMNIROUTE_API_KEY") // Client is a minimal OpenAI-compatible chat-completions client. type Client struct { baseURL string apiKey string model string session string http *http.Client logger *slog.Logger } // NewClient builds a Client. Empty baseURL/model fall back to the defaults. func NewClient(baseURL, apiKey, model string, logger *slog.Logger) *Client { baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") if baseURL == "" { baseURL = DefaultBaseURL } if strings.TrimSpace(model) == "" { model = DefaultModel } if logger == nil { logger = slog.New(slog.DiscardHandler) } return &Client{ baseURL: baseURL, apiKey: strings.TrimSpace(apiKey), model: model, session: fmt.Sprintf("email-agent-%d-%d", time.Now().Unix(), rand.Int63()), http: &http.Client{Timeout: 90 * time.Second}, logger: logger, } } // Configured reports whether an API key is present. func (c *Client) Configured() bool { return c.apiKey != "" } // Model returns the configured model alias. func (c *Client) Model() string { return c.model } // BaseURL returns the configured gateway URL (used in diagnostics only). func (c *Client) BaseURL() string { return c.baseURL } type chatMessage struct { Role string `json:"role"` Content string `json:"content"` } type chatRequest struct { Model string `json:"model"` Messages []chatMessage `json:"messages"` Stream bool `json:"stream"` Temperature float64 `json:"temperature"` } type chatResponse struct { Model string `json:"model"` Choices []struct { Message chatMessage `json:"message"` } `json:"choices"` Error *struct { Message string `json:"message"` Type string `json:"type"` } `json:"error"` } // Complete sends a system and user prompt and returns the assistant text. func (c *Client) Complete(ctx context.Context, system, user string) (string, error) { if !c.Configured() { return "", ErrNotConfigured } payload, err := json.Marshal(chatRequest{ Model: c.model, Messages: []chatMessage{ {Role: "system", Content: system}, {Role: "user", Content: user}, }, Stream: false, Temperature: 0.3, }) if err != nil { return "", fmt.Errorf("encode OmniRoute request: %w", err) } const maxAttempts = 3 var lastErr error for attempt := 1; attempt <= maxAttempts; attempt++ { text, retryable, err := c.do(ctx, payload) if err == nil { return text, nil } lastErr = err if !retryable || attempt == maxAttempts { break } delay := time.Duration(1<<(attempt-1)) * time.Second c.logger.Warn("omniroute request failed; retrying", "attempt", attempt, "delay_ms", delay.Milliseconds(), "error", err) select { case <-ctx.Done(): return "", ctx.Err() case <-time.After(delay): } } return "", lastErr } func (c *Client) do(ctx context.Context, payload []byte) (string, bool, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(payload)) if err != nil { return "", false, fmt.Errorf("build OmniRoute request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+c.apiKey) // Some OmniRoute upstreams (opencode-go) require a session id. req.Header.Set("x-opencode-session", c.session) resp, err := c.http.Do(req) if err != nil { return "", true, fmt.Errorf("call OmniRoute: %w", err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return "", true, fmt.Errorf("read OmniRoute response: %w", err) } if resp.StatusCode != http.StatusOK { retryable := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable || resp.StatusCode >= 500 return "", retryable, fmt.Errorf("OmniRoute returned %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) } var out chatResponse if err := json.Unmarshal(raw, &out); err != nil { return "", false, fmt.Errorf("decode OmniRoute response: %w", err) } if out.Error != nil && out.Error.Message != "" { return "", false, fmt.Errorf("OmniRoute error: %s", out.Error.Message) } if len(out.Choices) == 0 { return "", false, errors.New("OmniRoute response contained no choices") } text := strings.TrimSpace(out.Choices[0].Message.Content) if text == "" { return "", false, errors.New("OmniRoute returned an empty reply") } return text, false, nil }