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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -275,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
Expand All @@ -293,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:
Expand Down
61 changes: 61 additions & 0 deletions catalog/spec_parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
25 changes: 25 additions & 0 deletions catalog/v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions client/adaptive_ratelimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions client/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"`
Expand Down
5 changes: 1 addition & 4 deletions client/callbacks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
31 changes: 17 additions & 14 deletions client/compat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand All @@ -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"})
Expand All @@ -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"})
Expand All @@ -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"})
Expand All @@ -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"})
Expand All @@ -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"},
Expand All @@ -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)
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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")
}
Expand All @@ -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()
Expand All @@ -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")
}
}
2 changes: 1 addition & 1 deletion client/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
29 changes: 24 additions & 5 deletions client/fallback.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"sync"
"sync/atomic"
"time"
)

// FallbackProvider wraps multiple Providers and automatically falls back to the
Expand All @@ -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
Expand All @@ -32,10 +37,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 {
Expand All @@ -47,7 +51,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.
Expand Down Expand Up @@ -82,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) {
Expand All @@ -99,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(
Expand Down Expand Up @@ -149,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())
Expand Down
Loading
Loading