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.
81 lines
2.3 KiB
Go
81 lines
2.3 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/andydixon/noema/internal/llm"
|
|
)
|
|
|
|
// Anthropic speaks the Messages API.
|
|
type Anthropic struct{ cfg Config }
|
|
|
|
// NewAnthropic creates a client.
|
|
func NewAnthropic(cfg Config) *Anthropic { return &Anthropic{cfg: cfg} }
|
|
|
|
// Complete implements llm.LanguageModel.
|
|
func (p *Anthropic) Complete(ctx context.Context, req llm.Request) (llm.Response, error) {
|
|
msgs := make([]map[string]string, 0, len(req.Messages))
|
|
for _, m := range req.Messages {
|
|
role := m.Role
|
|
if role != "assistant" {
|
|
role = "user"
|
|
}
|
|
msgs = append(msgs, map[string]string{"role": role, "content": m.Content})
|
|
}
|
|
system := req.System
|
|
if len(req.JSONSchema) > 0 {
|
|
system = strings.TrimSpace(system + "\n\nRespond with a single JSON object only, matching this JSON schema:\n" + string(req.JSONSchema))
|
|
}
|
|
maxTokens := req.MaxTokens
|
|
if maxTokens <= 0 {
|
|
maxTokens = 1024
|
|
}
|
|
body := map[string]any{"model": p.cfg.Model, "messages": msgs, "max_tokens": maxTokens}
|
|
if system != "" {
|
|
body["system"] = system
|
|
}
|
|
if req.Temperature != nil {
|
|
body["temperature"] = *req.Temperature
|
|
}
|
|
if req.TopP != nil {
|
|
body["top_p"] = *req.TopP
|
|
}
|
|
if len(req.Stop) > 0 {
|
|
body["stop_sequences"] = req.Stop
|
|
}
|
|
headers := map[string]string{"anthropic-version": "2023-06-01"}
|
|
if p.cfg.APIKey != "" {
|
|
headers["x-api-key"] = p.cfg.APIKey
|
|
}
|
|
for k, v := range p.cfg.Headers {
|
|
headers[k] = v
|
|
}
|
|
var out struct {
|
|
Model string `json:"model"`
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
StopReason string `json:"stop_reason"`
|
|
Usage struct {
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
} `json:"usage"`
|
|
}
|
|
start := time.Now()
|
|
if err := post(ctx, p.cfg.client(), p.cfg.url("/v1/messages"), headers, body, &out); err != nil {
|
|
return llm.Response{}, err
|
|
}
|
|
var text strings.Builder
|
|
for _, c := range out.Content {
|
|
if c.Type == "text" {
|
|
text.WriteString(c.Text)
|
|
}
|
|
}
|
|
resp := llm.Response{Text: text.String(), Provider: p.cfg.Name, Model: firstNonEmpty(out.Model, p.cfg.Model), Latency: time.Since(start), FinishReason: out.StopReason,
|
|
Usage: llm.Usage{PromptTokens: out.Usage.InputTokens, CompletionTokens: out.Usage.OutputTokens, TotalTokens: out.Usage.InputTokens + out.Usage.OutputTokens}}
|
|
return resp, finish(req, &resp)
|
|
}
|