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
This commit is contained in:
5 files changed
+247
-26
No files matched your search
@@ -17,17 +17,69 @@ type Brain interface {
|
||||
Ask(ctx context.Context, system, prompt string) (text string, tokens int, err error)
|
||||
}
|
||||
|
||||
// NewBrain uses Claude when ANTHROPIC_API_KEY is set, otherwise an offline mock.
|
||||
// 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 {
|
||||
key := os.Getenv("ANTHROPIC_API_KEY")
|
||||
if key == "" {
|
||||
return MockBrain{}
|
||||
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")
|
||||
if model == "" {
|
||||
model = "claude-sonnet-5-5"
|
||||
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{}
|
||||
}
|
||||
return &AnthropicBrain{key: key, model: model, client: &http.Client{}}
|
||||
}
|
||||
|
||||
type AnthropicBrain struct {
|
||||
@@ -80,9 +132,93 @@ func (b *AnthropicBrain) Ask(ctx context.Context, system, prompt string) (string
|
||||
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{}
|
||||
|
||||
@@ -92,6 +228,7 @@ func (MockBrain) Ask(ctx context.Context, system, prompt string) (string, int, e
|
||||
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):
|
||||
|
||||
Reference in new issue
Block a user