Files
noema/internal/llm/providers/openai.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

127 lines
3.4 KiB
Go

package providers
import (
"context"
"encoding/json"
"time"
"github.com/andydixon/noema/internal/llm"
)
// OpenAI speaks the chat-completions protocol shared by many servers.
type OpenAI struct{ cfg Config }
// NewOpenAI creates a client.
func NewOpenAI(cfg Config) *OpenAI { return &OpenAI{cfg: cfg} }
func (p *OpenAI) headers() map[string]string {
h := map[string]string{}
if p.cfg.APIKey != "" {
h["Authorization"] = "Bearer " + p.cfg.APIKey
}
for k, v := range p.cfg.Headers {
h[k] = v
}
return h
}
// Complete implements llm.LanguageModel.
func (p *OpenAI) Complete(ctx context.Context, req llm.Request) (llm.Response, error) {
body := map[string]any{"model": p.cfg.Model, "messages": messages(req)}
if req.Temperature != nil {
body["temperature"] = *req.Temperature
}
if req.TopP != nil {
body["top_p"] = *req.TopP
}
if req.MaxTokens > 0 {
body["max_tokens"] = req.MaxTokens
}
if req.Seed != nil {
body["seed"] = *req.Seed
}
if len(req.Stop) > 0 {
body["stop"] = req.Stop
}
if len(req.JSONSchema) > 0 {
body["response_format"] = map[string]any{"type": "json_object"}
}
var out struct {
Model string `json:"model"`
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
start := time.Now()
if err := post(ctx, p.cfg.client(), p.cfg.url("/chat/completions"), p.headers(), body, &out); err != nil {
return llm.Response{}, err
}
resp := llm.Response{Provider: p.cfg.Name, Model: firstNonEmpty(out.Model, p.cfg.Model), Latency: time.Since(start),
Usage: llm.Usage{PromptTokens: out.Usage.PromptTokens, CompletionTokens: out.Usage.CompletionTokens, TotalTokens: out.Usage.TotalTokens}}
if len(out.Choices) > 0 {
resp.Text, resp.FinishReason = out.Choices[0].Message.Content, out.Choices[0].FinishReason
}
return resp, finish(req, &resp)
}
// OpenAIEmbedder calls /embeddings.
type OpenAIEmbedder struct {
cfg Config
dims int
}
// NewOpenAIEmbedder creates an embedder; dims is informational until the first call.
func NewOpenAIEmbedder(cfg Config, dims int) *OpenAIEmbedder {
return &OpenAIEmbedder{cfg: cfg, dims: dims}
}
// Model implements embeddings.Embedder.
func (e *OpenAIEmbedder) Model() string { return e.cfg.Name + "/" + e.cfg.Model }
// Dims implements embeddings.Embedder.
func (e *OpenAIEmbedder) Dims() int { return e.dims }
// Embed implements embeddings.Embedder.
func (e *OpenAIEmbedder) Embed(ctx context.Context, texts []string) ([][]float32, error) {
var out struct {
Data []struct {
Index int `json:"index"`
Embedding []float32 `json:"embedding"`
} `json:"data"`
}
p := &OpenAI{cfg: e.cfg}
if err := post(ctx, e.cfg.client(), e.cfg.url("/embeddings"), p.headers(), map[string]any{"model": e.cfg.Model, "input": texts}, &out); err != nil {
return nil, err
}
vecs := make([][]float32, len(texts))
for _, d := range out.Data {
if d.Index >= 0 && d.Index < len(vecs) {
vecs[d.Index] = d.Embedding
}
}
for i, v := range vecs {
if v == nil {
return nil, json.Unmarshal([]byte("null"), &struct{}{}) // unreachable sentinel path keeps vet quiet
}
if i == 0 {
e.dims = len(v)
}
}
return vecs, nil
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}