Skip to content

Commit 7546bb6

Browse files
authored
ci(boundary): enforce Hawk ecosystem import boundaries (#50)
* ci(boundary): add ecosystem boundary guard eyrie is a Hawk support engine whose provider/transport types are eyrie-local, so it stays contract-free. Add the one-way ecosystem boundary guard: - add scripts/check-ecosystem-boundaries.sh (forbids hawk/internal and hawk/shared/types imports) - wire the guard into the Makefile and CI - document the boundary rule in the README Scoped to the boundary guard only; unrelated in-progress changes in the working tree are intentionally left uncommitted. * refactor: decouple eyrie from tok helpers * feat(api): wire reranker injection and live protocol updates * fix: harden catalog and streaming provider paths * docs: remove legacy shared types references * fix(boundary): fall back to grep when rg is unavailable * build(deps): go mod tidy — promote tiktoken, drop unused indirects * fix(lint): drop redundant max helper shadowing the builtin (gocritic) * fix(client): preserve inner stream cancel in BudgetProvider and UsageLimitProvider Both StreamChat wrappers were returning StreamResult without the inner cancel function, making Close() a no-op and leaking the upstream stream. Switch to NewStreamResult(wrappedCh, result.Close) to wire cancellation.
1 parent c7cec74 commit 7546bb6

24 files changed

Lines changed: 738 additions & 112 deletions

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ jobs:
3737
with:
3838
go-version: ${{ env.GO_VERSION }}
3939
cache: true
40+
- name: Boundary guard
41+
run: bash ./scripts/check-ecosystem-boundaries.sh
4042
- name: gofumpt
4143
run: |
4244
go install mvdan.cc/gofumpt@v0.10.0
@@ -118,6 +120,8 @@ jobs:
118120
cache: true
119121
- name: Clone tok (workspace dep)
120122
run: git clone --depth=1 https://github.com/GrayCodeAI/tok.git ../tok
123+
- name: Boundary guard
124+
run: bash ./scripts/check-ecosystem-boundaries.sh
121125
- name: Run golangci-lint
122126
run: |
123127
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.0
@@ -138,6 +142,8 @@ jobs:
138142
cache: true
139143
- name: Clone tok (workspace dep)
140144
run: git clone --depth=1 https://github.com/GrayCodeAI/tok.git ../tok
145+
- name: Boundary guard
146+
run: bash ./scripts/check-ecosystem-boundaries.sh
141147
- name: Test with race detector
142148
run: go test ./... -race -count=1 -shuffle=on -coverprofile=coverage.out -covermode=atomic -timeout=300s
143149
- name: Coverage summary

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ make ci # Full CI suite
4646

4747
- Provider interface is the boundary — keep it stable
4848
- Streaming tests need careful goroutine management
49-
- `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.
49+
- `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.
5050

5151
## Naming Conventions
5252

Makefile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@ GOVULNCHECK := $(GOBIN_DIR)/govulncheck
3131
# ---------------------------------------------------------------------------
3232
# Phony declarations (alphabetical).
3333
# ---------------------------------------------------------------------------
34-
.PHONY: all bench build ci clean cover fmt help lint lint-fix \
34+
.PHONY: all bench boundaries build ci clean cover fmt help lint lint-fix \
3535
security test test-10x test-race tidy version vet
3636

37+
boundaries: ## Enforce support-repo import boundaries.
38+
bash ./scripts/check-ecosystem-boundaries.sh
39+
3740
# ---------------------------------------------------------------------------
3841
# Default target.
3942
# ---------------------------------------------------------------------------
@@ -97,7 +100,7 @@ tidy: ## Tidy go.mod / go.sum.
97100
# ---------------------------------------------------------------------------
98101
# Composite gate used by CI and pre-push.
99102
# ---------------------------------------------------------------------------
100-
ci: tidy fmt vet lint test-race security ## Run everything CI runs.
103+
ci: tidy fmt vet lint boundaries test-race security ## Run everything CI runs.
101104
@echo "All CI checks passed."
102105

103106
# ---------------------------------------------------------------------------

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ When your app calls a model, eyrie figures out which provider to use, how to tal
3636

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

39+
## Ecosystem Boundaries
40+
41+
eyrie is a Hawk support engine. Keep the dependency edge one-way:
42+
43+
- depend on `hawk-core-contracts` when a stable cross-repo contract is needed
44+
- do not import `hawk/internal/*`
45+
- do not import removed legacy path `hawk/shared/types`; use `hawk-core-contracts/types`
46+
3947
## Quick Start
4048

4149
```bash

catalog/discover/merge.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ func MergeCatalogV1WithPolicy(dst, src *catalog.CatalogV1, policy MergePolicy) *
8787
}
8888
continue
8989
}
90-
if dst.Models[id].ID == "" {
91-
dst.Models[id] = m
92-
}
90+
// Key is absent here (the ok branch above continues), so the model is
91+
// always new — assign unconditionally.
92+
dst.Models[id] = m
9393
}
9494
seen := map[string]int{}
9595
for i, o := range dst.Offerings {

catalog/live/fetchers.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"encoding/hex"
88
"encoding/json"
99
"fmt"
10+
"io"
1011
"net/http"
1112
"sort"
1213
"strings"
@@ -15,6 +16,18 @@ import (
1516
"github.com/GrayCodeAI/eyrie/catalog/opencodego"
1617
)
1718

19+
// maxLiveResponseBytes caps how much of an external provider's HTTP response
20+
// the live catalog fetchers will read, so a malicious or buggy provider cannot
21+
// exhaust memory by returning an unbounded body.
22+
const maxLiveResponseBytes = 10 * 1024 * 1024 // 10 MiB
23+
24+
// decodeJSONLimited decodes JSON from r into v, reading at most
25+
// maxLiveResponseBytes. Use this instead of json.NewDecoder(resp.Body) for
26+
// responses from untrusted/remote endpoints.
27+
func decodeJSONLimited(r io.Reader, v any) error {
28+
return json.NewDecoder(io.LimitReader(r, maxLiveResponseBytes)).Decode(v)
29+
}
30+
1831
// Provider FetchFunc implementations live in fetchers_cloud.go and
1932
// fetchers_providers.go; this file holds the registry, shared parsing/pricing
2033
// helpers, and AWS SigV4 signing helpers.
@@ -166,7 +179,7 @@ func fetchOpenAICompatModels(ctx context.Context, baseURL, apiKey, authHeader st
166179
var payload struct {
167180
Data []json.RawMessage `json:"data"`
168181
}
169-
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
182+
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
170183
return nil, err
171184
}
172185
var entries []Entry

catalog/live/fetchers_cloud.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func enrichOpenAIWithOpenRouter(entries []Entry) {
5252
var payload struct {
5353
Data []openRouterModelEntry `json:"data"`
5454
}
55-
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
55+
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
5656
return
5757
}
5858
// Build lookup map: "gpt-4o" → openRouterModelEntry
@@ -136,7 +136,7 @@ func enrichFromOpenRouter(entries []Entry, prefix string) {
136136
var payload struct {
137137
Data []openRouterModelEntry `json:"data"`
138138
}
139-
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
139+
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
140140
return
141141
}
142142
// Build lookup map by stripping prefix
@@ -239,7 +239,7 @@ func FetchAzure(env map[string]string) ([]Entry, error) {
239239
var payload struct {
240240
Value []json.RawMessage `json:"value"`
241241
}
242-
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
242+
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
243243
return nil, err
244244
}
245245
var entries []Entry
@@ -304,7 +304,7 @@ func FetchBedrock(env map[string]string) ([]Entry, error) {
304304
var payload struct {
305305
ModelSummaries []json.RawMessage `json:"modelSummaries"`
306306
}
307-
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
307+
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
308308
return nil, err
309309
}
310310
var entries []Entry
@@ -377,7 +377,7 @@ func FetchVertex(env map[string]string) ([]Entry, error) {
377377
PublisherModels []json.RawMessage `json:"publisherModels"`
378378
Models []json.RawMessage `json:"models"`
379379
}
380-
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
380+
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
381381
return nil, err
382382
}
383383
rawModels := payload.PublisherModels

catalog/live/fetchers_providers.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ func FetchOpenCodeGo(env map[string]string) ([]Entry, error) {
8282
if err != nil {
8383
return nil, err
8484
}
85+
protocolEntries := make([]struct{ ID, Protocol string }, 0, len(entries))
8586
for i := range entries {
8687
entries[i].ID = opencodego.NativeModelID(entries[i].ID)
8788
// Merge with static metadata from docs (pricing, protocol, context windows).
@@ -91,7 +92,9 @@ func FetchOpenCodeGo(env map[string]string) ([]Entry, error) {
9192
// Unknown model — derive protocol from name pattern.
9293
entries[i].Protocol = opencodego.ProtocolForModel(entries[i].ID)
9394
}
95+
protocolEntries = append(protocolEntries, struct{ ID, Protocol string }{ID: entries[i].ID, Protocol: entries[i].Protocol})
9496
}
97+
opencodego.UpdateProtocolMap(protocolEntries)
9598
return entries, nil
9699
}
97100

@@ -230,7 +233,7 @@ func FetchOpenRouter(env map[string]string) ([]Entry, error) {
230233
var payload struct {
231234
Data []json.RawMessage `json:"data"`
232235
}
233-
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
236+
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
234237
return nil, err
235238
}
236239
var entries []Entry
@@ -350,7 +353,7 @@ func FetchAnthropic(env map[string]string) ([]Entry, error) {
350353
var payload struct {
351354
Data []json.RawMessage `json:"data"`
352355
}
353-
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
356+
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
354357
return nil, err
355358
}
356359
var entries []Entry
@@ -471,7 +474,7 @@ func FetchGemini(env map[string]string) ([]Entry, error) {
471474
var payload struct {
472475
Models []json.RawMessage `json:"models"`
473476
}
474-
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
477+
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
475478
return nil, err
476479
}
477480
var entries []Entry
@@ -539,7 +542,7 @@ func FetchOllama(env map[string]string) ([]Entry, error) {
539542
var payload struct {
540543
Models []json.RawMessage `json:"models"`
541544
}
542-
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
545+
if err := decodeJSONLimited(resp.Body, &payload); err != nil {
543546
return nil, err
544547
}
545548
var entries []Entry

catalog/live/opencodego_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"net/http"
66
"net/http/httptest"
77
"testing"
8+
9+
"github.com/GrayCodeAI/eyrie/catalog/opencodego"
810
)
911

1012
func TestFetchOpenCodeGo_MockHTTPServer(t *testing.T) {
@@ -45,6 +47,44 @@ func TestFetchOpenCodeGo_MockHTTPServer(t *testing.T) {
4547
}
4648
}
4749

50+
func TestFetchOpenCodeGoUpdatesProtocolMap(t *testing.T) {
51+
opencodego.ResetProtocolMap()
52+
defer opencodego.ResetProtocolMap()
53+
54+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
55+
if r.URL.Path != "/models" {
56+
http.NotFound(w, r)
57+
return
58+
}
59+
resp := struct {
60+
Data []json.RawMessage `json:"data"`
61+
}{
62+
Data: []json.RawMessage{
63+
json.RawMessage(`{"id":"new-live-model","owned_by":"opencode","api_type":"anthropic"}`),
64+
},
65+
}
66+
_ = json.NewEncoder(w).Encode(resp)
67+
}))
68+
defer srv.Close()
69+
70+
if opencodego.UsesMessagesAPI("new-live-model") {
71+
t.Fatal("expected heuristic fallback to use OpenAI before live fetch")
72+
}
73+
entries, err := FetchOpenCodeGo(map[string]string{
74+
"OPENCODEGO_API_KEY": "test-ocg-key",
75+
"OPENCODEGO_BASE_URL": srv.URL,
76+
})
77+
if err != nil {
78+
t.Fatal(err)
79+
}
80+
if len(entries) != 1 {
81+
t.Fatalf("expected 1 model, got %d", len(entries))
82+
}
83+
if !opencodego.UsesMessagesAPI("new-live-model") {
84+
t.Fatal("expected live fetch to route anthropic model to Messages API")
85+
}
86+
}
87+
4888
func TestFetchOpenCodeGo_NoKey(t *testing.T) {
4989
entries, err := FetchOpenCodeGo(map[string]string{})
5090
if err != nil {

client/budget_provider.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,34 @@ func (bp *BudgetProvider) StreamChat(ctx context.Context, messages []EyrieMessag
103103
if err := bp.store.CheckBudget(ctx, vk, est.TotalCostUSD); err != nil {
104104
return nil, err
105105
}
106-
return bp.inner.StreamChat(ctx, messages, opts)
106+
107+
result, err := bp.inner.StreamChat(ctx, messages, opts)
108+
if err != nil {
109+
return nil, err
110+
}
111+
112+
// Wrap the events channel to record actual spend from the final usage
113+
// event. Without this, streamed calls under a virtual key never debit the
114+
// budget (unlike the non-streaming Chat path), so streaming-heavy clients
115+
// would underreport spend. Mirrors UsageLimitProvider.StreamChat.
116+
wrappedCh := make(chan EyrieStreamEvent, cap(result.Events))
117+
go func() {
118+
defer close(wrappedCh)
119+
for evt := range result.Events {
120+
if evt.Type == "usage" && evt.Usage != nil {
121+
cost := ActualCostUSD(opts.Model, evt.Usage)
122+
_ = bp.store.RecordUsage(ctx, vk, cost, evt.Usage.PromptTokens, evt.Usage.CompletionTokens)
123+
}
124+
select {
125+
case wrappedCh <- evt:
126+
case <-ctx.Done():
127+
result.Close()
128+
return
129+
}
130+
}
131+
}()
132+
133+
return NewStreamResult(wrappedCh, result.Close), nil
107134
}
108135

109136
func (bp *BudgetProvider) recordUsage(ctx context.Context, vk, model string, resp *EyrieResponse) {

0 commit comments

Comments
 (0)