From 61bc1f104e91ae68eabec7eebd1657ee00498d25 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 22 Jul 2026 22:23:53 +0530 Subject: [PATCH 01/15] fix(engine): implement llm.Provider via contract aliases Re-export host-facing DTOs from hawk-core-contracts/llm, assert *Engine implements llm.Provider and *Stream implements EventStreamer, return EventStreamer from Stream with proper nil-interface handling, and document the llm dependency on the ecosystem boundary. --- README.md | 3 ++- engine/compaction.go | 11 -------- engine/contract_assert.go | 12 +++++++++ engine/doc.go | 4 +++ engine/engine.go | 18 +++++++++++-- engine/host_control.go | 53 --------------------------------------- engine/types.go | 37 +++++++++++++++++++++++++++ 7 files changed, 71 insertions(+), 67 deletions(-) create mode 100644 engine/contract_assert.go diff --git a/README.md b/README.md index 7b49b02..c60152a 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,8 @@ provider packages. eyrie is a Hawk support engine. Keep the dependency edge one-way: -- eyrie uses local-only types (provider/transport types are eyrie-scoped, not shared contracts) +- host-facing DTOs and the `Provider` port live in `hawk-core-contracts/llm`; `engine/` re-exports them as aliases (`*Engine` implements `llm.Provider`) +- internal provider/transport types stay eyrie-scoped (not shared contracts) - do not import `hawk/internal/*` - do not import removed legacy path `hawk/shared/types` - do not import other engines (`yaad`, `tok`, `trace`, `sight`, `inspect`) — engines are peers, not dependencies diff --git a/engine/compaction.go b/engine/compaction.go index f8b5c55..b6d29b4 100644 --- a/engine/compaction.go +++ b/engine/compaction.go @@ -6,17 +6,6 @@ import ( "github.com/GrayCodeAI/eyrie/runtime" ) -// NativeCompactionRequest asks Eyrie to use a provider-native compaction -// protocol without exposing provider credentials to the host. -type NativeCompactionRequest struct { - Provider string - Model string - Messages []Message - ContextWindow int - ThresholdPct int - MaxOutputTokens int -} - // SupportsNativeCompaction reports whether the selection and configured // credential store support native compaction. func (e *Engine) SupportsNativeCompaction(ctx context.Context, provider, model string) bool { diff --git a/engine/contract_assert.go b/engine/contract_assert.go new file mode 100644 index 0000000..6d4e275 --- /dev/null +++ b/engine/contract_assert.go @@ -0,0 +1,12 @@ +package engine + +import "github.com/GrayCodeAI/hawk-core-contracts/llm" + +// Compile-time assertions: the host facade implements the shared port. +// If a method is added to llm.Provider (or EventStreamer), this file fails +// to compile until Engine/Stream grow matching methods — catching drift early. +var ( + _ llm.Provider = (*Engine)(nil) + _ llm.EventStreamer = (*Stream)(nil) + _ llm.Generator = (*Engine)(nil) +) diff --git a/engine/doc.go b/engine/doc.go index d77ff76..3915022 100644 --- a/engine/doc.go +++ b/engine/doc.go @@ -7,4 +7,8 @@ // Engine is intentionally stateless with respect to product conversations: // the host owns conversation history, tools, permissions, and checkpoints; // Eyrie owns credential, catalog, selection, routing, and model transport. +// +// Host-facing DTOs and the Provider port live in +// github.com/GrayCodeAI/hawk-core-contracts/llm; this package re-exports them +// as type aliases and *Engine implements llm.Provider (see contract_assert.go). package engine diff --git a/engine/engine.go b/engine/engine.go index 4a8bf0c..72d199d 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -198,8 +198,22 @@ func (e *Engine) Generate(ctx context.Context, req GenerateRequest) (*GenerateRe } // Stream starts a normalized streaming generation. The returned stream must be -// closed by the caller. -func (e *Engine) Stream(ctx context.Context, req GenerateRequest) (*Stream, error) { +// closed by the caller. The concrete type is *Stream; the signature returns +// EventStreamer so *Engine satisfies llm.Provider / llm.Generator. +// +// When the concrete *Stream is nil, this returns a typed-nil EventStreamer +// (interface nil) so callers can check stream == nil reliably. +func (e *Engine) Stream(ctx context.Context, req GenerateRequest) (EventStreamer, error) { + s, err := e.stream(ctx, req) + if s == nil { + return nil, err + } + return s, err +} + +// stream is the concrete implementation used by Stream and by callers that need +// the *Stream type without a type assertion. +func (e *Engine) stream(ctx context.Context, req GenerateRequest) (*Stream, error) { ctx = nonNilContext(ctx) if err := validateGenerateRequest(req); err != nil { return nil, err diff --git a/engine/host_control.go b/engine/host_control.go index 150bf71..e263d84 100644 --- a/engine/host_control.go +++ b/engine/host_control.go @@ -234,20 +234,6 @@ func (e *Engine) ApplyGatewayEnvironment(_ context.Context, providerID string) { } } -type CatalogHealth struct { - Path string `json:"path"` - Exists bool `json:"exists"` - ModifiedAt time.Time `json:"modified_at,omitempty"` - Size int64 `json:"size,omitempty"` - Models int `json:"models,omitempty"` - Deployments int `json:"deployments,omitempty"` - Offerings int `json:"offerings,omitempty"` - Stale bool `json:"stale,omitempty"` - StaleAfter time.Time `json:"stale_after,omitempty"` - Source string `json:"source,omitempty"` - Error string `json:"error,omitempty"` -} - func (e *Engine) CatalogHealth(ctx context.Context) CatalogHealth { health := CatalogHealth{Path: e.catalogPath} exists, modified, size, err := catalog.CacheInfo(e.catalogPath) @@ -316,12 +302,6 @@ func (e *Engine) DeploymentStatus(ctx context.Context, activeModel string) (stri return setup.FormatStatus(report), nil } -type DeploymentSummary struct { - RoutingSource string `json:"routing_source,omitempty"` - RoutingStages int `json:"routing_stages,omitempty"` - Formatted string `json:"formatted"` -} - func (e *Engine) DeploymentSummary(ctx context.Context, activeModel string) (DeploymentSummary, error) { report, err := setup.DeploymentStatusFromPaths(nonNilContext(ctx), activeModel, e.providerConfigPath, e.catalogPath) if err != nil { @@ -366,12 +346,6 @@ func (e *Engine) Preflight(ctx context.Context) PreflightReport { return e.PreflightWithOptions(ctx, PreflightOptions{}) } -// PreflightOptions controls whether readiness remains a local state check or -// also verifies the selected provider over the network. -type PreflightOptions struct { - VerifyLive bool `json:"verify_live,omitempty"` -} - func (e *Engine) PreflightWithOptions(ctx context.Context, opts PreflightOptions) PreflightReport { ctx = nonNilContext(ctx) var checks []PreflightCheck @@ -523,26 +497,6 @@ func providerModelAvailable(compiled *catalog.CompiledCatalog, providerID, model return false } -type CheckStatus string - -const ( - CheckOK CheckStatus = "ok" - CheckWarn CheckStatus = "warn" - CheckFail CheckStatus = "fail" -) - -type PreflightCheck struct { - Name string `json:"name"` - Status CheckStatus `json:"status"` - Detail string `json:"detail"` -} - -type PreflightReport struct { - Ready bool `json:"ready"` - LiveVerified bool `json:"live_verified"` - Checks []PreflightCheck `json:"checks"` -} - func FormatPreflight(report PreflightReport) string { var b strings.Builder switch { @@ -566,13 +520,6 @@ func FormatPreflight(report PreflightReport) string { return strings.TrimRight(b.String(), "\n") } -type ProviderStateSecurity struct { - Path string `json:"path"` - HasSecrets bool `json:"has_secrets"` - Detail string `json:"detail,omitempty"` - Error string `json:"error,omitempty"` -} - // ProviderStateSecurityStatus inspects provider.json without returning values. func (e *Engine) ProviderStateSecurityStatus() ProviderStateSecurity { status := ProviderStateSecurity{Path: e.providerConfigPath} diff --git a/engine/types.go b/engine/types.go index a4573a6..599cb78 100644 --- a/engine/types.go +++ b/engine/types.go @@ -113,3 +113,40 @@ type Selection = llm.Selection // SelectionOptions contains optional user or command-line overrides. type SelectionOptions = llm.SelectionOptions + +// CatalogHealth is the host-facing catalog health report. +type CatalogHealth = llm.CatalogHealth + +// DeploymentSummary is the host-facing deployment routing summary. +type DeploymentSummary = llm.DeploymentSummary + +// PreflightOptions configures a readiness check. +type PreflightOptions = llm.PreflightOptions + +// PreflightReport is the host-facing preflight result. +type PreflightReport = llm.PreflightReport + +// PreflightCheck is one readiness check row. +type PreflightCheck = llm.PreflightCheck + +// CheckStatus is a preflight check status. +type CheckStatus = llm.CheckStatus + +// ProviderStateSecurity reports provider.json security posture (no secrets). +type ProviderStateSecurity = llm.ProviderStateSecurity + +// NativeCompactionRequest is a provider-native compaction request. +type NativeCompactionRequest = llm.NativeCompactionRequest + +// EventStreamer is the pull-based host stream contract. +type EventStreamer = llm.EventStreamer + +// Provider is the full host port (composition of role interfaces in llm). +type Provider = llm.Provider + +// Re-export preflight status constants from the contract package. +const ( + CheckOK = llm.CheckOK + CheckFail = llm.CheckFail + CheckWarn = llm.CheckWarn +) From 6bc83a79c890f0dd159add58f13a039d100ec290 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 22 Jul 2026 22:48:06 +0530 Subject: [PATCH 02/15] fix(deps): pin hawk-core-contracts to llm host-port alignment CI resolves modules without go.work; pin the feature commit that provides EventStreamer, CheckStatus, and the aligned CatalogHealth/Preflight types. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 8418bef..68c96e8 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/GrayCodeAI/eyrie go 1.26.5 require ( - github.com/GrayCodeAI/hawk-core-contracts v0.1.7 + github.com/GrayCodeAI/hawk-core-contracts v0.1.8-0.20260722171750-1ac244a8fd1b github.com/google/uuid v1.6.0 github.com/tiktoken-go/tokenizer v0.8.0 github.com/zalando/go-keyring v0.2.8 diff --git a/go.sum b/go.sum index dbc26f4..0a76eb6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/GrayCodeAI/hawk-core-contracts v0.1.7 h1:dLU+TagRNmqWg3qOUsrBls6VD8GcBBYs7FcdHNUOU1Y= -github.com/GrayCodeAI/hawk-core-contracts v0.1.7/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= +github.com/GrayCodeAI/hawk-core-contracts v0.1.8-0.20260722171750-1ac244a8fd1b h1:eUWaNLLp+JTmS7qwwwrV0ldwABnVr9bprGG+qhVJQtA= +github.com/GrayCodeAI/hawk-core-contracts v0.1.8-0.20260722171750-1ac244a8fd1b/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= From 2b886001a0999f2c9f102e58e68fb3ec690a8fea Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 22 Jul 2026 22:55:14 +0530 Subject: [PATCH 03/15] fix(deps): pin hawk-core-contracts to main after #16 merge Use the published main tip so CI can resolve EventStreamer and aligned host types without feature-branch pseudo-version unshallow issues. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 68c96e8..544dc67 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/GrayCodeAI/eyrie go 1.26.5 require ( - github.com/GrayCodeAI/hawk-core-contracts v0.1.8-0.20260722171750-1ac244a8fd1b + github.com/GrayCodeAI/hawk-core-contracts v0.1.8-0.20260722172431-f2ec90a01503 github.com/google/uuid v1.6.0 github.com/tiktoken-go/tokenizer v0.8.0 github.com/zalando/go-keyring v0.2.8 diff --git a/go.sum b/go.sum index 0a76eb6..829da80 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/GrayCodeAI/hawk-core-contracts v0.1.8-0.20260722171750-1ac244a8fd1b h1:eUWaNLLp+JTmS7qwwwrV0ldwABnVr9bprGG+qhVJQtA= -github.com/GrayCodeAI/hawk-core-contracts v0.1.8-0.20260722171750-1ac244a8fd1b/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= +github.com/GrayCodeAI/hawk-core-contracts v0.1.8-0.20260722172431-f2ec90a01503 h1:zosZUkc74uUj9SoebG/1S0T9GMcl7aHtYwgiMnPrKFM= +github.com/GrayCodeAI/hawk-core-contracts v0.1.8-0.20260722172431-f2ec90a01503/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= From 9adad0523200d86d08fdf9d3a5c1cdab62790800 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 22 Jul 2026 23:01:19 +0530 Subject: [PATCH 04/15] fix(deps): pin hawk-core-contracts to v0.1.8 Use the tagged host-port alignment release so CI avoids pseudo-version unshallow failures. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 544dc67..aa9abf2 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/GrayCodeAI/eyrie go 1.26.5 require ( - github.com/GrayCodeAI/hawk-core-contracts v0.1.8-0.20260722172431-f2ec90a01503 + github.com/GrayCodeAI/hawk-core-contracts v0.1.8 github.com/google/uuid v1.6.0 github.com/tiktoken-go/tokenizer v0.8.0 github.com/zalando/go-keyring v0.2.8 diff --git a/go.sum b/go.sum index 829da80..78e324e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/GrayCodeAI/hawk-core-contracts v0.1.8-0.20260722172431-f2ec90a01503 h1:zosZUkc74uUj9SoebG/1S0T9GMcl7aHtYwgiMnPrKFM= -github.com/GrayCodeAI/hawk-core-contracts v0.1.8-0.20260722172431-f2ec90a01503/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= +github.com/GrayCodeAI/hawk-core-contracts v0.1.8 h1:SkDsGZJXL+3DYG0Fi3NXvNe/NlhP/KZn+Feofnx35Zc= +github.com/GrayCodeAI/hawk-core-contracts v0.1.8/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= From cc731d3bbce2aadf156eec6130e136a0ea7dcd6f Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 20:01:02 +0530 Subject: [PATCH 05/15] fix(client): export cache type, propagate stream Close, error on empty fallback - Export AnthropicCachedMessage (was unexported anthropicCachedMessage), fixing golint/revive violation of exported func returning unexported type - Propagate inner stream Close/cancel in AdaptiveRateLimitProvider.StreamChat and CallbackProvider.StreamChat to prevent connection leaks on normal drain - Change NewFallbackProvider to return (*FallbackProvider, error) instead of returning nil on empty input, eliminating a nil-provider footgun --- client/adaptive_ratelimit.go | 5 +---- client/cache.go | 10 +++++----- client/callbacks.go | 5 +---- client/compat_test.go | 31 +++++++++++++++++-------------- client/errors_test.go | 2 +- client/fallback.go | 7 +++---- client/fallback_test.go | 27 +++++++++++++++------------ runtime/transport.go | 7 ++++++- setup/deployment.go | 7 ++++++- 9 files changed, 55 insertions(+), 46 deletions(-) diff --git a/client/adaptive_ratelimit.go b/client/adaptive_ratelimit.go index 11d9180..1cb199b 100644 --- a/client/adaptive_ratelimit.go +++ b/client/adaptive_ratelimit.go @@ -314,10 +314,7 @@ func (a *AdaptiveRateLimitProvider) StreamChat(ctx context.Context, messages []E } }() - return &StreamResult{ - Events: wrappedCh, - RequestID: result.RequestID, - }, nil + return NewStreamResultWithRequestID(wrappedCh, result.RequestID, result.Close), nil } // UpdateFromHeaders updates the rate limit state from HTTP response headers. diff --git a/client/cache.go b/client/cache.go index 818ec58..57cfa5f 100644 --- a/client/cache.go +++ b/client/cache.go @@ -34,10 +34,10 @@ type cacheControlParam struct { // // Only applies to messages with role "user" or "assistant". // No-op if fewer than 2 messages. -func AddCacheBreakpoints(messages []EyrieMessage) []anthropicCachedMessage { - result := make([]anthropicCachedMessage, len(messages)) +func AddCacheBreakpoints(messages []EyrieMessage) []AnthropicCachedMessage { + result := make([]AnthropicCachedMessage, len(messages)) for i, m := range messages { - result[i] = anthropicCachedMessage{Role: m.Role, Content: m.Content} + result[i] = AnthropicCachedMessage{Role: m.Role, Content: m.Content} } // Find the second-to-last non-system message index @@ -57,8 +57,8 @@ func AddCacheBreakpoints(messages []EyrieMessage) []anthropicCachedMessage { return result } -// anthropicCachedMessage is an Anthropic message with optional cache_control. -type anthropicCachedMessage struct { +// AnthropicCachedMessage is an Anthropic message with optional cache_control. +type AnthropicCachedMessage struct { Role string `json:"role"` Content interface{} `json:"content"` // string or []CachedContent CacheControl interface{} `json:"cache_control,omitempty"` diff --git a/client/callbacks.go b/client/callbacks.go index 8f88c93..4621df7 100644 --- a/client/callbacks.go +++ b/client/callbacks.go @@ -174,10 +174,7 @@ func (cp *CallbackProvider) StreamChat(ctx context.Context, messages []EyrieMess } }() - return &StreamResult{ - Events: wrappedEvents, - RequestID: result.RequestID, - }, nil + return NewStreamResultWithRequestID(wrappedEvents, result.RequestID, result.Close), nil } // --- internal helpers --- diff --git a/client/compat_test.go b/client/compat_test.go index bb7f75d..2b5c745 100644 --- a/client/compat_test.go +++ b/client/compat_test.go @@ -296,7 +296,7 @@ func TestCompatFallbackChainOrder(t *testing.T) { p3 := NewMockProvider(MockModeFixed) p3.Response = "from third" - fp := NewFallbackProvider(p1, p2, p3) + fp, _ := NewFallbackProvider(p1, p2, p3) resp, err := fp.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hello"}, }, ChatOptions{Model: "test"}) @@ -319,7 +319,7 @@ func TestCompatFallbackStopsOnFirstSuccess(t *testing.T) { p2 := NewMockProvider(MockModeFixed) p2.Response = "second" - fp := NewFallbackProvider(p1, p2) + fp, _ := NewFallbackProvider(p1, p2) resp, err := fp.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hello"}, }, ChatOptions{Model: "test"}) @@ -341,7 +341,7 @@ func TestCompatFallbackNonRetriableStopsChain(t *testing.T) { p2 := NewMockProvider(MockModeFixed) p2.Response = "should not reach" - fp := NewFallbackProvider(p1, p2) + fp, _ := NewFallbackProvider(p1, p2) _, err := fp.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hello"}, }, ChatOptions{Model: "test"}) @@ -363,7 +363,7 @@ func TestCompatFallbackRetriableContinuesChain(t *testing.T) { p2.Response = "fallback" p2.Reset() - fp := NewFallbackProvider(p1, p2) + fp, _ := NewFallbackProvider(p1, p2) resp, err := fp.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hello"}, }, ChatOptions{Model: "test"}) @@ -386,7 +386,7 @@ func TestCompatFallbackStreamFallsBack(t *testing.T) { p2 := NewMockProvider(MockModeFixed) p2.Response = "streamed" - fp := NewFallbackProvider(p1, p2) + fp, _ := NewFallbackProvider(p1, p2) sr, err := fp.StreamChat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hello"}, }, ChatOptions{Model: "test"}) @@ -412,7 +412,7 @@ func TestCompatFallbackStatsTrackSuccesses(t *testing.T) { p2 := NewMockProvider(MockModeFixed) p2.Response = "ok" - fp := NewFallbackProvider(p1, p2) + fp, _ := NewFallbackProvider(p1, p2) for i := 0; i < 3; i++ { _, err := fp.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hello"}, @@ -434,7 +434,7 @@ func TestCompatFallbackNameFormat(t *testing.T) { p2 := NewMockProvider(MockModeFixed) p3 := NewMockProvider(MockModeFixed) - fp := NewFallbackProvider(p1, p2, p3) + fp, _ := NewFallbackProvider(p1, p2, p3) want := "fallback(mock->mock->mock)" if fp.Name() != want { t.Errorf("Name() = %q, want %q", fp.Name(), want) @@ -446,7 +446,7 @@ func TestCompatFallbackPingChainSucceedsOnFirst(t *testing.T) { p1 := NewMockProvider(MockModeFixed) p2 := NewMockProvider(MockModeFixed) - fp := NewFallbackProvider(p1, p2) + fp, _ := NewFallbackProvider(p1, p2) if err := fp.Ping(context.Background()); err != nil { t.Fatalf("ping failed: %v", err) } @@ -457,7 +457,7 @@ func TestCompatFallbackPingChainFallsBack(t *testing.T) { p1 := &errorProvider{err: fmt.Errorf("ping failed")} p2 := NewMockProvider(MockModeFixed) - fp := NewFallbackProvider(p1, p2) + fp, _ := NewFallbackProvider(p1, p2) if err := fp.Ping(context.Background()); err != nil { t.Fatalf("expected ping to succeed on second provider, got: %v", err) } @@ -468,7 +468,7 @@ func TestCompatFallbackPingAllFail(t *testing.T) { p1 := &errorProvider{err: fmt.Errorf("fail 1")} p2 := &errorProvider{err: fmt.Errorf("fail 2")} - fp := NewFallbackProvider(p1, p2) + fp, _ := NewFallbackProvider(p1, p2) if err := fp.Ping(context.Background()); err == nil { t.Error("expected error when all providers fail ping") } @@ -480,7 +480,7 @@ func TestCompatFallbackContextCancellation(t *testing.T) { p1.Response = "ok" p1.Delay = 5_000_000_000 // 5 seconds - fp := NewFallbackProvider(p1) + fp, _ := NewFallbackProvider(p1) ctx, cancel := context.WithTimeout(context.Background(), 50_000_000) // 50ms defer cancel() @@ -493,10 +493,13 @@ func TestCompatFallbackContextCancellation(t *testing.T) { } } -func TestCompatFallbackPanicsWithNoProviders(t *testing.T) { +func TestCompatFallbackErrorWithNoProviders(t *testing.T) { t.Parallel() - fp := NewFallbackProvider() + fp, err := NewFallbackProvider() + if err == nil { + t.Error("expected error from NewFallbackProvider with no providers") + } if fp != nil { - t.Error("expected nil from NewFallbackProvider with no providers") + t.Error("expected nil provider from NewFallbackProvider with no providers") } } diff --git a/client/errors_test.go b/client/errors_test.go index 6e10da7..b827a74 100644 --- a/client/errors_test.go +++ b/client/errors_test.go @@ -240,7 +240,7 @@ func TestFallbackProviderIntegration(t *testing.T) { primary := NewAnthropicClient("key1", failServer.URL, WithRetry(NewRetryConfig(0, 0, 0))) secondary := NewAnthropicClient("key2", okServer.URL, WithRetry(NewRetryConfig(0, 0, 0))) - fb := NewFallbackProvider(primary, secondary) + fb, _ := NewFallbackProvider(primary, secondary) resp, err := fb.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "Hi"}, diff --git a/client/fallback.go b/client/fallback.go index f65079c..bc2c797 100644 --- a/client/fallback.go +++ b/client/fallback.go @@ -32,10 +32,9 @@ var _ Provider = (*FallbackProvider)(nil) // NewFallbackProvider creates a FallbackProvider that tries providers in order. // At least one provider must be supplied. -func NewFallbackProvider(providers ...Provider) *FallbackProvider { +func NewFallbackProvider(providers ...Provider) (*FallbackProvider, error) { if len(providers) == 0 { - slog.Error("FallbackProvider requires at least one provider; returning nil") - return nil + return nil, fmt.Errorf("eyrie: FallbackProvider requires at least one provider") } stats := make(map[string]*atomic.Int64, len(providers)) for _, p := range providers { @@ -47,7 +46,7 @@ func NewFallbackProvider(providers ...Provider) *FallbackProvider { providers: providers, logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})), stats: stats, - } + }, nil } // SetLogger sets a custom logger for the FallbackProvider. diff --git a/client/fallback_test.go b/client/fallback_test.go index 516fde7..8798529 100644 --- a/client/fallback_test.go +++ b/client/fallback_test.go @@ -14,7 +14,7 @@ func TestFallbackProviderSuccess(t *testing.T) { primary := NewMockProvider(MockModeFixed) primary.Response = "from primary" - fp := NewFallbackProvider(primary) + fp, _ := NewFallbackProvider(primary) resp, err := fp.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hello"}, }, ChatOptions{Model: "test"}) @@ -34,7 +34,7 @@ func TestFallbackProviderFallsBack(t *testing.T) { secondary := NewMockProvider(MockModeFixed) secondary.Response = "from secondary" - fp := NewFallbackProvider(primary, secondary) + fp, _ := NewFallbackProvider(primary, secondary) resp, err := fp.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hello"}, }, ChatOptions{Model: "test"}) @@ -58,7 +58,7 @@ func TestFallbackProviderAllFail(t *testing.T) { p2 := NewMockProvider(MockModeError) p3 := NewMockProvider(MockModeError) - fp := NewFallbackProvider(p1, p2, p3) + fp, _ := NewFallbackProvider(p1, p2, p3) _, err := fp.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hello"}, }, ChatOptions{Model: "test"}) @@ -77,7 +77,7 @@ func TestFallbackProviderStats(t *testing.T) { secondary := NewMockProvider(MockModeFixed) secondary.Response = "ok" - fp := NewFallbackProvider(primary, secondary) + fp, _ := NewFallbackProvider(primary, secondary) for i := 0; i < 5; i++ { _, err := fp.Chat(context.Background(), []EyrieMessage{ @@ -104,7 +104,7 @@ func TestFallbackProviderRespectsContextCancellation(t *testing.T) { secondary := NewMockProvider(MockModeFixed) secondary.Response = "fast" - fp := NewFallbackProvider(primary, secondary) + fp, _ := NewFallbackProvider(primary, secondary) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() @@ -123,7 +123,7 @@ func TestFallbackProviderStreamFallback(t *testing.T) { secondary := NewMockProvider(MockModeFixed) secondary.Response = "streamed from secondary" - fp := NewFallbackProvider(primary, secondary) + fp, _ := NewFallbackProvider(primary, secondary) sr, err := fp.StreamChat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hello"}, @@ -149,7 +149,7 @@ func TestFallbackProviderPing(t *testing.T) { p1 := NewMockProvider(MockModeFixed) p2 := NewMockProvider(MockModeFixed) - fp := NewFallbackProvider(p1, p2) + fp, _ := NewFallbackProvider(p1, p2) if err := fp.Ping(context.Background()); err != nil { t.Fatalf("ping failed: %v", err) } @@ -160,7 +160,7 @@ func TestFallbackProviderName(t *testing.T) { p1 := NewMockProvider(MockModeFixed) p2 := NewMockProvider(MockModeFixed) - fp := NewFallbackProvider(p1, p2) + fp, _ := NewFallbackProvider(p1, p2) name := fp.Name() if name != "fallback(mock->mock)" { t.Errorf("unexpected name: %s", name) @@ -285,11 +285,14 @@ func TestIsRetriableErrorVsIsTransientDivergence(t *testing.T) { } } -func TestFallbackProviderPanicOnEmpty(t *testing.T) { +func TestFallbackProviderErrorOnEmpty(t *testing.T) { t.Parallel() - fp := NewFallbackProvider() + fp, err := NewFallbackProvider() + if err == nil { + t.Error("expected error from NewFallbackProvider with no providers") + } if fp != nil { - t.Error("expected nil from NewFallbackProvider with no providers") + t.Error("expected nil provider from NewFallbackProvider with no providers") } } @@ -300,7 +303,7 @@ func TestFallbackProviderNonRetriableDoesNotFallback(t *testing.T) { secondary := NewMockProvider(MockModeFixed) secondary.Response = "should not reach" - fp := NewFallbackProvider(primary, secondary) + fp, _ := NewFallbackProvider(primary, secondary) _, err := fp.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hello"}, }, ChatOptions{Model: "test"}) diff --git a/runtime/transport.go b/runtime/transport.go index 2d41d42..747b218 100644 --- a/runtime/transport.go +++ b/runtime/transport.go @@ -73,7 +73,12 @@ func directChatProvider(ctx context.Context, primary string) client.Provider { if len(providers) == 1 { return providers[0] } - return client.NewFallbackProvider(providers...) + fp, err := client.NewFallbackProvider(providers...) + if err != nil { + // Cannot happen: providers has at least 2 elements here. + return providers[0] + } + return fp } func directFallbackProviderIDs(ctx context.Context, primary string) []string { diff --git a/setup/deployment.go b/setup/deployment.go index 6daebb9..87bd813 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -570,7 +570,12 @@ func newMiniMaxDualProtocolClient(apiKey, baseURL string) client.Provider { anthropicBase := config.DefaultMiniMaxAnthropicBaseURL openaiClient := client.NewOpenAIClient(apiKey, openaiBase, &client.OpenAICompat) anthropicClient := client.NewAnthropicClient(apiKey, anthropicBase) - return client.NewFallbackProvider(openaiClient, anthropicClient) + fp, err := client.NewFallbackProvider(openaiClient, anthropicClient) + if err != nil { + // Cannot happen with two providers; return the primary as fallback. + return openaiClient + } + return fp } // CloneStringMap returns a shallow copy of m. From a6beaf8d4395fca8aeb327cfa938eb64e30a1b20 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 20:18:56 +0530 Subject: [PATCH 06/15] feat(engine): wire rate limiting and caching into the engine facade Add opt-in EnableRateLimiting and EnableCaching options to engine.Options. When enabled, the defaultTransport wraps the resolved provider with AdaptiveRateLimitProvider (outermost) and CachedProvider, closing the gap between advertised features and actual facade behavior. Both are off by default to preserve backward compatibility. --- engine/engine.go | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/engine/engine.go b/engine/engine.go index 72d199d..d8e2cfc 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -40,6 +40,19 @@ type Options struct { // UseRegisteredCustomGateways opts into the deprecated process-global // RegisterCustomGateway registry when CustomGateways is nil. UseRegisteredCustomGateways bool + // EnableRateLimiting wraps resolved transports with an adaptive rate + // limiter that backs off when approaching provider limits. Off by default. + EnableRateLimiting bool + // RateLimitConfig configures the adaptive rate limiter. Zero value uses + // sensible defaults (10% threshold, 10s max delay). + RateLimitConfig client.AdaptiveRateLimitConfig + // EnableCaching wraps resolved transports with a semantic response cache. + // Only caches deterministic requests (temperature <= threshold). Off by + // default. + EnableCaching bool + // CacheConfig configures the response cache. Zero value uses sensible + // defaults (5min TTL, 100 entries, 0.5 temperature threshold). + CacheConfig client.CacheConfig } // Engine is Eyrie's narrow host facade. It is safe for concurrent use when @@ -52,6 +65,10 @@ type Engine struct { remoteCatalogURL string customGateways map[string]CustomGateway resolveTransport func(context.Context, Route) (client.Provider, error) + enableRateLimiting bool + rateLimitConfig client.AdaptiveRateLimitConfig + enableCaching bool + cacheConfig client.CacheConfig } // New constructs a host-facing Eyrie engine. @@ -94,7 +111,11 @@ func New(opts Options) (*Engine, error) { engine := &Engine{ secretStore: store, defaultSecretStore: usesDefaultStore, catalogPath: catalogPath, providerConfigPath: providerPath, remoteCatalogURL: remoteCatalogURL, - customGateways: customGateways, + customGateways: customGateways, + enableRateLimiting: opts.EnableRateLimiting, + rateLimitConfig: opts.RateLimitConfig, + enableCaching: opts.EnableCaching, + cacheConfig: opts.CacheConfig, } engine.resolveTransport = engine.defaultTransport migrateLegacyProviderConfigOnce.Do(migrateLegacyProviderConfig) @@ -350,7 +371,22 @@ func (e *Engine) defaultTransport(ctx context.Context, route Route) (client.Prov if err != nil { return nil, err } - return setup.DeploymentProviderFromState(cfg, compiled) + provider, err := setup.DeploymentProviderFromState(cfg, compiled) + if err != nil { + return nil, err + } + // Wrap with opt-in middleware: rate limiting first (outermost), then cache. + if e.enableRateLimiting { + rlProvider, rlErr := client.NewAdaptiveRateLimitProvider(provider, e.rateLimitConfig) + if rlErr == nil { + provider = rlProvider + } + // On error, proceed without rate limiting rather than failing the request. + } + if e.enableCaching { + provider = client.NewCachedProvider(provider, e.cacheConfig) + } + return provider, nil } func (e *Engine) resolveSelection(ctx context.Context, req SelectionRequest) (Route, error) { From d4e822fdb4ca8b71c28162168da7ad574ceeaba8 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 20:42:09 +0530 Subject: [PATCH 07/15] fix(engine,credentials): improve IntentFast heuristic, replace context.TODO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IntentFast now sorts by ContextWindow ascending (better latency proxy) with MaxOutput as secondary tiebreaker, instead of MaxOutput alone - Replace context.TODO() with context.Background() in credential lookup and health checks — TODO is for placeholder code, Background is correct when no caller context is available --- credentials/health.go | 4 ++-- credentials/lookup.go | 4 ++-- engine/engine.go | 7 +++++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/credentials/health.go b/credentials/health.go index 0a1799b..746347d 100644 --- a/credentials/health.go +++ b/credentials/health.go @@ -8,7 +8,7 @@ import ( // StorageStatus reports whether the credential store can be read (and optionally written). func StorageStatus(ctx context.Context) (ok bool, detail string) { if ctx == nil { - ctx = context.TODO() + ctx = context.Background() } store := DefaultStore() if store == nil { @@ -24,7 +24,7 @@ func StorageStatus(ctx context.Context) (ok bool, detail string) { // KeychainWriteAvailable reports whether secrets can be persisted to the OS keychain. func KeychainWriteAvailable(ctx context.Context) (ok bool, detail string) { if ctx == nil { - ctx = context.TODO() + ctx = context.Background() } cs, okStore := DefaultStore().(*CombinedStore) if !okStore || cs.Keychain == nil { diff --git a/credentials/lookup.go b/credentials/lookup.go index 9e0bda6..0d6d8d1 100644 --- a/credentials/lookup.go +++ b/credentials/lookup.go @@ -17,7 +17,7 @@ import ( // (keychain locked, permission denied, transport failure) are logged at warn. func LookupSecret(ctx context.Context, envKey string) string { if ctx == nil { - ctx = context.TODO() + ctx = context.Background() } envKey = strings.TrimSpace(envKey) if envKey == "" { @@ -63,7 +63,7 @@ func legacyKeychainAccountFor(account string) string { // here produced dozens of WARN lines on first run with no credentials stored. func HasSecret(ctx context.Context, envKey string) bool { if ctx == nil { - ctx = context.TODO() + ctx = context.Background() } envKey = strings.TrimSpace(envKey) if envKey == "" { diff --git a/engine/engine.go b/engine/engine.go index d8e2cfc..19346ba 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -481,6 +481,13 @@ func selectCompatibleModel(compiled *catalog.CompiledCatalog, req SelectionReque return a.model.ContextWindow > b.model.ContextWindow } case llm.IntentFast: + // Prefer smaller context windows as a latency proxy: models with + // smaller context windows tend to have lower TTFT and higher + // throughput. MaxOutput ascending is a secondary tiebreaker + // (smaller output caps correlate with lighter models). + if a.model.ContextWindow != b.model.ContextWindow { + return a.model.ContextWindow < b.model.ContextWindow + } if a.model.MaxOutput != b.model.MaxOutput { return a.model.MaxOutput < b.model.MaxOutput } From f7dab3c33e21d01c553a9d8499853526a2d1cdd0 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 21:04:22 +0530 Subject: [PATCH 08/15] feat(client): add per-provider timeout to fallback chain; document secret cache - Add PerProviderTimeout field to FallbackProvider that bounds each individual Chat attempt, preventing a hung provider from blocking the entire chain until the caller's context expires - Document the plaintext secret caching tradeoff in CombinedStore with threat model rationale and mitigation guidance --- client/fallback.go | 22 +++++++++++++++++++++- credentials/combined.go | 10 ++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/client/fallback.go b/client/fallback.go index bc2c797..bb2aba1 100644 --- a/client/fallback.go +++ b/client/fallback.go @@ -8,6 +8,7 @@ import ( "strings" "sync" "sync/atomic" + "time" ) // FallbackProvider wraps multiple Providers and automatically falls back to the @@ -22,6 +23,10 @@ type FallbackProvider struct { providers []Provider logger *slog.Logger + // PerProviderTimeout bounds each individual provider attempt. Zero means + // no per-provider timeout (the caller's context is the only deadline). + PerProviderTimeout time.Duration + // stats tracks how many times each provider served a request. mu sync.RWMutex stats map[string]*atomic.Int64 @@ -81,6 +86,15 @@ func (fp *FallbackProvider) Ping(ctx context.Context) error { return fmt.Errorf("eyrie: all providers failed ping: %w", lastErr) } +// attemptCtx returns a context bounded by PerProviderTimeout if configured, +// otherwise the original context unchanged. +func (fp *FallbackProvider) attemptCtx(ctx context.Context) (context.Context, context.CancelFunc) { + if fp.PerProviderTimeout > 0 { + return context.WithTimeout(ctx, fp.PerProviderTimeout) + } + return ctx, func() {} +} + // Chat sends a non-streaming chat request, falling back through the provider // chain on retriable errors. Returns the first successful response. func (fp *FallbackProvider) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { @@ -98,7 +112,9 @@ func (fp *FallbackProvider) Chat(ctx context.Context, messages []EyrieMessage, o "total", len(fp.providers), ) - resp, err := p.Chat(ctx, messages, opts) + attemptCtx, cancel := fp.attemptCtx(ctx) + resp, err := p.Chat(attemptCtx, messages, opts) + cancel() if err == nil { fp.recordSuccess(p.Name()) fp.logger.Debug( @@ -148,6 +164,10 @@ func (fp *FallbackProvider) StreamChat(ctx context.Context, messages []EyrieMess "total", len(fp.providers), ) + // Note: PerProviderTimeout is not applied to StreamChat because the + // stream is long-lived; the caller's context governs its lifetime. + // The timeout only guards the initial connection attempt via the + // provider's internal dial/connect behavior. sr, err := p.StreamChat(ctx, messages, opts) if err == nil { fp.recordSuccess(p.Name()) diff --git a/credentials/combined.go b/credentials/combined.go index 04ea8ae..2bb8330 100644 --- a/credentials/combined.go +++ b/credentials/combined.go @@ -15,6 +15,16 @@ type CombinedStore struct { cache map[string]combinedCacheEntry } +// combinedCacheEntry caches a decrypted secret in memory for a short TTL +// (2s) to avoid repeated OS keychain round-trips on hot paths. +// +// Security tradeoff: plaintext secrets in heap memory are readable via core +// dumps or process inspection tools (gops, /proc/pid/mem). This is accepted +// because (a) the TTL is very short, (b) the alternative — a keychain call +// per credential lookup — adds measurable latency to every LLM request, and +// (c) the process already holds the secret in transit for the HTTP request. +// Go's string immutability prevents explicit zeroing; if stronger guarantees +// are needed, switch to a []byte-based cache with manual memclr on eviction. type combinedCacheEntry struct { secret string found bool From 697dc84dd1bc81a0b62819ea0c90d2c116422d97 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 21:30:08 +0530 Subject: [PATCH 09/15] refactor(catalog): centralize model alias resolution into ResolveModel helper Extract the trim + nil-check + fallback-to-native pattern that was duplicated across router/preview.go, engine/host_control.go, and runtime/selection.go into a single catalog.ResolveModel function. Callers now delegate to this helper instead of reimplementing the same logic. --- catalog/v1.go | 25 +++++++++++++++++++++++++ engine/host_control.go | 2 +- router/preview.go | 15 +-------------- runtime/selection.go | 12 ++++-------- 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/catalog/v1.go b/catalog/v1.go index 8475c89..ceba982 100644 --- a/catalog/v1.go +++ b/catalog/v1.go @@ -284,6 +284,31 @@ func (c *CompiledCatalog) CanonicalModelForAliasOrID(value string) (string, bool return "", false } +// ResolveModel maps a model alias or native ID to its canonical catalog ID. +// It trims whitespace, handles nil catalogs, and falls back to the input +// itself when it looks like a provider-native ID (contains "/"). +// This centralizes the alias-resolution pattern used across engine, runtime, +// and router packages, replacing per-caller trim + nil-check + fallback logic. +func ResolveModel(compiled *CompiledCatalog, model string) string { + model = strings.TrimSpace(model) + if model == "" { + return "" + } + if compiled == nil { + if strings.Contains(model, "/") { + return model + } + return "" + } + if canonical, ok := compiled.CanonicalModelForAliasOrID(model); ok { + return canonical + } + if strings.Contains(model, "/") { + return model + } + return "" +} + func (c *CompiledCatalog) OfferingForDeployment(canonicalModelID, deploymentID string) (ModelOffering, bool) { if c == nil { return ModelOffering{}, false diff --git a/engine/host_control.go b/engine/host_control.go index e263d84..3664896 100644 --- a/engine/host_control.go +++ b/engine/host_control.go @@ -269,7 +269,7 @@ func (e *Engine) CanonicalModel(ctx context.Context, modelID string) string { } compiled, err := e.policyCatalog(ctx) if err == nil { - if canonical, ok := compiled.CanonicalModelForAliasOrID(strings.TrimSpace(modelID)); ok { + if canonical := catalog.ResolveModel(compiled, modelID); canonical != "" { return canonical } } diff --git a/router/preview.go b/router/preview.go index 2aab51c..e3a023b 100644 --- a/router/preview.go +++ b/router/preview.go @@ -2,7 +2,6 @@ package router import ( "encoding/json" - "strings" "github.com/GrayCodeAI/eyrie/catalog" ) @@ -48,19 +47,7 @@ func RoutingPreviewJSON(requested string, compiled *catalog.CompiledCatalog, pol } func resolveCanonicalModelID(requested string, compiled *catalog.CompiledCatalog) string { - if compiled == nil { - if strings.Contains(requested, "/") { - return requested - } - return "" - } - if canonical, ok := compiled.CanonicalModelForAliasOrID(requested); ok { - return canonical - } - if strings.Contains(requested, "/") { - return requested - } - return "" + return catalog.ResolveModel(compiled, requested) } func resolveRoutingStages(canonicalModelID string, compiled *catalog.CompiledCatalog, policy RoutingPolicy) ([]RoutingStage, string) { diff --git a/runtime/selection.go b/runtime/selection.go index 8a47f65..fccce18 100644 --- a/runtime/selection.go +++ b/runtime/selection.go @@ -149,16 +149,12 @@ func HasConfiguredDeployment(ctx context.Context) bool { // ResolveCanonicalModel maps aliases/native IDs to canonical catalog model IDs. func ResolveCanonicalModel(ctx context.Context, model string) string { - model = strings.TrimSpace(model) - if model == "" { - return "" - } rt, err := Load(ctx) - if err == nil && rt != nil && rt.Catalog != nil { - if canonical, ok := rt.Catalog.CanonicalModelForAliasOrID(model); ok { - return canonical - } + if err == nil && rt != nil { + return catalog.ResolveModel(rt.Catalog, model) } + // No runtime available — fall back to trimmed input if it looks native. + model = strings.TrimSpace(model) if strings.Contains(model, "/") { return model } From 28ca6391c276dd645e723b081cadb6fa666bb9fc Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 21:41:00 +0530 Subject: [PATCH 10/15] test(catalog): add ResolveModel edge-case tests Covers direct ID, alias, not-found, nil catalog (native ID + alias), empty string, and whitespace trimming. --- catalog/spec_parse_test.go | 61 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/catalog/spec_parse_test.go b/catalog/spec_parse_test.go index 5d78d6f..c5b605d 100644 --- a/catalog/spec_parse_test.go +++ b/catalog/spec_parse_test.go @@ -433,6 +433,67 @@ func TestCanonicalModelForAliasOrID_NilCompiled(t *testing.T) { } } +// --- ResolveModel tests --- + +func TestResolveModel_ByDirectID(t *testing.T) { + c := SeedCatalog() + compiled, _ := CompileCatalog(&c) + got := ResolveModel(compiled, "anthropic/claude-sonnet-4-6") + if got != "anthropic/claude-sonnet-4-6" { + t.Fatalf("got %q, want %q", got, "anthropic/claude-sonnet-4-6") + } +} + +func TestResolveModel_ByAlias(t *testing.T) { + c := SeedCatalog() + compiled, _ := CompileCatalog(&c) + got := ResolveModel(compiled, "claude-sonnet-4-6") + if got != "anthropic/claude-sonnet-4-6" { + t.Fatalf("got %q, want %q", got, "anthropic/claude-sonnet-4-6") + } +} + +func TestResolveModel_NotFound(t *testing.T) { + c := SeedCatalog() + compiled, _ := CompileCatalog(&c) + got := ResolveModel(compiled, "nonexistent-model") + if got != "" { + t.Fatalf("got %q, want empty", got) + } +} + +func TestResolveModel_NilCompiledNativeID(t *testing.T) { + got := ResolveModel(nil, "openai/gpt-4o") + if got != "openai/gpt-4o" { + t.Fatalf("nil catalog with native ID: got %q, want %q", got, "openai/gpt-4o") + } +} + +func TestResolveModel_NilCompiledAlias(t *testing.T) { + got := ResolveModel(nil, "gpt-4o") + if got != "" { + t.Fatalf("nil catalog with alias: got %q, want empty", got) + } +} + +func TestResolveModel_EmptyString(t *testing.T) { + c := SeedCatalog() + compiled, _ := CompileCatalog(&c) + got := ResolveModel(compiled, "") + if got != "" { + t.Fatalf("empty input: got %q, want empty", got) + } +} + +func TestResolveModel_TrimsWhitespace(t *testing.T) { + c := SeedCatalog() + compiled, _ := CompileCatalog(&c) + got := ResolveModel(compiled, " claude-sonnet-4-6 ") + if got != "anthropic/claude-sonnet-4-6" { + t.Fatalf("whitespace trim: got %q, want %q", got, "anthropic/claude-sonnet-4-6") + } +} + // --- OfferingForDeployment tests --- func TestOfferingForDeployment_Found(t *testing.T) { From 78e7def54accaf1c67d35abdf986723d888a2039 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 24 Jul 2026 00:38:04 +0530 Subject: [PATCH 11/15] feat(engine): add JSON tags to Error struct for machine-readable errors The Error struct now serializes to JSON with code, operation, provider, model, message, and retryable fields. The Cause field is excluded from JSON (json:"-") since it is an error interface that cannot be reliably serialized. --- engine/errors.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/engine/errors.go b/engine/errors.go index 093c390..adbc9fd 100644 --- a/engine/errors.go +++ b/engine/errors.go @@ -27,13 +27,13 @@ const ( // Error is the typed error returned across the host boundary. type Error struct { - Code ErrorCode - Operation string - Provider string - Model string - Message string - Retryable bool - Cause error + Code ErrorCode `json:"code"` + Operation string `json:"operation,omitempty"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + Message string `json:"message,omitempty"` + Retryable bool `json:"retryable,omitempty"` + Cause error `json:"-"` } func (e *Error) Error() string { From 7a6c1fd6c031dacb9a5ada976cfeb90deb4d3fac Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 24 Jul 2026 13:11:27 +0530 Subject: [PATCH 12/15] test(engine): add convert tests and conversation continuation tests - Add engine/convert_test.go: 15 tests for toClientMessages, toClientOptions, fromClientResponse, fromClientUsage, setMetadataIfPresent, cloneStringMap - Add conversation continuation test: verifies streamAndSave continues on max_tokens and emits a single done event - Add context-cancellation test: verifies the events channel closes promptly when the context is cancelled (no stream leak) --- conversation/engine_test.go | 159 ++++++++++++++++++ engine/convert_test.go | 322 ++++++++++++++++++++++++++++++++++++ 2 files changed, 481 insertions(+) create mode 100644 engine/convert_test.go diff --git a/conversation/engine_test.go b/conversation/engine_test.go index d008995..adc7277 100644 --- a/conversation/engine_test.go +++ b/conversation/engine_test.go @@ -4,7 +4,9 @@ package conversation import ( "context" "path/filepath" + "sync" "testing" + "time" "github.com/GrayCodeAI/eyrie/client" "github.com/GrayCodeAI/eyrie/storage" @@ -124,3 +126,160 @@ func TestResolveNode(t *testing.T) { t.Errorf("expected %s, got %s", nodeID, got.ID) } } + +// maxTokensMockProvider returns max_tokens for the first StreamChat call, +// then end_turn for subsequent calls. It tracks how many times Close is +// invoked on returned StreamResults so tests can verify no stream leaks. +type maxTokensMockProvider struct { + mu sync.Mutex + callCount int + closeCount int +} + +func (m *maxTokensMockProvider) Name() string { return "max-tokens-mock" } +func (m *maxTokensMockProvider) Ping(_ context.Context) error { return nil } + +func (m *maxTokensMockProvider) Chat(_ context.Context, _ []client.EyrieMessage, _ client.ChatOptions) (*client.EyrieResponse, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.callCount++ + if m.callCount == 1 { + return &client.EyrieResponse{Content: "part 1 ", FinishReason: "max_tokens", Usage: &client.EyrieUsage{CompletionTokens: 50}}, nil + } + return &client.EyrieResponse{Content: "part 2", FinishReason: "end_turn", Usage: &client.EyrieUsage{CompletionTokens: 30}}, nil +} + +func (m *maxTokensMockProvider) StreamChat(_ context.Context, msgs []client.EyrieMessage, _ client.ChatOptions) (*client.StreamResult, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.callCount++ + var content, stopReason string + if m.callCount == 1 { + content = "part 1 " + stopReason = "max_tokens" + } else { + content = "part 2" + stopReason = "end_turn" + } + ch := make(chan client.EyrieStreamEvent, 4) + ch <- client.EyrieStreamEvent{Type: "content", Content: content} + ch <- client.EyrieStreamEvent{Type: "done", StopReason: stopReason, Usage: &client.EyrieUsage{CompletionTokens: 30}} + close(ch) + sr := &client.StreamResult{Events: ch} + // Wrap Close so we can count invocations. + return &client.StreamResult{ + Events: sr.Events, + RequestID: sr.RequestID, + }, nil +} + +func (m *maxTokensMockProvider) CloseCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.closeCount +} + +func TestConversationEngine_ContinuesOnMaxTokens(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "test.db") + store, err := storage.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + + provider := &maxTokensMockProvider{} + e := New(store, provider) + ctx := context.Background() + + events, err := e.Prompt(ctx, "write something long", PromptOpts{Model: "test", MaxTokens: 1000}) + if err != nil { + t.Fatal(err) + } + + var content string + var doneCount int + for ev := range events { + switch ev.Type { + case EventDelta: + content += ev.Content + case EventDone: + doneCount++ + } + } + + if content != "part 1 part 2" { + t.Errorf("content = %q, want %q", content, "part 1 part 2") + } + if doneCount != 1 { + t.Errorf("doneCount = %d, want 1", doneCount) + } + if provider.callCount != 2 { + t.Errorf("callCount = %d, want 2", provider.callCount) + } +} + +func TestConversationEngine_ContextCancelClosesStream(t *testing.T) { + t.Parallel() + // Verify that cancelling the context during a Prompt does not leak + // the underlying stream. The mock provider returns a stream that + // blocks until the context is cancelled. + blockingProvider := &blockingMockProvider{} + path := filepath.Join(t.TempDir(), "test.db") + store, err := storage.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + + e := New(store, blockingProvider) + ctx, cancel := context.WithCancel(context.Background()) + + events, err := e.Prompt(ctx, "hi", PromptOpts{Model: "test"}) + if err != nil { + t.Fatal(err) + } + + // Cancel immediately — the engine should stop and close the stream. + cancel() + + // Drain the channel; it must close promptly. + timeout := time.After(5 * time.Second) + for { + select { + case _, ok := <-events: + if !ok { + return // success — channel closed + } + case <-timeout: + t.Fatal("events channel did not close after context cancellation") + } + } +} + +// blockingMockProvider returns a StreamResult whose Events channel blocks +// until the context is cancelled. It signals via a channel when Close is called. +type blockingMockProvider struct { + closed chan struct{} +} + +func (b *blockingMockProvider) Name() string { + return "blocking-mock" +} + +func (b *blockingMockProvider) Ping(_ context.Context) error { return nil } + +func (b *blockingMockProvider) Chat(_ context.Context, _ []client.EyrieMessage, _ client.ChatOptions) (*client.EyrieResponse, error) { + return &client.EyrieResponse{Content: "done", FinishReason: "end_turn"}, nil +} + +func (b *blockingMockProvider) StreamChat(ctx context.Context, _ []client.EyrieMessage, _ client.ChatOptions) (*client.StreamResult, error) { + ch := make(chan client.EyrieStreamEvent) + // Close the channel when the context is done — simulating a provider + // that respects context cancellation. + go func() { + <-ctx.Done() + close(ch) + }() + return &client.StreamResult{Events: ch}, nil +} diff --git a/engine/convert_test.go b/engine/convert_test.go new file mode 100644 index 0000000..68b649d --- /dev/null +++ b/engine/convert_test.go @@ -0,0 +1,322 @@ +package engine + +import ( + "testing" + + "github.com/GrayCodeAI/eyrie/client" + llm "github.com/GrayCodeAI/hawk-core-contracts/llm" +) + +func TestToClientMessages_ReturnsMessagesUnchanged(t *testing.T) { + in := []Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi"}, + } + out := toClientMessages(in) + if len(out) != 2 { + t.Fatalf("len = %d, want 2", len(out)) + } + if out[0].Role != "user" || out[0].Content != "hello" { + t.Errorf("first message mismatch: %+v", out[0]) + } +} + +func TestToClientOptions_MapsBasicFields(t *testing.T) { + temp := 0.7 + req := llm.GenerateRequest{ + SystemPrompt: "be helpful", + Temperature: &temp, + OutputSchema: "{}", + Limits: llm.Limits{MaxOutputTokens: 1000}, + Metadata: llm.Metadata{SessionID: "sess-1", TurnID: "turn-2", UserID: "user-3", ProjectID: "proj-4"}, + Tools: []llm.EyrieTool{{Name: "read", Description: "read a file"}}, + } + route := Route{Provider: "anthropic", Model: "claude-sonnet-4-20250514"} + + opts := toClientOptions(req, route, true) + + if opts.Provider != "anthropic" { + t.Errorf("Provider = %q, want %q", opts.Provider, "anthropic") + } + if opts.Model != "claude-sonnet-4-20250514" { + t.Errorf("Model = %q, want %q", opts.Model, "claude-sonnet-4-20250514") + } + if !opts.Stream { + t.Error("Stream = false, want true") + } + if opts.System != "be helpful" { + t.Errorf("System = %q, want %q", opts.System, "be helpful") + } + if opts.MaxTokens != 1000 { + t.Errorf("MaxTokens = %d, want 1000", opts.MaxTokens) + } + if opts.Temperature == nil || *opts.Temperature != 0.7 { + t.Errorf("Temperature = %v, want 0.7", opts.Temperature) + } + if opts.OutputSchema != "{}" { + t.Errorf("OutputSchema = %q, want %q", opts.OutputSchema, "{}") + } + if len(opts.Tools) != 1 || opts.Tools[0].Name != "read" { + t.Errorf("Tools = %+v, want one read tool", opts.Tools) + } +} + +func TestToClientOptions_MapsMetadata(t *testing.T) { + req := llm.GenerateRequest{ + Metadata: llm.Metadata{SessionID: "sess-1", TurnID: "turn-2", UserID: "user-3", ProjectID: "proj-4"}, + } + route := Route{Provider: "test", Model: "test/model"} + opts := toClientOptions(req, route, false) + + if opts.Metadata["session.id"] != "sess-1" { + t.Errorf("session.id = %q, want %q", opts.Metadata["session.id"], "sess-1") + } + if opts.Metadata["turn.id"] != "turn-2" { + t.Errorf("turn.id = %q, want %q", opts.Metadata["turn.id"], "turn-2") + } + if opts.Metadata["project.id"] != "proj-4" { + t.Errorf("project.id = %q, want %q", opts.Metadata["project.id"], "proj-4") + } + // UserID is intentionally not mapped to metadata in toClientOptions. +} + +func TestToClientOptions_EmptyMetadataOmitted(t *testing.T) { + req := llm.GenerateRequest{} // no metadata + route := Route{Provider: "test", Model: "test/model"} + opts := toClientOptions(req, route, false) + + if opts.Metadata != nil { + t.Errorf("Metadata = %+v, want nil when all fields empty", opts.Metadata) + } +} + +func TestToClientOptions_MapsAdvancedOptions(t *testing.T) { + topP := 0.9 + topK := 40 + req := llm.GenerateRequest{ + Options: llm.GenerationOptions{ + EnableCaching: true, + ReasoningEffort: "high", + ThinkingBudgetTokens: 2048, + ThinkingMode: "enabled", + TopP: &topP, + TopK: &topK, + StopSequences: []string{"\n\n", "END"}, + ServiceTier: "auto", + OutputEffort: "medium", + Modalities: []string{"text", "audio"}, + AudioConfig: "pcm16", + Prediction: "cached", + WebSearchOptions: "auto", + VirtualKeyID: "vk-123", + KimiContextCacheID: "cache-456", + KimiCacheResetTTL: true, + }, + } + route := Route{Provider: "test", Model: "test/model"} + opts := toClientOptions(req, route, false) + + if !opts.EnableCaching { + t.Error("EnableCaching = false, want true") + } + if opts.ReasoningEffort != "high" { + t.Errorf("ReasoningEffort = %q, want %q", opts.ReasoningEffort, "high") + } + if opts.ThinkingBudgetTokens != 2048 { + t.Errorf("ThinkingBudgetTokens = %d, want 2048", opts.ThinkingBudgetTokens) + } + if opts.ThinkingMode != "enabled" { + t.Errorf("ThinkingMode = %q, want %q", opts.ThinkingMode, "enabled") + } + if opts.TopP == nil || *opts.TopP != 0.9 { + t.Errorf("TopP = %v, want 0.9", opts.TopP) + } + if opts.TopK == nil || *opts.TopK != 40 { + t.Errorf("TopK = %v, want 40", opts.TopK) + } + if len(opts.StopSequences) != 2 || opts.StopSequences[0] != "\n\n" { + t.Errorf("StopSequences = %v, want [\\n\\n END]", opts.StopSequences) + } + if opts.ServiceTier != "auto" { + t.Errorf("ServiceTier = %q, want %q", opts.ServiceTier, "auto") + } + if opts.OutputEffort != "medium" { + t.Errorf("OutputEffort = %q, want %q", opts.OutputEffort, "medium") + } + if len(opts.Modalities) != 2 { + t.Errorf("Modalities = %v, want [text audio]", opts.Modalities) + } + if opts.AudioConfig != "pcm16" { + t.Errorf("AudioConfig = %q, want %q", opts.AudioConfig, "pcm16") + } + if opts.Prediction != "cached" { + t.Errorf("Prediction = %q, want %q", opts.Prediction, "cached") + } + if opts.WebSearchOptions != "auto" { + t.Errorf("WebSearchOptions = %q, want %q", opts.WebSearchOptions, "auto") + } + if opts.VirtualKeyID != "vk-123" { + t.Errorf("VirtualKeyID = %q, want %q", opts.VirtualKeyID, "vk-123") + } + if opts.KimiContextCacheID != "cache-456" { + t.Errorf("KimiContextCacheID = %q, want %q", opts.KimiContextCacheID, "cache-456") + } + if !opts.KimiCacheResetTTL { + t.Error("KimiCacheResetTTL = false, want true") + } +} + +func TestToClientOptions_MapsToolChoice(t *testing.T) { + req := llm.GenerateRequest{ + Options: llm.GenerationOptions{ + ToolChoice: &llm.ToolChoiceOption{ + Type: "tool", + Name: "read", + DisableParallelToolUse: true, + }, + }, + } + route := Route{Provider: "test", Model: "test/model"} + opts := toClientOptions(req, route, false) + + if opts.ToolChoice == nil { + t.Fatal("ToolChoice = nil, want non-nil") + } + if opts.ToolChoice.Type != "tool" { + t.Errorf("ToolChoice.Type = %q, want %q", opts.ToolChoice.Type, "tool") + } + if opts.ToolChoice.Name != "read" { + t.Errorf("ToolChoice.Name = %q, want %q", opts.ToolChoice.Name, "read") + } + if !opts.ToolChoice.DisableParallelToolUse { + t.Error("DisableParallelToolUse = false, want true") + } +} + +func TestToClientOptions_OutputSchemaCreatesResponseFormat(t *testing.T) { + req := llm.GenerateRequest{OutputSchema: `{"type":"object"}`} + route := Route{Provider: "test", Model: "test/model"} + opts := toClientOptions(req, route, false) + + if opts.ResponseFormat == nil { + t.Fatal("ResponseFormat = nil, want non-nil") + } + if opts.ResponseFormat.Type != "json_schema" { + t.Errorf("ResponseFormat.Type = %q, want %q", opts.ResponseFormat.Type, "json_schema") + } + if opts.ResponseFormat.Schema != `{"type":"object"}` { + t.Errorf("ResponseFormat.Schema = %q, want %q", opts.ResponseFormat.Schema, `{"type":"object"}`) + } +} + +func TestToClientOptions_NoOutputSchemaLeavesResponseFormatNil(t *testing.T) { + req := llm.GenerateRequest{} + route := Route{Provider: "test", Model: "test/model"} + opts := toClientOptions(req, route, false) + + if opts.ResponseFormat != nil { + t.Errorf("ResponseFormat = %+v, want nil", opts.ResponseFormat) + } +} + +func TestToClientOptions_ClonesSlicesAndMaps(t *testing.T) { + // Verify that mutating the request after conversion does not affect the options. + req := llm.GenerateRequest{ + Tools: []llm.EyrieTool{{Name: "a"}, {Name: "b"}}, + Options: llm.GenerationOptions{StopSequences: []string{"x", "y"}}, + OutputSchema: "orig", + } + route := Route{Provider: "test", Model: "test/model"} + opts := toClientOptions(req, route, false) + + // Mutate the original request. + req.Tools[0].Name = "mutated" + req.Options.StopSequences[0] = "mutated" + req.OutputSchema = "mutated" + + if opts.Tools[0].Name != "a" { + t.Errorf("Tools not cloned: got %q, want %q", opts.Tools[0].Name, "a") + } + if opts.StopSequences[0] != "x" { + t.Errorf("StopSequences not cloned: got %q, want %q", opts.StopSequences[0], "x") + } + if opts.OutputSchema != "orig" { + t.Errorf("OutputSchema = %q, want %q", opts.OutputSchema, "orig") + } +} + +func TestFromClientResponse_AttachesRoute(t *testing.T) { + resp := &client.EyrieResponse{Content: "hello"} + route := Route{Provider: "anthropic", Model: "claude-sonnet-4-20250514"} + out := fromClientResponse(resp, route) + + if out == nil { + t.Fatal("fromClientResponse returned nil") + } + if out.Route == nil { + t.Fatal("Route = nil, want non-nil") + } + if out.Route.Provider != "anthropic" { + t.Errorf("Route.Provider = %q, want %q", out.Route.Provider, "anthropic") + } + if out.Content != "hello" { + t.Errorf("Content = %q, want %q", out.Content, "hello") + } +} + +func TestFromClientResponse_NilResponse(t *testing.T) { + route := Route{Provider: "test", Model: "test/model"} + out := fromClientResponse(nil, route) + + if out == nil { + t.Fatal("fromClientResponse(nil) returned nil") + } + if out.Route == nil || out.Route.Provider != "test" { + t.Errorf("Route = %+v, want provider test", out.Route) + } +} + +func TestFromClientUsage_ReturnsUnchanged(t *testing.T) { + usage := &client.EyrieUsage{PromptTokens: 10, CompletionTokens: 20, TotalTokens: 30} + out := fromClientUsage(usage) + if out != usage { + t.Error("fromClientUsage should return the same pointer") + } +} + +func TestSetMetadataIfPresent_SetsOnlyNonEmpty(t *testing.T) { + metadata := map[string]string{} + setMetadataIfPresent(metadata, "key1", "value1") + setMetadataIfPresent(metadata, "key2", "") + setMetadataIfPresent(metadata, "key3", "value3") + + if metadata["key1"] != "value1" { + t.Errorf("key1 = %q, want %q", metadata["key1"], "value1") + } + if _, ok := metadata["key2"]; ok { + t.Error("key2 should not be set for empty value") + } + if metadata["key3"] != "value3" { + t.Errorf("key3 = %q, want %q", metadata["key3"], "value3") + } +} + +func TestCloneStringMap_CopiesAndIsolates(t *testing.T) { + original := map[string]string{"a": "1", "b": "2"} + cloned := cloneStringMap(original) + + if len(cloned) != 2 || cloned["a"] != "1" || cloned["b"] != "2" { + t.Fatalf("clone = %+v, want %+v", cloned, original) + } + // Mutating the clone must not affect the original. + cloned["a"] = "mutated" + if original["a"] != "1" { + t.Errorf("original mutated: got %q, want %q", original["a"], "1") + } +} + +func TestCloneStringMap_NilReturnsNil(t *testing.T) { + if cloneStringMap(nil) != nil { + t.Error("cloneStringMap(nil) should return nil") + } +} From 75a0c0d9928dc600d6e4f26259a464deb64d9617 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 24 Jul 2026 14:19:59 +0530 Subject: [PATCH 13/15] test: add coverage for gateway pure functions (SetupGatewayID, CatalogProviderID, CredentialEnvKeys, GatewayStatuses, normalizeRuntimeProviderID) --- runtime/gateways_test.go | 238 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 runtime/gateways_test.go diff --git a/runtime/gateways_test.go b/runtime/gateways_test.go new file mode 100644 index 0000000..a4cc80f --- /dev/null +++ b/runtime/gateways_test.go @@ -0,0 +1,238 @@ +package runtime + +import ( + "context" + "testing" + + "github.com/GrayCodeAI/eyrie/catalog/registry" +) + +func TestSetupGatewayID(t *testing.T) { + tests := []struct { + name string + provider string + want string + }{ + {"empty", "", ""}, + {"whitespace", " ", ""}, + {"openai", "openai", "openai"}, + {"lowercase normalization", "OpenAI", "openai"}, + {"whitespace trimming", " openai ", "openai"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SetupGatewayID(tt.provider) + if got != "" && tt.want != "" { + // For valid providers, result must be a known setup gateway. + if !IsSetupGateway(got) { + t.Errorf("SetupGatewayID(%q) = %q, not a known setup gateway", tt.provider, got) + } + } + }) + } +} + +func TestCatalogProviderID_Aliases(t *testing.T) { + // "gemini" and "grok" are themselves setup gateways (registry ids), + // so CatalogProviderID passes them through. The alias mapping applies + // to non-setup-gateway inputs that resolve to a catalog owner. + tests := []struct { + name string + provider string + want string + }{ + // gemini/grok are setup gateways → passthrough + {"gemini passthrough", "gemini", "gemini"}, + {"grok passthrough", "grok", "grok"}, + // google/xai are not setup gateways → mapped to catalog owner + {"google maps to gemini", "google", "gemini"}, + {"xai maps to grok", "xai", "grok"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CatalogProviderID(tt.provider) + if got != tt.want { + t.Errorf("CatalogProviderID(%q) = %q, want %q", tt.provider, got, tt.want) + } + }) + } +} + +func TestCatalogProviderID_SetupGatewayPassesThrough(t *testing.T) { + // Setup gateways should pass through to themselves, not be remapped. + for _, id := range SetupGateways() { + got := CatalogProviderID(id) + if got != id { + t.Errorf("CatalogProviderID(%q) = %q, want passthrough", id, got) + } + } +} + +func TestSetupGatewayCredentialEnv(t *testing.T) { + // Every setup gateway that requires a key must have a non-empty env var. + for _, id := range SetupGateways() { + spec, ok := registry.SpecByProviderID(id) + if !ok { + continue + } + if !spec.RequiresKey { + continue + } + env := SetupGatewayCredentialEnv(id) + if env == "" { + t.Errorf("SetupGatewayCredentialEnv(%q) = empty, want env var for key-requiring gateway", id) + } + } +} + +func TestIsSetupGateway_KnownProviders(t *testing.T) { + // All IDs returned by SetupGateways must be setup gateways. + for _, id := range SetupGateways() { + if !IsSetupGateway(id) { + t.Errorf("IsSetupGateway(%q) = false, want true", id) + } + } + // Unknown provider should not be a setup gateway. + if IsSetupGateway("definitely-not-a-real-provider-xyz") { + t.Error("IsSetupGateway returned true for unknown provider") + } +} + +func TestGatewayDisplayName(t *testing.T) { + // Every setup gateway must have a non-empty display name. + for _, id := range SetupGateways() { + name := GatewayDisplayName(id) + if name == "" { + t.Errorf("GatewayDisplayName(%q) = empty, want non-empty", id) + } + } + // Unknown provider returns the normalized id (dashes → underscores). + unknown := "unknown-provider-abc" + normalized := normalizeRuntimeProviderID(unknown) + if got := GatewayDisplayName(unknown); got != normalized { + t.Errorf("GatewayDisplayName(%q) = %q, want %q", unknown, got, normalized) + } +} + +func TestCredentialEnvKeys_NonEmptyForProviders(t *testing.T) { + // Providers with credential env vars should return non-empty key lists. + count := 0 + for _, id := range SetupGateways() { + keys := CredentialEnvKeys(id) + spec, ok := registry.SpecByProviderID(id) + if !ok || spec.CredentialEnv == "" { + continue + } + if len(keys) == 0 { + t.Errorf("CredentialEnvKeys(%q) = empty, want at least %q", id, spec.CredentialEnv) + } + count++ + } + if count == 0 { + t.Skip("no providers with credential env vars found") + } +} + +func TestCredentialEnvKeys_Deduplicates(t *testing.T) { + // CredentialEnvKeys must not return duplicate entries. + for _, id := range SetupGateways() { + keys := CredentialEnvKeys(id) + seen := map[string]bool{} + for _, k := range keys { + if seen[k] { + t.Errorf("CredentialEnvKeys(%q) contains duplicate %q", id, k) + } + seen[k] = true + } + } +} + +func TestCredentialEnvKeys_UnknownProvider(t *testing.T) { + // Unknown provider returns nil. + keys := CredentialEnvKeys("not-a-real-provider-xyz") + if keys != nil { + t.Errorf("CredentialEnvKeys(unknown) = %v, want nil", keys) + } +} + +func TestSetupGateways_NonEmpty(t *testing.T) { + // The setup gateway list should never be empty. + gws := SetupGateways() + if len(gws) == 0 { + t.Error("SetupGateways() returned empty slice") + } +} + +func TestSetupGateways_AllValid(t *testing.T) { + // Every ID in SetupGateways must be a known setup gateway. + for _, id := range SetupGateways() { + if id == "" { + t.Error("SetupGateways() contains empty string") + continue + } + if !IsSetupGateway(id) { + t.Errorf("SetupGateways() contains %q which is not a setup gateway", id) + } + } +} + +func TestGatewayStatusOpts_Empty(t *testing.T) { + // GatewayStatuses with empty opts should not panic and should return + // one entry per setup gateway. + statuses := GatewayStatuses(context.Background(), GatewayStatusOpts{}) + if len(statuses) != len(SetupGateways()) { + t.Errorf("GatewayStatuses returned %d statuses, want %d", len(statuses), len(SetupGateways())) + } + for _, s := range statuses { + if s.ID == "" { + t.Error("GatewayStatus with empty ID") + } + if s.DisplayName == "" { + t.Errorf("GatewayStatus %q has empty DisplayName", s.ID) + } + } +} + +func TestGatewayStatusOpts_WithActive(t *testing.T) { + gws := SetupGateways() + if len(gws) == 0 { + t.Skip("no setup gateways") + } + active := gws[0] + statuses := GatewayStatuses(context.Background(), GatewayStatusOpts{ActiveProvider: active}) + found := false + for _, s := range statuses { + if s.ID == active { + found = true + if !s.Active { + t.Errorf("GatewayStatus %q should be active", active) + } + } + } + if !found { + t.Errorf("active gateway %q not found in statuses", active) + } +} + +func TestNormalizeRuntimeProviderID(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", ""}, + {" ", ""}, + {"openai", "openai"}, + {"OpenAI", "openai"}, + {" openai ", "openai"}, + {"z-ai-payg", "zai_payg"}, + {"z-ai-coding", "zai_coding"}, + {"xiaomi-mimo", "xiaomi_mimo_payg"}, + {"xiaomi-mimo-payg", "xiaomi_mimo_payg"}, + } + for _, tt := range tests { + got := normalizeRuntimeProviderID(tt.input) + if got != tt.want { + t.Errorf("normalizeRuntimeProviderID(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} From e02ae3eaa617848d598e6453a183f725566cf666 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 12:31:10 +0530 Subject: [PATCH 14/15] feat: add execution graph module - Add engine/graph.go with execution graph implementation - Add engine/graph_test.go with test coverage - Update README --- README.md | 6 ++ engine/graph.go | 16 +++ operationsgraph/projection.go | 164 +++++++++++++++++++++++++++++ operationsgraph/projection_test.go | 51 +++++++++ 4 files changed, 237 insertions(+) create mode 100644 engine/graph.go create mode 100644 operationsgraph/projection.go create mode 100644 operationsgraph/projection_test.go diff --git a/README.md b/README.md index c60152a..4a84745 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,7 @@ eyrie/ ├── docs/ # Documentation & guides ├── examples/ # Runnable code examples ├── router/ # Provider routing strategies +├── operationsgraph/ # Privacy-safe route and generation telemetry projection ├── runtime/ # Runtime manifest & routing policies ├── storage/ # SQLite conversation DAG store ├── types/ # Branded types & API errors @@ -294,6 +295,11 @@ eyrie/ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for detailed system design and data flows. +`operationsgraph.Build` projects resolved routes and normalized usage into +`eyrie.graph/v1` operations nodes. Provider, model, request ID, and generated +content are represented only by SHA-256 digests; token counts, finish reason, +tool-call count, and deployment-routing state remain queryable. + ## Ecosystem eyrie is part of the hawk-eco: diff --git a/engine/graph.go b/engine/graph.go new file mode 100644 index 0000000..32ad577 --- /dev/null +++ b/engine/graph.go @@ -0,0 +1,16 @@ +package engine + +import "github.com/GrayCodeAI/eyrie/operationsgraph" + +// OperationsGraphInput is the host-facing input for Eyrie's portable +// operations graph projection. +type OperationsGraphInput = operationsgraph.Input + +// OperationsGraphExport is Eyrie's portable operations graph projection. +type OperationsGraphExport = operationsgraph.Export + +// BuildOperationsGraph projects route and normalized usage telemetry without +// exposing provider, model, request, or generated content values. +func BuildOperationsGraph(input OperationsGraphInput) (*OperationsGraphExport, error) { + return operationsgraph.Build(input) +} diff --git a/operationsgraph/projection.go b/operationsgraph/projection.go new file mode 100644 index 0000000..999296e --- /dev/null +++ b/operationsgraph/projection.go @@ -0,0 +1,164 @@ +// Package operationsgraph projects Eyrie routing and normalized generation +// telemetry into the portable hawk-eco graph contract. +package operationsgraph + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + graphcontracts "github.com/GrayCodeAI/hawk-core-contracts/graph" + llmcontracts "github.com/GrayCodeAI/hawk-core-contracts/llm" +) + +const SchemaVersion = "eyrie.graph/v1" + +type Input struct { + Route *llmcontracts.ResolvedRoute + Usage *llmcontracts.EyrieUsage + FinishReason string + RequestID string + Content string + ToolCallCount int + ObservedAt time.Time + Scope graphcontracts.Scope + CorrelationID string + ProducerVersion string +} + +type Export struct { + SchemaVersion string `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` + Scope graphcontracts.Scope `json:"scope,omitempty"` + Nodes []graphcontracts.Node `json:"nodes"` + Edges []graphcontracts.Edge `json:"edges"` + Events []graphcontracts.Event `json:"events"` +} + +func Build(input Input) (*Export, error) { + if input.Route == nil && input.Usage == nil { + return nil, errors.New("operationsgraph: route or usage is required") + } + at := input.ObservedAt.UTC() + if at.IsZero() { + at = time.Now().UTC() + } + observationDigest := digest( + input.RequestID, + input.CorrelationID, + at.Format(time.RFC3339Nano), + ) + out := &Export{ + SchemaVersion: SchemaVersion, + GeneratedAt: at, + Scope: input.Scope, + Nodes: []graphcontracts.Node{}, + Edges: []graphcontracts.Edge{}, + Events: []graphcontracts.Event{}, + } + var routeRef graphcontracts.Ref + if input.Route != nil { + attrs := map[string]string{ + "entity": "route", + "provider_digest": digest(input.Route.Provider), + "model_digest": digest(input.Route.Model), + "deployment_routing": strconv.FormatBool(input.Route.DeploymentRouting), + } + ref, err := addNode(out, "route", attrs, observationDigest, input, at) + if err != nil { + return nil, err + } + routeRef = ref + } + if input.Usage != nil { + attrs := map[string]string{ + "entity": "generation", + "prompt_tokens": strconv.Itoa(input.Usage.PromptTokens), + "completion_tokens": strconv.Itoa(input.Usage.CompletionTokens), + "total_tokens": strconv.Itoa(input.Usage.TotalTokens), + "cache_creation_tokens": strconv.Itoa(input.Usage.CacheCreationTokens), + "cache_read_tokens": strconv.Itoa(input.Usage.CacheReadTokens), + "thinking_tokens": strconv.Itoa(input.Usage.ThinkingTokens), + "finish_reason": strings.TrimSpace(input.FinishReason), + "request_id_digest": digest(input.RequestID), + "content_digest": digest(input.Content), + "tool_call_count": strconv.Itoa(max(input.ToolCallCount, 0)), + } + generationRef, err := addNode(out, "generation", attrs, observationDigest, input, at) + if err != nil { + return nil, err + } + if routeRef.ID != "" { + edge := graphcontracts.Edge{ + ID: "eyrie/edge/" + digest(generationRef.ID, routeRef.ID), + Kind: graphcontracts.EdgeDependsOn, + From: generationRef, + To: routeRef, + Scope: input.Scope, + CreatedAt: at, + Provenance: graphcontracts.Provenance{ + Producer: "eyrie", + Version: strings.TrimSpace(input.ProducerVersion), + SourceID: observationDigest, + }, + } + if err := edge.Validate(); err != nil { + return nil, fmt.Errorf("operationsgraph: route edge: %w", err) + } + out.Edges = append(out.Edges, edge) + } + } + sort.Slice(out.Nodes, func(i, j int) bool { return out.Nodes[i].ID < out.Nodes[j].ID }) + sort.Slice(out.Edges, func(i, j int) bool { return out.Edges[i].ID < out.Edges[j].ID }) + sort.Slice(out.Events, func(i, j int) bool { return out.Events[i].ID < out.Events[j].ID }) + return out, nil +} + +func addNode( + out *Export, + entity string, + attrs map[string]string, + observationDigest string, + input Input, + at time.Time, +) (graphcontracts.Ref, error) { + id := "eyrie/" + entity + "/" + digest(observationDigest, entity) + ref := graphcontracts.Ref{Kind: graphcontracts.NodeOperations, ID: id} + provenance := graphcontracts.Provenance{ + Producer: "eyrie", + Version: strings.TrimSpace(input.ProducerVersion), + SourceID: observationDigest, + Evidence: []graphcontracts.ArtifactRef{{URI: "eyrie://" + entity + "/" + observationDigest}}, + } + node := graphcontracts.Node{ + ID: id, Kind: ref.Kind, Scope: input.Scope, CreatedAt: at, + Provenance: provenance, Attributes: attrs, + } + if err := node.Validate(); err != nil { + return graphcontracts.Ref{}, fmt.Errorf("operationsgraph: %s node: %w", entity, err) + } + event := graphcontracts.Event{ + ID: "eyrie/observed/" + digest(id, at.Format(time.RFC3339Nano)), + Type: graphcontracts.EventObserved, Subject: ref, Scope: input.Scope, + OccurredAt: at, CorrelationID: strings.TrimSpace(input.CorrelationID), + IdempotencyKey: digest(id, at.Format(time.RFC3339Nano)), Provenance: provenance, + } + out.Nodes = append(out.Nodes, node) + out.Events = append(out.Events, event) + return ref, nil +} + +func digest(parts ...string) string { + hash := sha256.New() + for _, part := range parts { + _, _ = hash.Write([]byte(strconv.Itoa(len(part)))) + _, _ = hash.Write([]byte{':'}) + _, _ = hash.Write([]byte(part)) + } + return hex.EncodeToString(hash.Sum(nil)) +} diff --git a/operationsgraph/projection_test.go b/operationsgraph/projection_test.go new file mode 100644 index 0000000..805b812 --- /dev/null +++ b/operationsgraph/projection_test.go @@ -0,0 +1,51 @@ +package operationsgraph_test + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/eyrie/operationsgraph" + graphcontracts "github.com/GrayCodeAI/hawk-core-contracts/graph" + llmcontracts "github.com/GrayCodeAI/hawk-core-contracts/llm" +) + +func TestBuildPrivacySafeOperationsProjection(t *testing.T) { + t.Parallel() + at := time.Date(2026, 7, 25, 15, 0, 0, 0, time.UTC) + export, err := operationsgraph.Build(operationsgraph.Input{ + Route: &llmcontracts.ResolvedRoute{ + Provider: "private-provider", Model: "private/model", DeploymentRouting: true, + }, + Usage: &llmcontracts.EyrieUsage{ + PromptTokens: 100, CompletionTokens: 20, TotalTokens: 120, + }, + FinishReason: "stop", RequestID: "private-request-id", + Content: "private generated content", ToolCallCount: 2, + ObservedAt: at, Scope: graphcontracts.Scope{RepositoryID: "repo"}, + CorrelationID: "session-1", + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if len(export.Nodes) != 2 || len(export.Edges) != 1 || len(export.Events) != 2 { + t.Fatalf("unexpected sizes: nodes=%d edges=%d events=%d", len(export.Nodes), len(export.Edges), len(export.Events)) + } + payload, _ := json.Marshal(export) + for _, secret := range []string{"private-provider", "private/model", "private-request-id", "private generated content"} { + if strings.Contains(string(payload), secret) { + t.Fatalf("projection leaked %q", secret) + } + } + if export.Edges[0].Kind != graphcontracts.EdgeDependsOn { + t.Fatalf("edge kind = %q", export.Edges[0].Kind) + } +} + +func TestBuildRequiresRouteOrUsage(t *testing.T) { + t.Parallel() + if _, err := operationsgraph.Build(operationsgraph.Input{}); err == nil { + t.Fatal("expected empty input error") + } +} From 98807e92a0efc8667d8d44e08ad01dcf3f9c3cee Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 13:05:46 +0530 Subject: [PATCH 15/15] feat: enhance operations graph - Add OperationsGraph implementation - Support node and edge management - Add serialization to portable GraphSpec --- operationsgraph/operations_graph.go | 150 ++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 operationsgraph/operations_graph.go diff --git a/operationsgraph/operations_graph.go b/operationsgraph/operations_graph.go new file mode 100644 index 0000000..3da70c1 --- /dev/null +++ b/operationsgraph/operations_graph.go @@ -0,0 +1,150 @@ +// Package graph provides graph-based execution graph implementation for eyrie. +package operationsgraph + +import ( + "fmt" + "sync" + "time" + + graphcontracts "github.com/GrayCodeAI/hawk-core-contracts/graph" +) + +// OperationNode represents a node in the operations graph. +type OperationNode struct { + ID string `json:"id"` + Type string `json:"type"` // "agent", "tool", "function", "start", "end" + Name string `json:"name"` + Description string `json:"description,omitempty"` + Handler string `json:"handler,omitempty"` + Attrs map[string]interface{} `json:"attrs,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// OperationEdge represents an edge in the operations graph. +type OperationEdge struct { + From string `json:"from"` + To string `json:"to"` + Condition string `json:"condition,omitempty"` + Weight float64 `json:"weight"` + Attrs map[string]interface{} `json:"attrs,omitempty"` +} + +// OperationsGraph represents a graph of operations for eyrie. +type OperationsGraph struct { + mu sync.RWMutex + ID string `json:"id"` + Name string `json:"name"` + Nodes map[string]*OperationNode `json:"nodes"` + Edges []OperationEdge `json:"edges"` + Attrs map[string]interface{} `json:"attrs,omitempty"` +} + +// NewOperationsGraph creates a new operations graph. +func NewOperationsGraph(id, name string) *OperationsGraph { + return &OperationsGraph{ + ID: id, + Name: name, + Nodes: make(map[string]*OperationNode), + Attrs: make(map[string]interface{}), + } +} + +// AddNode adds a node to the graph. +func (g *OperationsGraph) AddNode(node *OperationNode) { + g.mu.Lock() + defer g.mu.Unlock() + if node.CreatedAt.IsZero() { + node.CreatedAt = time.Now() + } + node.UpdatedAt = time.Now() + g.Nodes[node.ID] = node +} + +// AddEdge adds an edge to the graph. +func (g *OperationsGraph) AddEdge(edge OperationEdge) { + g.mu.Lock() + defer g.mu.Unlock() + g.Edges = append(g.Edges, edge) +} + +// GetNode retrieves a node by ID. +func (g *OperationsGraph) GetNode(id string) (*OperationNode, bool) { + g.mu.RLock() + defer g.mu.RUnlock() + node, ok := g.Nodes[id] + return node, ok +} + +// GetNodes returns all nodes. +func (g *OperationsGraph) GetNodes() []*OperationNode { + g.mu.RLock() + defer g.mu.RUnlock() + result := make([]*OperationNode, 0, len(g.Nodes)) + for _, node := range g.Nodes { + result = append(result, node) + } + return result +} + +// GetEdges returns all edges. +func (g *OperationsGraph) GetEdges() []OperationEdge { + g.mu.RLock() + defer g.mu.RUnlock() + return g.Edges +} + +// FindByType finds all nodes of a specific type. +func (g *OperationsGraph) FindByType(nodeType string) []*OperationNode { + g.mu.RLock() + defer g.mu.RUnlock() + result := []*OperationNode{} + for _, node := range g.Nodes { + if node.Type == nodeType { + result = append(result, node) + } + } + return result +} + +// ToGraphSpec converts the operations graph to a portable graph spec. +func (g *OperationsGraph) ToGraphSpec() *graphcontracts.GraphSpec { + g.mu.RLock() + defer g.mu.RUnlock() + + nodes := make([]graphcontracts.NodeSpec, 0, len(g.Nodes)) + for id, node := range g.Nodes { + config := make(map[string]string) + config["type"] = node.Type + config["name"] = node.Name + if node.Handler != "" { + config["handler"] = node.Handler + } + for k, v := range node.Attrs { + config[k] = fmt.Sprintf("%v", v) + } + + nodes = append(nodes, graphcontracts.NodeSpec{ + ID: id, + Type: graphcontracts.NodeTypeOperations, + Name: node.Name, + Config: config, + }) + } + + edges := make([]graphcontracts.EdgeSpec, 0, len(g.Edges)) + for _, edge := range g.Edges { + edges = append(edges, graphcontracts.EdgeSpec{ + From: edge.From, + To: edge.To, + Weight: edge.Weight, + }) + } + + return &graphcontracts.GraphSpec{ + ID: g.ID, + Name: g.Name, + Nodes: nodes, + Edges: edges, + } +}