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
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fakeChat(t *testing.T, status int, reply string) (*ChatBrain, *http.Request) {
|
||||
t.Helper()
|
||||
var got http.Request
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = *r
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Errorf("request body is not JSON: %v", err)
|
||||
}
|
||||
if body["model"] != "openrouter/free" {
|
||||
t.Errorf("model = %v", body["model"])
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(reply))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return &ChatBrain{provider: "OpenRouter", url: srv.URL, key: "k", model: "openrouter/free", client: srv.Client(),
|
||||
headers: map[string]string{"X-OpenRouter-Title": "So Many Crows"}}, &got
|
||||
}
|
||||
|
||||
func TestChatBrainReply(t *testing.T) {
|
||||
b, req := fakeChat(t, 200, `{"model":"some/model:free","choices":[{"message":{"content":"caw"}}],"usage":{"prompt_tokens":10,"completion_tokens":5}}`)
|
||||
var model string
|
||||
text, tokens, err := b.Ask(withModelSlot(context.Background(), &model), "sys", "hi")
|
||||
if err != nil || text != "caw" || tokens != 15 || model != "some/model:free" {
|
||||
t.Fatalf("got %q %d %q %v", text, tokens, model, err)
|
||||
}
|
||||
if req.Header.Get("Authorization") != "Bearer k" || req.Header.Get("X-OpenRouter-Title") != "So Many Crows" {
|
||||
t.Fatalf("headers: %v", req.Header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatBrainEstimatesTokensWhenUsageMissing(t *testing.T) {
|
||||
b, _ := fakeChat(t, 200, `{"choices":[{"message":{"content":"`+strings.Repeat("a", 400)+`"}}]}`)
|
||||
_, tokens, err := b.Ask(context.Background(), "", "")
|
||||
if err != nil || tokens != 100 {
|
||||
t.Fatalf("tokens = %d, err %v; want the 4-chars-per-token estimate so budgets still bite", tokens, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatBrainErrors(t *testing.T) {
|
||||
for name, c := range map[string]struct {
|
||||
status int
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
"rate limit": {429, `{"error":{"message":"Rate limit exceeded"}}`, "429"},
|
||||
"error body": {200, `{"error":{"message":"no free models"}}`, "no free models"},
|
||||
"empty": {200, `{"choices":[{"message":{"content":" "}}]}`, "empty reply"},
|
||||
} {
|
||||
b, _ := fakeChat(t, c.status, c.body)
|
||||
if _, _, err := b.Ask(context.Background(), "s", "p"); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: err = %v, want %q", name, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBrainPicksProvider(t *testing.T) {
|
||||
t.Setenv("ANTHROPIC_API_KEY", "")
|
||||
t.Setenv("OPENROUTER_API_KEY", "or")
|
||||
t.Setenv("CROWS_PROVIDER", "")
|
||||
t.Setenv("CROWS_MODEL", "")
|
||||
if got := NewBrain().Name(); got != "OpenRouter (openrouter/free)" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
t.Setenv("CROWS_PROVIDER", "anthropic")
|
||||
if got := NewBrain().Name(); got != "offline mock" {
|
||||
t.Fatalf("anthropic with no key should fall back to mock, got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user