Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Boundary guard
run: bash ./scripts/check-ecosystem-boundaries.sh
- name: gofumpt
run: |
go install mvdan.cc/gofumpt@v0.10.0
Expand Down Expand Up @@ -118,6 +120,8 @@ jobs:
cache: true
- name: Clone tok (workspace dep)
run: git clone --depth=1 https://github.com/GrayCodeAI/tok.git ../tok
- name: Boundary guard
run: bash ./scripts/check-ecosystem-boundaries.sh
- name: Run golangci-lint
run: |
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.0
Expand All @@ -138,6 +142,8 @@ jobs:
cache: true
- name: Clone tok (workspace dep)
run: git clone --depth=1 https://github.com/GrayCodeAI/tok.git ../tok
- name: Boundary guard
run: bash ./scripts/check-ecosystem-boundaries.sh
- name: Test with race detector
run: go test ./... -race -count=1 -shuffle=on -coverprofile=coverage.out -covermode=atomic -timeout=300s
- name: Coverage summary
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ make ci # Full CI suite

- Provider interface is the boundary — keep it stable
- Streaming tests need careful goroutine management
- `go.work` here replaces only `github.com/GrayCodeAI/tok => ../tok`; hawk's own `go.work` adds an `external/eyrie` replace so hawk can develop against a local eyrie checkout. Do not add other `replace` directives here without coordinating with hawk's workspace.
- `go.work` here should stay minimal; hawk's own `go.work` adds an `external/eyrie` replace so hawk can develop against a local eyrie checkout. Do not add extra local `replace` directives here without coordinating with hawk's workspace.

## Naming Conventions

Expand Down
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ GOVULNCHECK := $(GOBIN_DIR)/govulncheck
# ---------------------------------------------------------------------------
# Phony declarations (alphabetical).
# ---------------------------------------------------------------------------
.PHONY: all bench build ci clean cover fmt help lint lint-fix \
.PHONY: all bench boundaries build ci clean cover fmt help lint lint-fix \
security test test-10x test-race tidy version vet

boundaries: ## Enforce support-repo import boundaries.
bash ./scripts/check-ecosystem-boundaries.sh

# ---------------------------------------------------------------------------
# Default target.
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -97,7 +100,7 @@ tidy: ## Tidy go.mod / go.sum.
# ---------------------------------------------------------------------------
# Composite gate used by CI and pre-push.
# ---------------------------------------------------------------------------
ci: tidy fmt vet lint test-race security ## Run everything CI runs.
ci: tidy fmt vet lint boundaries test-race security ## Run everything CI runs.
@echo "All CI checks passed."

# ---------------------------------------------------------------------------
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ When your app calls a model, eyrie figures out which provider to use, how to tal

**Your app never talks to an LLM API directly. eyrie does.**

## Ecosystem Boundaries

eyrie is a Hawk support engine. Keep the dependency edge one-way:

- depend on `hawk-core-contracts` when a stable cross-repo contract is needed
- do not import `hawk/internal/*`
- do not import removed legacy path `hawk/shared/types`; use `hawk-core-contracts/types`

## Quick Start

```bash
Expand Down
6 changes: 3 additions & 3 deletions catalog/discover/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ func MergeCatalogV1WithPolicy(dst, src *catalog.CatalogV1, policy MergePolicy) *
}
continue
}
if dst.Models[id].ID == "" {
dst.Models[id] = m
}
// Key is absent here (the ok branch above continues), so the model is
// always new — assign unconditionally.
dst.Models[id] = m
}
seen := map[string]int{}
for i, o := range dst.Offerings {
Expand Down
15 changes: 14 additions & 1 deletion catalog/live/fetchers.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
Expand All @@ -15,6 +16,18 @@ import (
"github.com/GrayCodeAI/eyrie/catalog/opencodego"
)

// maxLiveResponseBytes caps how much of an external provider's HTTP response
// the live catalog fetchers will read, so a malicious or buggy provider cannot
// exhaust memory by returning an unbounded body.
const maxLiveResponseBytes = 10 * 1024 * 1024 // 10 MiB

// decodeJSONLimited decodes JSON from r into v, reading at most
// maxLiveResponseBytes. Use this instead of json.NewDecoder(resp.Body) for
// responses from untrusted/remote endpoints.
func decodeJSONLimited(r io.Reader, v any) error {
return json.NewDecoder(io.LimitReader(r, maxLiveResponseBytes)).Decode(v)
}

// Provider FetchFunc implementations live in fetchers_cloud.go and
// fetchers_providers.go; this file holds the registry, shared parsing/pricing
// helpers, and AWS SigV4 signing helpers.
Expand Down Expand Up @@ -166,7 +179,7 @@ func fetchOpenAICompatModels(ctx context.Context, baseURL, apiKey, authHeader st
var payload struct {
Data []json.RawMessage `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
return nil, err
}
var entries []Entry
Expand Down
10 changes: 5 additions & 5 deletions catalog/live/fetchers_cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func enrichOpenAIWithOpenRouter(entries []Entry) {
var payload struct {
Data []openRouterModelEntry `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
return
}
// Build lookup map: "gpt-4o" → openRouterModelEntry
Expand Down Expand Up @@ -136,7 +136,7 @@ func enrichFromOpenRouter(entries []Entry, prefix string) {
var payload struct {
Data []openRouterModelEntry `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
return
}
// Build lookup map by stripping prefix
Expand Down Expand Up @@ -239,7 +239,7 @@ func FetchAzure(env map[string]string) ([]Entry, error) {
var payload struct {
Value []json.RawMessage `json:"value"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
return nil, err
}
var entries []Entry
Expand Down Expand Up @@ -304,7 +304,7 @@ func FetchBedrock(env map[string]string) ([]Entry, error) {
var payload struct {
ModelSummaries []json.RawMessage `json:"modelSummaries"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
return nil, err
}
var entries []Entry
Expand Down Expand Up @@ -377,7 +377,7 @@ func FetchVertex(env map[string]string) ([]Entry, error) {
PublisherModels []json.RawMessage `json:"publisherModels"`
Models []json.RawMessage `json:"models"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
return nil, err
}
rawModels := payload.PublisherModels
Expand Down
11 changes: 7 additions & 4 deletions catalog/live/fetchers_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ func FetchOpenCodeGo(env map[string]string) ([]Entry, error) {
if err != nil {
return nil, err
}
protocolEntries := make([]struct{ ID, Protocol string }, 0, len(entries))
for i := range entries {
entries[i].ID = opencodego.NativeModelID(entries[i].ID)
// Merge with static metadata from docs (pricing, protocol, context windows).
Expand All @@ -91,7 +92,9 @@ func FetchOpenCodeGo(env map[string]string) ([]Entry, error) {
// Unknown model — derive protocol from name pattern.
entries[i].Protocol = opencodego.ProtocolForModel(entries[i].ID)
}
protocolEntries = append(protocolEntries, struct{ ID, Protocol string }{ID: entries[i].ID, Protocol: entries[i].Protocol})
}
opencodego.UpdateProtocolMap(protocolEntries)
return entries, nil
}

Expand Down Expand Up @@ -230,7 +233,7 @@ func FetchOpenRouter(env map[string]string) ([]Entry, error) {
var payload struct {
Data []json.RawMessage `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
return nil, err
}
var entries []Entry
Expand Down Expand Up @@ -350,7 +353,7 @@ func FetchAnthropic(env map[string]string) ([]Entry, error) {
var payload struct {
Data []json.RawMessage `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
return nil, err
}
var entries []Entry
Expand Down Expand Up @@ -471,7 +474,7 @@ func FetchGemini(env map[string]string) ([]Entry, error) {
var payload struct {
Models []json.RawMessage `json:"models"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
return nil, err
}
var entries []Entry
Expand Down Expand Up @@ -539,7 +542,7 @@ func FetchOllama(env map[string]string) ([]Entry, error) {
var payload struct {
Models []json.RawMessage `json:"models"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
return nil, err
}
var entries []Entry
Expand Down
40 changes: 40 additions & 0 deletions catalog/live/opencodego_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"net/http"
"net/http/httptest"
"testing"

"github.com/GrayCodeAI/eyrie/catalog/opencodego"
)

func TestFetchOpenCodeGo_MockHTTPServer(t *testing.T) {
Expand Down Expand Up @@ -45,6 +47,44 @@ func TestFetchOpenCodeGo_MockHTTPServer(t *testing.T) {
}
}

func TestFetchOpenCodeGoUpdatesProtocolMap(t *testing.T) {
opencodego.ResetProtocolMap()
defer opencodego.ResetProtocolMap()

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/models" {
http.NotFound(w, r)
return
}
resp := struct {
Data []json.RawMessage `json:"data"`
}{
Data: []json.RawMessage{
json.RawMessage(`{"id":"new-live-model","owned_by":"opencode","api_type":"anthropic"}`),
},
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()

if opencodego.UsesMessagesAPI("new-live-model") {
t.Fatal("expected heuristic fallback to use OpenAI before live fetch")
}
entries, err := FetchOpenCodeGo(map[string]string{
"OPENCODEGO_API_KEY": "test-ocg-key",
"OPENCODEGO_BASE_URL": srv.URL,
})
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 model, got %d", len(entries))
}
if !opencodego.UsesMessagesAPI("new-live-model") {
t.Fatal("expected live fetch to route anthropic model to Messages API")
}
}

func TestFetchOpenCodeGo_NoKey(t *testing.T) {
entries, err := FetchOpenCodeGo(map[string]string{})
if err != nil {
Expand Down
32 changes: 31 additions & 1 deletion client/budget_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,37 @@ func (bp *BudgetProvider) StreamChat(ctx context.Context, messages []EyrieMessag
if err := bp.store.CheckBudget(ctx, vk, est.TotalCostUSD); err != nil {
return nil, err
}
return bp.inner.StreamChat(ctx, messages, opts)

result, err := bp.inner.StreamChat(ctx, messages, opts)
if err != nil {
return nil, err
}

// Wrap the events channel to record actual spend from the final usage
// event. Without this, streamed calls under a virtual key never debit the
// budget (unlike the non-streaming Chat path), so streaming-heavy clients
// would underreport spend. Mirrors UsageLimitProvider.StreamChat.
wrappedCh := make(chan EyrieStreamEvent, cap(result.Events))
go func() {
defer close(wrappedCh)
for evt := range result.Events {
if evt.Type == "usage" && evt.Usage != nil {
cost := ActualCostUSD(opts.Model, evt.Usage)
_ = bp.store.RecordUsage(ctx, vk, cost, evt.Usage.PromptTokens, evt.Usage.CompletionTokens)
}
select {
case wrappedCh <- evt:
case <-ctx.Done():
result.Close()
return
}
}
}()

return &StreamResult{
Events: wrappedCh,
RequestID: result.RequestID,
}, nil
}

func (bp *BudgetProvider) recordUsage(ctx context.Context, vk, model string, resp *EyrieResponse) {
Expand Down
6 changes: 5 additions & 1 deletion client/condenser.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,11 @@ func (c *LLMSummarizingCondenser) summarize(ctx context.Context, span []EyrieMes
for _, m := range span {
b.WriteString(m.Role)
b.WriteString(": ")
b.WriteString(m.Content)
// Indent embedded newlines so a message body cannot forge a new
// "role:" turn at column 0 (e.g. content containing "\nassistant: ...").
// Without this, a flat "role: content" transcript is a prompt-injection
// vector into the summarization call.
b.WriteString(strings.ReplaceAll(m.Content, "\n", "\n "))
b.WriteByte('\n')
}

Expand Down
15 changes: 6 additions & 9 deletions client/cost_estimator.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import (
"fmt"
"strings"
"sync"

"github.com/GrayCodeAI/tok"
)

// CostEstimator estimates the cost of an API call BEFORE sending it.
Expand Down Expand Up @@ -65,14 +63,14 @@ func (ce *CostEstimator) IsExpensive(est CostEstimate, threshold float64) bool {
func (ce *CostEstimator) countInputTokens(messages []EyrieMessage) int {
total := 0
for _, m := range messages {
total += tok.EstimateTokens(m.Content)
total += estimateTextTokens(m.Content)
for _, tr := range m.ToolResults {
total += tok.EstimateTokens(tr.Content)
total += estimateTextTokens(tr.Content)
}
for _, tc := range m.ToolUse {
total += 50 // tool call overhead
for _, v := range tc.Arguments {
total += tok.EstimateTokens(fmt.Sprintf("%v", v))
total += estimateTextTokens(fmt.Sprintf("%v", v))
}
}
}
Expand Down Expand Up @@ -100,7 +98,7 @@ func NewStreamingTokenCounter(model string, inputTokens int) *StreamingTokenCoun
// AddOutput records streamed output tokens.
func (stc *StreamingTokenCounter) AddOutput(text string) {
stc.mu.Lock()
stc.outputTokens += tok.EstimateTokens(text)
stc.outputTokens += estimateTextTokens(text)
stc.mu.Unlock()
}

Expand Down Expand Up @@ -157,7 +155,7 @@ func NewPromptOptimizer(maxInputTokens int) *PromptOptimizer {
func (po *PromptOptimizer) Optimize(messages []EyrieMessage) []EyrieMessage {
totalTokens := 0
for _, m := range messages {
totalTokens += tok.EstimateTokens(m.Content) + 10 // +10 for overhead
totalTokens += estimateTextTokens(m.Content) + 10 // +10 for overhead
}

if totalTokens <= po.maxInputTokens {
Expand Down Expand Up @@ -196,8 +194,7 @@ func compressMessages(messages []EyrieMessage) string {
}
raw := strings.Join(parts, "\n")

// Use tok compression pipeline for intelligent summarization
compressed, _ := tok.Compress(raw, tok.Minimal)
compressed := compressForSummary(raw)
if len(compressed) > 0 && len(compressed) < len(raw) {
return compressed
}
Expand Down
Loading
Loading