diff --git a/README.md b/README.md index 7b49b02..4a84745 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 @@ -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 @@ -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: 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) { 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/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..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 @@ -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 { @@ -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. @@ -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) { @@ -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( @@ -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()) 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/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/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 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/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/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") + } +} 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..19346ba 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) @@ -198,8 +219,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 @@ -336,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) { @@ -431,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 } 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 { 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/engine/host_control.go b/engine/host_control.go index 150bf71..3664896 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) @@ -283,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 } } @@ -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 +) diff --git a/go.mod b/go.mod index 8418bef..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.7 + 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 dbc26f4..78e324e 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 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= 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, + } +} 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") + } +} 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/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) + } + } +} 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 } 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.