Files
noema/internal/llm/providers/common.go
T
Andy Dixon d9c6ffa30f feat: add provider-neutral LLM layer with secrets, budgets, recording and prompts
Phase 7 (ADR-0004). Cognitive code asks the router for a role; the
router maps roles to configured models with ordered fallbacks, applies
global and per-mind daily token budgets and a concurrency limit, records
every call (request, response, usage, latency, cost, seed, request hash)
and can serve recordings back for exact replay. Providers are raw HTTP
clients for OpenAI-compatible, Anthropic and Ollama endpoints, with
embedding support for the embed role wired into memory retrieval.

Secrets are references only: env:, file:, or enc: values encrypted with
AES-256-GCM under NOEMA_MASTER_KEY; keys are resolved at call time and
never rendered, logged or exported. Outbound HTTP refuses private and
loopback addresses at dial time unless a provider explicitly allows it,
bounds redirects and caps bodies. Provider errors are scrubbed.

Prompts are named, versioned rows with system and user templates and an
optional JSON schema; untrusted text enters only through delimited data
blocks. Six defaults are seeded and never overwritten. The UI adds the
LLM page (providers, models, role matrix, usage, errors, budgets), a
provider page with connectivity test, and a prompt manager with editor,
test runner, history, restore, clone and diff. CLI: provider, prompt.
2026-06-23 23:43:33 +01:00

144 lines
3.9 KiB
Go

// Package providers implements LanguageModel and Embedder clients over raw
// HTTP for OpenAI-compatible, Anthropic and Ollama endpoints. No vendor SDKs:
// the surface we need is small and this keeps the dependency tree flat.
package providers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/internal/security/httpclient"
)
// Config is the shared provider configuration.
type Config struct {
Name string
Endpoint string
APIKey string // resolved plaintext; never stored on the struct beyond the request
Model string
Timeout time.Duration
AllowPrivate bool
Headers map[string]string
}
func (c Config) client() *http.Client {
t := c.Timeout
if t <= 0 {
t = 60 * time.Second
}
return httpclient.New(httpclient.Options{Timeout: t, AllowPrivate: c.AllowPrivate, MaxBody: 16 << 20})
}
func (c Config) url(path string) string {
return strings.TrimRight(c.Endpoint, "/") + path
}
// post sends JSON and decodes JSON, mapping non-2xx to llm.ErrProvider with a
// scrubbed message (bodies may echo request content, never our key).
func post(ctx context.Context, client *http.Client, url string, headers map[string]string, in any, out any) error {
body, err := json.Marshal(in)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "noema/1")
for k, v := range headers {
req.Header.Set(k, v)
}
res, err := client.Do(req)
if err != nil {
return fmt.Errorf("%w: %v", llm.ErrProvider, scrub(err.Error()))
}
defer res.Body.Close()
raw, err := httpclient.ReadAll(res.Body, 16<<20)
if err != nil {
return fmt.Errorf("%w: %v", llm.ErrProvider, err)
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("%w: HTTP %d: %s", llm.ErrProvider, res.StatusCode, truncate(scrub(string(raw)), 300))
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("%w: undecodable response: %v", llm.ErrProvider, err)
}
return nil
}
// scrub removes anything that looks like a bearer token from error text.
func scrub(s string) string {
for _, marker := range []string{"Bearer ", "sk-", "x-api-key"} {
if i := strings.Index(s, marker); i >= 0 {
end := i + len(marker)
for end < len(s) && s[end] != ' ' && s[end] != '"' && s[end] != '\n' {
end++
}
s = s[:i] + marker + "[redacted]" + s[end:]
}
}
return s
}
func truncate(s string, n int) string {
if len(s) > n {
return s[:n] + "…"
}
return s
}
// messages prepends the system prompt for protocols that carry it inline.
func messages(req llm.Request) []llm.Message {
if req.System == "" {
return req.Messages
}
return append([]llm.Message{{Role: "system", Content: req.System}}, req.Messages...)
}
// parseJSON extracts the first JSON object from text (models sometimes wrap
// it in prose or fences) and returns it compacted.
func parseJSON(text string) (json.RawMessage, bool) {
s := strings.TrimSpace(text)
if strings.HasPrefix(s, "```") {
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```")
if i := strings.LastIndex(s, "```"); i >= 0 {
s = s[:i]
}
s = strings.TrimSpace(s)
}
start := strings.IndexAny(s, "{[")
if start < 0 {
return nil, false
}
dec := json.NewDecoder(strings.NewReader(s[start:]))
var v json.RawMessage
if err := dec.Decode(&v); err != nil {
return nil, false
}
var buf bytes.Buffer
if err := json.Compact(&buf, v); err != nil {
return nil, false
}
return buf.Bytes(), true
}
func finish(req llm.Request, resp *llm.Response) error {
if len(req.JSONSchema) > 0 {
j, ok := parseJSON(resp.Text)
if !ok {
return llm.ErrBadJSON
}
resp.JSON = j
}
return nil
}