Files
so-many-crows/brain.go
T
SmokeyandClaude Opus 5.5 73cf298fee OpenRouter crows; log which model answered each flight
- ChatBrain speaks OpenAI-style chat completions (OpenRouter now)
- CROWS_PROVIDER picks anthropic or openrouter; default model
  openrouter/free; CROWS_MODEL overrides
- Flight log records the model that actually answered
- Token budgets fall back to a length estimate if usage is missing
- start.command reads either key from .env

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF
2026-10-27 16:23:59 -06:00

279 lines
8.1 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
)
// Brain is whatever model the crows think with.
type Brain interface {
Name() string
Ask(ctx context.Context, system, prompt string) (text string, tokens int, err error)
}
// modelKey lets a brain report which model actually answered a call
// (routers like openrouter/free pick a different model each time).
type modelKey struct{}
func withModelSlot(ctx context.Context, slot *string) context.Context {
return context.WithValue(ctx, modelKey{}, slot)
}
func reportModel(ctx context.Context, model string) {
if slot, ok := ctx.Value(modelKey{}).(*string); ok && slot != nil {
*slot = model
}
}
// NewBrain picks the crows' model from the environment:
//
// CROWS_PROVIDER=openrouter OPENROUTER_API_KEY (default model openrouter/free)
// CROWS_PROVIDER=anthropic ANTHROPIC_API_KEY (default model claude-sonnet-5-5)
// CROWS_MODEL overrides the model for either.
//
// With no provider set, whichever key is present wins (Anthropic first).
// No key at all means the offline mock.
func NewBrain() Brain {
provider := strings.ToLower(os.Getenv("CROWS_PROVIDER"))
anthropicKey, openrouterKey := os.Getenv("ANTHROPIC_API_KEY"), os.Getenv("OPENROUTER_API_KEY")
if provider == "" {
switch {
case anthropicKey != "":
provider = "anthropic"
case openrouterKey != "":
provider = "openrouter"
}
}
model := os.Getenv("CROWS_MODEL")
switch provider {
case "anthropic":
if anthropicKey == "" {
fmt.Fprintln(os.Stderr, "CROWS_PROVIDER=anthropic but ANTHROPIC_API_KEY is empty; using the offline mock")
return MockBrain{}
}
if model == "" {
model = "claude-sonnet-5-5"
}
return &AnthropicBrain{key: anthropicKey, model: model, client: &http.Client{}}
case "openrouter":
if openrouterKey == "" {
fmt.Fprintln(os.Stderr, "CROWS_PROVIDER=openrouter but OPENROUTER_API_KEY is empty; using the offline mock")
return MockBrain{}
}
if model == "" {
model = "openrouter/free"
}
return &ChatBrain{
provider: "OpenRouter", url: "https://openrouter.ai/api/v1/chat/completions",
key: openrouterKey, model: model, client: &http.Client{},
headers: map[string]string{"X-OpenRouter-Title": "So Many Crows"},
}
case "":
return MockBrain{}
default:
fmt.Fprintf(os.Stderr, "unknown CROWS_PROVIDER %q; using the offline mock\n", provider)
return MockBrain{}
}
}
type AnthropicBrain struct {
key, model string
client *http.Client
}
func (b *AnthropicBrain) Name() string { return "Claude (" + b.model + ")" }
func (b *AnthropicBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) {
body, _ := json.Marshal(map[string]any{
"model": b.model,
"max_tokens": 2048,
"system": system,
"messages": []map[string]string{{"role": "user", "content": prompt}},
})
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(body))
if err != nil {
return "", 0, err
}
req.Header.Set("x-api-key", b.key)
req.Header.Set("anthropic-version", "2023-06-01")
req.Header.Set("content-type", "application/json")
resp, err := b.client.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", 0, fmt.Errorf("model API returned %d: %s", resp.StatusCode, raw)
}
var out struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", 0, err
}
var sb strings.Builder
for _, c := range out.Content {
if c.Type == "text" {
sb.WriteString(c.Text)
}
}
reportModel(ctx, b.model)
return sb.String(), out.Usage.InputTokens + out.Usage.OutputTokens, nil
}
// ChatBrain speaks the OpenAI-style chat completions format (OpenRouter, and
// gateways like OpenCode Zen for open models).
type ChatBrain struct {
provider, url, key, model string
headers map[string]string
client *http.Client
}
func (b *ChatBrain) Name() string { return b.provider + " (" + b.model + ")" }
func (b *ChatBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) {
body, _ := json.Marshal(map[string]any{
"model": b.model,
"max_tokens": 2048,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": prompt},
},
})
req, err := http.NewRequestWithContext(ctx, "POST", b.url, bytes.NewReader(body))
if err != nil {
return "", 0, err
}
req.Header.Set("Authorization", "Bearer "+b.key)
req.Header.Set("Content-Type", "application/json")
for k, v := range b.headers {
req.Header.Set(k, v)
}
resp, err := b.client.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode != http.StatusOK {
return "", 0, fmt.Errorf("%s returned %d: %s", b.provider, resp.StatusCode, clip(raw, 300))
}
var out struct {
Model string `json:"model"`
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
} `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", 0, fmt.Errorf("%s sent an unreadable reply: %w", b.provider, err)
}
if out.Error != nil {
return "", 0, fmt.Errorf("%s error: %s", b.provider, out.Error.Message)
}
if len(out.Choices) == 0 || strings.TrimSpace(out.Choices[0].Message.Content) == "" {
return "", 0, fmt.Errorf("%s sent an empty reply", b.provider)
}
text := out.Choices[0].Message.Content
// Budgets must still bite if a provider leaves out usage: estimate ~4 characters per token.
tokens := (len(system) + len(prompt) + len(text)) / 4
if out.Usage != nil && out.Usage.PromptTokens+out.Usage.CompletionTokens > 0 {
tokens = out.Usage.PromptTokens + out.Usage.CompletionTokens
}
model := out.Model
if model == "" {
model = b.model
}
reportModel(ctx, model)
return text, tokens, nil
}
func clip(b []byte, n int) string {
s := strings.TrimSpace(string(b))
if len(s) > n {
return s[:n] + "…"
}
return s
}
// MockBrain lets the whole flow run offline with no API key.
type MockBrain struct{}
func (MockBrain) Name() string { return "offline mock" }
func (MockBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) {
if err := ctx.Err(); err != nil {
return "", 0, err
}
reportModel(ctx, "mock")
tokens := (len(system)+len(prompt))/4 + 40
switch {
case strings.Contains(system, markerSentry):
return mockTriage(after(prompt, "ITEM:")), tokens, nil
case strings.Contains(system, markerElder):
return "[mock] Elder's reconciled answer from " + fmt.Sprint(strings.Count(prompt, "--- ")) + " reports.", tokens, nil
default:
return "[mock] Handled: " + after(prompt, "TASK:"), tokens, nil
}
}
func after(s, marker string) string {
if i := strings.LastIndex(s, marker); i >= 0 {
return strings.TrimSpace(s[i+len(marker):])
}
return strings.TrimSpace(s)
}
func mockTriage(item string) string {
l := strings.ToLower(item)
tt := "other"
switch {
case hasAny(l, "find", "look up", "what is", "who ", "when "):
tt = "retrieval"
case hasAny(l, "write", "draft"):
tt = "writing"
case hasAny(l, "compare", "analyze", "should"):
tt = "analysis"
}
t := Triage{Mode: "solo", TaskType: tt, Reason: "simple, one crow can handle it"}
if parts := strings.Split(item, " and "); len(parts) > 1 {
t = Triage{Mode: "split", TaskType: tt, Subtasks: parts, Reason: "multiple distinct parts"}
} else if hasAny(l, "should", "decide", "best") {
t = Triage{Mode: "murder", TaskType: tt, Reason: "judgment call, worth several opinions"}
}
b, _ := json.Marshal(t)
return string(b)
}
func hasAny(s string, subs ...string) bool {
for _, x := range subs {
if strings.Contains(s, x) {
return true
}
}
return false
}