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.
127 lines
3.4 KiB
Go
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
|
|
}
|