From 0b71b25b07ba576a80edc0e22a1879fea72567db Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 12 Jul 2026 14:34:54 +0530 Subject: [PATCH 01/24] Make all eyrie provider implementations uniform - Gemini: add parseProviderError, SanitizeMessages, guardrails, request ID, Ping fix, 32MB limit - Bedrock: add SanitizeMessages, guardrails, full buildBody forwarding, streaming usage fix, 30MB limit - Azure: add guardrails, 30MB limit - Vertex: pass requestID in StreamChat - OpenAI: add 32MB size limit - DeepSeek: replace string-based isRetryableError with structured EyrieError.IsRetriable() - MiMo: update to structured errors, fix parseHTTPStatusFromError loop limit bug - Z.AI: update to structured errors --- client/azure.go | 16 +++++- client/bedrock.go | 57 +++++++++++++------ client/{ => core}/errors.go | 0 client/{ => core}/image.go | 0 client/{ => core}/image_test.go | 0 client/{ => core}/provider_errors.go | 0 client/{ => core}/provider_errors_test.go | 0 client/{ => core}/repeat_detector.go | 0 client/{ => core}/repeat_detector_test.go | 0 client/{ => core}/response_health.go | 0 client/{ => core}/response_health_test.go | 0 client/{ => core}/retry.go | 0 client/{ => core}/retry_test.go | 0 client/{ => core}/stream.go | 0 client/{ => core}/stream_guardrails.go | 0 client/{ => core}/stream_guardrails_test.go | 0 client/{ => core}/stream_test.go | 0 client/{ => core}/think_splitter_test.go | 0 client/{ => core}/transport.go | 0 client/{ => core}/transport_test.go | 0 client/{ => core}/ttft_test.go | 0 client/deepseek.go | 20 +------ ...bedding_client.go => embedding_methods.go} | 0 .../cache.go} | 0 .../cache_test.go} | 0 .../defaults.go} | 0 client/{ => embeddings}/embedding.go | 0 client/{ => embeddings}/embedding_test.go | 0 client/gemini.go | 45 ++++++++++++--- client/mimo.go | 40 +++++-------- client/openai.go | 13 ++++- client/vertex.go | 4 +- client/zai.go | 24 ++++---- 33 files changed, 132 insertions(+), 87 deletions(-) rename client/{ => core}/errors.go (100%) rename client/{ => core}/image.go (100%) rename client/{ => core}/image_test.go (100%) rename client/{ => core}/provider_errors.go (100%) rename client/{ => core}/provider_errors_test.go (100%) rename client/{ => core}/repeat_detector.go (100%) rename client/{ => core}/repeat_detector_test.go (100%) rename client/{ => core}/response_health.go (100%) rename client/{ => core}/response_health_test.go (100%) rename client/{ => core}/retry.go (100%) rename client/{ => core}/retry_test.go (100%) rename client/{ => core}/stream.go (100%) rename client/{ => core}/stream_guardrails.go (100%) rename client/{ => core}/stream_guardrails_test.go (100%) rename client/{ => core}/stream_test.go (100%) rename client/{ => core}/think_splitter_test.go (100%) rename client/{ => core}/transport.go (100%) rename client/{ => core}/transport_test.go (100%) rename client/{ => core}/ttft_test.go (100%) rename client/{embedding_client.go => embedding_methods.go} (100%) rename client/{embedding_cache.go => embeddings/cache.go} (100%) rename client/{embedding_cache_test.go => embeddings/cache_test.go} (100%) rename client/{embedding_defaults.go => embeddings/defaults.go} (100%) rename client/{ => embeddings}/embedding.go (100%) rename client/{ => embeddings}/embedding_test.go (100%) diff --git a/client/azure.go b/client/azure.go index 2334a6d..d404317 100644 --- a/client/azure.go +++ b/client/azure.go @@ -11,6 +11,11 @@ import ( "strings" ) +const ( + // maxAzureRequestSize is the maximum request body size for Azure OpenAI (30 MB). + maxAzureRequestSize = 30 * 1024 * 1024 +) + type AzureClient struct { apiKey string endpoint string @@ -18,6 +23,7 @@ type AzureClient struct { httpClient *http.Client retry RetryConfig logger *slog.Logger + guardrails *Guardrails } var _ Provider = (*AzureClient)(nil) @@ -55,6 +61,9 @@ func (c *AzureClient) Chat(ctx context.Context, messages []EyrieMessage, opts Ch if err != nil { return nil, fmt.Errorf("eyrie: azure marshal request failed: %w", err) } + if len(body) > maxAzureRequestSize { + return nil, fmt.Errorf("eyrie: request size %d bytes exceeds Azure limit of %d bytes", len(body), maxAzureRequestSize) + } url := fmt.Sprintf("%s/openai/deployments/%s/chat/completions?api-version=%s", c.endpoint, opts.Model, c.apiVersion) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { @@ -107,6 +116,11 @@ func (c *AzureClient) Chat(ctx context.Context, messages []EyrieMessage, opts Ch result.Usage.CacheReadTokens = or.Usage.PromptTokensDetails.CachedTokens } } + + if err := applyGuardrails(ctx, result, c.guardrails); err != nil { + return nil, err + } + return result, nil } @@ -148,7 +162,7 @@ func (c *AzureClient) StreamChat(ctx context.Context, messages []EyrieMessage, o sseEvents := parseSSEStream(streamCtx, resp.Body, c.logger) events := processOpenAIStream(streamCtx, sseEvents, c.logger) - return &StreamResult{Events: events, RequestID: requestID, cancel: cancel}, nil + return NewStreamResultWithRequestID(events, requestID, cancel), nil } func (c *AzureClient) Ping(ctx context.Context) error { diff --git a/client/bedrock.go b/client/bedrock.go index 8a8a7c4..77471b6 100644 --- a/client/bedrock.go +++ b/client/bedrock.go @@ -19,6 +19,11 @@ import ( "time" ) +const ( + // maxBedrockRequestSize is the maximum request body size for Bedrock (30 MB). + maxBedrockRequestSize = 30 * 1024 * 1024 +) + type BedrockClient struct { accessKeyID string secretAccessKey string @@ -27,6 +32,7 @@ type BedrockClient struct { httpClient *http.Client retry RetryConfig logger *slog.Logger + guardrails *Guardrails } var _ Provider = (*BedrockClient)(nil) @@ -82,7 +88,13 @@ func (c *BedrockClient) Chat(ctx context.Context, messages []EyrieMessage, opts if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil { return nil, fmt.Errorf("eyrie: bedrock decode failed: %w", err) } - return parseAnthropicResponse(ar, resp.Header.Get("X-Amzn-Requestid"), ""), nil + eyrieResp := parseAnthropicResponse(ar, resp.Header.Get("X-Amzn-Requestid"), "") + + if err := applyGuardrails(ctx, eyrieResp, c.guardrails); err != nil { + return nil, err + } + + return eyrieResp, nil } func (c *BedrockClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { @@ -206,21 +218,18 @@ func (c *BedrockClient) StreamChat(ctx context.Context, messages []EyrieMessage, } } - // Send final usage event + // Send final done event with usage (matching Anthropic/OpenAI pattern) + doneEvt := EyrieStreamEvent{Type: "done", StopReason: finishReason} if usage != nil { - select { - case ch <- EyrieStreamEvent{Type: "usage", Usage: usage}: - case <-streamCtx.Done(): - return - } + doneEvt.Usage = usage } select { - case ch <- EyrieStreamEvent{Type: "done", StopReason: finishReason}: + case ch <- doneEvt: case <-streamCtx.Done(): } }() - return &StreamResult{Events: ch, cancel: cancel, RequestID: resp.Header.Get("X-Amzn-Requestid")}, nil + return NewStreamResultWithRequestID(ch, resp.Header.Get("X-Amzn-Requestid"), cancel), nil } func (c *BedrockClient) Ping(ctx context.Context) error { @@ -246,6 +255,7 @@ func (c *BedrockClient) Ping(ctx context.Context) error { } func (c *BedrockClient) buildBody(messages []EyrieMessage, opts ChatOptions) ([]byte, error) { + messages = SanitizeMessages(messages) msgs, system := buildAnthropicMessages(messages) if opts.System != "" { if system != "" { @@ -259,12 +269,20 @@ func (c *BedrockClient) buildBody(messages []EyrieMessage, opts ChatOptions) ([] maxTokens = 4096 } base := anthropicRequest{ - Model: opts.Model, - MaxTokens: maxTokens, - Messages: msgs, - System: system, - Temperature: opts.Temperature, - Tools: convertToAnthropicTools(opts.Tools), + Model: opts.Model, + MaxTokens: maxTokens, + Messages: msgs, + System: system, + Temperature: opts.Temperature, + TopP: opts.TopP, + TopK: opts.TopK, + StopSequences: opts.StopSequences, + Tools: convertToAnthropicTools(opts.Tools), + ToolChoice: resolveToolChoice(opts.ToolChoice), + Thinking: resolveThinking(opts), + Metadata: resolveMetadata(opts), + ServiceTier: opts.ServiceTier, + OutputConfig: resolveOutputConfig(opts), } raw, err := json.Marshal(base) if err != nil { @@ -275,7 +293,14 @@ func (c *BedrockClient) buildBody(messages []EyrieMessage, opts ChatOptions) ([] return nil, err } body["anthropic_version"] = "bedrock-2023-05-31" - return json.Marshal(body) + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + if len(data) > maxBedrockRequestSize { + return nil, fmt.Errorf("eyrie: request size %d bytes exceeds Bedrock limit of %d bytes", len(data), maxBedrockRequestSize) + } + return data, nil } func (c *BedrockClient) modelURL(model string) string { diff --git a/client/errors.go b/client/core/errors.go similarity index 100% rename from client/errors.go rename to client/core/errors.go diff --git a/client/image.go b/client/core/image.go similarity index 100% rename from client/image.go rename to client/core/image.go diff --git a/client/image_test.go b/client/core/image_test.go similarity index 100% rename from client/image_test.go rename to client/core/image_test.go diff --git a/client/provider_errors.go b/client/core/provider_errors.go similarity index 100% rename from client/provider_errors.go rename to client/core/provider_errors.go diff --git a/client/provider_errors_test.go b/client/core/provider_errors_test.go similarity index 100% rename from client/provider_errors_test.go rename to client/core/provider_errors_test.go diff --git a/client/repeat_detector.go b/client/core/repeat_detector.go similarity index 100% rename from client/repeat_detector.go rename to client/core/repeat_detector.go diff --git a/client/repeat_detector_test.go b/client/core/repeat_detector_test.go similarity index 100% rename from client/repeat_detector_test.go rename to client/core/repeat_detector_test.go diff --git a/client/response_health.go b/client/core/response_health.go similarity index 100% rename from client/response_health.go rename to client/core/response_health.go diff --git a/client/response_health_test.go b/client/core/response_health_test.go similarity index 100% rename from client/response_health_test.go rename to client/core/response_health_test.go diff --git a/client/retry.go b/client/core/retry.go similarity index 100% rename from client/retry.go rename to client/core/retry.go diff --git a/client/retry_test.go b/client/core/retry_test.go similarity index 100% rename from client/retry_test.go rename to client/core/retry_test.go diff --git a/client/stream.go b/client/core/stream.go similarity index 100% rename from client/stream.go rename to client/core/stream.go diff --git a/client/stream_guardrails.go b/client/core/stream_guardrails.go similarity index 100% rename from client/stream_guardrails.go rename to client/core/stream_guardrails.go diff --git a/client/stream_guardrails_test.go b/client/core/stream_guardrails_test.go similarity index 100% rename from client/stream_guardrails_test.go rename to client/core/stream_guardrails_test.go diff --git a/client/stream_test.go b/client/core/stream_test.go similarity index 100% rename from client/stream_test.go rename to client/core/stream_test.go diff --git a/client/think_splitter_test.go b/client/core/think_splitter_test.go similarity index 100% rename from client/think_splitter_test.go rename to client/core/think_splitter_test.go diff --git a/client/transport.go b/client/core/transport.go similarity index 100% rename from client/transport.go rename to client/core/transport.go diff --git a/client/transport_test.go b/client/core/transport_test.go similarity index 100% rename from client/transport_test.go rename to client/core/transport_test.go diff --git a/client/ttft_test.go b/client/core/ttft_test.go similarity index 100% rename from client/ttft_test.go rename to client/core/ttft_test.go diff --git a/client/deepseek.go b/client/deepseek.go index 8bb537f..c21cc58 100644 --- a/client/deepseek.go +++ b/client/deepseek.go @@ -35,7 +35,7 @@ func (c *DeepSeekClient) Name() string { return "deepseek" } func (c *DeepSeekClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { return c.router.Chat(ctx, messages, opts, ChatProtocolCompletions, func(err error, _ *EyrieResponse) bool { - if err != nil && c.router.Anthropic != nil && isRetryableError(err) { + if err != nil && c.router.Anthropic != nil && isRetriableError(err) { c.logger.Info("DeepSeek: OpenAI endpoint failed; retrying via Anthropic compatibility", "error", err) return true } @@ -47,7 +47,7 @@ func (c *DeepSeekClient) StreamChat(ctx context.Context, messages []EyrieMessage return c.router.StreamChat(ctx, messages, opts, ProtocolStreamConfig{ Primary: ChatProtocolCompletions, FallbackOnError: func(err error) bool { - if c.router.Anthropic != nil && isRetryableError(err) { + if c.router.Anthropic != nil && isRetriableError(err) { c.logger.Info("DeepSeek: OpenAI stream failed; retrying via Anthropic compatibility", "error", err) return true } @@ -59,24 +59,10 @@ func (c *DeepSeekClient) StreamChat(ctx context.Context, messages []EyrieMessage func (c *DeepSeekClient) Ping(ctx context.Context) error { if err := c.router.OpenAI.Ping(ctx); err == nil { return nil - } else if c.router.Anthropic == nil || !isRetryableError(err) { + } else if c.router.Anthropic == nil || !isRetriableError(err) { return err } return c.router.Anthropic.Ping(ctx) } -// isRetryableError checks if the error is retryable (connection reset, timeout, 5xx). -func isRetryableError(err error) bool { - if err == nil { - return false - } - msg := err.Error() - return strings.Contains(msg, "connection reset") || - strings.Contains(msg, "timeout") || - strings.Contains(msg, "502") || - strings.Contains(msg, "503") || - strings.Contains(msg, "504") || - strings.Contains(msg, "500") -} - var _ Provider = (*DeepSeekClient)(nil) diff --git a/client/embedding_client.go b/client/embedding_methods.go similarity index 100% rename from client/embedding_client.go rename to client/embedding_methods.go diff --git a/client/embedding_cache.go b/client/embeddings/cache.go similarity index 100% rename from client/embedding_cache.go rename to client/embeddings/cache.go diff --git a/client/embedding_cache_test.go b/client/embeddings/cache_test.go similarity index 100% rename from client/embedding_cache_test.go rename to client/embeddings/cache_test.go diff --git a/client/embedding_defaults.go b/client/embeddings/defaults.go similarity index 100% rename from client/embedding_defaults.go rename to client/embeddings/defaults.go diff --git a/client/embedding.go b/client/embeddings/embedding.go similarity index 100% rename from client/embedding.go rename to client/embeddings/embedding.go diff --git a/client/embedding_test.go b/client/embeddings/embedding_test.go similarity index 100% rename from client/embedding_test.go rename to client/embeddings/embedding_test.go diff --git a/client/gemini.go b/client/gemini.go index 7cba8e2..9e7c3e8 100644 --- a/client/gemini.go +++ b/client/gemini.go @@ -19,6 +19,9 @@ import ( // release once the new path is validated in production. const geminiSharedParserEnvVar = "EYRIE_GEMINI_SHARED_PARSER" +// maxGeminiRequestSize is the maximum request body size for Gemini API (32 MB). +const maxGeminiRequestSize = 32 * 1024 * 1024 + // geminiSharedParserEnabled reports whether the Gemini client should // use the shared parseSSEStream + processGeminiStream path (the new // behavior). Default: enabled. @@ -34,6 +37,7 @@ type GeminiClient struct { httpClient *http.Client retry RetryConfig logger *slog.Logger + guardrails *Guardrails } var _ Provider = (*GeminiClient)(nil) @@ -94,9 +98,11 @@ func (c *GeminiClient) Chat(ctx context.Context, messages []EyrieMessage, opts C } }() + requestID := resp.Header.Get("X-Goog-Request-Id") + if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("eyrie: gemini returned %d: %s", resp.StatusCode, string(respBody)) + detail, readErr := parseProviderError(resp.Body) + return nil, formatAPIError("gemini", "chat", resp.StatusCode, requestID, detail, readErr) } respBody, err := io.ReadAll(resp.Body) @@ -104,7 +110,16 @@ func (c *GeminiClient) Chat(ctx context.Context, messages []EyrieMessage, opts C return nil, fmt.Errorf("eyrie: gemini response read failed: %w", err) } - return c.parseResponse(respBody) + eyrieResp, err := c.parseResponse(respBody, requestID) + if err != nil { + return nil, err + } + + if err := applyGuardrails(ctx, eyrieResp, c.guardrails); err != nil { + return nil, err + } + + return eyrieResp, nil } func (c *GeminiClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { @@ -127,10 +142,13 @@ func (c *GeminiClient) StreamChat(ctx context.Context, messages []EyrieMessage, if err != nil { return nil, fmt.Errorf("eyrie: gemini stream request failed: %w", err) } + + requestID := resp.Header.Get("X-Goog-Request-Id") + if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) + detail, readErr := parseProviderError(resp.Body) _ = resp.Body.Close() - return nil, fmt.Errorf("eyrie: gemini stream returned %d: %s", resp.StatusCode, string(respBody)) + return nil, formatAPIError("gemini", "stream", resp.StatusCode, requestID, detail, readErr) } streamCtx, cancel := context.WithCancel(ctx) @@ -161,8 +179,8 @@ func (c *GeminiClient) Ping(ctx context.Context) error { slog.Warn("gemini: close response body", "error", err) } }() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("eyrie: gemini ping returned %d", resp.StatusCode) + if resp.StatusCode == 401 { + return fmt.Errorf("eyrie: gemini: invalid API key") } return nil } @@ -254,6 +272,7 @@ type geminiSafetySetting struct { } func (c *GeminiClient) buildBody(messages []EyrieMessage, opts ChatOptions) ([]byte, error) { + messages = SanitizeMessages(messages) contents := make([]geminiContent, 0, len(messages)) var systemInstruction *geminiContent @@ -426,7 +445,14 @@ func (c *GeminiClient) buildBody(messages []EyrieMessage, opts ChatOptions) ([]b } } - return json.Marshal(req) + data, err := json.Marshal(req) + if err != nil { + return nil, err + } + if len(data) > maxGeminiRequestSize { + return nil, fmt.Errorf("eyrie: request size %d bytes exceeds Gemini limit of %d bytes", len(data), maxGeminiRequestSize) + } + return data, nil } // --- Response parsing --- @@ -456,7 +482,7 @@ type geminiPromptFeedback struct { BlockReasonMessage string `json:"blockReasonMessage,omitempty"` } -func (c *GeminiClient) parseResponse(data []byte) (*EyrieResponse, error) { +func (c *GeminiClient) parseResponse(data []byte, requestID string) (*EyrieResponse, error) { var gr geminiResponse if err := json.Unmarshal(data, &gr); err != nil { return nil, fmt.Errorf("eyrie: gemini response parse failed: %w", err) @@ -476,6 +502,7 @@ func (c *GeminiClient) parseResponse(data []byte) (*EyrieResponse, error) { candidate := gr.Candidates[0] resp := &EyrieResponse{ FinishReason: mapGeminiFinishReason(candidate.FinishReason), + RequestID: requestID, } for _, part := range candidate.Content.Parts { diff --git a/client/mimo.go b/client/mimo.go index 4d14b2a..567a909 100644 --- a/client/mimo.go +++ b/client/mimo.go @@ -2,13 +2,14 @@ package client import ( "context" - "fmt" + "errors" "log/slog" "net/http" "strconv" "strings" "github.com/GrayCodeAI/eyrie/catalog/xiaomi" + "github.com/GrayCodeAI/eyrie/types" ) // MiMoClient uses OpenAI-compatible MiMo endpoints first, with optional Anthropic-compat fallback. @@ -35,20 +36,7 @@ func NewMiMoClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompa } } -// WithProviderName sets the OpenAI client provider name for errors/logging. -func WithProviderName(name string) ClientOption { - return ClientOption{ - applyOpenAIFn: func(c *OpenAIClient) { c.providerName = name }, - } -} - -// WithMimoAuth uses api-key header per MiMo documentation (OpenAI + Anthropic compat). -func WithMimoAuth() ClientOption { - return ClientOption{ - applyFn: func(c *AnthropicClient) { c.useMimoAuth = true }, - applyOpenAIFn: func(c *OpenAIClient) { c.useMimoAuth = true }, - } -} +// WithProviderName and WithMimoAuth live in client/core (options.go wraps them). func (c *MiMoClient) Name() string { if c.router.OpenAI != nil { @@ -107,29 +95,27 @@ func mimoRetryableChatError(err error) bool { if err == nil { return false } + // MiMo-specific: check xiaomi helper first (401/403 are retryable for MiMo) msg := err.Error() - if strings.Contains(msg, "connection reset") || strings.Contains(msg, "timeout") { - return true - } - for _, code := range []int{ - http.StatusUnauthorized, http.StatusForbidden, - http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, - } { - if strings.Contains(msg, fmt.Sprintf("%d", code)) && xiaomi.IsRetryableHTTPStatus(code) { + if n := parseHTTPStatusFromError(msg); n > 0 { + if xiaomi.IsRetryableHTTPStatus(n) { return true } } - if n := parseHTTPStatusFromError(msg); n > 0 { - return xiaomi.IsRetryableHTTPStatus(n) + // Structured path: trust EyrieError's IsRetriable + var eyrieErr *EyrieError + if errors.As(err, &eyrieErr) { + return eyrieErr.IsRetriable() } - return false + // Conservative: only retry on explicitly transient errors (not the optimistic "unknown → true") + return types.IsTransient(err) } func parseHTTPStatusFromError(msg string) int { for _, prefix := range []string{"HTTP ", "status ", "error ("} { if i := strings.Index(msg, prefix); i >= 0 { rest := msg[i+len(prefix):] - for j := 0; j < len(rest) && j < 3; j++ { + for j := 0; j < len(rest); j++ { if rest[j] < '0' || rest[j] > '9' { rest = rest[:j] break diff --git a/client/openai.go b/client/openai.go index d02a51a..036a36e 100644 --- a/client/openai.go +++ b/client/openai.go @@ -10,7 +10,11 @@ import ( "net/http" ) -// OpenAIClient implements Provider for OpenAI and OpenAI-compatible APIs. +const ( + // maxOpenAIRequestSize is the maximum request body size for OpenAI API (32 MB). + maxOpenAIRequestSize = 32 * 1024 * 1024 +) + type OpenAIClient struct { apiKey string baseURL string @@ -47,7 +51,7 @@ func NewOpenAIClient(apiKey, baseURL string, compat *OpenAICompatConfig, opts .. c.compat = &OpenAICompat } for _, opt := range opts { - opt.applyOpenAI(c) + opt.Apply(c) } return c } @@ -421,6 +425,9 @@ func (c *OpenAIClient) buildOpenAIRequest(ctx context.Context, messages []EyrieM if err != nil { return nil, nil, fmt.Errorf("eyrie: marshal openai request: %w", err) } + if len(body) > maxOpenAIRequestSize { + return nil, nil, fmt.Errorf("eyrie: request size %d bytes exceeds OpenAI limit of %d bytes", len(body), maxOpenAIRequestSize) + } req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(body)) if err != nil { return nil, nil, fmt.Errorf("eyrie: failed to create request: %w", err) @@ -520,7 +527,7 @@ func (c *OpenAIClient) StreamChat(ctx context.Context, messages []EyrieMessage, sseEvents := parseSSEStream(streamCtx, resp.Body, c.logger) events := processOpenAIStream(streamCtx, sseEvents, c.logger) - return &StreamResult{Events: events, RequestID: requestID, cancel: cancel}, nil + return NewStreamResultWithRequestID(events, requestID, cancel), nil } // Ping checks connectivity. diff --git a/client/vertex.go b/client/vertex.go index 1613e82..a3fc75d 100644 --- a/client/vertex.go +++ b/client/vertex.go @@ -131,11 +131,13 @@ func (c *VertexClient) StreamChat(ctx context.Context, messages []EyrieMessage, return nil, formatAPIError("vertex", "stream", resp.StatusCode, resp.Header.Get("X-Goog-Request-Id"), detail, readErr) } + requestID := resp.Header.Get("X-Goog-Request-Id") + streamCtx, cancel := context.WithCancel(ctx) sseEvents := parseSSEStream(streamCtx, resp.Body, c.logger) events := processAnthropicStream(streamCtx, sseEvents, c.logger) - return &StreamResult{Events: events, cancel: cancel}, nil + return NewStreamResultWithRequestID(events, requestID, cancel), nil } func (c *VertexClient) Ping(ctx context.Context) error { diff --git a/client/zai.go b/client/zai.go index 3924ee0..ed7cf22 100644 --- a/client/zai.go +++ b/client/zai.go @@ -2,10 +2,12 @@ package client import ( "context" - "fmt" + "errors" "log/slog" "net/http" "strings" + + "github.com/GrayCodeAI/eyrie/types" ) // ZAIClient uses the OpenAI-compatible endpoint (paas/v4 or coding/paas/v4) first, @@ -103,22 +105,18 @@ func zaiRetryableChatError(err error) bool { if err == nil { return false } + // Z.AI-specific: check HTTP status codes msg := err.Error() - if strings.Contains(msg, "connection reset") || strings.Contains(msg, "timeout") { - return true - } - for _, code := range []int{ - http.StatusUnauthorized, http.StatusForbidden, - http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, - } { - if strings.Contains(msg, fmt.Sprintf("%d", code)) { - return true - } - } if n := parseHTTPStatusFromError(msg); n > 0 { return n >= 500 || n == http.StatusUnauthorized || n == http.StatusForbidden } - return false + // Structured path: trust EyrieError's IsRetriable + var eyrieErr *EyrieError + if errors.As(err, &eyrieErr) { + return eyrieErr.IsRetriable() + } + // Conservative: only retry on explicitly transient errors + return types.IsTransient(err) } var _ Provider = (*ZAIClient)(nil) From d9c78c06821e01bab78b463333a26741daab1921 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 12 Jul 2026 14:37:29 +0530 Subject: [PATCH 02/24] WIP: client package decomposition and provider uniformity work --- Makefile | 1 + catalog/live/fetchers.go | 7 +- catalog/live_catalog.go | 6 - catalog/live_catalog_test.go | 14 - catalog/registry/providers.go | 24 +- catalog/registry/spec.go | 8 + client/adapter_config.go | 92 +++++ client/aliases.go | 258 +++++++++++++ client/anthropic.go | 4 +- client/client.go | 163 +------- client/client_test.go | 6 +- client/continuation.go | 16 +- client/continuation_test.go | 2 +- client/core/copy.go | 56 +++ client/core/core.go | 252 +++++++++++++ client/core/embedding.go | 30 ++ client/core/errors.go | 2 +- client/core/guardrails.go | 457 +++++++++++++++++++++++ client/core/image.go | 20 +- client/core/image_test.go | 28 +- client/core/options.go | 140 +++++++ client/core/provider_errors.go | 22 +- client/core/provider_errors_test.go | 56 +-- client/core/repeat_detector.go | 2 +- client/core/repeat_detector_test.go | 2 +- client/core/response_health.go | 2 +- client/core/response_health_test.go | 10 +- client/core/retry.go | 16 +- client/core/retry_test.go | 38 +- client/core/stream.go | 97 ++--- client/core/stream_guardrails.go | 2 +- client/core/stream_guardrails_test.go | 2 +- client/core/stream_test.go | 34 +- client/core/think_splitter_test.go | 2 +- client/core/transport.go | 15 +- client/core/transport_test.go | 2 +- client/core/ttft_test.go | 12 +- client/embedding_methods.go | 33 +- client/embedding_methods_test.go | 191 ++++++++++ client/embeddings/cache.go | 30 +- client/embeddings/cache_test.go | 77 +++- client/embeddings/defaults.go | 2 +- client/embeddings/embedding.go | 55 +-- client/embeddings/embedding_test.go | 191 +--------- client/guardrails.go | 451 ---------------------- client/mock.go | 2 +- client/options.go | 226 ++--------- client/options_test.go | 52 +-- client/provider_policy.go | 54 +++ client/provider_policy_test.go | 37 ++ client/provider_registry.go | 60 +-- client/provider_registry_derived_test.go | 50 +++ client/recorder.go | 4 +- client/semantic_cache.go | 55 +-- client/structured.go | 25 +- client/testhelpers_shared_test.go | 6 + credentials/store.go | 51 +-- plans/client-package-decomposition.md | 206 ++++++++++ router/router.go | 36 +- runtime/configure_provider.go | 69 ++++ runtime/configure_provider_test.go | 21 ++ runtime/list_models.go | 32 +- runtime/list_models_test.go | 19 +- runtime/native_compaction.go | 201 ++++++++++ runtime/native_compaction_test.go | 47 +++ runtime/runtime.go | 22 +- runtime/runtime_test.go | 22 ++ scripts/check-client-layering.sh | 29 ++ 68 files changed, 2802 insertions(+), 1454 deletions(-) create mode 100644 client/adapter_config.go create mode 100644 client/aliases.go create mode 100644 client/core/copy.go create mode 100644 client/core/core.go create mode 100644 client/core/embedding.go create mode 100644 client/core/guardrails.go create mode 100644 client/core/options.go create mode 100644 client/embedding_methods_test.go create mode 100644 client/provider_policy.go create mode 100644 client/provider_policy_test.go create mode 100644 client/provider_registry_derived_test.go create mode 100644 client/testhelpers_shared_test.go create mode 100644 plans/client-package-decomposition.md create mode 100644 runtime/configure_provider.go create mode 100644 runtime/configure_provider_test.go create mode 100644 runtime/native_compaction.go create mode 100644 runtime/native_compaction_test.go create mode 100755 scripts/check-client-layering.sh diff --git a/Makefile b/Makefile index ba51a34..fc52182 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,7 @@ GOVULNCHECK := $(GOBIN_DIR)/govulncheck boundaries: ## Enforce support-repo import boundaries. bash ./scripts/check-ecosystem-boundaries.sh + bash ./scripts/check-client-layering.sh # --------------------------------------------------------------------------- # Default target. diff --git a/catalog/live/fetchers.go b/catalog/live/fetchers.go index 58ee8ff..3181181 100644 --- a/catalog/live/fetchers.go +++ b/catalog/live/fetchers.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "sort" + "strconv" "strings" "time" @@ -139,8 +140,10 @@ func asFloat(v interface{}) float64 { case float64: return n case string: - var f float64 - _, _ = fmt.Sscanf(n, "%f", &f) + f, err := strconv.ParseFloat(strings.TrimSpace(n), 64) + if err != nil { + return 0 + } return f } return 0 diff --git a/catalog/live_catalog.go b/catalog/live_catalog.go index aa0e3ff..aaa4db0 100644 --- a/catalog/live_catalog.go +++ b/catalog/live_catalog.go @@ -6,12 +6,6 @@ import ( "github.com/GrayCodeAI/eyrie/catalog/registry" ) -// IsLiveOnlyProvider reports whether a provider uses live API discovery only. -// All providers are now fully dynamic. -func IsLiveOnlyProvider(providerID string) bool { - return true -} - // DeploymentIDForLiveCatalogKey maps a live fetch catalog key to a deployment ID. func DeploymentIDForLiveCatalogKey(catalogKey string) string { for _, spec := range registry.All() { diff --git a/catalog/live_catalog_test.go b/catalog/live_catalog_test.go index 3c82026..ba29856 100644 --- a/catalog/live_catalog_test.go +++ b/catalog/live_catalog_test.go @@ -6,20 +6,6 @@ import ( "github.com/GrayCodeAI/eyrie/catalog" ) -func TestIsLiveOnlyProvider(t *testing.T) { - t.Parallel() - // All providers are now fully dynamic - allProviders := []string{ - "anthropic", "openai", "gemini", "grok", "canopywave", "z_ai", "openrouter", "ollama", "opencodego", - "azure", "bedrock", "vertex", "kimi", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan", "deepseek", - } - for _, p := range allProviders { - if !catalog.IsLiveOnlyProvider(p) { - t.Fatalf("%s should be live-only (all providers are fully dynamic)", p) - } - } -} - func TestFirstModelForProvider(t *testing.T) { t.Parallel() c := catalog.SeedCatalog() diff --git a/catalog/registry/providers.go b/catalog/registry/providers.go index 98c3df1..a010229 100644 --- a/catalog/registry/providers.go +++ b/catalog/registry/providers.go @@ -18,7 +18,8 @@ func providerSpecs() []ProviderSpec { // ── Direct API providers ────────────────────────────────────────── { ProviderID: "anthropic", DisplayName: "Anthropic", DeploymentID: "anthropic-direct", SortOrder: 1, ChatPreference: 2, - RequiresKey: true, CredentialEnv: "ANTHROPIC_API_KEY", + TransportKind: "anthropic", + RequiresKey: true, CredentialEnv: "ANTHROPIC_API_KEY", CredentialAliases: []string{"CLAUDE_API_KEY"}, BaseURLEnv: []string{"ANTHROPIC_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeAnthropic, @@ -28,7 +29,8 @@ func providerSpecs() []ProviderSpec { }, { ProviderID: "openai", DisplayName: "OpenAI", DeploymentID: "openai-direct", SortOrder: 2, ChatPreference: 1, - RequiresKey: true, CredentialEnv: "OPENAI_API_KEY", + TransportKind: "openai", + RequiresKey: true, CredentialEnv: "OPENAI_API_KEY", BaseURLEnv: []string{"OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.openai.com/v1", LiveFetcherKey: "openai", LiveCatalogKey: "openai", @@ -37,7 +39,8 @@ func providerSpecs() []ProviderSpec { }, { ProviderID: "gemini", DisplayName: "Gemini API", DeploymentID: "gemini-direct", SortOrder: 3, ChatPreference: 5, - RequiresKey: true, CredentialEnv: "GEMINI_API_KEY", + RuntimeBaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + RequiresKey: true, CredentialEnv: "GEMINI_API_KEY", CredentialAliases: []string{"GOOGLE_API_KEY"}, BaseURLEnv: []string{"GEMINI_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeGemini, @@ -124,7 +127,8 @@ func providerSpecs() []ProviderSpec { // ── Cloud platform providers ────────────────────────────────────── { ProviderID: "azure", DisplayName: "Azure OpenAI", DeploymentID: "openai-azure", SortOrder: 13, ChatPreference: 12, - RequiresKey: true, CredentialEnv: "AZURE_OPENAI_API_KEY", + TransportKind: "azure", + RequiresKey: true, CredentialEnv: "AZURE_OPENAI_API_KEY", BaseURLEnv: []string{"AZURE_OPENAI_ENDPOINT"}, ProbeKind: ProbeNone, LiveFetcherKey: "azure", LiveCatalogKey: "azure", @@ -132,7 +136,8 @@ func providerSpecs() []ProviderSpec { }, { ProviderID: "bedrock", DisplayName: "Amazon Bedrock", DeploymentID: "anthropic-bedrock", SortOrder: 14, ChatPreference: 7, - RequiresKey: true, CredentialEnv: "AWS_SECRET_ACCESS_KEY", + TransportKind: "bedrock", + RequiresKey: true, CredentialEnv: "AWS_SECRET_ACCESS_KEY", CredentialEnvFallbacks: []string{"AWS_ACCESS_KEY_ID", "AWS_SESSION_TOKEN"}, BaseURLEnv: []string{"AWS_REGION", "AWS_DEFAULT_REGION"}, ProbeKind: ProbeNone, @@ -141,7 +146,8 @@ func providerSpecs() []ProviderSpec { }, { ProviderID: "vertex", DisplayName: "Vertex AI", DeploymentID: "gemini-vertex", SortOrder: 15, ChatPreference: 6, - RequiresKey: true, CredentialEnv: "VERTEX_ACCESS_TOKEN", + TransportKind: "vertex", + RequiresKey: true, CredentialEnv: "VERTEX_ACCESS_TOKEN", CredentialEnvFallbacks: []string{"GOOGLE_OAUTH_ACCESS_TOKEN"}, BaseURLEnv: []string{"VERTEX_PROJECT_ID", "VERTEX_REGION"}, ProbeKind: ProbeNone, @@ -186,7 +192,8 @@ func providerSpecs() []ProviderSpec { }, { ProviderID: "clinepass", DisplayName: "ClinePass", DeploymentID: "clinepass", SortOrder: 20, ChatPreference: 22, - RequiresKey: true, CredentialEnv: "CLINE_API_KEY", + RuntimeBaseURL: "https://api.cline.bot/api/v1", + RequiresKey: true, CredentialEnv: "CLINE_API_KEY", BaseURLEnv: []string{"CLINE_API_BASE", "OPENAI_BASE_URL", "OPENAI_API_BASE"}, ProbeKind: ProbeNone, LiveFetcherKey: "clinepass", LiveCatalogKey: "clinepass", @@ -204,7 +211,8 @@ func providerSpecs() []ProviderSpec { // ── Local ───────────────────────────────────────────────────────── { - ProviderID: "ollama", DisplayName: "Ollama", DeploymentID: "ollama-local", SortOrder: 21, ChatPreference: 19, + ProviderID: "ollama", DisplayName: "Ollama", DeploymentID: "ollama-local", SortOrder: 22, ChatPreference: 19, + RuntimeBaseURL: "http://localhost:11434/v1", RuntimeCredentialEnv: "OLLAMA_API_KEY", RequiresKey: false, CredentialEnv: "OLLAMA_BASE_URL", BaseURLEnv: []string{"OLLAMA_BASE_URL"}, ProbeKind: ProbeOllama, diff --git a/catalog/registry/spec.go b/catalog/registry/spec.go index 34b1402..f2232e7 100644 --- a/catalog/registry/spec.go +++ b/catalog/registry/spec.go @@ -45,6 +45,14 @@ type ProviderSpec struct { PrepareCredentialEnv bool RetryConfig *RetryConfig IsLocal bool + // TransportKind selects the runtime client implementation. Empty means + // OpenAI-compatible; dedicated transports declare their kind explicitly. + TransportKind string + // RuntimeBaseURL overrides ProbeBaseURL when chat and model-list endpoints + // differ. RuntimeCredentialEnv overrides CredentialEnv when setup metadata + // describes a non-secret value, as with Ollama's base URL. + RuntimeBaseURL string + RuntimeCredentialEnv string } // EnvFallback describes one deployment env_fallback row. diff --git a/client/adapter_config.go b/client/adapter_config.go new file mode 100644 index 0000000..460fcc3 --- /dev/null +++ b/client/adapter_config.go @@ -0,0 +1,92 @@ +package client + +import ( + "log/slog" + "net/http" + "time" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +// Functional-option setter surface for the two protocol adapters (see +// core.Configurable in options.go). Exported so the implementations keep +// satisfying the interface when the adapters move to their own package. + +var ( + _ core.Configurable = (*AnthropicClient)(nil) + _ core.Configurable = (*OpenAIClient)(nil) +) + +// SetTimeout sets the HTTP client timeout. +func (c *AnthropicClient) SetTimeout(d time.Duration) { c.httpClient.Timeout = d } + +// SetHTTPClient replaces the HTTP client. +func (c *AnthropicClient) SetHTTPClient(hc *http.Client) { c.httpClient = hc } + +// SetRetry sets the retry configuration. +func (c *AnthropicClient) SetRetry(rc RetryConfig) { c.retry = rc } + +// SetLogger sets the logger. +func (c *AnthropicClient) SetLogger(l *slog.Logger) { c.logger = l } + +// SetAPIKey sets the API key. +func (c *AnthropicClient) SetAPIKey(key string) { c.apiKey = key } + +// SetBaseURL sets the base URL. +func (c *AnthropicClient) SetBaseURL(url string) { c.baseURL = url } + +// SetDefaultModel sets the default model for requests. +func (c *AnthropicClient) SetDefaultModel(model string) { c.defaultModel = model } + +// SetDefaultMaxTokens sets the default max tokens for requests. +func (c *AnthropicClient) SetDefaultMaxTokens(n int) { c.defaultMaxTokens = n } + +// SetDefaultTemperature sets the default temperature for requests. +func (c *AnthropicClient) SetDefaultTemperature(t float64) { c.defaultTemperature = &t } + +// SetGuardrails attaches output guardrails. +func (c *AnthropicClient) SetGuardrails(g *Guardrails) { c.guardrails = g } + +// SetProviderName is a no-op: the Anthropic adapter reports a fixed provider +// name. Present to satisfy core.Configurable; WithProviderName only affects +// OpenAI-compatible adapters. +func (c *AnthropicClient) SetProviderName(string) {} + +// SetMimoAuth switches authentication to MiMo's api-key header. +func (c *AnthropicClient) SetMimoAuth() { c.useMimoAuth = true } + +// SetTimeout sets the HTTP client timeout. +func (c *OpenAIClient) SetTimeout(d time.Duration) { c.httpClient.Timeout = d } + +// SetHTTPClient replaces the HTTP client. +func (c *OpenAIClient) SetHTTPClient(hc *http.Client) { c.httpClient = hc } + +// SetRetry sets the retry configuration. +func (c *OpenAIClient) SetRetry(rc RetryConfig) { c.retry = rc } + +// SetLogger sets the logger. +func (c *OpenAIClient) SetLogger(l *slog.Logger) { c.logger = l } + +// SetAPIKey sets the API key. +func (c *OpenAIClient) SetAPIKey(key string) { c.apiKey = key } + +// SetBaseURL sets the base URL. +func (c *OpenAIClient) SetBaseURL(url string) { c.baseURL = url } + +// SetDefaultModel sets the default model for requests. +func (c *OpenAIClient) SetDefaultModel(model string) { c.defaultModel = model } + +// SetDefaultMaxTokens sets the default max tokens for requests. +func (c *OpenAIClient) SetDefaultMaxTokens(n int) { c.defaultMaxTokens = n } + +// SetDefaultTemperature sets the default temperature for requests. +func (c *OpenAIClient) SetDefaultTemperature(t float64) { c.defaultTemperature = &t } + +// SetGuardrails attaches output guardrails. +func (c *OpenAIClient) SetGuardrails(g *Guardrails) { c.guardrails = g } + +// SetProviderName sets the provider name used in errors and logging. +func (c *OpenAIClient) SetProviderName(name string) { c.providerName = name } + +// SetMimoAuth switches authentication to MiMo's api-key header. +func (c *OpenAIClient) SetMimoAuth() { c.useMimoAuth = true } diff --git a/client/aliases.go b/client/aliases.go new file mode 100644 index 0000000..d345e22 --- /dev/null +++ b/client/aliases.go @@ -0,0 +1,258 @@ +package client + +import ( + "context" + "net/http" + "time" + + "github.com/GrayCodeAI/eyrie/client/core" + "github.com/GrayCodeAI/eyrie/client/embeddings" +) + +// The provider contract and request/response data types live in +// client/core so subpackages can share them without importing this facade +// (see plans/client-package-decomposition.md). The aliases below keep the +// long-standing client.* names as the public API — they are the same types, +// not copies, so existing code and type assertions are unaffected. + +type ( + // Provider is the core interface for LLM providers. + Provider = core.Provider + // EyrieConfig holds client configuration. + EyrieConfig = core.EyrieConfig + // ContentPart represents a piece of content in a multi-modal message. + ContentPart = core.ContentPart + // ImageURLPart represents an image content part. + ImageURLPart = core.ImageURLPart + // InputAudioPart represents an audio content part (base64 encoded). + InputAudioPart = core.InputAudioPart + // EyrieMessage represents a chat message. + EyrieMessage = core.EyrieMessage + // ToolResult represents the result of a tool execution. + ToolResult = core.ToolResult + // EyrieTool represents a tool definition. + EyrieTool = core.EyrieTool + // EyrieUsage tracks token usage. + EyrieUsage = core.EyrieUsage + // EyrieResponse is the response from a chat call. + EyrieResponse = core.EyrieResponse + // ToolCall represents a tool invocation. + ToolCall = core.ToolCall + // EyrieStreamEvent is a streaming event. + EyrieStreamEvent = core.EyrieStreamEvent + // StreamResult wraps a streaming response with cleanup. + StreamResult = core.StreamResult + // ResponseFormat specifies the desired output format for the model response. + ResponseFormat = core.ResponseFormat + // ChatOptions holds options for a chat request. + ChatOptions = core.ChatOptions + // ToolChoiceOption controls how the model uses tools (Anthropic). + ToolChoiceOption = core.ToolChoiceOption + // ContinuationConfig controls output continuation behavior. + ContinuationConfig = core.ContinuationConfig + // EyrieError is a structured error that preserves provider context, + // HTTP metadata, and request identification for debugging. + EyrieError = core.EyrieError + // RetryConfig controls retry behavior for HTTP clients. + RetryConfig = core.RetryConfig + // SSEEvent is one server-sent event from a streaming response body. + SSEEvent = core.SSEEvent + // RepeatDetector detects degenerate repeating output in streamed text. + RepeatDetector = core.RepeatDetector + // ResponseHealth classifies whether a provider response carried usable output. + ResponseHealth = core.ResponseHealth + // ResponseSignals are the observations needed to classify response health. + ResponseSignals = core.ResponseSignals + // GuardrailType classifies a guardrail rule. + GuardrailType = core.GuardrailType + // GuardrailAction is what happens when a guardrail rule matches. + GuardrailAction = core.GuardrailAction + // GuardrailSeverity ranks how serious a violation is. + GuardrailSeverity = core.GuardrailSeverity + // GuardrailRule is one output-filtering rule. + GuardrailRule = core.GuardrailRule + // GuardrailViolation records a rule match in a response. + GuardrailViolation = core.GuardrailViolation + // GuardrailError is returned when a blocking rule matches. + GuardrailError = core.GuardrailError + // Guardrails is a compiled set of output-filtering rules. + Guardrails = core.Guardrails + // StreamGuardrailConfig configures incremental guardrail scanning. + StreamGuardrailConfig = core.StreamGuardrailConfig + // StreamGuardrailResult is the outcome of scanning one stream chunk. + StreamGuardrailResult = core.StreamGuardrailResult + // StreamGuardrails applies guardrail rules to a response stream chunk by chunk. + StreamGuardrails = core.StreamGuardrails +) + +// NewStreamGuardrails builds an incremental guardrail scanner over a rule set. +func NewStreamGuardrails(g *Guardrails, config StreamGuardrailConfig) *StreamGuardrails { + return core.NewStreamGuardrails(g, config) +} + +// Guardrail rule types, actions, and severities (see core). +const ( + GuardrailPII = core.GuardrailPII + GuardrailPromptInjection = core.GuardrailPromptInjection + GuardrailHarmfulContent = core.GuardrailHarmfulContent + GuardrailSecretLeak = core.GuardrailSecretLeak + GuardrailCustom = core.GuardrailCustom + GuardrailBlock = core.GuardrailBlock + GuardrailRedact = core.GuardrailRedact + GuardrailWarn = core.GuardrailWarn + SeverityLow = core.SeverityLow + SeverityMedium = core.SeverityMedium + SeverityHigh = core.SeverityHigh + SeverityCritical = core.SeverityCritical +) + +// NewGuardrails compiles a guardrail rule set (panics on invalid patterns). +func NewGuardrails(rules ...GuardrailRule) *Guardrails { return core.NewGuardrails(rules...) } + +// NewGuardrailsSafe compiles a guardrail rule set, returning pattern errors. +func NewGuardrailsSafe(rules ...GuardrailRule) (*Guardrails, error) { + return core.NewGuardrailsSafe(rules...) +} + +// ApplyRedactions scrubs matched content from a response. +func ApplyRedactions(response string, violations []GuardrailViolation) string { + return core.ApplyRedactions(response, violations) +} + +// DefaultPIIRules returns the built-in PII redaction rules. +func DefaultPIIRules() []GuardrailRule { return core.DefaultPIIRules() } + +// DefaultSecretLeakRules returns the built-in secret-leak blocking rules. +func DefaultSecretLeakRules() []GuardrailRule { return core.DefaultSecretLeakRules() } + +// DefaultPromptInjectionRules returns the built-in prompt-injection rules. +func DefaultPromptInjectionRules() []GuardrailRule { return core.DefaultPromptInjectionRules() } + +// DefaultHarmfulContentRules returns the built-in harmful-content rules. +func DefaultHarmfulContentRules() []GuardrailRule { return core.DefaultHarmfulContentRules() } + +// AllDefaultRules returns every built-in guardrail rule. +func AllDefaultRules() []GuardrailRule { return core.AllDefaultRules() } + +// RulesForType returns the built-in rules for one guardrail type. +func RulesForType(t GuardrailType) []GuardrailRule { return core.RulesForType(t) } + +// Response health classifications (see core.DetectResponseHealth). +const ( + ResponseOK = core.ResponseOK + ResponseErrorOnlyReasoning = core.ResponseErrorOnlyReasoning + ResponseEmpty = core.ResponseEmpty + ResponseMalformedStream = core.ResponseMalformedStream +) + +// streamChannelBuffer bridges the buffer-size constant that moved to core. +const streamChannelBuffer = core.StreamChannelBuffer + +// DetectResponseHealth classifies a response from stream/response signals. +func DetectResponseHealth(sig ResponseSignals) ResponseHealth { + return core.DetectResponseHealth(sig) +} + +// ResponseHasContent reports whether a response carries content or tool calls. +func ResponseHasContent(resp *EyrieResponse) bool { + return core.ResponseHasContent(resp) +} + +// DefaultRepeatDetector returns a RepeatDetector with production thresholds. +func DefaultRepeatDetector() *RepeatDetector { + return core.DefaultRepeatDetector() +} + +// NewPooledHTTPClient returns an *http.Client sharing the pooled transport. +func NewPooledHTTPClient(timeout time.Duration) *http.Client { + return core.NewPooledHTTPClient(timeout) +} + +// CloseIdleConnections closes idle pooled connections across all provider clients. +func CloseIdleConnections() { + core.CloseIdleConnections() +} + +// ParseInlineToolCalls extracts inline/Hermes-style tool calls from text. +func ParseInlineToolCalls(text string) (string, []ToolCall) { + return core.ParseInlineToolCalls(text) +} + +// NewRetryConfig constructs a RetryConfig from core fields and optional +// HTTP status codes to retry on. +func NewRetryConfig(maxRetries int, baseDelay, maxDelay time.Duration, retryOn ...int) RetryConfig { + return core.NewRetryConfig(maxRetries, baseDelay, maxDelay, retryOn...) +} + +// DefaultRetryConfig returns sensible defaults. +func DefaultRetryConfig() RetryConfig { + return core.DefaultRetryConfig() +} + +// Embedding API moved to client/embeddings; aliased here for compatibility. +type ( + // Embedder is the interface for creating embeddings. + Embedder = embeddings.Embedder + // EmbeddingParams holds asymmetric params for indexing vs query. + EmbeddingParams = embeddings.EmbeddingParams + // EmbeddingRequest represents an embedding API call. + EmbeddingRequest = embeddings.EmbeddingRequest + // EmbeddingResponse holds embedding results. + EmbeddingResponse = embeddings.EmbeddingResponse + // SemanticCacheConfig configures the embedding-based semantic cache. + SemanticCacheConfig = embeddings.SemanticCacheConfig + // SemanticCacheStats reports semantic cache effectiveness. + SemanticCacheStats = embeddings.SemanticCacheStats + // EmbeddingCachedProvider caches chat responses keyed by embedding similarity. + EmbeddingCachedProvider = embeddings.EmbeddingCachedProvider +) + +// DefaultEmbeddingParams returns known-good asymmetric params for common embedding models. +func DefaultEmbeddingParams(model string) EmbeddingParams { + return embeddings.DefaultEmbeddingParams(model) +} + +// DefaultSemanticCacheConfig returns sensible semantic cache defaults. +func DefaultSemanticCacheConfig() SemanticCacheConfig { + return embeddings.DefaultSemanticCacheConfig() +} + +// NewEmbeddingCachedProvider wraps a provider with an embedding-similarity cache. +func NewEmbeddingCachedProvider(inner Provider, embedder Embedder, cfg SemanticCacheConfig) *EmbeddingCachedProvider { + return embeddings.NewEmbeddingCachedProvider(inner, embedder, cfg) +} + +// Package-local bridges for helpers that moved to client/core. Declared as +// unexported names so the many in-package call sites read unchanged while +// the single implementation lives in core. +type providerErrorDetail = core.ProviderErrorDetail + +var ( + doWithRetry = core.DoWithRetry + parseProviderError = core.ParseProviderError + formatAPIError = core.FormatAPIError + copyResponse = core.CopyResponse + parseSSEStream = core.ParseSSEStream + processAnthropicStream = core.ProcessAnthropicStream + processOpenAIStream = core.ProcessOpenAIStream + emit = core.Emit + openAIImageURL = core.OpenAIImageURL + parseImageString = core.ParseImageString + userAgent = core.UserAgent + applyGuardrails = core.ApplyGuardrails +) + +// NewStreamResult creates a StreamResult with a cancel function for resource cleanup. +func NewStreamResult(events <-chan EyrieStreamEvent, cancel context.CancelFunc) *StreamResult { + return core.NewStreamResult(events, cancel) +} + +// NewStreamResultWithRequestID is NewStreamResult carrying the provider's request ID. +func NewStreamResultWithRequestID(events <-chan EyrieStreamEvent, requestID string, cancel context.CancelFunc) *StreamResult { + return core.NewStreamResultWithRequestID(events, requestID, cancel) +} + +// DefaultContinuationConfig returns sensible defaults. +func DefaultContinuationConfig() ContinuationConfig { + return core.DefaultContinuationConfig() +} diff --git a/client/anthropic.go b/client/anthropic.go index 0c8882f..b03ae00 100644 --- a/client/anthropic.go +++ b/client/anthropic.go @@ -46,7 +46,7 @@ func NewAnthropicClient(apiKey, baseURL string, opts ...ClientOption) *Anthropic c.baseURL = "https://api.anthropic.com" } for _, opt := range opts { - opt.apply(c) + opt.Apply(c) } return c } @@ -584,7 +584,7 @@ func (c *AnthropicClient) StreamChat(ctx context.Context, messages []EyrieMessag sseEvents := parseSSEStream(streamCtx, resp.Body, c.logger) events := processAnthropicStream(streamCtx, sseEvents, c.logger) - return &StreamResult{Events: events, RequestID: requestID, cancel: cancel}, nil + return NewStreamResultWithRequestID(events, requestID, cancel), nil } func (c *AnthropicClient) doRequestWithMimoAuthRetry(ctx context.Context, req *http.Request, body []byte) (*http.Response, error) { diff --git a/client/client.go b/client/client.go index b378d51..8f31a51 100644 --- a/client/client.go +++ b/client/client.go @@ -6,160 +6,25 @@ import ( "context" "os" "strings" + "time" + "sync" + "github.com/GrayCodeAI/eyrie/client/core" + "github.com/GrayCodeAI/eyrie/catalog" ) -// Version is set by the root eyrie package's init() from the VERSION file. +// Version mirrors core.Version for backward compatibility; the canonical +// value lives in client/core so subpackages can build User-Agent strings. // Default is "dev" until the root package initialises. var Version = "dev" // SetVersion is called by the root eyrie package's init to wire the canonical -// version from the VERSION file into this sub-package. -func SetVersion(v string) { Version = v } - -// userAgent returns the User-Agent string for HTTP requests. -func userAgent() string { return "eyrie/" + Version } - -// Provider is the core interface for LLM providers. -// Implementations must be safe for concurrent use. -type Provider interface { - // Chat sends a non-streaming chat request. - Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) - // StreamChat sends a streaming chat request. - // The caller must call Close() on the returned StreamResult when done. - StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) - // Ping checks connectivity and authentication. - Ping(ctx context.Context) error - // Name returns the provider name (e.g. "anthropic", "openai"). - Name() string -} - -// EyrieConfig holds client configuration. -type EyrieConfig struct { - Provider string `json:"provider,omitempty"` - APIKey string `json:"-"` - BaseURL string `json:"base_url,omitempty"` - Model string `json:"model,omitempty"` - MaxRetries int `json:"max_retries,omitempty"` -} - -// ContentPart represents a piece of content in a multi-modal message. -// Use the helper types (TextPart, ImagePart, AudioPart) to construct these. -type ContentPart struct { - Type string `json:"type"` // "text", "image_url", "input_audio" - Text string `json:"text,omitempty"` // for type="text" - ImageURL *ImageURLPart `json:"image_url,omitempty"` // for type="image_url" - InputAudio *InputAudioPart `json:"input_audio,omitempty"` // for type="input_audio" -} - -// ImageURLPart represents an image content part. -// URL can be an HTTP(S) URL or a data URI (data:image/png;base64,...). -type ImageURLPart struct { - URL string `json:"url"` - Detail string `json:"detail,omitempty"` // "auto", "low", "high" (OpenAI-specific) -} - -// InputAudioPart represents an audio content part (base64 encoded). -type InputAudioPart struct { - Data string `json:"data"` // base64 encoded audio - Format string `json:"format"` // "wav", "mp3" (OpenAI) or used to derive media_type (Anthropic) -} - -// EyrieMessage represents a chat message. -// For simple text messages, set Content directly. -// For multi-modal messages (images, audio), use ContentParts. -// When ContentParts is non-empty, it takes precedence over Content and Images. -// The Images field is retained for backward compatibility. -type EyrieMessage struct { - Role string `json:"role"` - Content string `json:"content,omitempty"` - Thinking string `json:"thinking,omitempty"` // chain-of-thought captured from a prior response (never forwarded to providers that reject it) - ContentParts []ContentPart `json:"content_parts,omitempty"` // multi-modal content (takes precedence over Content/Images) - Images []string `json:"images,omitempty"` - ToolUse []ToolCall `json:"tool_use,omitempty"` // assistant message with tool calls - ToolResults []ToolResult `json:"tool_results,omitempty"` // user message with one or more tool results -} - -// ToolResult represents the result of a tool execution. -type ToolResult struct { - ToolUseID string `json:"tool_use_id"` - Content string `json:"content"` - IsError bool `json:"is_error,omitempty"` -} - -// EyrieTool represents a tool definition. -type EyrieTool struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters map[string]interface{} `json:"parameters"` -} - -// EyrieUsage tracks token usage. -type EyrieUsage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - CacheCreationTokens int `json:"cache_creation_tokens,omitempty"` - CacheReadTokens int `json:"cache_read_tokens,omitempty"` - ThinkingTokens int `json:"thinking_tokens,omitempty"` -} - -// EyrieResponse is the response from a chat call. -type EyrieResponse struct { - Content string `json:"content"` - Thinking string `json:"thinking,omitempty"` - Usage *EyrieUsage `json:"usage,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - FinishReason string `json:"finish_reason"` - RequestID string `json:"request_id,omitempty"` - OrganizationID string `json:"organization_id,omitempty"` -} - -// ToolCall represents a tool invocation. -type ToolCall struct { - ID string `json:"id,omitempty"` - Name string `json:"name"` - Arguments map[string]interface{} `json:"arguments"` -} - -// EyrieStreamEvent is a streaming event. -type EyrieStreamEvent struct { - Type string `json:"type"` // content, tool_call, tool_input_delta, thinking, done, error, ttft - Content string `json:"content,omitempty"` - ToolCall *ToolCall `json:"tool_call,omitempty"` - Thinking string `json:"thinking,omitempty"` - Error string `json:"error,omitempty"` - RequestID string `json:"request_id,omitempty"` - Usage *EyrieUsage `json:"usage,omitempty"` - StopReason string `json:"stop_reason,omitempty"` - TTFTms int `json:"ttft_ms,omitempty"` // time-to-first-token milliseconds, set on "done" event - // TTFT carries time-to-first-token milliseconds on Type=="ttft" events. - // It is emitted as a dedicated event immediately before the first content - // or tool-call delta so consumers can measure latency without waiting for - // the stream to finish. - TTFT int `json:"ttft,omitempty"` -} - -// StreamResult wraps a streaming response with cleanup. -// Callers must call Close() when done reading events, or cancel the context. -type StreamResult struct { - Events <-chan EyrieStreamEvent - RequestID string - cancel context.CancelFunc -} - -// Close stops the stream and releases resources. -func (sr *StreamResult) Close() { - if sr.cancel != nil { - sr.cancel() - } -} - -// NewStreamResult creates a StreamResult with a cancel function for resource cleanup. -func NewStreamResult(events <-chan EyrieStreamEvent, cancel context.CancelFunc) *StreamResult { - return &StreamResult{Events: events, cancel: cancel} +// version from the VERSION file into this sub-package (and client/core). +func SetVersion(v string) { + Version = v + core.SetVersion(v) } // EyrieClient is the universal LLM client. @@ -194,11 +59,17 @@ func Client(cfg *EyrieConfig, opts ...ClientOption) *EyrieClient { } // Apply options (including coalescing) for _, opt := range opts { - opt.applyEyrie(c) + opt.ApplyEyrie(c) } return c } +// SetCoalescingTTL enables request coalescing with the given reuse TTL. +// Implements core.EyrieConfigurable for WithCoalescing. +func (c *EyrieClient) SetCoalescingTTL(ttl time.Duration) { + c.coalescer = NewCoalescer(ttl) +} + // SetAPIKey sets an API key for a provider. func (c *EyrieClient) SetAPIKey(provider, apiKey string) { c.mu.Lock() diff --git a/client/client_test.go b/client/client_test.go index 7aecebf..675199c 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -237,13 +237,13 @@ func TestRetryConfig(t *testing.T) { if rc.MaxRetries != 3 { t.Errorf("expected 3 max retries, got %d", rc.MaxRetries) } - if !rc.shouldRetry(429) { + if !rc.ShouldRetry(429) { t.Error("expected 429 to be retryable") } - if rc.shouldRetry(200) { + if rc.ShouldRetry(200) { t.Error("expected 200 to not be retryable") } - if !rc.shouldRetry(529) { + if !rc.ShouldRetry(529) { t.Error("expected 529 to be retryable") } } diff --git a/client/continuation.go b/client/continuation.go index 41d4d90..ad679d7 100644 --- a/client/continuation.go +++ b/client/continuation.go @@ -7,18 +7,8 @@ import ( "time" ) -// ContinuationConfig controls output continuation behavior. -type ContinuationConfig struct { - // MaxContinuations is the maximum number of continuation calls (default 3). - MaxContinuations int - // MaxTotalTokens caps the total output tokens across all continuations (0 = unlimited). - MaxTotalTokens int -} - -// DefaultContinuationConfig returns sensible defaults. -func DefaultContinuationConfig() ContinuationConfig { - return ContinuationConfig{MaxContinuations: 3, MaxTotalTokens: 32000} -} +// ContinuationConfig and DefaultContinuationConfig live in client/core; +// the client.* names remain available via aliases.go. // ChatWithContinuation calls Chat and automatically continues if stop_reason is "max_tokens". // It appends the partial response as an assistant message and retries, accumulating content. @@ -199,5 +189,5 @@ func StreamChatWithContinuation(ctx context.Context, p Provider, messages []Eyri emit(cancelCtx, outCh, EyrieStreamEvent{Type: "done", StopReason: "max_tokens"}) }() - return &StreamResult{Events: outCh, cancel: cancel}, nil + return NewStreamResult(outCh, cancel), nil } diff --git a/client/continuation_test.go b/client/continuation_test.go index 1725add..46528a9 100644 --- a/client/continuation_test.go +++ b/client/continuation_test.go @@ -313,5 +313,5 @@ func (s *sequentialMock) StreamChat(ctx context.Context, messages []EyrieMessage } }() - return &StreamResult{Events: ch, cancel: cancel}, nil + return NewStreamResult(ch, cancel), nil } diff --git a/client/core/copy.go b/client/core/copy.go new file mode 100644 index 0000000..ded1268 --- /dev/null +++ b/client/core/copy.go @@ -0,0 +1,56 @@ +package core + +// CopyResponse returns a deep copy of an EyrieResponse so that callers +// cannot mutate the cached version. +func CopyResponse(resp *EyrieResponse) *EyrieResponse { + if resp == nil { + return nil + } + cp := *resp + if resp.Usage != nil { + u := *resp.Usage + cp.Usage = &u + } + if len(resp.ToolCalls) > 0 { + cp.ToolCalls = make([]ToolCall, len(resp.ToolCalls)) + for i, tc := range resp.ToolCalls { + cp.ToolCalls[i] = tc + if tc.Arguments != nil { + cp.ToolCalls[i].Arguments = deepCopyMap(tc.Arguments) + } + } + } + return &cp +} + +// deepCopyMap returns a deep copy of a map[string]interface{}. +func deepCopyMap(m map[string]interface{}) map[string]interface{} { + cp := make(map[string]interface{}, len(m)) + for k, v := range m { + switch val := v.(type) { + case map[string]interface{}: + cp[k] = deepCopyMap(val) + case []interface{}: + cp[k] = deepCopySlice(val) + default: + cp[k] = v + } + } + return cp +} + +// deepCopySlice returns a deep copy of a []interface{}. +func deepCopySlice(s []interface{}) []interface{} { + cp := make([]interface{}, len(s)) + for i, v := range s { + switch val := v.(type) { + case map[string]interface{}: + cp[i] = deepCopyMap(val) + case []interface{}: + cp[i] = deepCopySlice(val) + default: + cp[i] = v + } + } + return cp +} diff --git a/client/core/core.go b/client/core/core.go new file mode 100644 index 0000000..6a02fd4 --- /dev/null +++ b/client/core/core.go @@ -0,0 +1,252 @@ +// Package core holds the provider contract and the data types shared by +// every layer of the eyrie client: adapters, middleware, caching, embeddings, +// and the client facade itself. +// +// core is a leaf package — it must not import any other eyrie/client +// subpackage. The public names remain available as aliases in +// github.com/GrayCodeAI/eyrie/client, which is the API consumers should keep +// importing; this package exists so subpackages can share the contract +// without an import cycle through the facade. +// +// See plans/client-package-decomposition.md for the migration plan. +package core + +import "context" + +// Provider is the core interface for LLM providers. +// Implementations must be safe for concurrent use. +type Provider interface { + // Chat sends a non-streaming chat request. + Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) + // StreamChat sends a streaming chat request. + // The caller must call Close() on the returned StreamResult when done. + StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) + // Ping checks connectivity and authentication. + Ping(ctx context.Context) error + // Name returns the provider name (e.g. "anthropic", "openai"). + Name() string +} + +// EyrieConfig holds client configuration. +type EyrieConfig struct { + Provider string `json:"provider,omitempty"` + APIKey string `json:"-"` + BaseURL string `json:"base_url,omitempty"` + Model string `json:"model,omitempty"` + MaxRetries int `json:"max_retries,omitempty"` +} + +// ContentPart represents a piece of content in a multi-modal message. +// Use the helper types (TextPart, ImagePart, AudioPart) to construct these. +type ContentPart struct { + Type string `json:"type"` // "text", "image_url", "input_audio" + Text string `json:"text,omitempty"` // for type="text" + ImageURL *ImageURLPart `json:"image_url,omitempty"` // for type="image_url" + InputAudio *InputAudioPart `json:"input_audio,omitempty"` // for type="input_audio" +} + +// ImageURLPart represents an image content part. +// URL can be an HTTP(S) URL or a data URI (data:image/png;base64,...). +type ImageURLPart struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` // "auto", "low", "high" (OpenAI-specific) +} + +// InputAudioPart represents an audio content part (base64 encoded). +type InputAudioPart struct { + Data string `json:"data"` // base64 encoded audio + Format string `json:"format"` // "wav", "mp3" (OpenAI) or used to derive media_type (Anthropic) +} + +// EyrieMessage represents a chat message. +// For simple text messages, set Content directly. +// For multi-modal messages (images, audio), use ContentParts. +// When ContentParts is non-empty, it takes precedence over Content and Images. +// The Images field is retained for backward compatibility. +type EyrieMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + Thinking string `json:"thinking,omitempty"` // chain-of-thought captured from a prior response (never forwarded to providers that reject it) + ContentParts []ContentPart `json:"content_parts,omitempty"` // multi-modal content (takes precedence over Content/Images) + Images []string `json:"images,omitempty"` + ToolUse []ToolCall `json:"tool_use,omitempty"` // assistant message with tool calls + ToolResults []ToolResult `json:"tool_results,omitempty"` // user message with one or more tool results +} + +// ToolResult represents the result of a tool execution. +type ToolResult struct { + ToolUseID string `json:"tool_use_id"` + Content string `json:"content"` + IsError bool `json:"is_error,omitempty"` +} + +// EyrieTool represents a tool definition. +type EyrieTool struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` +} + +// EyrieUsage tracks token usage. +type EyrieUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + CacheCreationTokens int `json:"cache_creation_tokens,omitempty"` + CacheReadTokens int `json:"cache_read_tokens,omitempty"` + ThinkingTokens int `json:"thinking_tokens,omitempty"` +} + +// EyrieResponse is the response from a chat call. +type EyrieResponse struct { + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` + Usage *EyrieUsage `json:"usage,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + FinishReason string `json:"finish_reason"` + RequestID string `json:"request_id,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` +} + +// ToolCall represents a tool invocation. +type ToolCall struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments"` +} + +// EyrieStreamEvent is a streaming event. +type EyrieStreamEvent struct { + Type string `json:"type"` // content, tool_call, tool_input_delta, thinking, done, error, ttft + Content string `json:"content,omitempty"` + ToolCall *ToolCall `json:"tool_call,omitempty"` + Thinking string `json:"thinking,omitempty"` + Error string `json:"error,omitempty"` + RequestID string `json:"request_id,omitempty"` + Usage *EyrieUsage `json:"usage,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + TTFTms int `json:"ttft_ms,omitempty"` // time-to-first-token milliseconds, set on "done" event + // TTFT carries time-to-first-token milliseconds on Type=="ttft" events. + // It is emitted as a dedicated event immediately before the first content + // or tool-call delta so consumers can measure latency without waiting for + // the stream to finish. + TTFT int `json:"ttft,omitempty"` +} + +// StreamResult wraps a streaming response with cleanup. +// Callers must call Close() when done reading events, or cancel the context. +type StreamResult struct { + Events <-chan EyrieStreamEvent + RequestID string + cancel context.CancelFunc +} + +// Close stops the stream and releases resources. +func (sr *StreamResult) Close() { + if sr.cancel != nil { + sr.cancel() + } +} + +// NewStreamResult creates a StreamResult with a cancel function for resource cleanup. +func NewStreamResult(events <-chan EyrieStreamEvent, cancel context.CancelFunc) *StreamResult { + return &StreamResult{Events: events, cancel: cancel} +} + +// NewStreamResultWithRequestID is NewStreamResult carrying the provider's request ID. +func NewStreamResultWithRequestID(events <-chan EyrieStreamEvent, requestID string, cancel context.CancelFunc) *StreamResult { + return &StreamResult{Events: events, RequestID: requestID, cancel: cancel} +} + +// ResponseFormat specifies the desired output format for the model response. +type ResponseFormat struct { + Type string `json:"type"` // "json_object" or "json_schema" + Schema string `json:"schema,omitempty"` // optional JSON schema for structured output +} + +// ChatOptions holds options for a chat request. +type ChatOptions struct { + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` + Tools []EyrieTool `json:"tools,omitempty"` + System string `json:"system,omitempty"` + EnableCaching bool `json:"enable_caching,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + // ReasoningEffort hints how much reasoning the model should perform. + // Valid values are "low", "medium", or "high" (see ReasoningLow/Medium/High). + // Only applied for OpenAI-compatible providers whose compat config sets + // SupportsReasoningEffort. + ReasoningEffort string `json:"reasoning_effort,omitempty"` + // ThinkingBudgetTokens enables Anthropic extended thinking with the given + // token budget when greater than zero. Ignored by other providers. + ThinkingBudgetTokens int `json:"thinking_budget_tokens,omitempty"` + // ThinkingMode controls Anthropic thinking behavior: "enabled", "adaptive", or "disabled". + // When set, takes precedence over ThinkingBudgetTokens (except "enabled" which uses the budget). + ThinkingMode string `json:"thinking_mode,omitempty"` + // ThinkingDisplay controls how thinking is shown: "summarized" (default) or "omitted". + ThinkingDisplay string `json:"thinking_display,omitempty"` + // GLMThinkingEnabled toggles GLM/Z.ai extended reasoning via the provider's + // non-OpenAI thinking={"type":"enabled"|"disabled"} request parameter. Only + // applied for OpenAI-compatible providers whose compat config sets + // ThinkingFormat to "zai". When nil the parameter is omitted and the model + // uses its default (GLM defaults to enabled). Ignored by other providers. + GLMThinkingEnabled *bool `json:"glm_thinking_enabled,omitempty"` + // VirtualKeyID optionally attributes the request to a logical virtual key + // for budget enforcement and cost accounting (see BudgetProvider). When + // empty, the BudgetProvider also checks the request context. + VirtualKeyID string `json:"virtual_key_id,omitempty"` + // KimiContextCacheID, when set, prepends a cache-role message to the + // request for Kimi/Moonshot context caching. Only effective when the + // provider compat is KimiCompat (SupportsCacheRole true). + KimiContextCacheID string `json:"kimi_context_cache_id,omitempty"` + // KimiCacheResetTTL resets the TTL of the cache on use when true. + // Only effective when KimiContextCacheID is also set. + KimiCacheResetTTL bool `json:"kimi_cache_reset_ttl,omitempty"` + + // Shared parameters (Anthropic + OpenAI) + TopP *float64 `json:"top_p,omitempty"` // nucleus sampling (0.0-1.0) + TopK *int `json:"top_k,omitempty"` // top-K sampling (Anthropic only) + StopSequences []string `json:"stop_sequences,omitempty"` // custom stop sequences + ToolChoice *ToolChoiceOption `json:"tool_choice,omitempty"` // tool use control + MetadataUserID string `json:"metadata_user_id,omitempty"` // user ID for abuse detection / monitoring + ServiceTier string `json:"service_tier,omitempty"` // "auto", "default", "flex", "priority" + OutputEffort string `json:"output_effort,omitempty"` // "low","medium","high","xhigh","max" (Anthropic) + OutputSchema string `json:"output_schema,omitempty"` // JSON schema string for structured output (Anthropic) + + // OpenAI-specific parameters + PresencePenalty *float64 `json:"presence_penalty,omitempty"` // -2.0 to 2.0 + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` // -2.0 to 2.0 + N *int `json:"n,omitempty"` // number of completions (1-128) + LogProbs *bool `json:"logprobs,omitempty"` // return log probabilities + TopLogProbs *int `json:"top_logprobs,omitempty"` // 0-20, requires logprobs=true + Seed *int `json:"seed,omitempty"` // deterministic sampling + Store *bool `json:"store,omitempty"` // store output for Responses API + Metadata map[string]string `json:"metadata,omitempty"` // developer tags + Modalities []string `json:"modalities,omitempty"` // "text", "audio" + AudioConfig string `json:"audio_config,omitempty"` // JSON: {voice, format} + Prediction string `json:"prediction,omitempty"` // JSON: {type:"content", content:"..."} + WebSearchOptions string `json:"web_search_options,omitempty"` // JSON: {search_context_size, user_location} +} + +// ToolChoiceOption controls how the model uses tools (Anthropic). +type ToolChoiceOption struct { + Type string `json:"type"` // "auto", "any", "tool", "none" + Name string `json:"name,omitempty"` // required when type="tool" + DisableParallelToolUse bool `json:"disable_parallel_tool_use,omitempty"` +} + +// ContinuationConfig controls output continuation behavior. +type ContinuationConfig struct { + // MaxContinuations is the maximum number of continuation calls (default 3). + MaxContinuations int + // MaxTotalTokens caps the total output tokens across all continuations (0 = unlimited). + MaxTotalTokens int +} + +// DefaultContinuationConfig returns sensible defaults. +func DefaultContinuationConfig() ContinuationConfig { + return ContinuationConfig{MaxContinuations: 3, MaxTotalTokens: 32000} +} diff --git a/client/core/embedding.go b/client/core/embedding.go new file mode 100644 index 0000000..81366cd --- /dev/null +++ b/client/core/embedding.go @@ -0,0 +1,30 @@ +package core + +import "context" + +// Embedder is the interface for creating embeddings. It lives in core (rather +// than client/embeddings) because protocol adapters implement it — keeping the +// "subpackages import core only" layering rule intact. +type Embedder interface { + CreateEmbedding(ctx context.Context, req EmbeddingRequest) (*EmbeddingResponse, error) +} + +// EmbeddingParams holds asymmetric params for indexing vs query. +type EmbeddingParams struct { + Indexing map[string]string `json:"indexing,omitempty"` + Query map[string]string `json:"query,omitempty"` +} + +// EmbeddingRequest represents an embedding API call. +type EmbeddingRequest struct { + Model string `json:"model"` + Input []string `json:"input"` + Params map[string]string `json:"params,omitempty"` // indexing or query params +} + +// EmbeddingResponse holds embedding results. +type EmbeddingResponse struct { + Embeddings [][]float32 `json:"embeddings"` + Model string `json:"model"` + Usage *EyrieUsage `json:"usage,omitempty"` +} diff --git a/client/core/errors.go b/client/core/errors.go index 6f9bf22..4653fe6 100644 --- a/client/core/errors.go +++ b/client/core/errors.go @@ -1,4 +1,4 @@ -package client +package core import "fmt" diff --git a/client/core/guardrails.go b/client/core/guardrails.go new file mode 100644 index 0000000..c79cb9a --- /dev/null +++ b/client/core/guardrails.go @@ -0,0 +1,457 @@ +package core + +import ( + "context" + "fmt" + "regexp" + "sort" + "strings" + "sync" +) + +// GuardrailType classifies a guardrail rule. +type GuardrailType string + +const ( + // GuardrailPII detects personally identifiable information. + GuardrailPII GuardrailType = "pii" + // GuardrailPromptInjection detects prompt injection attempts in responses. + GuardrailPromptInjection GuardrailType = "prompt_injection" + // GuardrailHarmfulContent detects harmful or dangerous content patterns. + GuardrailHarmfulContent GuardrailType = "harmful_content" + // GuardrailSecretLeak detects leaked secrets, API keys, tokens, and passwords. + GuardrailSecretLeak GuardrailType = "secret_leak" + // GuardrailCustom is a user-defined rule with a custom pattern. + GuardrailCustom GuardrailType = "custom" +) + +// GuardrailAction determines what happens when a rule matches. +type GuardrailAction string + +const ( + // GuardrailBlock prevents the response from being returned to the caller. + GuardrailBlock GuardrailAction = "block" + // GuardrailRedact replaces the matched content with a redaction marker. + GuardrailRedact GuardrailAction = "redact" + // GuardrailWarn allows the response but records the violation. + GuardrailWarn GuardrailAction = "warn" +) + +// GuardrailSeverity indicates how critical a violation is. +type GuardrailSeverity string + +const ( + SeverityLow GuardrailSeverity = "low" + SeverityMedium GuardrailSeverity = "medium" + SeverityHigh GuardrailSeverity = "high" + SeverityCritical GuardrailSeverity = "critical" +) + +// GuardrailRule defines a single guardrail check. +type GuardrailRule struct { + Type GuardrailType `json:"type"` + Name string `json:"name"` + Pattern string `json:"pattern"` + Action GuardrailAction `json:"action"` + Severity GuardrailSeverity `json:"severity"` + compiled *regexp.Regexp // lazily compiled +} + +// GuardrailViolation records a single rule match in the response. +type GuardrailViolation struct { + Rule GuardrailRule `json:"rule"` + MatchedText string `json:"matched_text"` + RedactedResult string `json:"redacted_result,omitempty"` + matchStart int // byte offset of the match in the original response + matchEnd int // byte offset one past the last matched byte +} + +// GuardrailError is returned when a guardrail blocks a response. +type GuardrailError struct { + Violations []GuardrailViolation `json:"violations"` + Message string `json:"message"` +} + +func (e *GuardrailError) Error() string { + return fmt.Sprintf("eyrie: guardrail blocked: %s (%d violation(s))", e.Message, len(e.Violations)) +} + +// Guardrails holds registered rules and runs them against LLM responses. +type Guardrails struct { + mu sync.RWMutex + rules []GuardrailRule +} + +// NewGuardrails creates a Guardrails instance with the given rules. +func NewGuardrails(rules ...GuardrailRule) *Guardrails { + g := &Guardrails{} + for _, r := range rules { + g.AddRule(r) + } + return g +} + +// AddRule registers a guardrail rule. It panics if the pattern is invalid. +// This follows the regexp.MustCompile convention for programmatic rules +// where an invalid pattern indicates a programmer error. For rules that +// may originate from untrusted sources (config files, user input), use +// AddRuleSafe instead. +func (g *Guardrails) AddRule(r GuardrailRule) { + if r.Pattern != "" { + compiled, err := regexp.Compile(r.Pattern) + if err != nil { + panic(fmt.Sprintf("eyrie: guardrails: invalid regex %q in rule %q: %v", r.Pattern, r.Name, err)) + } + r.compiled = compiled + } + g.mu.Lock() + defer g.mu.Unlock() + g.rules = append(g.rules, r) +} + +// AddRuleSafe registers a guardrail rule and returns an error if the pattern +// is invalid, instead of panicking. Use this when rules may come from +// untrusted sources (config files, user input). +func (g *Guardrails) AddRuleSafe(r GuardrailRule) error { + if r.Pattern != "" { + compiled, err := regexp.Compile(r.Pattern) + if err != nil { + return fmt.Errorf("eyrie: guardrails: invalid regex %q in rule %q: %w", r.Pattern, r.Name, err) + } + r.compiled = compiled + } + g.mu.Lock() + defer g.mu.Unlock() + g.rules = append(g.rules, r) + return nil +} + +// NewGuardrailsSafe creates a Guardrails instance and returns an error if any +// rule has an invalid pattern. Use this when rules may come from untrusted +// sources; use NewGuardrails for programmatic rules where invalid patterns +// indicate a programmer error (matching regexp.MustCompile convention). +func NewGuardrailsSafe(rules ...GuardrailRule) (*Guardrails, error) { + g := &Guardrails{} + for _, r := range rules { + if err := g.AddRuleSafe(r); err != nil { + return nil, err + } + } + return g, nil +} + +// Rules returns a snapshot of the currently registered rules. +func (g *Guardrails) Rules() []GuardrailRule { + g.mu.RLock() + defer g.mu.RUnlock() + out := make([]GuardrailRule, len(g.rules)) + copy(out, g.rules) + return out +} + +// Check evaluates all rules against the response text. +// It returns violations and an error only if a rule with Action=Block matches. +// For Redact actions, the redacted result is populated in the violation. +// For Warn actions, the violation is recorded but no error is returned. +func (g *Guardrails) Check(ctx context.Context, response string) ([]GuardrailViolation, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + g.mu.RLock() + rules := make([]GuardrailRule, len(g.rules)) + copy(rules, g.rules) + g.mu.RUnlock() + + var violations []GuardrailViolation + hasBlock := false + + for _, rule := range rules { + if rule.compiled == nil { + continue + } + matches := rule.compiled.FindAllStringIndex(response, -1) + if len(matches) == 0 { + continue + } + for _, match := range matches { + v := GuardrailViolation{ + Rule: rule, + MatchedText: response[match[0]:match[1]], + matchStart: match[0], + matchEnd: match[1], + } + if rule.Action == GuardrailRedact { + v.RedactedResult = strings.Repeat("*", len(v.MatchedText)) + } + violations = append(violations, v) + if rule.Action == GuardrailBlock { + hasBlock = true + } + } + } + + if hasBlock { + return violations, &GuardrailError{ + Violations: violations, + Message: "response blocked by guardrail", + } + } + + return violations, nil +} + +// ApplyRedactions takes the response text and violations, replacing redacted +// matches with their redaction markers. Non-redact violations are left intact. +// Match positions are used directly from the violations (captured during Check) +// so the correct instance of each match is redacted even when the matched text +// appears multiple times in the response. +func ApplyRedactions(response string, violations []GuardrailViolation) string { + type replacement struct { + start int + end int + text string + } + var reps []replacement + for _, v := range violations { + if v.Rule.Action != GuardrailRedact { + continue + } + if v.matchEnd == 0 && v.matchStart == 0 && v.MatchedText != "" { + // Fallback: violation came from outside Check (e.g. constructed + // manually). Search for the first occurrence. + idx := strings.Index(response, v.MatchedText) + if idx < 0 { + continue + } + reps = append(reps, replacement{start: idx, end: idx + len(v.MatchedText), text: v.RedactedResult}) + continue + } + reps = append(reps, replacement{start: v.matchStart, end: v.matchEnd, text: v.RedactedResult}) + } + if len(reps) == 0 { + return response + } + + // Sort by start position descending; for same start, longer match first. + sort.Slice(reps, func(i, j int) bool { + if reps[i].start == reps[j].start { + return (reps[i].end - reps[i].start) > (reps[j].end - reps[j].start) + } + return reps[i].start > reps[j].start + }) + + // Remove overlapping replacements (keep longest/leftmost) + filtered := reps[:1] + for i := 1; i < len(reps); i++ { + prev := filtered[len(filtered)-1] + if reps[i].end > prev.start { + continue + } + filtered = append(filtered, reps[i]) + } + + result := response + for _, r := range filtered { + result = result[:r.start] + r.text + result[r.end:] + } + return result +} + +// --------------------------------------------------------------------------- +// Built-in rule constructors +// --------------------------------------------------------------------------- + +// DefaultPIIRules returns built-in rules for detecting PII in responses. +func DefaultPIIRules() []GuardrailRule { + return []GuardrailRule{ + { + Type: GuardrailPII, + Name: "ssn", + Pattern: `\b\d{3}-\d{2}-\d{4}\b`, + Action: GuardrailRedact, + Severity: SeverityCritical, + }, + { + Type: GuardrailPII, + Name: "credit_card", + Pattern: `\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|6(?:011|5[0-9]{2})[0-9]{12}|(?:2131|1800|35\d{3})\d{11})\b`, + Action: GuardrailRedact, + Severity: SeverityCritical, + }, + { + Type: GuardrailPII, + Name: "phone_number", + Pattern: `\b(?:\+?1[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)?\d{3}[-.\s]?\d{4}\b`, + Action: GuardrailRedact, + Severity: SeverityMedium, + }, + } +} + +// DefaultSecretLeakRules returns built-in rules for detecting leaked secrets. +func DefaultSecretLeakRules() []GuardrailRule { + return []GuardrailRule{ + { + Type: GuardrailSecretLeak, + Name: "api_key_generic", + Pattern: `(?i)(?:api[_-]?key|apikey)\s*[:=]\s*['"` + "`" + `]?([A-Za-z0-9_\-]{20,})['"` + "`" + `]?`, + Action: GuardrailBlock, + Severity: SeverityCritical, + }, + { + Type: GuardrailSecretLeak, + Name: "bearer_token", + Pattern: `(?i)bearer\s+[A-Za-z0-9_\-\.]{20,}`, + Action: GuardrailBlock, + Severity: SeverityCritical, + }, + { + Type: GuardrailSecretLeak, + Name: "password_assignment", + Pattern: `(?i)(?:password|passwd|pwd)\s*[:=]\s*['"` + "`" + `]?[^\s'"` + "`" + `]{8,}['"` + "`" + `]?`, + Action: GuardrailBlock, + Severity: SeverityHigh, + }, + { + Type: GuardrailSecretLeak, + Name: "aws_secret_key", + Pattern: `(?i)(?:aws_secret_access_key)\s*[:=]\s*['"` + "`" + `]?([A-Za-z0-9/+=]{40})['"` + "`" + `]?`, + Action: GuardrailBlock, + Severity: SeverityCritical, + }, + { + Type: GuardrailSecretLeak, + Name: "private_key_block", + Pattern: `-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----`, + Action: GuardrailBlock, + Severity: SeverityCritical, + }, + } +} + +// DefaultPromptInjectionRules returns built-in rules for detecting prompt injection in responses. +func DefaultPromptInjectionRules() []GuardrailRule { + return []GuardrailRule{ + { + Type: GuardrailPromptInjection, + Name: "ignore_instructions", + Pattern: `(?i)ignore\s+(?:all\s+)?(?:previous|prior|above)\s+(?:instructions|prompts|rules|constraints)`, + Action: GuardrailBlock, + Severity: SeverityHigh, + }, + { + Type: GuardrailPromptInjection, + Name: "you_are_now", + Pattern: `(?i)you\s+are\s+now\s+(?:a|an|the)\s+\w+`, + Action: GuardrailWarn, + Severity: SeverityMedium, + }, + { + Type: GuardrailPromptInjection, + Name: "disregard_above", + Pattern: `(?i)disregard\s+(?:all\s+)?(?:the\s+)?(?:above|previous|prior)\s+(?:instructions|prompts|context)`, + Action: GuardrailBlock, + Severity: SeverityHigh, + }, + { + Type: GuardrailPromptInjection, + Name: "system_prompt_leak", + Pattern: `(?i)(?:reveal|show|print|output|display)\s+(?:your|the)\s+(?:system|initial)\s+(?:prompt|instructions|message)`, + Action: GuardrailBlock, + Severity: SeverityHigh, + }, + { + Type: GuardrailPromptInjection, + Name: "new_instructions", + Pattern: `(?i)\[(?:new|updated|revised)\s+(?:system\s+)?(?:instructions|rules|prompt)\]`, + Action: GuardrailBlock, + Severity: SeverityHigh, + }, + } +} + +// DefaultHarmfulContentRules returns built-in rules for detecting harmful content patterns. +func DefaultHarmfulContentRules() []GuardrailRule { + return []GuardrailRule{ + { + Type: GuardrailHarmfulContent, + Name: "bomb_making", + Pattern: `(?i)(?:how\s+to\s+make|instructions\s+for\s+(?:making|building))\s+(?:a\s+)?(?:bomb|explosive|detonator|grenade|ied)`, + Action: GuardrailBlock, + Severity: SeverityCritical, + }, + { + Type: GuardrailHarmfulContent, + Name: "synthesis_drugs", + Pattern: `(?i)(?:how\s+to\s+(?:synthesize|make|cook|manufacture))\s+(?:methamphetamine|cocaine|heroin|fentanyl|lsd|mdma|ecstasy)`, + Action: GuardrailBlock, + Severity: SeverityCritical, + }, + { + Type: GuardrailHarmfulContent, + Name: "harm_self", + Pattern: `(?i)(?:ways?\s+to\s+(?:hurt|harm|kill)\s+(?:your)?self|methods?\s+of\s+suicide)`, + Action: GuardrailBlock, + Severity: SeverityCritical, + }, + { + Type: GuardrailHarmfulContent, + Name: "weapon_instructions", + Pattern: `(?i)(?:step[- ]by[- ]step|detailed)\s+(?:instructions?|guide)\s+(?:for\s+)?(?:building|making|assembling)\s+(?:a\s+)?(?:firearm|gun|weapon|silencer|suppressor)`, + Action: GuardrailBlock, + Severity: SeverityCritical, + }, + } +} + +// --------------------------------------------------------------------------- +// Rule lookup helpers +// --------------------------------------------------------------------------- + +// AllDefaultRules returns all built-in guardrail rules (PII, secrets, +// prompt injection, and harmful content). +func AllDefaultRules() []GuardrailRule { + var rules []GuardrailRule + rules = append(rules, DefaultPIIRules()...) + rules = append(rules, DefaultSecretLeakRules()...) + rules = append(rules, DefaultPromptInjectionRules()...) + rules = append(rules, DefaultHarmfulContentRules()...) + return rules +} + +// RulesForType returns the built-in rules for a single GuardrailType. +func RulesForType(t GuardrailType) []GuardrailRule { + switch t { + case GuardrailPII: + return DefaultPIIRules() + case GuardrailSecretLeak: + return DefaultSecretLeakRules() + case GuardrailPromptInjection: + return DefaultPromptInjectionRules() + case GuardrailHarmfulContent: + return DefaultHarmfulContentRules() + default: + return nil + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +// ApplyGuardrails runs guardrail checks on the response and applies redactions. +// This is called by provider Chat methods after receiving the LLM response. +func ApplyGuardrails(ctx context.Context, resp *EyrieResponse, g *Guardrails) error { + if g == nil || resp == nil || resp.Content == "" { + return nil + } + violations, err := g.Check(ctx, resp.Content) + if err != nil { + return err + } + if len(violations) > 0 { + resp.Content = ApplyRedactions(resp.Content, violations) + } + return nil +} diff --git a/client/core/image.go b/client/core/image.go index 0af7847..a09d7e4 100644 --- a/client/core/image.go +++ b/client/core/image.go @@ -1,4 +1,4 @@ -package client +package core import ( "encoding/base64" @@ -28,7 +28,7 @@ var extToMediaType = map[string]string{ "gif": "image/gif", } -// normalizeImageSource turns any of the three image source forms into a canonical +// NormalizeImageSource turns any of the three image source forms into a canonical // representation for provider clients: // // - data:;base64, → returned as base64 (mediaType, data, true) @@ -40,7 +40,7 @@ var extToMediaType = map[string]string{ // hawk no longer each carry their own divergent encoder. Local files and // data-URLs are validated against supportedImageMediaTypes; HTTP URLs are left // for the provider to fetch (avoiding an SSRF surface inside eyrie). -func normalizeImageSource(src string) (mediaType, data string, isBase64 bool, err error) { +func NormalizeImageSource(src string) (mediaType, data string, isBase64 bool, err error) { switch { case strings.HasPrefix(src, "data:"): mt, d, ok := parseDataURL(src) @@ -84,17 +84,17 @@ func parseDataURL(src string) (mediaType, data string, ok bool) { return "", "", false } -// openAIImageURL renders an image source into the single-string form the +// OpenAIImageURL renders an image source into the single-string form the // OpenAI-compatible image_url field expects: an http(s) URL or a data URL are // passed through; a local file is read and encoded into a data URL. On any // normalization error it falls back to the raw input so the request is not // dropped (the provider will surface a clearer error than eyrie can here). -func openAIImageURL(src string) string { +func OpenAIImageURL(src string) string { // Already a usable URL form: pass through unchanged. if strings.HasPrefix(src, "data:") || strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") { return src } - mt, data, isBase64, err := normalizeImageSource(src) + mt, data, isBase64, err := NormalizeImageSource(src) if err != nil { return src } @@ -109,12 +109,12 @@ func openAIImageURL(src string) string { return "data:image/png;base64," + data } -// parseImageString is the backward-compatible shim retained for existing call -// sites. It now routes through normalizeImageSource so local paths are encoded +// ParseImageString is the backward-compatible shim retained for existing call +// sites. It now routes through NormalizeImageSource so local paths are encoded // and formats are validated. On error it falls back to treating the input as a // pass-through URL, preserving the previous lenient behavior. -func parseImageString(img string) (mediaType, data string, isBase64 bool) { - mt, d, b64, err := normalizeImageSource(img) +func ParseImageString(img string) (mediaType, data string, isBase64 bool) { + mt, d, b64, err := NormalizeImageSource(img) if err != nil { return "", img, false } diff --git a/client/core/image_test.go b/client/core/image_test.go index 623df3f..6265990 100644 --- a/client/core/image_test.go +++ b/client/core/image_test.go @@ -1,4 +1,4 @@ -package client +package core import ( "encoding/base64" @@ -10,7 +10,7 @@ import ( func TestNormalizeImageSource_DataURL(t *testing.T) { t.Parallel() - mt, data, isB64, err := normalizeImageSource("data:image/png;base64,QUJD") + mt, data, isB64, err := NormalizeImageSource("data:image/png;base64,QUJD") if err != nil { t.Fatalf("err: %v", err) } @@ -21,7 +21,7 @@ func TestNormalizeImageSource_DataURL(t *testing.T) { func TestNormalizeImageSource_DataURLUnsupported(t *testing.T) { t.Parallel() - _, _, _, err := normalizeImageSource("data:image/tiff;base64,QUJD") + _, _, _, err := NormalizeImageSource("data:image/tiff;base64,QUJD") if err == nil || !strings.Contains(err.Error(), "unsupported image format") { t.Errorf("expected unsupported-format error, got %v", err) } @@ -29,7 +29,7 @@ func TestNormalizeImageSource_DataURLUnsupported(t *testing.T) { func TestNormalizeImageSource_HTTPPassthrough(t *testing.T) { t.Parallel() - mt, data, isB64, err := normalizeImageSource("https://example.com/cat.png") + mt, data, isB64, err := NormalizeImageSource("https://example.com/cat.png") if err != nil { t.Fatalf("err: %v", err) } @@ -47,7 +47,7 @@ func TestNormalizeImageSource_LocalFile(t *testing.T) { t.Fatal(err) } - mt, data, isB64, err := normalizeImageSource(path) + mt, data, isB64, err := NormalizeImageSource(path) if err != nil { t.Fatalf("err: %v", err) } @@ -70,7 +70,7 @@ func TestNormalizeImageSource_NonImageExtensionTreatedAsRawBase64(t *testing.T) t.Parallel() // A token without a recognized image extension is treated as raw base64 // data, not a file path (preserving eyrie's long-standing default). - mt, data, isB64, err := normalizeImageSource("QUJDtoken") + mt, data, isB64, err := NormalizeImageSource("QUJDtoken") if err != nil { t.Fatalf("err: %v", err) } @@ -81,7 +81,7 @@ func TestNormalizeImageSource_NonImageExtensionTreatedAsRawBase64(t *testing.T) func TestNormalizeImageSource_MissingFile(t *testing.T) { t.Parallel() - _, _, _, err := normalizeImageSource(filepath.Join(t.TempDir(), "nope.png")) + _, _, _, err := NormalizeImageSource(filepath.Join(t.TempDir(), "nope.png")) if err == nil || !strings.Contains(err.Error(), "reading image file") { t.Errorf("expected read error, got %v", err) } @@ -90,11 +90,11 @@ func TestNormalizeImageSource_MissingFile(t *testing.T) { func TestOpenAIImageURL(t *testing.T) { t.Parallel() // HTTP passes through. - if got := openAIImageURL("https://x/y.png"); got != "https://x/y.png" { + if got := OpenAIImageURL("https://x/y.png"); got != "https://x/y.png" { t.Errorf("http url = %q", got) } // data URL passes through. - if got := openAIImageURL("data:image/png;base64,QUJD"); got != "data:image/png;base64,QUJD" { + if got := OpenAIImageURL("data:image/png;base64,QUJD"); got != "data:image/png;base64,QUJD" { t.Errorf("data url = %q", got) } // Local file becomes a data URL. @@ -103,25 +103,25 @@ func TestOpenAIImageURL(t *testing.T) { if err := os.WriteFile(path, []byte("abc"), 0o600); err != nil { t.Fatal(err) } - got := openAIImageURL(path) + got := OpenAIImageURL(path) if !strings.HasPrefix(got, "data:image/png;base64,") { t.Errorf("local file url = %q, want data:image/png prefix", got) } // A bare token (no path extension, no scheme) is wrapped as raw base64 PNG. - if got := openAIImageURL("AAAA"); got != "data:image/png;base64,AAAA" { + if got := OpenAIImageURL("AAAA"); got != "data:image/png;base64,AAAA" { t.Errorf("raw base64 = %q, want data:image/png;base64,AAAA", got) } } -// parseImageString shim must keep its lenient behavior for callers. +// ParseImageString shim must keep its lenient behavior for callers. func TestParseImageStringShim(t *testing.T) { t.Parallel() - mt, data, isB64 := parseImageString("data:image/png;base64,QUJD") + mt, data, isB64 := ParseImageString("data:image/png;base64,QUJD") if !isB64 || mt != "image/png" || data != "QUJD" { t.Errorf("data url shim got (%q,%q,%v)", mt, data, isB64) } // An HTTP URL stays a pass-through URL. - mt, data, isB64 = parseImageString("https://x/y.png") + mt, data, isB64 = ParseImageString("https://x/y.png") if isB64 || data != "https://x/y.png" { t.Errorf("http shim got (%q,%q,%v)", mt, data, isB64) } diff --git a/client/core/options.go b/client/core/options.go new file mode 100644 index 0000000..8891ef2 --- /dev/null +++ b/client/core/options.go @@ -0,0 +1,140 @@ +package core + +import ( + "log/slog" + "net/http" + "time" +) + +// Configurable is the setter surface protocol adapters expose to the +// functional-options system. It decouples ClientOption from concrete adapter +// types so the adapters can live in their own package. +type Configurable interface { + SetTimeout(d time.Duration) + SetHTTPClient(hc *http.Client) + SetRetry(rc RetryConfig) + SetLogger(l *slog.Logger) + SetAPIKey(key string) + SetBaseURL(url string) + SetDefaultModel(model string) + SetDefaultMaxTokens(n int) + SetDefaultTemperature(t float64) + SetGuardrails(g *Guardrails) + SetProviderName(name string) + SetMimoAuth() +} + +// EyrieConfigurable is the setter surface the top-level EyrieClient exposes +// for options that configure the universal client rather than an adapter. +type EyrieConfigurable interface { + SetCoalescingTTL(ttl time.Duration) +} + +// ClientOption configures clients. Options built with the constructors below +// apply to any Configurable adapter; options built with NewEyrieOption apply +// to the top-level EyrieClient. +type ClientOption struct { + applyConfigurable func(Configurable) + applyEyrie func(EyrieConfigurable) +} + +// NewOption builds a ClientOption from an adapter-level apply function. +func NewOption(fn func(Configurable)) ClientOption { + return ClientOption{applyConfigurable: fn} +} + +// NewEyrieOption builds a ClientOption from an EyrieClient-level apply function. +func NewEyrieOption(fn func(EyrieConfigurable)) ClientOption { + return ClientOption{applyEyrie: fn} +} + +// Apply runs the option against an adapter. No-op for EyrieClient-level options. +func (o ClientOption) Apply(c Configurable) { + if o.applyConfigurable != nil { + o.applyConfigurable(c) + } +} + +// ApplyEyrie runs the option against the top-level client. No-op for +// adapter-level options. +func (o ClientOption) ApplyEyrie(e EyrieConfigurable) { + if o.applyEyrie != nil { + o.applyEyrie(e) + } +} + +// WithTimeout sets the HTTP client timeout. +func WithTimeout(d time.Duration) ClientOption { + return NewOption(func(c Configurable) { c.SetTimeout(d) }) +} + +// WithHTTPClient sets a custom HTTP client. +func WithHTTPClient(hc *http.Client) ClientOption { + return NewOption(func(c Configurable) { c.SetHTTPClient(hc) }) +} + +// WithRetry sets retry configuration. +func WithRetry(rc RetryConfig) ClientOption { + return NewOption(func(c Configurable) { c.SetRetry(rc) }) +} + +// WithLogger sets the logger. +func WithLogger(l *slog.Logger) ClientOption { + return NewOption(func(c Configurable) { c.SetLogger(l) }) +} + +// WithAPIKey sets the API key. +func WithAPIKey(key string) ClientOption { + return NewOption(func(c Configurable) { c.SetAPIKey(key) }) +} + +// WithBaseURL sets the base URL. +func WithBaseURL(url string) ClientOption { + return NewOption(func(c Configurable) { c.SetBaseURL(url) }) +} + +// WithModel sets the default model for requests. +func WithModel(model string) ClientOption { + return NewOption(func(c Configurable) { c.SetDefaultModel(model) }) +} + +// WithMaxTokens sets the default max tokens for requests. +func WithMaxTokens(n int) ClientOption { + return NewOption(func(c Configurable) { c.SetDefaultMaxTokens(n) }) +} + +// WithTemperature sets the default temperature for requests. +func WithTemperature(t float64) ClientOption { + return NewOption(func(c Configurable) { c.SetDefaultTemperature(t) }) +} + +// WithGuardrails attaches output guardrails to the client. Guardrails run +// after the LLM response but before returning to the caller. Blocked +// responses are replaced with an error; redacted responses have matches +// replaced with asterisks. +func WithGuardrails(rules ...GuardrailRule) ClientOption { + g := NewGuardrails(rules...) + return NewOption(func(c Configurable) { c.SetGuardrails(g) }) +} + +// WithGuardrailType attaches output guardrails using built-in rules for the +// specified types. For example, WithGuardrailType(GuardrailPII, GuardrailSecretLeak) +// enables PII redaction and secret leak blocking with default patterns. +func WithGuardrailType(types ...GuardrailType) ClientOption { + var rules []GuardrailRule + for _, t := range types { + rules = append(rules, RulesForType(t)...) + } + return WithGuardrails(rules...) +} + +// WithProviderName sets the OpenAI client provider name for errors/logging. +// No-op for the Anthropic adapter, which reports a fixed provider name. +func WithProviderName(name string) ClientOption { + return NewOption(func(c Configurable) { c.SetProviderName(name) }) +} + +// WithMimoAuth uses api-key header per MiMo documentation (OpenAI + Anthropic compat). +func WithMimoAuth() ClientOption { + return NewOption(func(c Configurable) { c.SetMimoAuth() }) +} diff --git a/client/core/provider_errors.go b/client/core/provider_errors.go index cf0316b..643aef1 100644 --- a/client/core/provider_errors.go +++ b/client/core/provider_errors.go @@ -1,4 +1,4 @@ -package client +package core import ( "encoding/json" @@ -8,25 +8,25 @@ import ( "strings" ) -// providerErrorDetail holds the structured fields eyrie can extract from a +// ProviderErrorDetail holds the structured fields eyrie can extract from a // provider's error body. Providers vary (OpenAI nests under "error", some put // a top-level "code"); the parser is lenient and fills what it can. -type providerErrorDetail struct { +type ProviderErrorDetail struct { Message string // human-readable message from the body Type string // provider error type, e.g. "invalid_request_error" Code string // provider error code, e.g. "invalid_api_key", "model_not_found" Raw string // raw body (truncated) when nothing structured was found } -// parseProviderError reads and classifies an error response body. It +// ParseProviderError reads and classifies an error response body. It // never returns a zero detail: on a read failure the detail is filled // with a placeholder message and the read error is returned alongside // so callers can attach it to the structured *EyrieError. -func parseProviderError(body io.ReadCloser) (providerErrorDetail, error) { +func ParseProviderError(body io.ReadCloser) (ProviderErrorDetail, error) { defer func() { _ = body.Close() }() data, err := io.ReadAll(io.LimitReader(body, 8192)) if err != nil { - return providerErrorDetail{Message: "failed to read error body"}, err + return ProviderErrorDetail{Message: "failed to read error body"}, err } // Most OpenAI-compatible and Anthropic errors nest the detail under "error". @@ -40,7 +40,7 @@ func parseProviderError(body io.ReadCloser) (providerErrorDetail, error) { Message string `json:"message"` Type string `json:"type"` } - d := providerErrorDetail{Raw: string(data)} + d := ProviderErrorDetail{Raw: string(data)} if json.Unmarshal(data, &nested) == nil { switch { case nested.Error.Message != "": @@ -72,7 +72,7 @@ func rawToString(raw json.RawMessage) string { // It is intentionally small and keyed on portable signals (HTTP status plus the // common cross-provider code/type/message strings) rather than a per-provider // table, to avoid becoming a maintenance sink across eyrie's many providers. -func classifyProviderError(statusCode int, d providerErrorDetail) string { +func classifyProviderError(statusCode int, d ProviderErrorDetail) string { code := strings.ToLower(d.Code) typ := strings.ToLower(d.Type) msg := strings.ToLower(d.Message) @@ -120,7 +120,7 @@ func classifyProviderError(statusCode int, d providerErrorDetail) string { return "" } -// formatAPIError builds the *EyrieError used across every provider +// FormatAPIError builds the *EyrieError used across every provider // request path (chat, stream, embeddings). It always includes the // provider name, the operation (e.g. "chat", "stream"), the HTTP // status, the upstream correlation id (for support tickets), a @@ -132,11 +132,11 @@ func classifyProviderError(statusCode int, d providerErrorDetail) string { // IsRetriable()/IsAuthError(), observability code can pull the // provider/status/request-id without re-parsing the message. // -// inner is the read error from parseProviderError (when the body +// inner is the read error from ParseProviderError (when the body // could not be read). It is attached via EyrieError.Err so // errors.Is(err, io.EOF) and similar checks succeed; pass nil when // the body was read cleanly. -func formatAPIError(provider, op string, statusCode int, requestID string, d providerErrorDetail, inner error) error { +func FormatAPIError(provider, op string, statusCode int, requestID string, d ProviderErrorDetail, inner error) error { detail := d.Message if detail == "" { detail = d.Raw diff --git a/client/core/provider_errors_test.go b/client/core/provider_errors_test.go index 7a13e42..556155e 100644 --- a/client/core/provider_errors_test.go +++ b/client/core/provider_errors_test.go @@ -1,4 +1,4 @@ -package client +package core import ( "errors" @@ -12,7 +12,7 @@ func body(s string) io.ReadCloser { return io.NopCloser(strings.NewReader(s)) } func TestParseProviderError(t *testing.T) { t.Parallel() - d, readErr := parseProviderError(body(`{"error":{"message":"Incorrect API key provided","type":"invalid_request_error","code":"invalid_api_key"}}`)) + d, readErr := ParseProviderError(body(`{"error":{"message":"Incorrect API key provided","type":"invalid_request_error","code":"invalid_api_key"}}`)) if readErr != nil { t.Errorf("readErr = %v, want nil", readErr) } @@ -30,7 +30,7 @@ func TestParseProviderError(t *testing.T) { func TestParseProviderError_NumericCode(t *testing.T) { t.Parallel() // Some providers send a bare numeric code. - d, readErr := parseProviderError(body(`{"error":{"message":"nope","code":404}}`)) + d, readErr := ParseProviderError(body(`{"error":{"message":"nope","code":404}}`)) if readErr != nil { t.Errorf("readErr = %v, want nil", readErr) } @@ -41,7 +41,7 @@ func TestParseProviderError_NumericCode(t *testing.T) { func TestParseProviderError_Unstructured(t *testing.T) { t.Parallel() - d, readErr := parseProviderError(body(`upstream proxy: 502 bad gateway`)) + d, readErr := ParseProviderError(body(`upstream proxy: 502 bad gateway`)) if readErr != nil { t.Errorf("readErr = %v, want nil", readErr) } @@ -58,18 +58,18 @@ func TestClassifyProviderError(t *testing.T) { cases := []struct { name string status int - detail providerErrorDetail + detail ProviderErrorDetail want string // substring expected in the hint }{ - {"invalid key by code", 400, providerErrorDetail{Code: "invalid_api_key"}, "invalid API key"}, - {"model not found by code", 404, providerErrorDetail{Code: "model_not_found"}, "model not found"}, - {"quota by message", 429, providerErrorDetail{Message: "You exceeded your current quota"}, "billing/quota"}, - {"content filter", 400, providerErrorDetail{Type: "content_filter"}, "content filter"}, - {"context length", 400, providerErrorDetail{Message: "maximum context length is 8192"}, "context window"}, - {"bare 401", http.StatusUnauthorized, providerErrorDetail{}, "unauthorized"}, - {"bare 429", http.StatusTooManyRequests, providerErrorDetail{}, "rate limited"}, - {"bare 503", http.StatusServiceUnavailable, providerErrorDetail{}, "transient"}, - {"no hint", 400, providerErrorDetail{Message: "weird"}, ""}, + {"invalid key by code", 400, ProviderErrorDetail{Code: "invalid_api_key"}, "invalid API key"}, + {"model not found by code", 404, ProviderErrorDetail{Code: "model_not_found"}, "model not found"}, + {"quota by message", 429, ProviderErrorDetail{Message: "You exceeded your current quota"}, "billing/quota"}, + {"content filter", 400, ProviderErrorDetail{Type: "content_filter"}, "content filter"}, + {"context length", 400, ProviderErrorDetail{Message: "maximum context length is 8192"}, "context window"}, + {"bare 401", http.StatusUnauthorized, ProviderErrorDetail{}, "unauthorized"}, + {"bare 429", http.StatusTooManyRequests, ProviderErrorDetail{}, "rate limited"}, + {"bare 503", http.StatusServiceUnavailable, ProviderErrorDetail{}, "transient"}, + {"no hint", 400, ProviderErrorDetail{Message: "weird"}, ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -89,8 +89,8 @@ func TestClassifyProviderError(t *testing.T) { func TestFormatAPIError(t *testing.T) { t.Parallel() - err := formatAPIError("openai", "chat", 401, "req_123", - providerErrorDetail{Message: "bad key", Type: "auth_error", Code: "invalid_api_key"}, nil) + err := FormatAPIError("openai", "chat", 401, "req_123", + ProviderErrorDetail{Message: "bad key", Type: "auth_error", Code: "invalid_api_key"}, nil) s := err.Error() for _, want := range []string{"openai", "chat", "HTTP 401", "request_id=req_123", "invalid API key", "bad key"} { if !strings.Contains(s, want) { @@ -101,7 +101,7 @@ func TestFormatAPIError(t *testing.T) { func TestFormatAPIError_OmitsRequestIDWhenEmpty(t *testing.T) { t.Parallel() - err := formatAPIError("vertex", "chat", 400, "", providerErrorDetail{Raw: "totally opaque"}, nil) + err := FormatAPIError("vertex", "chat", 400, "", ProviderErrorDetail{Raw: "totally opaque"}, nil) s := err.Error() if !strings.Contains(s, "totally opaque") { t.Errorf("error %q should include raw detail", s) @@ -118,11 +118,11 @@ func TestFormatAPIError_OmitsRequestIDWhenEmpty(t *testing.T) { // instead of regex-parsing the message. func TestFormatAPIError_ReturnsEyrieError(t *testing.T) { t.Parallel() - err := formatAPIError("openai", "chat", 429, "req_429", - providerErrorDetail{Message: "rate limited"}, nil) + err := FormatAPIError("openai", "chat", 429, "req_429", + ProviderErrorDetail{Message: "rate limited"}, nil) var eyrieErr *EyrieError if !errors.As(err, &eyrieErr) { - t.Fatalf("formatAPIError must return *EyrieError, got %T (%v)", err, err) + t.Fatalf("FormatAPIError must return *EyrieError, got %T (%v)", err, err) } if eyrieErr.Provider != "openai" { t.Errorf("Provider = %q, want openai", eyrieErr.Provider) @@ -152,8 +152,8 @@ func TestFormatAPIError_ReturnsEyrieError(t *testing.T) { func TestFormatAPIError_AuthError(t *testing.T) { t.Parallel() for _, status := range []int{401, 403} { - err := formatAPIError("openai", "chat", status, "req", - providerErrorDetail{Message: "unauthorized", Code: "invalid_api_key"}, nil) + err := FormatAPIError("openai", "chat", status, "req", + ProviderErrorDetail{Message: "unauthorized", Code: "invalid_api_key"}, nil) var eyrieErr *EyrieError if !errors.As(err, &eyrieErr) { t.Fatalf("status %d: not *EyrieError: %T", status, err) @@ -171,8 +171,8 @@ func TestFormatAPIError_AuthError(t *testing.T) { func TestFormatAPIError_RetriableCodes(t *testing.T) { t.Parallel() for _, status := range []int{408, 429, 500, 502, 503, 504, 529} { - err := formatAPIError("openai", "chat", status, "req", - providerErrorDetail{Message: "try again"}, nil) + err := FormatAPIError("openai", "chat", status, "req", + ProviderErrorDetail{Message: "try again"}, nil) var eyrieErr *EyrieError if !errors.As(err, &eyrieErr) { t.Fatalf("status %d: not *EyrieError: %T", status, err) @@ -184,19 +184,19 @@ func TestFormatAPIError_RetriableCodes(t *testing.T) { } // TestFormatAPIError_InnerErrorUnwrap: a non-nil inner error passed -// in (e.g. a body read error from parseProviderError) is wired into +// in (e.g. a body read error from ParseProviderError) is wired into // EyrieError.Err, so errors.Is / errors.Unwrap traverse it. This // fixes the contract gap where Unwrap() always returned nil even // when the provider body failed to read. func TestFormatAPIError_InnerErrorUnwrap(t *testing.T) { t.Parallel() inner := io.ErrUnexpectedEOF - err := formatAPIError("openai", "chat", 500, "req_inner", - providerErrorDetail{Message: "bad gateway"}, inner) + err := FormatAPIError("openai", "chat", 500, "req_inner", + ProviderErrorDetail{Message: "bad gateway"}, inner) var eyrieErr *EyrieError if !errors.As(err, &eyrieErr) { - t.Fatalf("formatAPIError must return *EyrieError, got %T (%v)", err, err) + t.Fatalf("FormatAPIError must return *EyrieError, got %T (%v)", err, err) } if !errors.Is(err, io.ErrUnexpectedEOF) { t.Errorf("errors.Is(err, io.ErrUnexpectedEOF) = false, want true (Err field must be wired)") diff --git a/client/core/repeat_detector.go b/client/core/repeat_detector.go index faca68a..82103b0 100644 --- a/client/core/repeat_detector.go +++ b/client/core/repeat_detector.go @@ -1,4 +1,4 @@ -package client +package core import "sync" diff --git a/client/core/repeat_detector_test.go b/client/core/repeat_detector_test.go index 8f261fa..48c2e93 100644 --- a/client/core/repeat_detector_test.go +++ b/client/core/repeat_detector_test.go @@ -1,4 +1,4 @@ -package client +package core import ( "strings" diff --git a/client/core/response_health.go b/client/core/response_health.go index 0d44aa1..21fd663 100644 --- a/client/core/response_health.go +++ b/client/core/response_health.go @@ -1,4 +1,4 @@ -package client +package core import ( "fmt" diff --git a/client/core/response_health_test.go b/client/core/response_health_test.go index 6a58b81..9e50ec9 100644 --- a/client/core/response_health_test.go +++ b/client/core/response_health_test.go @@ -1,4 +1,4 @@ -package client +package core import ( "context" @@ -70,7 +70,7 @@ func TestStreamEmitsErrorOnlyReasoningDiagnostic(t *testing.T) { events <- SSEEvent{Data: `{"choices":[{"delta":{},"finish_reason":"stop"}]}`} close(events) - ch := processOpenAIStream(context.Background(), events, testLogger()) + ch := ProcessOpenAIStream(context.Background(), events, testLogger()) var sawThinking, sawDiagnostic, sawContent bool for evt := range ch { @@ -96,7 +96,7 @@ func TestStreamEmitsErrorOnlyReasoningDiagnostic(t *testing.T) { } } -// A normal stream with content must NOT emit a health diagnostic. +// A normal stream with content must NOT Emit a health diagnostic. func TestStreamNoDiagnosticOnHealthyResponse(t *testing.T) { t.Parallel() events := make(chan SSEEvent, 10) @@ -104,10 +104,10 @@ func TestStreamNoDiagnosticOnHealthyResponse(t *testing.T) { events <- SSEEvent{Data: `{"choices":[{"delta":{},"finish_reason":"stop"}]}`} close(events) - ch := processOpenAIStream(context.Background(), events, testLogger()) + ch := ProcessOpenAIStream(context.Background(), events, testLogger()) for evt := range ch { if evt.Type == "error" { - t.Errorf("healthy stream should not emit error diagnostic, got %q", evt.Error) + t.Errorf("healthy stream should not Emit error diagnostic, got %q", evt.Error) } } } diff --git a/client/core/retry.go b/client/core/retry.go index ee74750..91c6774 100644 --- a/client/core/retry.go +++ b/client/core/retry.go @@ -1,4 +1,4 @@ -package client +package core import ( "context" @@ -34,8 +34,8 @@ func DefaultRetryConfig() RetryConfig { return NewRetryConfig(3, 500*time.Millisecond, 30*time.Second, 429, 500, 502, 503, 529) } -// shouldRetry checks if a status code is retryable. -func (rc RetryConfig) shouldRetry(statusCode int) bool { +// ShouldRetry checks if a status code is retryable. +func (rc RetryConfig) ShouldRetry(statusCode int) bool { for _, code := range rc.RetryOn { if code == statusCode { return true @@ -93,16 +93,16 @@ func parseRetryDelay(errMsg string) time.Duration { } } -// doWithRetry executes an HTTP request with retry logic. +// DoWithRetry executes an HTTP request with retry logic. // -// Note: doWithRetry operates at the transport layer, before +// Note: DoWithRetry operates at the transport layer, before // formatAPIError constructs *EyrieError. Structured-error awareness // lives in the fallback chain (fallback.go:240-244) where // *EyrieError.IsRetriable() / IsAuthError() drive provider -// rotation. doWithRetry only needs the raw transport status code +// rotation. DoWithRetry only needs the raw transport status code // and the underlying network error to decide whether to retry the // same request. -func doWithRetry(ctx context.Context, httpClient *http.Client, req *http.Request, rc RetryConfig, logger *slog.Logger) (*http.Response, error) { +func DoWithRetry(ctx context.Context, httpClient *http.Client, req *http.Request, rc RetryConfig, logger *slog.Logger) (*http.Response, error) { var lastErr error var lastResp *http.Response @@ -149,7 +149,7 @@ func doWithRetry(ctx context.Context, httpClient *http.Client, req *http.Request continue } - if !rc.shouldRetry(resp.StatusCode) { + if !rc.ShouldRetry(resp.StatusCode) { return resp, nil } diff --git a/client/core/retry_test.go b/client/core/retry_test.go index a43fc50..5d1872e 100644 --- a/client/core/retry_test.go +++ b/client/core/retry_test.go @@ -1,4 +1,4 @@ -package client +package core import ( "context" @@ -43,8 +43,8 @@ func TestRetryShouldRetryTrue(t *testing.T) { cfg := DefaultRetryConfig() codes := []int{429, 500, 502, 503, 529} for _, code := range codes { - if !cfg.shouldRetry(code) { - t.Errorf("shouldRetry(%d) = false, want true", code) + if !cfg.ShouldRetry(code) { + t.Errorf("ShouldRetry(%d) = false, want true", code) } } } @@ -54,8 +54,8 @@ func TestRetryShouldRetryFalse(t *testing.T) { cfg := DefaultRetryConfig() codes := []int{400, 401, 403, 404} for _, code := range codes { - if cfg.shouldRetry(code) { - t.Errorf("shouldRetry(%d) = true, want false", code) + if cfg.ShouldRetry(code) { + t.Errorf("ShouldRetry(%d) = true, want false", code) } } } @@ -154,9 +154,9 @@ func TestDoWithRetrySuccess(t *testing.T) { logger := slog.Default() req, _ := http.NewRequestWithContext(context.Background(), "GET", srv.URL, nil) - resp, err := doWithRetry(context.Background(), http.DefaultClient, req, rc, logger) + resp, err := DoWithRetry(context.Background(), http.DefaultClient, req, rc, logger) if err != nil { - t.Fatalf("doWithRetry: %v", err) + t.Fatalf("DoWithRetry: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -182,9 +182,9 @@ func TestDoWithRetryRetriesOn500ThenSucceeds(t *testing.T) { logger := slog.Default() req, _ := http.NewRequestWithContext(context.Background(), "GET", srv.URL, nil) - resp, err := doWithRetry(context.Background(), http.DefaultClient, req, rc, logger) + resp, err := DoWithRetry(context.Background(), http.DefaultClient, req, rc, logger) if err != nil { - t.Fatalf("doWithRetry: %v", err) + t.Fatalf("DoWithRetry: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -208,9 +208,9 @@ func TestDoWithRetryExhaustsRetries(t *testing.T) { logger := slog.Default() req, _ := http.NewRequestWithContext(context.Background(), "GET", srv.URL, nil) - _, err := doWithRetry(context.Background(), http.DefaultClient, req, rc, logger) + _, err := DoWithRetry(context.Background(), http.DefaultClient, req, rc, logger) if err == nil { - t.Fatal("doWithRetry should fail after exhausting retries") + t.Fatal("DoWithRetry should fail after exhausting retries") } // Initial attempt + 2 retries = 3 total if n := attempts.Load(); n != 3 { @@ -231,9 +231,9 @@ func TestDoWithRetryNoRetryOn400(t *testing.T) { logger := slog.Default() req, _ := http.NewRequestWithContext(context.Background(), "GET", srv.URL, nil) - resp, err := doWithRetry(context.Background(), http.DefaultClient, req, rc, logger) + resp, err := DoWithRetry(context.Background(), http.DefaultClient, req, rc, logger) if err != nil { - t.Fatalf("doWithRetry: %v", err) + t.Fatalf("DoWithRetry: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { @@ -259,7 +259,7 @@ func TestDoWithRetryContextCancellation(t *testing.T) { done := make(chan error, 1) go func() { - _, err := doWithRetry(ctx, http.DefaultClient, req, rc, logger) + _, err := DoWithRetry(ctx, http.DefaultClient, req, rc, logger) done <- err }() @@ -269,7 +269,7 @@ func TestDoWithRetryContextCancellation(t *testing.T) { err := <-done if err == nil { - t.Fatal("doWithRetry should fail when context is cancelled") + t.Fatal("DoWithRetry should fail when context is cancelled") } } @@ -291,9 +291,9 @@ func TestDoWithRetryRespectsRetryAfter(t *testing.T) { logger := slog.Default() req, _ := http.NewRequestWithContext(context.Background(), "GET", srv.URL, nil) - resp, err := doWithRetry(context.Background(), http.DefaultClient, req, rc, logger) + resp, err := DoWithRetry(context.Background(), http.DefaultClient, req, rc, logger) if err != nil { - t.Fatalf("doWithRetry: %v", err) + t.Fatalf("DoWithRetry: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -323,9 +323,9 @@ func TestDoWithRetryBodyReplay(t *testing.T) { return io.NopCloser(strings.NewReader(body)), nil } - resp, err := doWithRetry(context.Background(), http.DefaultClient, req, rc, logger) + resp, err := DoWithRetry(context.Background(), http.DefaultClient, req, rc, logger) if err != nil { - t.Fatalf("doWithRetry: %v", err) + t.Fatalf("DoWithRetry: %v", err) } defer resp.Body.Close() } diff --git a/client/core/stream.go b/client/core/stream.go index 08ac8c4..aebc191 100644 --- a/client/core/stream.go +++ b/client/core/stream.go @@ -1,4 +1,4 @@ -package client +package core import ( "bufio" @@ -21,16 +21,17 @@ type SSEEvent struct { // SSE stream constants. const ( - sseChannelBuffer = 128 - sseScannerInitBuf = 64 * 1024 - sseScannerMaxBuf = 2 * 1024 * 1024 - streamChannelBuffer = 256 + sseChannelBuffer = 128 + sseScannerInitBuf = 64 * 1024 + sseScannerMaxBuf = 2 * 1024 * 1024 + // StreamChannelBuffer is the default buffer size for provider event channels. + StreamChannelBuffer = 256 ) -// parseSSEStream reads an SSE stream and sends events to a channel. +// ParseSSEStream reads an SSE stream and sends events to a channel. // The goroutine closes the channel and body when done or context is cancelled. // Scanner errors are emitted as SSEEvent with Event="error" so callers can detect truncation. -func parseSSEStream(ctx context.Context, body io.ReadCloser, logger *slog.Logger) <-chan SSEEvent { +func ParseSSEStream(ctx context.Context, body io.ReadCloser, logger *slog.Logger) <-chan SSEEvent { ch := make(chan SSEEvent, sseChannelBuffer) go func() { defer close(ch) @@ -106,14 +107,14 @@ type anthropicStreamEvent struct { } `json:"content_block,omitempty"` } -// processAnthropicStream converts Anthropic SSE events to EyrieStreamEvents. +// ProcessAnthropicStream converts Anthropic SSE events to EyrieStreamEvents. // Handles text, tool_use (with input_json_delta), and thinking blocks. -func processAnthropicStream(ctx context.Context, sseEvents <-chan SSEEvent, logger *slog.Logger) <-chan EyrieStreamEvent { - return processAnthropicStreamWithOpts(ctx, sseEvents, logger, time.Now()) +func ProcessAnthropicStream(ctx context.Context, sseEvents <-chan SSEEvent, logger *slog.Logger) <-chan EyrieStreamEvent { + return ProcessAnthropicStreamWithOpts(ctx, sseEvents, logger, time.Now()) } -func processAnthropicStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEvent, logger *slog.Logger, start time.Time) <-chan EyrieStreamEvent { - ch := make(chan EyrieStreamEvent, streamChannelBuffer) +func ProcessAnthropicStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEvent, logger *slog.Logger, start time.Time) <-chan EyrieStreamEvent { + ch := make(chan EyrieStreamEvent, StreamChannelBuffer) go func() { defer close(ch) @@ -136,9 +137,9 @@ func processAnthropicStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEve return case evt, ok := <-sseEvents: if !ok { - // Stream closed — emit partial tool call as error if incomplete + // Stream closed — Emit partial tool call as error if incomplete if currentTool != nil { - emit(ctx, ch, EyrieStreamEvent{ + Emit(ctx, ch, EyrieStreamEvent{ Type: "error", Error: fmt.Sprintf("stream closed with incomplete tool call: %s", currentTool.name), }) @@ -148,7 +149,7 @@ func processAnthropicStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEve } // Propagate SSE-level errors if evt.Event == "error" { - emit(ctx, ch, EyrieStreamEvent{Type: "error", Error: evt.Data}) + Emit(ctx, ch, EyrieStreamEvent{Type: "error", Error: evt.Data}) return } data := strings.TrimSpace(evt.Data) @@ -181,15 +182,15 @@ func processAnthropicStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEve case "text_delta": if ae.Delta.Text != "" { if strings.Contains(blockTypes[ae.Index], "thinking") { - emit(ctx, ch, EyrieStreamEvent{Type: "thinking", Thinking: ae.Delta.Text}) + Emit(ctx, ch, EyrieStreamEvent{Type: "thinking", Thinking: ae.Delta.Text}) } else { - // TTFT: record and emit on the first content token. + // TTFT: record and Emit on the first content token. if !ttftEmitted { ttftEmitted = true ttftMs = int(time.Since(start) / time.Millisecond) - emit(ctx, ch, EyrieStreamEvent{Type: "ttft", TTFT: ttftMs}) + Emit(ctx, ch, EyrieStreamEvent{Type: "ttft", TTFT: ttftMs}) } - emit(ctx, ch, EyrieStreamEvent{Type: "content", Content: ae.Delta.Text}) + Emit(ctx, ch, EyrieStreamEvent{Type: "content", Content: ae.Delta.Text}) } } case "input_json_delta": @@ -198,13 +199,13 @@ func processAnthropicStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEve if !ttftEmitted { ttftEmitted = true ttftMs = int(time.Since(start) / time.Millisecond) - emit(ctx, ch, EyrieStreamEvent{Type: "ttft", TTFT: ttftMs}) + Emit(ctx, ch, EyrieStreamEvent{Type: "ttft", TTFT: ttftMs}) } currentTool.jsonBuf.WriteString(ae.Delta.PartialJSON) } case "thinking_delta": if ae.Delta.Text != "" { - emit(ctx, ch, EyrieStreamEvent{Type: "thinking", Thinking: ae.Delta.Text}) + Emit(ctx, ch, EyrieStreamEvent{Type: "thinking", Thinking: ae.Delta.Text}) } } @@ -215,12 +216,12 @@ func processAnthropicStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEve var args map[string]interface{} if err := json.Unmarshal([]byte(rawJSON), &args); err != nil { logger.Warn("invalid tool call JSON accumulated", "tool", currentTool.name, "error", err) - emit(ctx, ch, EyrieStreamEvent{ + Emit(ctx, ch, EyrieStreamEvent{ Type: "error", Error: fmt.Sprintf("invalid tool call JSON for %s: %v", currentTool.name, err), }) } else { - emit(ctx, ch, EyrieStreamEvent{ + Emit(ctx, ch, EyrieStreamEvent{ Type: "tool_call", ToolCall: &ToolCall{ID: currentTool.id, Name: currentTool.name, Arguments: args}, }) @@ -229,7 +230,7 @@ func processAnthropicStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEve } case "message_stop": - emit(ctx, ch, EyrieStreamEvent{Type: "done", StopReason: stopReason, TTFTms: ttftMs}) + Emit(ctx, ch, EyrieStreamEvent{Type: "done", StopReason: stopReason, TTFTms: ttftMs}) return case "message_delta": @@ -249,7 +250,7 @@ func processAnthropicStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEve stopReason = md.Delta.StopReason } if md.Usage != nil && md.Usage.OutputTokens > 0 { - emit(ctx, ch, EyrieStreamEvent{ + Emit(ctx, ch, EyrieStreamEvent{ Type: "usage", Usage: &EyrieUsage{ CompletionTokens: md.Usage.OutputTokens, @@ -271,7 +272,7 @@ func processAnthropicStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEve logger.Warn("stream: failed to parse anthropic message_start", "error", err) } if ms.Message.Usage.InputTokens > 0 { - emit(ctx, ch, EyrieStreamEvent{ + Emit(ctx, ch, EyrieStreamEvent{ Type: "usage", Usage: &EyrieUsage{ PromptTokens: ms.Message.Usage.InputTokens, @@ -280,7 +281,7 @@ func processAnthropicStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEve } case "error": - emit(ctx, ch, EyrieStreamEvent{Type: "error", Error: data}) + Emit(ctx, ch, EyrieStreamEvent{Type: "error", Error: data}) return } } @@ -322,7 +323,7 @@ type openaiStreamPayload struct { // thinkSplitter separates inline ... reasoning out of streamed // content. Some OpenAI-compatible models (DeepSeek, Qwen, and local models via -// Ollama/openrouter) emit chain-of-thought inline in delta.content wrapped in +// Ollama/openrouter) Emit chain-of-thought inline in delta.content wrapped in // tags rather than in a dedicated reasoning_content field. Left in the // content stream this leaks reasoning into the assistant's answer text. The // splitter is fed each content delta in order and routes the pieces to the @@ -349,7 +350,7 @@ func (s *thinkSplitter) feed(delta string) (content, thinking string) { idx := strings.Index(buf, thinkClose) if idx == -1 { // No close tag yet. Keep a possible partial close tag suffix - // buffered; emit the rest as thinking. + // buffered; Emit the rest as thinking. keep := partialSuffixLen(buf, thinkClose) t.WriteString(buf[:len(buf)-keep]) s.pending = buf[len(buf)-keep:] @@ -392,16 +393,16 @@ func partialSuffixLen(s, tag string) int { return 0 } -// processOpenAIStream converts OpenAI SSE events to EyrieStreamEvents. +// ProcessOpenAIStream converts OpenAI SSE events to EyrieStreamEvents. // Handles text deltas and tool call streaming by index. // Also instruments TTFT (time-to-first-token) and runs a RepeatDetector to // synthesise a finish_reason:"repeat" event when the stream loops. -func processOpenAIStream(ctx context.Context, sseEvents <-chan SSEEvent, logger *slog.Logger) <-chan EyrieStreamEvent { - return processOpenAIStreamWithOpts(ctx, sseEvents, logger, DefaultRepeatDetector(), time.Now()) +func ProcessOpenAIStream(ctx context.Context, sseEvents <-chan SSEEvent, logger *slog.Logger) <-chan EyrieStreamEvent { + return ProcessOpenAIStreamWithOpts(ctx, sseEvents, logger, DefaultRepeatDetector(), time.Now()) } -func processOpenAIStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEvent, logger *slog.Logger, repeat *RepeatDetector, start time.Time) <-chan EyrieStreamEvent { - ch := make(chan EyrieStreamEvent, streamChannelBuffer) +func ProcessOpenAIStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEvent, logger *slog.Logger, repeat *RepeatDetector, start time.Time) <-chan EyrieStreamEvent { + ch := make(chan EyrieStreamEvent, StreamChannelBuffer) go func() { defer close(ch) @@ -444,7 +445,7 @@ func processOpenAIStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEvent, // the caller sees something rather than a nil map. args = map[string]interface{}{"_raw": t.argsBuf.String()} } - emit(ctx, ch, EyrieStreamEvent{ + Emit(ctx, ch, EyrieStreamEvent{ Type: "tool_call", ToolCall: &ToolCall{ID: t.id, Name: t.name, Arguments: args}, }) @@ -464,9 +465,9 @@ func processOpenAIStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEvent, StreamEnded: true, }) if d := health.Diagnostic(); d != "" { - emit(ctx, ch, EyrieStreamEvent{Type: "error", Error: d}) + Emit(ctx, ch, EyrieStreamEvent{Type: "error", Error: d}) } - emit(ctx, ch, EyrieStreamEvent{Type: "done", StopReason: stopReason, TTFTms: ttftMs}) + Emit(ctx, ch, EyrieStreamEvent{Type: "done", StopReason: stopReason, TTFTms: ttftMs}) } for { @@ -480,7 +481,7 @@ func processOpenAIStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEvent, } // Propagate SSE-level errors if evt.Event == "error" { - emit(ctx, ch, EyrieStreamEvent{Type: "error", Error: evt.Data}) + Emit(ctx, ch, EyrieStreamEvent{Type: "error", Error: evt.Data}) return } data := strings.TrimSpace(evt.Data) @@ -497,7 +498,7 @@ func processOpenAIStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEvent, // Emit usage if present (final chunk with stream_options.include_usage) if oe.Usage != nil { - emit(ctx, ch, EyrieStreamEvent{ + Emit(ctx, ch, EyrieStreamEvent{ Type: "usage", Usage: &EyrieUsage{ PromptTokens: oe.Usage.PromptTokens, @@ -516,7 +517,7 @@ func processOpenAIStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEvent, // route it to a thinking event so it never lands in the answer. if choice.Delta.ReasoningContent != "" { sawReasoning = true - emit(ctx, ch, EyrieStreamEvent{Type: "thinking", Thinking: choice.Delta.ReasoningContent}) + Emit(ctx, ch, EyrieStreamEvent{Type: "thinking", Thinking: choice.Delta.ReasoningContent}) } // Text content. Split out any inline ... reasoning @@ -525,17 +526,17 @@ func processOpenAIStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEvent, content, thinking := splitter.feed(choice.Delta.Content) if thinking != "" { sawReasoning = true - emit(ctx, ch, EyrieStreamEvent{Type: "thinking", Thinking: thinking}) + Emit(ctx, ch, EyrieStreamEvent{Type: "thinking", Thinking: thinking}) } if content != "" { - // TTFT: record and emit a dedicated event on the first token. + // TTFT: record and Emit a dedicated event on the first token. if !ttftEmitted { ttftEmitted = true ttftMs = int(time.Since(start) / time.Millisecond) - emit(ctx, ch, EyrieStreamEvent{Type: "ttft", TTFT: ttftMs}) + Emit(ctx, ch, EyrieStreamEvent{Type: "ttft", TTFT: ttftMs}) } contentLen += len(content) - emit(ctx, ch, EyrieStreamEvent{Type: "content", Content: content}) + Emit(ctx, ch, EyrieStreamEvent{Type: "content", Content: content}) // Repeat detection: abort if stream is looping. if repeat != nil { repeat.Feed(content) @@ -554,7 +555,7 @@ func processOpenAIStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEvent, if !ttftEmitted && tc.Function.Arguments != "" { ttftEmitted = true ttftMs = int(time.Since(start) / time.Millisecond) - emit(ctx, ch, EyrieStreamEvent{Type: "ttft", TTFT: ttftMs}) + Emit(ctx, ch, EyrieStreamEvent{Type: "ttft", TTFT: ttftMs}) } t, ok := tools[tc.Index] if !ok { @@ -583,7 +584,7 @@ func processOpenAIStreamWithOpts(ctx context.Context, sseEvents <-chan SSEEvent, return ch } -func emit(ctx context.Context, ch chan<- EyrieStreamEvent, evt EyrieStreamEvent) { +func Emit(ctx context.Context, ch chan<- EyrieStreamEvent, evt EyrieStreamEvent) { select { case ch <- evt: case <-ctx.Done(): @@ -599,7 +600,7 @@ func emit(ctx context.Context, ch chan<- EyrieStreamEvent, evt EyrieStreamEvent) // - Hermes/Nous (Qwen, and most OpenAI-compatible local models served via // vLLM/SGLang/Ollama): each call is a JSON object {"name":...,"arguments":{...}} // wrapped in ... XML tags, with parallel calls emitted -// as repeated tag pairs. This is the format Qwen2.5/QwQ emit; Qwen3-Coder uses +// as repeated tag pairs. This is the format Qwen2.5/QwQ Emit; Qwen3-Coder uses // a structurally similar but distinct "qwen3_xml" variant not handled here. // - Bare JSON brace-match (last resort): a {"name":...,"arguments":{...}} object // found via strings.Index(text, `{"`) / strings.LastIndex(text, "}") without @@ -667,7 +668,7 @@ func ParseInlineToolCalls(text string) (cleanText string, toolCalls []ToolCall) } // hermesToolCallOpen/Close delimit a Hermes/Nous-format tool call. Qwen and most -// OpenAI-compatible local models emit calls as {json}. +// OpenAI-compatible local models Emit calls as {json}. const ( hermesToolCallOpen = "" hermesToolCallClose = "" diff --git a/client/core/stream_guardrails.go b/client/core/stream_guardrails.go index 284f593..c95e313 100644 --- a/client/core/stream_guardrails.go +++ b/client/core/stream_guardrails.go @@ -1,4 +1,4 @@ -package client +package core import ( "log/slog" diff --git a/client/core/stream_guardrails_test.go b/client/core/stream_guardrails_test.go index b9ea661..e45bb26 100644 --- a/client/core/stream_guardrails_test.go +++ b/client/core/stream_guardrails_test.go @@ -1,4 +1,4 @@ -package client +package core import ( "strings" diff --git a/client/core/stream_test.go b/client/core/stream_test.go index 67bd19b..dee3bf6 100644 --- a/client/core/stream_test.go +++ b/client/core/stream_test.go @@ -1,5 +1,5 @@ //nolint:errcheck -package client +package core import ( "context" @@ -14,7 +14,7 @@ func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } -// --- parseSSEStream tests --- +// --- ParseSSEStream tests --- func TestSSEParseBasicEvents(t *testing.T) { t.Parallel() @@ -22,7 +22,7 @@ func TestSSEParseBasicEvents(t *testing.T) { body := io.NopCloser(strings.NewReader(sseData)) ctx := context.Background() - ch := parseSSEStream(ctx, body, testLogger()) + ch := ParseSSEStream(ctx, body, testLogger()) var events []SSEEvent for evt := range ch { @@ -45,7 +45,7 @@ func TestSSEParseMultilineData(t *testing.T) { body := io.NopCloser(strings.NewReader(sseData)) ctx := context.Background() - ch := parseSSEStream(ctx, body, testLogger()) + ch := ParseSSEStream(ctx, body, testLogger()) var events []SSEEvent for evt := range ch { @@ -66,7 +66,7 @@ func TestSSEParseEmptyEvents(t *testing.T) { body := io.NopCloser(strings.NewReader(sseData)) ctx := context.Background() - ch := parseSSEStream(ctx, body, testLogger()) + ch := ParseSSEStream(ctx, body, testLogger()) var events []SSEEvent for evt := range ch { @@ -86,7 +86,7 @@ func TestSSEParseContextCancellation(t *testing.T) { pr, pw := io.Pipe() ctx, cancel := context.WithCancel(context.Background()) - ch := parseSSEStream(ctx, pr, testLogger()) + ch := ParseSSEStream(ctx, pr, testLogger()) // Write one event _, _ = pw.Write([]byte("event:first\ndata:one\n\n")) @@ -112,7 +112,7 @@ func TestSSEParseContextCancellation(t *testing.T) { } } -// --- processAnthropicStream tests --- +// --- ProcessAnthropicStream tests --- func TestSSEAnthropicContentBlockDelta(t *testing.T) { t.Parallel() @@ -124,7 +124,7 @@ func TestSSEAnthropicContentBlockDelta(t *testing.T) { close(events) ctx := context.Background() - ch := processAnthropicStream(ctx, events, testLogger()) + ch := ProcessAnthropicStream(ctx, events, testLogger()) var results []EyrieStreamEvent for evt := range ch { @@ -165,7 +165,7 @@ func TestSSEAnthropicToolUse(t *testing.T) { close(events) ctx := context.Background() - ch := processAnthropicStream(ctx, events, testLogger()) + ch := ProcessAnthropicStream(ctx, events, testLogger()) var results []EyrieStreamEvent for evt := range ch { @@ -206,7 +206,7 @@ func TestSSEAnthropicThinkingDelta(t *testing.T) { close(events) ctx := context.Background() - ch := processAnthropicStream(ctx, events, testLogger()) + ch := ProcessAnthropicStream(ctx, events, testLogger()) var results []EyrieStreamEvent for evt := range ch { @@ -234,7 +234,7 @@ func TestSSEAnthropicThinkingTextDeltaHidden(t *testing.T) { close(events) ctx := context.Background() - ch := processAnthropicStream(ctx, events, testLogger()) + ch := ProcessAnthropicStream(ctx, events, testLogger()) var sawThinking, sawContent bool for evt := range ch { @@ -263,7 +263,7 @@ func TestSSEAnthropicStopReason(t *testing.T) { close(events) ctx := context.Background() - ch := processAnthropicStream(ctx, events, testLogger()) + ch := ProcessAnthropicStream(ctx, events, testLogger()) var results []EyrieStreamEvent for evt := range ch { @@ -284,7 +284,7 @@ func TestSSEAnthropicStopReason(t *testing.T) { } } -// --- processOpenAIStream tests --- +// --- ProcessOpenAIStream tests --- func TestSSEOpenAIChoicesDelta(t *testing.T) { t.Parallel() @@ -295,7 +295,7 @@ func TestSSEOpenAIChoicesDelta(t *testing.T) { close(events) ctx := context.Background() - ch := processOpenAIStream(ctx, events, testLogger()) + ch := ProcessOpenAIStream(ctx, events, testLogger()) var results []EyrieStreamEvent for evt := range ch { @@ -342,7 +342,7 @@ func TestSSEOpenAIToolCallsAccumulation(t *testing.T) { close(events) ctx := context.Background() - ch := processOpenAIStream(ctx, events, testLogger()) + ch := ProcessOpenAIStream(ctx, events, testLogger()) var results []EyrieStreamEvent for evt := range ch { @@ -379,7 +379,7 @@ func TestSSEOpenAIFinishReason(t *testing.T) { close(events) ctx := context.Background() - ch := processOpenAIStream(ctx, events, testLogger()) + ch := ProcessOpenAIStream(ctx, events, testLogger()) var results []EyrieStreamEvent for evt := range ch { @@ -406,7 +406,7 @@ func TestSSEOpenAIUsage(t *testing.T) { close(events) ctx := context.Background() - ch := processOpenAIStream(ctx, events, testLogger()) + ch := ProcessOpenAIStream(ctx, events, testLogger()) var results []EyrieStreamEvent for evt := range ch { diff --git a/client/core/think_splitter_test.go b/client/core/think_splitter_test.go index edb3538..b8d3f96 100644 --- a/client/core/think_splitter_test.go +++ b/client/core/think_splitter_test.go @@ -1,4 +1,4 @@ -package client +package core import "testing" diff --git a/client/core/transport.go b/client/core/transport.go index 0a30fea..10e472f 100644 --- a/client/core/transport.go +++ b/client/core/transport.go @@ -1,4 +1,4 @@ -package client +package core import ( "net" @@ -7,6 +7,19 @@ import ( "time" ) +// DefaultTimeout is the default end-to-end HTTP timeout for provider clients. +const DefaultTimeout = 10 * time.Minute + +// Version is set by the root eyrie package's init() from the VERSION file +// (via the client facade's SetVersion). Default is "dev". +var Version = "dev" + +// SetVersion wires the canonical version into this package. +func SetVersion(v string) { Version = v } + +// UserAgent returns the User-Agent string for HTTP requests. +func UserAgent() string { return "eyrie/" + Version } + var ( sharedTransport *http.Transport transportOnce sync.Once diff --git a/client/core/transport_test.go b/client/core/transport_test.go index a4a1e45..4dad13e 100644 --- a/client/core/transport_test.go +++ b/client/core/transport_test.go @@ -1,4 +1,4 @@ -package client +package core import ( "net/http" diff --git a/client/core/ttft_test.go b/client/core/ttft_test.go index b31ddd2..27b790c 100644 --- a/client/core/ttft_test.go +++ b/client/core/ttft_test.go @@ -1,4 +1,4 @@ -package client +package core import ( "context" @@ -17,7 +17,7 @@ func TestTTFTEventFiresBeforeContent(t *testing.T) { close(events) ctx := context.Background() - ch := processOpenAIStream(ctx, events, testLogger()) + ch := ProcessOpenAIStream(ctx, events, testLogger()) var results []EyrieStreamEvent for evt := range ch { @@ -64,7 +64,7 @@ func TestTTFTEventFiresOnToolCallDelta(t *testing.T) { close(events) ctx := context.Background() - ch := processOpenAIStream(ctx, events, testLogger()) + ch := ProcessOpenAIStream(ctx, events, testLogger()) var results []EyrieStreamEvent for evt := range ch { @@ -96,7 +96,7 @@ func TestTTFTEventFiredExactlyOnce(t *testing.T) { close(events) ctx := context.Background() - ch := processOpenAIStream(ctx, events, testLogger()) + ch := ProcessOpenAIStream(ctx, events, testLogger()) var ttftCount int for evt := range ch { @@ -114,14 +114,14 @@ func TestTTFTValue(t *testing.T) { t.Parallel() events := make(chan SSEEvent, 10) - // Use processOpenAIStreamWithOpts with a known start time to test the value. + // Use ProcessOpenAIStreamWithOpts with a known start time to test the value. start := time.Now().Add(-50 * time.Millisecond) // pretend 50ms elapsed already events <- SSEEvent{Data: `{"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}`} events <- SSEEvent{Data: `{"choices":[{"delta":{},"finish_reason":"stop"}]}`} close(events) ctx := context.Background() - ch := processOpenAIStreamWithOpts(ctx, events, testLogger(), DefaultRepeatDetector(), start) + ch := ProcessOpenAIStreamWithOpts(ctx, events, testLogger(), DefaultRepeatDetector(), start) var ttftEvt *EyrieStreamEvent for evt := range ch { diff --git a/client/embedding_methods.go b/client/embedding_methods.go index 198393a..5244cce 100644 --- a/client/embedding_methods.go +++ b/client/embedding_methods.go @@ -8,16 +8,29 @@ import ( "io" "log/slog" "net/http" -) -// Embedder is the interface for creating embeddings. -type Embedder interface { - CreateEmbedding(ctx context.Context, req EmbeddingRequest) (*EmbeddingResponse, error) -} + "github.com/GrayCodeAI/eyrie/client/core" +) -// Compile-time check that OpenAIClient implements Embedder. +// Compile-time check that OpenAIClient implements embeddings.Embedder. var _ Embedder = (*OpenAIClient)(nil) +// CreateEmbedding sends an embedding request to the specified (or default) provider. +func (c *EyrieClient) CreateEmbedding(ctx context.Context, req EmbeddingRequest, provider string) (*EmbeddingResponse, error) { + if provider == "" { + provider = c.defaultProvider + } + p, err := c.getOrCreateProvider(provider) + if err != nil { + return nil, err + } + embedder, ok := p.(Embedder) + if !ok { + return nil, fmt.Errorf("eyrie: provider %s does not support embeddings", provider) + } + return embedder.CreateEmbedding(ctx, req) +} + // openaiEmbeddingResponse is the wire format for OpenAI-compatible embedding APIs. type openaiEmbeddingResponse struct { Object string `json:"object"` @@ -65,7 +78,7 @@ func (c *OpenAIClient) CreateEmbedding(ctx context.Context, req EmbeddingRequest c.logger.Debug("openai embedding", "provider", c.providerName, "model", req.Model) - resp, err := doWithRetry(ctx, c.httpClient, httpReq, c.retry, c.logger) + resp, err := core.DoWithRetry(ctx, c.httpClient, httpReq, c.retry, c.logger) if err != nil { return nil, fmt.Errorf("eyrie: %s embedding request failed: %w", c.providerName, err) } @@ -77,8 +90,8 @@ func (c *OpenAIClient) CreateEmbedding(ctx context.Context, req EmbeddingRequest if resp.StatusCode != 200 { requestID := resp.Header.Get("X-Request-Id") - detail, readErr := parseProviderError(resp.Body) - return nil, formatAPIError(c.providerName+" embedding", "embedding", resp.StatusCode, requestID, detail, readErr) + detail, readErr := core.ParseProviderError(resp.Body) + return nil, core.FormatAPIError(c.providerName+" embedding", "embedding", resp.StatusCode, requestID, detail, readErr) } var or openaiEmbeddingResponse @@ -100,7 +113,7 @@ func (c *OpenAIClient) CreateEmbedding(ctx context.Context, req EmbeddingRequest } } if or.Usage.PromptTokens > 0 || or.Usage.TotalTokens > 0 { - result.Usage = &EyrieUsage{ + result.Usage = &core.EyrieUsage{ PromptTokens: or.Usage.PromptTokens, TotalTokens: or.Usage.TotalTokens, } diff --git a/client/embedding_methods_test.go b/client/embedding_methods_test.go new file mode 100644 index 0000000..3aeab85 --- /dev/null +++ b/client/embedding_methods_test.go @@ -0,0 +1,191 @@ +package client + +import ( + "context" + "encoding/json" + "math" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCreateEmbeddingSuccess(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/embeddings" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Method != "POST" { + t.Errorf("unexpected method: %s", r.Method) + } + if auth := r.Header.Get("Authorization"); auth != "Bearer test-key" { + t.Errorf("unexpected auth header: %s", auth) + } + + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + if body["model"] != "text-embedding-ada-002" { + t.Errorf("unexpected model: %v", body["model"]) + } + + json.NewEncoder(w).Encode(openaiEmbeddingResponse{ + Object: "list", + Model: "text-embedding-ada-002", + Data: []openaiEmbeddingData{ + {Object: "embedding", Index: 0, Embedding: []float64{0.1, 0.2, 0.3}}, + {Object: "embedding", Index: 1, Embedding: []float64{0.4, 0.5, 0.6}}, + }, + Usage: openaiEmbeddingUsage{PromptTokens: 8, TotalTokens: 8}, + }) + })) + defer srv.Close() + + c := newTestOpenAIClient(srv.URL, nil) + resp, err := c.CreateEmbedding(context.Background(), EmbeddingRequest{ + Model: "text-embedding-ada-002", + Input: []string{"hello", "world"}, + }) + if err != nil { + t.Fatalf("CreateEmbedding: %v", err) + } + if resp.Model != "text-embedding-ada-002" { + t.Errorf("Model = %q, want %q", resp.Model, "text-embedding-ada-002") + } + if len(resp.Embeddings) != 2 { + t.Fatalf("Embeddings length = %d, want 2", len(resp.Embeddings)) + } + if len(resp.Embeddings[0]) != 3 { + t.Errorf("Embeddings[0] length = %d, want 3", len(resp.Embeddings[0])) + } + if resp.Embeddings[0][0] != 0.1 { + t.Errorf("Embeddings[0][0] = %f, want 0.1", resp.Embeddings[0][0]) + } + if resp.Embeddings[1][2] != 0.6 { + t.Errorf("Embeddings[1][2] = %f, want 0.6", resp.Embeddings[1][2]) + } + if resp.Usage == nil { + t.Fatal("Usage is nil") + } + if resp.Usage.PromptTokens != 8 { + t.Errorf("Usage.PromptTokens = %d, want 8", resp.Usage.PromptTokens) + } +} + +func TestCreateEmbeddingMissingModel(t *testing.T) { + t.Parallel() + c := newTestOpenAIClient("http://unused", nil) + _, err := c.CreateEmbedding(context.Background(), EmbeddingRequest{ + Input: []string{"hello"}, + }) + if err == nil { + t.Fatal("CreateEmbedding should fail without model") + } +} + +func TestCreateEmbeddingServerError(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal error")) + })) + defer srv.Close() + + c := newTestOpenAIClient(srv.URL, nil) + _, err := c.CreateEmbedding(context.Background(), EmbeddingRequest{ + Model: "test-model", + Input: []string{"hello"}, + }) + if err == nil { + t.Fatal("CreateEmbedding should fail on server error") + } +} + +func TestCreateEmbeddingWithParams(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + + if body["input_type"] != "search_document" { + t.Errorf("expected extra param input_type=search_document, got %v", body["input_type"]) + } + + json.NewEncoder(w).Encode(openaiEmbeddingResponse{ + Object: "list", + Model: "test-model", + Data: []openaiEmbeddingData{ + {Object: "embedding", Index: 0, Embedding: []float64{0.1}}, + }, + }) + })) + defer srv.Close() + + c := newTestOpenAIClient(srv.URL, nil) + resp, err := c.CreateEmbedding(context.Background(), EmbeddingRequest{ + Model: "test-model", + Input: []string{"hello"}, + Params: map[string]string{"input_type": "search_document"}, + }) + if err != nil { + t.Fatalf("CreateEmbedding: %v", err) + } + if len(resp.Embeddings) != 1 { + t.Fatalf("Embeddings length = %d, want 1", len(resp.Embeddings)) + } +} + +func TestCreateEmbeddingNoUsage(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(openaiEmbeddingResponse{ + Object: "list", + Model: "test-model", + Data: []openaiEmbeddingData{ + {Object: "embedding", Index: 0, Embedding: []float64{0.5}}, + }, + Usage: openaiEmbeddingUsage{}, // zero usage + }) + })) + defer srv.Close() + + c := newTestOpenAIClient(srv.URL, nil) + resp, err := c.CreateEmbedding(context.Background(), EmbeddingRequest{ + Model: "test-model", + Input: []string{"hello"}, + }) + if err != nil { + t.Fatalf("CreateEmbedding: %v", err) + } + if resp.Usage != nil { + t.Error("Usage should be nil when both prompt and total tokens are zero") + } +} + +func TestCreateEmbeddingFloat32Precision(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(openaiEmbeddingResponse{ + Object: "list", + Model: "test-model", + Data: []openaiEmbeddingData{ + {Object: "embedding", Index: 0, Embedding: []float64{0.123456789012345}}, + }, + }) + })) + defer srv.Close() + + c := newTestOpenAIClient(srv.URL, nil) + resp, err := c.CreateEmbedding(context.Background(), EmbeddingRequest{ + Model: "test-model", + Input: []string{"test"}, + }) + if err != nil { + t.Fatalf("CreateEmbedding: %v", err) + } + // float64 -> float32 conversion + expected := float32(0.123456789012345) + if math.Abs(float64(resp.Embeddings[0][0]-expected)) > 1e-6 { + t.Errorf("Embeddings[0][0] = %f, want %f (within float32 precision)", resp.Embeddings[0][0], expected) + } +} + diff --git a/client/embeddings/cache.go b/client/embeddings/cache.go index 9fdaaec..6622d99 100644 --- a/client/embeddings/cache.go +++ b/client/embeddings/cache.go @@ -1,4 +1,4 @@ -package client +package embeddings import ( "context" @@ -7,6 +7,8 @@ import ( "strings" "sync" "time" + + "github.com/GrayCodeAI/eyrie/client/core" ) // errEmptyEmbedding is returned internally when the embedder yields no vector. @@ -46,21 +48,21 @@ func DefaultSemanticCacheConfig() SemanticCacheConfig { type semanticEntry struct { vector []float32 model string // embedding model that produced vector; gates cross-model reuse - response *EyrieResponse + response *core.EyrieResponse createdAt time.Time // Doubly-linked list pointers for LRU ordering. prev, next *semanticEntry } -// EmbeddingCachedProvider wraps a Provider and serves cached responses when a +// EmbeddingCachedProvider wraps a core.Provider and serves cached responses when a // new request is semantically similar (cosine similarity above a threshold) to // a previously seen request. Unlike CachedProvider's exact-match SHA256 keying, // this tolerates paraphrases and minor wording changes. // // It is safe for concurrent use. StreamChat is passed through unchanged. type EmbeddingCachedProvider struct { - inner Provider + inner core.Provider embedder Embedder mu sync.Mutex @@ -79,13 +81,13 @@ type EmbeddingCachedProvider struct { misses int } -// Compile-time check that EmbeddingCachedProvider implements Provider. -var _ Provider = (*EmbeddingCachedProvider)(nil) +// Compile-time check that EmbeddingCachedProvider implements core.Provider. +var _ core.Provider = (*EmbeddingCachedProvider)(nil) // NewEmbeddingCachedProvider wraps inner with a semantic (embedding-similarity) // cache. embedder is used to embed requests; cfg zero-value fields are replaced // with defaults. -func NewEmbeddingCachedProvider(inner Provider, embedder Embedder, cfg SemanticCacheConfig) *EmbeddingCachedProvider { +func NewEmbeddingCachedProvider(inner core.Provider, embedder Embedder, cfg SemanticCacheConfig) *EmbeddingCachedProvider { if cfg.MaxAge <= 0 { cfg.MaxAge = 5 * time.Minute } @@ -118,7 +120,7 @@ func (sp *EmbeddingCachedProvider) Ping(ctx context.Context) error { return sp.i // Chat returns a semantically-cached response on a hit; otherwise calls the // inner provider and caches the result. -func (sp *EmbeddingCachedProvider) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { +func (sp *EmbeddingCachedProvider) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { if !sp.enabled || sp.embedder == nil { return sp.inner.Chat(ctx, messages, opts) } @@ -146,7 +148,7 @@ func (sp *EmbeddingCachedProvider) Chat(ctx context.Context, messages []EyrieMes } // StreamChat delegates to the inner provider without caching. -func (sp *EmbeddingCachedProvider) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { +func (sp *EmbeddingCachedProvider) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { return sp.inner.StreamChat(ctx, messages, opts) } @@ -176,7 +178,7 @@ func (sp *EmbeddingCachedProvider) Stats() SemanticCacheStats { // embed builds an embedding for the request from the system prompt and message // contents. -func (sp *EmbeddingCachedProvider) embed(ctx context.Context, messages []EyrieMessage, opts ChatOptions) ([]float32, error) { +func (sp *EmbeddingCachedProvider) embed(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) ([]float32, error) { var b strings.Builder if opts.System != "" { b.WriteString(opts.System) @@ -203,7 +205,7 @@ func (sp *EmbeddingCachedProvider) embed(ctx context.Context, messages []EyrieMe // lookup returns the response of the most-similar cached entry whose cosine // similarity meets the threshold and which has not expired. -func (sp *EmbeddingCachedProvider) lookup(vec []float32) (*EyrieResponse, bool) { +func (sp *EmbeddingCachedProvider) lookup(vec []float32) (*core.EyrieResponse, bool) { sp.mu.Lock() defer sp.mu.Unlock() @@ -233,11 +235,11 @@ func (sp *EmbeddingCachedProvider) lookup(vec []float32) (*EyrieResponse, bool) } sp.hits++ sp.promoteLocked(best) - return copyResponse(best.response), true + return core.CopyResponse(best.response), true } // store inserts a new entry, evicting expired/LRU entries as needed. -func (sp *EmbeddingCachedProvider) store(vec []float32, resp *EyrieResponse) { +func (sp *EmbeddingCachedProvider) store(vec []float32, resp *core.EyrieResponse) { sp.mu.Lock() defer sp.mu.Unlock() @@ -249,7 +251,7 @@ func (sp *EmbeddingCachedProvider) store(vec []float32, resp *EyrieResponse) { e := &semanticEntry{ vector: vec, model: sp.model, - response: copyResponse(resp), + response: core.CopyResponse(resp), createdAt: time.Now(), } sp.entries = append(sp.entries, e) diff --git a/client/embeddings/cache_test.go b/client/embeddings/cache_test.go index 77046dc..0478d3d 100644 --- a/client/embeddings/cache_test.go +++ b/client/embeddings/cache_test.go @@ -1,11 +1,54 @@ -package client +package embeddings import ( "context" "strings" + "sync" "testing" + + "github.com/GrayCodeAI/eyrie/client/core" ) +// echoMock is a minimal core.Provider that echoes the last user message and +// counts calls. It replaces client.NewMockProvider, which this +// package cannot import without a cycle. +type echoMock struct { + mu sync.Mutex + calls int +} + +func (m *echoMock) Chat(_ context.Context, msgs []core.EyrieMessage, _ core.ChatOptions) (*core.EyrieResponse, error) { + m.mu.Lock() + m.calls++ + m.mu.Unlock() + content := "" + if len(msgs) > 0 { + content = msgs[len(msgs)-1].Content + } + return &core.EyrieResponse{Content: "echo: " + content, FinishReason: "stop"}, nil +} + +func (m *echoMock) StreamChat(ctx context.Context, msgs []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + resp, err := m.Chat(ctx, msgs, opts) + if err != nil { + return nil, err + } + ch := make(chan core.EyrieStreamEvent, 2) + ch <- core.EyrieStreamEvent{Type: "content", Content: resp.Content} + ch <- core.EyrieStreamEvent{Type: "done", StopReason: "stop"} + close(ch) + return core.NewStreamResult(ch, func() {}), nil +} + +func (m *echoMock) Ping(context.Context) error { return nil } +func (m *echoMock) Name() string { return "echo-mock" } + +func (m *echoMock) CallCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.calls +} + // stubEmbedder returns a deterministic vector chosen by keyword in the input, // so the test can control which requests are "similar". type stubEmbedder struct{} @@ -29,22 +72,22 @@ func (stubEmbedder) CreateEmbedding(_ context.Context, req EmbeddingRequest) (*E return &EmbeddingResponse{Embeddings: [][]float32{vec}}, nil } -func userMsg(s string) []EyrieMessage { return []EyrieMessage{{Role: "user", Content: s}} } +func userMsg(s string) []core.EyrieMessage { return []core.EyrieMessage{{Role: "user", Content: s}} } func TestEmbeddingCache_HitOnSimilarPrompt(t *testing.T) { t.Parallel() - mock := NewMockProvider(MockModeEcho) + mock := &echoMock{} sp := NewEmbeddingCachedProvider(mock, stubEmbedder{}, DefaultSemanticCacheConfig()) ctx := context.Background() - first, err := sp.Chat(ctx, userMsg("what is the weather today"), ChatOptions{}) + first, err := sp.Chat(ctx, userMsg("what is the weather today"), core.ChatOptions{}) if err != nil { t.Fatalf("first chat: %v", err) } // A differently-worded but semantically-similar prompt should return the // cached response, not a fresh echo. - second, err := sp.Chat(ctx, userMsg("give me the weather please"), ChatOptions{}) + second, err := sp.Chat(ctx, userMsg("give me the weather please"), core.ChatOptions{}) if err != nil { t.Fatalf("second chat: %v", err) } @@ -61,14 +104,14 @@ func TestEmbeddingCache_HitOnSimilarPrompt(t *testing.T) { func TestEmbeddingCache_MissOnDissimilarPrompt(t *testing.T) { t.Parallel() - mock := NewMockProvider(MockModeEcho) + mock := &echoMock{} sp := NewEmbeddingCachedProvider(mock, stubEmbedder{}, DefaultSemanticCacheConfig()) ctx := context.Background() - if _, err := sp.Chat(ctx, userMsg("weather forecast lookup"), ChatOptions{}); err != nil { + if _, err := sp.Chat(ctx, userMsg("weather forecast lookup"), core.ChatOptions{}); err != nil { t.Fatal(err) } - if _, err := sp.Chat(ctx, userMsg("database schema migration"), ChatOptions{}); err != nil { + if _, err := sp.Chat(ctx, userMsg("database schema migration"), core.ChatOptions{}); err != nil { t.Fatal(err) } if mock.CallCount() != 2 { @@ -78,13 +121,13 @@ func TestEmbeddingCache_MissOnDissimilarPrompt(t *testing.T) { func TestEmbeddingCache_SkipsHighTemperature(t *testing.T) { t.Parallel() - mock := NewMockProvider(MockModeEcho) + mock := &echoMock{} sp := NewEmbeddingCachedProvider(mock, stubEmbedder{}, DefaultSemanticCacheConfig()) ctx := context.Background() temp := 0.9 - _, _ = sp.Chat(ctx, userMsg("weather today"), ChatOptions{Temperature: &temp}) - _, _ = sp.Chat(ctx, userMsg("weather today"), ChatOptions{Temperature: &temp}) + _, _ = sp.Chat(ctx, userMsg("weather today"), core.ChatOptions{Temperature: &temp}) + _, _ = sp.Chat(ctx, userMsg("weather today"), core.ChatOptions{Temperature: &temp}) if mock.CallCount() != 2 { t.Errorf("high-temperature requests should bypass cache; got %d calls", mock.CallCount()) } @@ -92,9 +135,9 @@ func TestEmbeddingCache_SkipsHighTemperature(t *testing.T) { func TestEmbeddingCache_DegradesOnEmbedError(t *testing.T) { t.Parallel() - mock := NewMockProvider(MockModeEcho) + mock := &echoMock{} sp := NewEmbeddingCachedProvider(mock, errEmbedder{}, DefaultSemanticCacheConfig()) - if _, err := sp.Chat(context.Background(), userMsg("weather"), ChatOptions{}); err != nil { + if _, err := sp.Chat(context.Background(), userMsg("weather"), core.ChatOptions{}); err != nil { t.Fatalf("expected graceful degradation, got error: %v", err) } if mock.CallCount() != 1 { @@ -113,14 +156,14 @@ func (errEmbedder) CreateEmbedding(_ context.Context, _ EmbeddingRequest) (*Embe // even when the vectors are identical — they live in incompatible spaces. func TestEmbeddingCache_ModelIsolation(t *testing.T) { t.Parallel() - mock := NewMockProvider(MockModeEcho) + mock := &echoMock{} cfg := DefaultSemanticCacheConfig() cfg.EmbeddingModel = "model-A" sp := NewEmbeddingCachedProvider(mock, stubEmbedder{}, cfg) ctx := context.Background() // Warm the cache under model-A. - if _, err := sp.Chat(ctx, userMsg("what is the weather today"), ChatOptions{}); err != nil { + if _, err := sp.Chat(ctx, userMsg("what is the weather today"), core.ChatOptions{}); err != nil { t.Fatalf("warm: %v", err) } if mock.CallCount() != 1 { @@ -131,7 +174,7 @@ func TestEmbeddingCache_ModelIsolation(t *testing.T) { // entry (tagged model-A) must be skipped, forcing a fresh inner call rather // than a cross-model false hit. sp.model = "model-B" - if _, err := sp.Chat(ctx, userMsg("give me the weather please"), ChatOptions{}); err != nil { + if _, err := sp.Chat(ctx, userMsg("give me the weather please"), core.ChatOptions{}); err != nil { t.Fatalf("post-swap: %v", err) } if mock.CallCount() != 2 { @@ -139,7 +182,7 @@ func TestEmbeddingCache_ModelIsolation(t *testing.T) { } // Requests under model-B should now cache and hit among themselves. - if _, err := sp.Chat(ctx, userMsg("weather report now"), ChatOptions{}); err != nil { + if _, err := sp.Chat(ctx, userMsg("weather report now"), core.ChatOptions{}); err != nil { t.Fatalf("model-B hit: %v", err) } if mock.CallCount() != 2 { diff --git a/client/embeddings/defaults.go b/client/embeddings/defaults.go index dcf1ad7..9793609 100644 --- a/client/embeddings/defaults.go +++ b/client/embeddings/defaults.go @@ -1,4 +1,4 @@ -package client +package embeddings import "strings" diff --git a/client/embeddings/embedding.go b/client/embeddings/embedding.go index e6a4cb7..8c8d144 100644 --- a/client/embeddings/embedding.go +++ b/client/embeddings/embedding.go @@ -1,42 +1,17 @@ -package client +package embeddings -import ( - "context" - "fmt" -) - -// EmbeddingParams holds asymmetric params for indexing vs query. -type EmbeddingParams struct { - Indexing map[string]string `json:"indexing,omitempty"` - Query map[string]string `json:"query,omitempty"` -} - -// EmbeddingRequest represents an embedding API call. -type EmbeddingRequest struct { - Model string `json:"model"` - Input []string `json:"input"` - Params map[string]string `json:"params,omitempty"` // indexing or query params -} +import "github.com/GrayCodeAI/eyrie/client/core" -// EmbeddingResponse holds embedding results. -type EmbeddingResponse struct { - Embeddings [][]float32 `json:"embeddings"` - Model string `json:"model"` - Usage *EyrieUsage `json:"usage,omitempty"` -} - -// CreateEmbedding sends an embedding request to the specified (or default) provider. -func (c *EyrieClient) CreateEmbedding(ctx context.Context, req EmbeddingRequest, provider string) (*EmbeddingResponse, error) { - if provider == "" { - provider = c.defaultProvider - } - p, err := c.getOrCreateProvider(provider) - if err != nil { - return nil, err - } - embedder, ok := p.(Embedder) - if !ok { - return nil, fmt.Errorf("eyrie: provider %s does not support embeddings", provider) - } - return embedder.CreateEmbedding(ctx, req) -} +// The embedding DTOs and the Embedder interface live in client/core because +// the protocol adapters implement Embedder. Aliased here so this package's +// API is unchanged. +type ( + // Embedder is the interface for creating embeddings. + Embedder = core.Embedder + // EmbeddingParams holds asymmetric params for indexing vs query. + EmbeddingParams = core.EmbeddingParams + // EmbeddingRequest represents an embedding API call. + EmbeddingRequest = core.EmbeddingRequest + // EmbeddingResponse holds embedding results. + EmbeddingResponse = core.EmbeddingResponse +) diff --git a/client/embeddings/embedding_test.go b/client/embeddings/embedding_test.go index 84e8eec..8a31bcb 100644 --- a/client/embeddings/embedding_test.go +++ b/client/embeddings/embedding_test.go @@ -1,12 +1,9 @@ -package client +package embeddings import ( - "context" - "encoding/json" - "math" - "net/http" - "net/http/httptest" "testing" + + "github.com/GrayCodeAI/eyrie/client/core" ) func TestDefaultEmbeddingParamsCohere(t *testing.T) { @@ -78,186 +75,6 @@ func TestDefaultEmbeddingParamsCaseInsensitive(t *testing.T) { } } -func TestCreateEmbeddingSuccess(t *testing.T) { - t.Parallel() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/embeddings" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - if r.Method != "POST" { - t.Errorf("unexpected method: %s", r.Method) - } - if auth := r.Header.Get("Authorization"); auth != "Bearer test-key" { - t.Errorf("unexpected auth header: %s", auth) - } - - var body map[string]interface{} - json.NewDecoder(r.Body).Decode(&body) - if body["model"] != "text-embedding-ada-002" { - t.Errorf("unexpected model: %v", body["model"]) - } - - json.NewEncoder(w).Encode(openaiEmbeddingResponse{ - Object: "list", - Model: "text-embedding-ada-002", - Data: []openaiEmbeddingData{ - {Object: "embedding", Index: 0, Embedding: []float64{0.1, 0.2, 0.3}}, - {Object: "embedding", Index: 1, Embedding: []float64{0.4, 0.5, 0.6}}, - }, - Usage: openaiEmbeddingUsage{PromptTokens: 8, TotalTokens: 8}, - }) - })) - defer srv.Close() - - c := newTestOpenAIClient(srv.URL, nil) - resp, err := c.CreateEmbedding(context.Background(), EmbeddingRequest{ - Model: "text-embedding-ada-002", - Input: []string{"hello", "world"}, - }) - if err != nil { - t.Fatalf("CreateEmbedding: %v", err) - } - if resp.Model != "text-embedding-ada-002" { - t.Errorf("Model = %q, want %q", resp.Model, "text-embedding-ada-002") - } - if len(resp.Embeddings) != 2 { - t.Fatalf("Embeddings length = %d, want 2", len(resp.Embeddings)) - } - if len(resp.Embeddings[0]) != 3 { - t.Errorf("Embeddings[0] length = %d, want 3", len(resp.Embeddings[0])) - } - if resp.Embeddings[0][0] != 0.1 { - t.Errorf("Embeddings[0][0] = %f, want 0.1", resp.Embeddings[0][0]) - } - if resp.Embeddings[1][2] != 0.6 { - t.Errorf("Embeddings[1][2] = %f, want 0.6", resp.Embeddings[1][2]) - } - if resp.Usage == nil { - t.Fatal("Usage is nil") - } - if resp.Usage.PromptTokens != 8 { - t.Errorf("Usage.PromptTokens = %d, want 8", resp.Usage.PromptTokens) - } -} - -func TestCreateEmbeddingMissingModel(t *testing.T) { - t.Parallel() - c := newTestOpenAIClient("http://unused", nil) - _, err := c.CreateEmbedding(context.Background(), EmbeddingRequest{ - Input: []string{"hello"}, - }) - if err == nil { - t.Fatal("CreateEmbedding should fail without model") - } -} - -func TestCreateEmbeddingServerError(t *testing.T) { - t.Parallel() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("internal error")) - })) - defer srv.Close() - - c := newTestOpenAIClient(srv.URL, nil) - _, err := c.CreateEmbedding(context.Background(), EmbeddingRequest{ - Model: "test-model", - Input: []string{"hello"}, - }) - if err == nil { - t.Fatal("CreateEmbedding should fail on server error") - } -} - -func TestCreateEmbeddingWithParams(t *testing.T) { - t.Parallel() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var body map[string]interface{} - json.NewDecoder(r.Body).Decode(&body) - - if body["input_type"] != "search_document" { - t.Errorf("expected extra param input_type=search_document, got %v", body["input_type"]) - } - - json.NewEncoder(w).Encode(openaiEmbeddingResponse{ - Object: "list", - Model: "test-model", - Data: []openaiEmbeddingData{ - {Object: "embedding", Index: 0, Embedding: []float64{0.1}}, - }, - }) - })) - defer srv.Close() - - c := newTestOpenAIClient(srv.URL, nil) - resp, err := c.CreateEmbedding(context.Background(), EmbeddingRequest{ - Model: "test-model", - Input: []string{"hello"}, - Params: map[string]string{"input_type": "search_document"}, - }) - if err != nil { - t.Fatalf("CreateEmbedding: %v", err) - } - if len(resp.Embeddings) != 1 { - t.Fatalf("Embeddings length = %d, want 1", len(resp.Embeddings)) - } -} - -func TestCreateEmbeddingNoUsage(t *testing.T) { - t.Parallel() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(openaiEmbeddingResponse{ - Object: "list", - Model: "test-model", - Data: []openaiEmbeddingData{ - {Object: "embedding", Index: 0, Embedding: []float64{0.5}}, - }, - Usage: openaiEmbeddingUsage{}, // zero usage - }) - })) - defer srv.Close() - - c := newTestOpenAIClient(srv.URL, nil) - resp, err := c.CreateEmbedding(context.Background(), EmbeddingRequest{ - Model: "test-model", - Input: []string{"hello"}, - }) - if err != nil { - t.Fatalf("CreateEmbedding: %v", err) - } - if resp.Usage != nil { - t.Error("Usage should be nil when both prompt and total tokens are zero") - } -} - -func TestCreateEmbeddingFloat32Precision(t *testing.T) { - t.Parallel() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(openaiEmbeddingResponse{ - Object: "list", - Model: "test-model", - Data: []openaiEmbeddingData{ - {Object: "embedding", Index: 0, Embedding: []float64{0.123456789012345}}, - }, - }) - })) - defer srv.Close() - - c := newTestOpenAIClient(srv.URL, nil) - resp, err := c.CreateEmbedding(context.Background(), EmbeddingRequest{ - Model: "test-model", - Input: []string{"test"}, - }) - if err != nil { - t.Fatalf("CreateEmbedding: %v", err) - } - // float64 -> float32 conversion - expected := float32(0.123456789012345) - if math.Abs(float64(resp.Embeddings[0][0]-expected)) > 1e-6 { - t.Errorf("Embeddings[0][0] = %f, want %f (within float32 precision)", resp.Embeddings[0][0], expected) - } -} - func TestEmbeddingRequestStruct(t *testing.T) { t.Parallel() req := EmbeddingRequest{ @@ -281,7 +98,7 @@ func TestEmbeddingResponseStruct(t *testing.T) { resp := &EmbeddingResponse{ Embeddings: [][]float32{{0.1, 0.2}}, Model: "test", - Usage: &EyrieUsage{PromptTokens: 5, TotalTokens: 5}, + Usage: &core.EyrieUsage{PromptTokens: 5, TotalTokens: 5}, } if resp.Model != "test" { t.Errorf("Model = %q, want %q", resp.Model, "test") diff --git a/client/guardrails.go b/client/guardrails.go index dbae67f..283d95b 100644 --- a/client/guardrails.go +++ b/client/guardrails.go @@ -2,460 +2,9 @@ package client import ( "context" - "fmt" "log/slog" - "regexp" - "sort" - "strings" - "sync" ) -// GuardrailType classifies a guardrail rule. -type GuardrailType string - -const ( - // GuardrailPII detects personally identifiable information. - GuardrailPII GuardrailType = "pii" - // GuardrailPromptInjection detects prompt injection attempts in responses. - GuardrailPromptInjection GuardrailType = "prompt_injection" - // GuardrailHarmfulContent detects harmful or dangerous content patterns. - GuardrailHarmfulContent GuardrailType = "harmful_content" - // GuardrailSecretLeak detects leaked secrets, API keys, tokens, and passwords. - GuardrailSecretLeak GuardrailType = "secret_leak" - // GuardrailCustom is a user-defined rule with a custom pattern. - GuardrailCustom GuardrailType = "custom" -) - -// GuardrailAction determines what happens when a rule matches. -type GuardrailAction string - -const ( - // GuardrailBlock prevents the response from being returned to the caller. - GuardrailBlock GuardrailAction = "block" - // GuardrailRedact replaces the matched content with a redaction marker. - GuardrailRedact GuardrailAction = "redact" - // GuardrailWarn allows the response but records the violation. - GuardrailWarn GuardrailAction = "warn" -) - -// GuardrailSeverity indicates how critical a violation is. -type GuardrailSeverity string - -const ( - SeverityLow GuardrailSeverity = "low" - SeverityMedium GuardrailSeverity = "medium" - SeverityHigh GuardrailSeverity = "high" - SeverityCritical GuardrailSeverity = "critical" -) - -// GuardrailRule defines a single guardrail check. -type GuardrailRule struct { - Type GuardrailType `json:"type"` - Name string `json:"name"` - Pattern string `json:"pattern"` - Action GuardrailAction `json:"action"` - Severity GuardrailSeverity `json:"severity"` - compiled *regexp.Regexp // lazily compiled -} - -// GuardrailViolation records a single rule match in the response. -type GuardrailViolation struct { - Rule GuardrailRule `json:"rule"` - MatchedText string `json:"matched_text"` - RedactedResult string `json:"redacted_result,omitempty"` - matchStart int // byte offset of the match in the original response - matchEnd int // byte offset one past the last matched byte -} - -// GuardrailError is returned when a guardrail blocks a response. -type GuardrailError struct { - Violations []GuardrailViolation `json:"violations"` - Message string `json:"message"` -} - -func (e *GuardrailError) Error() string { - return fmt.Sprintf("eyrie: guardrail blocked: %s (%d violation(s))", e.Message, len(e.Violations)) -} - -// Guardrails holds registered rules and runs them against LLM responses. -type Guardrails struct { - mu sync.RWMutex - rules []GuardrailRule -} - -// NewGuardrails creates a Guardrails instance with the given rules. -func NewGuardrails(rules ...GuardrailRule) *Guardrails { - g := &Guardrails{} - for _, r := range rules { - g.AddRule(r) - } - return g -} - -// AddRule registers a guardrail rule. It panics if the pattern is invalid. -// This follows the regexp.MustCompile convention for programmatic rules -// where an invalid pattern indicates a programmer error. For rules that -// may originate from untrusted sources (config files, user input), use -// AddRuleSafe instead. -func (g *Guardrails) AddRule(r GuardrailRule) { - if r.Pattern != "" { - compiled, err := regexp.Compile(r.Pattern) - if err != nil { - panic(fmt.Sprintf("eyrie: guardrails: invalid regex %q in rule %q: %v", r.Pattern, r.Name, err)) - } - r.compiled = compiled - } - g.mu.Lock() - defer g.mu.Unlock() - g.rules = append(g.rules, r) -} - -// AddRuleSafe registers a guardrail rule and returns an error if the pattern -// is invalid, instead of panicking. Use this when rules may come from -// untrusted sources (config files, user input). -func (g *Guardrails) AddRuleSafe(r GuardrailRule) error { - if r.Pattern != "" { - compiled, err := regexp.Compile(r.Pattern) - if err != nil { - return fmt.Errorf("eyrie: guardrails: invalid regex %q in rule %q: %w", r.Pattern, r.Name, err) - } - r.compiled = compiled - } - g.mu.Lock() - defer g.mu.Unlock() - g.rules = append(g.rules, r) - return nil -} - -// NewGuardrailsSafe creates a Guardrails instance and returns an error if any -// rule has an invalid pattern. Use this when rules may come from untrusted -// sources; use NewGuardrails for programmatic rules where invalid patterns -// indicate a programmer error (matching regexp.MustCompile convention). -func NewGuardrailsSafe(rules ...GuardrailRule) (*Guardrails, error) { - g := &Guardrails{} - for _, r := range rules { - if err := g.AddRuleSafe(r); err != nil { - return nil, err - } - } - return g, nil -} - -// Rules returns a snapshot of the currently registered rules. -func (g *Guardrails) Rules() []GuardrailRule { - g.mu.RLock() - defer g.mu.RUnlock() - out := make([]GuardrailRule, len(g.rules)) - copy(out, g.rules) - return out -} - -// Check evaluates all rules against the response text. -// It returns violations and an error only if a rule with Action=Block matches. -// For Redact actions, the redacted result is populated in the violation. -// For Warn actions, the violation is recorded but no error is returned. -func (g *Guardrails) Check(ctx context.Context, response string) ([]GuardrailViolation, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - - g.mu.RLock() - rules := make([]GuardrailRule, len(g.rules)) - copy(rules, g.rules) - g.mu.RUnlock() - - var violations []GuardrailViolation - hasBlock := false - - for _, rule := range rules { - if rule.compiled == nil { - continue - } - matches := rule.compiled.FindAllStringIndex(response, -1) - if len(matches) == 0 { - continue - } - for _, match := range matches { - v := GuardrailViolation{ - Rule: rule, - MatchedText: response[match[0]:match[1]], - matchStart: match[0], - matchEnd: match[1], - } - if rule.Action == GuardrailRedact { - v.RedactedResult = strings.Repeat("*", len(v.MatchedText)) - } - violations = append(violations, v) - if rule.Action == GuardrailBlock { - hasBlock = true - } - } - } - - if hasBlock { - return violations, &GuardrailError{ - Violations: violations, - Message: "response blocked by guardrail", - } - } - - return violations, nil -} - -// ApplyRedactions takes the response text and violations, replacing redacted -// matches with their redaction markers. Non-redact violations are left intact. -// Match positions are used directly from the violations (captured during Check) -// so the correct instance of each match is redacted even when the matched text -// appears multiple times in the response. -func ApplyRedactions(response string, violations []GuardrailViolation) string { - type replacement struct { - start int - end int - text string - } - var reps []replacement - for _, v := range violations { - if v.Rule.Action != GuardrailRedact { - continue - } - if v.matchEnd == 0 && v.matchStart == 0 && v.MatchedText != "" { - // Fallback: violation came from outside Check (e.g. constructed - // manually). Search for the first occurrence. - idx := strings.Index(response, v.MatchedText) - if idx < 0 { - continue - } - reps = append(reps, replacement{start: idx, end: idx + len(v.MatchedText), text: v.RedactedResult}) - continue - } - reps = append(reps, replacement{start: v.matchStart, end: v.matchEnd, text: v.RedactedResult}) - } - if len(reps) == 0 { - return response - } - - // Sort by start position descending; for same start, longer match first. - sort.Slice(reps, func(i, j int) bool { - if reps[i].start == reps[j].start { - return (reps[i].end - reps[i].start) > (reps[j].end - reps[j].start) - } - return reps[i].start > reps[j].start - }) - - // Remove overlapping replacements (keep longest/leftmost) - filtered := reps[:1] - for i := 1; i < len(reps); i++ { - prev := filtered[len(filtered)-1] - if reps[i].end > prev.start { - continue - } - filtered = append(filtered, reps[i]) - } - - result := response - for _, r := range filtered { - result = result[:r.start] + r.text + result[r.end:] - } - return result -} - -// --------------------------------------------------------------------------- -// Built-in rule constructors -// --------------------------------------------------------------------------- - -// DefaultPIIRules returns built-in rules for detecting PII in responses. -func DefaultPIIRules() []GuardrailRule { - return []GuardrailRule{ - { - Type: GuardrailPII, - Name: "ssn", - Pattern: `\b\d{3}-\d{2}-\d{4}\b`, - Action: GuardrailRedact, - Severity: SeverityCritical, - }, - { - Type: GuardrailPII, - Name: "credit_card", - Pattern: `\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|6(?:011|5[0-9]{2})[0-9]{12}|(?:2131|1800|35\d{3})\d{11})\b`, - Action: GuardrailRedact, - Severity: SeverityCritical, - }, - { - Type: GuardrailPII, - Name: "phone_number", - Pattern: `\b(?:\+?1[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)?\d{3}[-.\s]?\d{4}\b`, - Action: GuardrailRedact, - Severity: SeverityMedium, - }, - } -} - -// DefaultSecretLeakRules returns built-in rules for detecting leaked secrets. -func DefaultSecretLeakRules() []GuardrailRule { - return []GuardrailRule{ - { - Type: GuardrailSecretLeak, - Name: "api_key_generic", - Pattern: `(?i)(?:api[_-]?key|apikey)\s*[:=]\s*['"` + "`" + `]?([A-Za-z0-9_\-]{20,})['"` + "`" + `]?`, - Action: GuardrailBlock, - Severity: SeverityCritical, - }, - { - Type: GuardrailSecretLeak, - Name: "bearer_token", - Pattern: `(?i)bearer\s+[A-Za-z0-9_\-\.]{20,}`, - Action: GuardrailBlock, - Severity: SeverityCritical, - }, - { - Type: GuardrailSecretLeak, - Name: "password_assignment", - Pattern: `(?i)(?:password|passwd|pwd)\s*[:=]\s*['"` + "`" + `]?[^\s'"` + "`" + `]{8,}['"` + "`" + `]?`, - Action: GuardrailBlock, - Severity: SeverityHigh, - }, - { - Type: GuardrailSecretLeak, - Name: "aws_secret_key", - Pattern: `(?i)(?:aws_secret_access_key)\s*[:=]\s*['"` + "`" + `]?([A-Za-z0-9/+=]{40})['"` + "`" + `]?`, - Action: GuardrailBlock, - Severity: SeverityCritical, - }, - { - Type: GuardrailSecretLeak, - Name: "private_key_block", - Pattern: `-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----`, - Action: GuardrailBlock, - Severity: SeverityCritical, - }, - } -} - -// DefaultPromptInjectionRules returns built-in rules for detecting prompt injection in responses. -func DefaultPromptInjectionRules() []GuardrailRule { - return []GuardrailRule{ - { - Type: GuardrailPromptInjection, - Name: "ignore_instructions", - Pattern: `(?i)ignore\s+(?:all\s+)?(?:previous|prior|above)\s+(?:instructions|prompts|rules|constraints)`, - Action: GuardrailBlock, - Severity: SeverityHigh, - }, - { - Type: GuardrailPromptInjection, - Name: "you_are_now", - Pattern: `(?i)you\s+are\s+now\s+(?:a|an|the)\s+\w+`, - Action: GuardrailWarn, - Severity: SeverityMedium, - }, - { - Type: GuardrailPromptInjection, - Name: "disregard_above", - Pattern: `(?i)disregard\s+(?:all\s+)?(?:the\s+)?(?:above|previous|prior)\s+(?:instructions|prompts|context)`, - Action: GuardrailBlock, - Severity: SeverityHigh, - }, - { - Type: GuardrailPromptInjection, - Name: "system_prompt_leak", - Pattern: `(?i)(?:reveal|show|print|output|display)\s+(?:your|the)\s+(?:system|initial)\s+(?:prompt|instructions|message)`, - Action: GuardrailBlock, - Severity: SeverityHigh, - }, - { - Type: GuardrailPromptInjection, - Name: "new_instructions", - Pattern: `(?i)\[(?:new|updated|revised)\s+(?:system\s+)?(?:instructions|rules|prompt)\]`, - Action: GuardrailBlock, - Severity: SeverityHigh, - }, - } -} - -// DefaultHarmfulContentRules returns built-in rules for detecting harmful content patterns. -func DefaultHarmfulContentRules() []GuardrailRule { - return []GuardrailRule{ - { - Type: GuardrailHarmfulContent, - Name: "bomb_making", - Pattern: `(?i)(?:how\s+to\s+make|instructions\s+for\s+(?:making|building))\s+(?:a\s+)?(?:bomb|explosive|detonator|grenade|ied)`, - Action: GuardrailBlock, - Severity: SeverityCritical, - }, - { - Type: GuardrailHarmfulContent, - Name: "synthesis_drugs", - Pattern: `(?i)(?:how\s+to\s+(?:synthesize|make|cook|manufacture))\s+(?:methamphetamine|cocaine|heroin|fentanyl|lsd|mdma|ecstasy)`, - Action: GuardrailBlock, - Severity: SeverityCritical, - }, - { - Type: GuardrailHarmfulContent, - Name: "harm_self", - Pattern: `(?i)(?:ways?\s+to\s+(?:hurt|harm|kill)\s+(?:your)?self|methods?\s+of\s+suicide)`, - Action: GuardrailBlock, - Severity: SeverityCritical, - }, - { - Type: GuardrailHarmfulContent, - Name: "weapon_instructions", - Pattern: `(?i)(?:step[- ]by[- ]step|detailed)\s+(?:instructions?|guide)\s+(?:for\s+)?(?:building|making|assembling)\s+(?:a\s+)?(?:firearm|gun|weapon|silencer|suppressor)`, - Action: GuardrailBlock, - Severity: SeverityCritical, - }, - } -} - -// --------------------------------------------------------------------------- -// Rule lookup helpers -// --------------------------------------------------------------------------- - -// AllDefaultRules returns all built-in guardrail rules (PII, secrets, -// prompt injection, and harmful content). -func AllDefaultRules() []GuardrailRule { - var rules []GuardrailRule - rules = append(rules, DefaultPIIRules()...) - rules = append(rules, DefaultSecretLeakRules()...) - rules = append(rules, DefaultPromptInjectionRules()...) - rules = append(rules, DefaultHarmfulContentRules()...) - return rules -} - -// RulesForType returns the built-in rules for a single GuardrailType. -func RulesForType(t GuardrailType) []GuardrailRule { - switch t { - case GuardrailPII: - return DefaultPIIRules() - case GuardrailSecretLeak: - return DefaultSecretLeakRules() - case GuardrailPromptInjection: - return DefaultPromptInjectionRules() - case GuardrailHarmfulContent: - return DefaultHarmfulContentRules() - default: - return nil - } -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -// applyGuardrails runs guardrail checks on the response and applies redactions. -// This is called by provider Chat methods after receiving the LLM response. -func applyGuardrails(ctx context.Context, resp *EyrieResponse, g *Guardrails) error { - if g == nil || resp == nil || resp.Content == "" { - return nil - } - violations, err := g.Check(ctx, resp.Content) - if err != nil { - return err - } - if len(violations) > 0 { - resp.Content = ApplyRedactions(resp.Content, violations) - } - return nil -} // --------------------------------------------------------------------------- // GuardrailProvider: standalone Provider wrapper diff --git a/client/mock.go b/client/mock.go index 013247d..698df62 100644 --- a/client/mock.go +++ b/client/mock.go @@ -134,7 +134,7 @@ func (m *MockProvider) StreamChat(ctx context.Context, messages []EyrieMessage, emit(streamCtx, ch, EyrieStreamEvent{Type: "done"}) }() - return &StreamResult{Events: ch, cancel: cancel}, nil + return NewStreamResult(ch, cancel), nil } // CallCount returns the number of recorded calls. diff --git a/client/options.go b/client/options.go index 2d7bb92..df8f83c 100644 --- a/client/options.go +++ b/client/options.go @@ -4,226 +4,72 @@ import ( "log/slog" "net/http" "time" -) - -const defaultTimeout = 10 * time.Minute -// ResponseFormat specifies the desired output format for the model response. -type ResponseFormat struct { - Type string `json:"type"` // "json_object" or "json_schema" - Schema string `json:"schema,omitempty"` // optional JSON schema for structured output -} + "github.com/GrayCodeAI/eyrie/client/core" +) -// ChatOptions holds options for a chat request. -type ChatOptions struct { - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Stream bool `json:"stream,omitempty"` - Tools []EyrieTool `json:"tools,omitempty"` - System string `json:"system,omitempty"` - EnableCaching bool `json:"enable_caching,omitempty"` - ResponseFormat *ResponseFormat `json:"response_format,omitempty"` - // ReasoningEffort hints how much reasoning the model should perform. - // Valid values are "low", "medium", or "high" (see ReasoningLow/Medium/High). - // Only applied for OpenAI-compatible providers whose compat config sets - // SupportsReasoningEffort. - ReasoningEffort string `json:"reasoning_effort,omitempty"` - // ThinkingBudgetTokens enables Anthropic extended thinking with the given - // token budget when greater than zero. Ignored by other providers. - ThinkingBudgetTokens int `json:"thinking_budget_tokens,omitempty"` - // ThinkingMode controls Anthropic thinking behavior: "enabled", "adaptive", or "disabled". - // When set, takes precedence over ThinkingBudgetTokens (except "enabled" which uses the budget). - ThinkingMode string `json:"thinking_mode,omitempty"` - // ThinkingDisplay controls how thinking is shown: "summarized" (default) or "omitted". - ThinkingDisplay string `json:"thinking_display,omitempty"` - // GLMThinkingEnabled toggles GLM/Z.ai extended reasoning via the provider's - // non-OpenAI thinking={"type":"enabled"|"disabled"} request parameter. Only - // applied for OpenAI-compatible providers whose compat config sets - // ThinkingFormat to "zai". When nil the parameter is omitted and the model - // uses its default (GLM defaults to enabled). Ignored by other providers. - GLMThinkingEnabled *bool `json:"glm_thinking_enabled,omitempty"` - // VirtualKeyID optionally attributes the request to a logical virtual key - // for budget enforcement and cost accounting (see BudgetProvider). When - // empty, the BudgetProvider also checks the request context. - VirtualKeyID string `json:"virtual_key_id,omitempty"` - // KimiContextCacheID, when set, prepends a cache-role message to the - // request for Kimi/Moonshot context caching. Only effective when the - // provider compat is KimiCompat (SupportsCacheRole true). - KimiContextCacheID string `json:"kimi_context_cache_id,omitempty"` - // KimiCacheResetTTL resets the TTL of the cache on use when true. - // Only effective when KimiContextCacheID is also set. - KimiCacheResetTTL bool `json:"kimi_cache_reset_ttl,omitempty"` - - // Shared parameters (Anthropic + OpenAI) - TopP *float64 `json:"top_p,omitempty"` // nucleus sampling (0.0-1.0) - TopK *int `json:"top_k,omitempty"` // top-K sampling (Anthropic only) - StopSequences []string `json:"stop_sequences,omitempty"` // custom stop sequences - ToolChoice *ToolChoiceOption `json:"tool_choice,omitempty"` // tool use control - MetadataUserID string `json:"metadata_user_id,omitempty"` // user ID for abuse detection / monitoring - ServiceTier string `json:"service_tier,omitempty"` // "auto", "default", "flex", "priority" - OutputEffort string `json:"output_effort,omitempty"` // "low","medium","high","xhigh","max" (Anthropic) - OutputSchema string `json:"output_schema,omitempty"` // JSON schema string for structured output (Anthropic) - - // OpenAI-specific parameters - PresencePenalty *float64 `json:"presence_penalty,omitempty"` // -2.0 to 2.0 - FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` // -2.0 to 2.0 - N *int `json:"n,omitempty"` // number of completions (1-128) - LogProbs *bool `json:"logprobs,omitempty"` // return log probabilities - TopLogProbs *int `json:"top_logprobs,omitempty"` // 0-20, requires logprobs=true - Seed *int `json:"seed,omitempty"` // deterministic sampling - Store *bool `json:"store,omitempty"` // store output for Responses API - Metadata map[string]string `json:"metadata,omitempty"` // developer tags - Modalities []string `json:"modalities,omitempty"` // "text", "audio" - AudioConfig string `json:"audio_config,omitempty"` // JSON: {voice, format} - Prediction string `json:"prediction,omitempty"` // JSON: {type:"content", content:"..."} - WebSearchOptions string `json:"web_search_options,omitempty"` // JSON: {search_context_size, user_location} -} +const defaultTimeout = core.DefaultTimeout -// ToolChoiceOption controls how the model uses tools (Anthropic). -type ToolChoiceOption struct { - Type string `json:"type"` // "auto", "any", "tool", "none" - Name string `json:"name,omitempty"` // required when type="tool" - DisableParallelToolUse bool `json:"disable_parallel_tool_use,omitempty"` -} +// ClientOption and the adapter-level With* constructors live in client/core +// (see core.Configurable); they are aliased/wrapped here so the public +// client.* API is unchanged. Only WithCoalescing is defined locally — it +// configures the EyrieClient itself, which lives in this package. // ClientOption configures clients. -type ClientOption struct { - applyFn func(*AnthropicClient) - applyOpenAIFn func(*OpenAIClient) - applyEyrieFn func(*EyrieClient) - - // Structured output configuration (used by WithStructuredOutput) - structuredSchema map[string]interface{} - structuredMaxRetries int - structuredSchemaJSON string -} - -func (o ClientOption) apply(c *AnthropicClient) { - if o.applyFn != nil { - o.applyFn(c) - } -} - -func (o ClientOption) applyOpenAI(c *OpenAIClient) { - if o.applyOpenAIFn != nil { - o.applyOpenAIFn(c) - } -} - -func (o ClientOption) applyEyrie(c *EyrieClient) { - if o.applyEyrieFn != nil { - o.applyEyrieFn(c) - } -} +type ClientOption = core.ClientOption // WithTimeout sets the HTTP client timeout. -func WithTimeout(d time.Duration) ClientOption { - return ClientOption{ - applyFn: func(c *AnthropicClient) { c.httpClient.Timeout = d }, - applyOpenAIFn: func(c *OpenAIClient) { c.httpClient.Timeout = d }, - } -} +func WithTimeout(d time.Duration) ClientOption { return core.WithTimeout(d) } // WithHTTPClient sets a custom HTTP client. -func WithHTTPClient(hc *http.Client) ClientOption { - return ClientOption{ - applyFn: func(c *AnthropicClient) { c.httpClient = hc }, - applyOpenAIFn: func(c *OpenAIClient) { c.httpClient = hc }, - } -} +func WithHTTPClient(hc *http.Client) ClientOption { return core.WithHTTPClient(hc) } // WithRetry sets retry configuration. -func WithRetry(rc RetryConfig) ClientOption { - return ClientOption{ - applyFn: func(c *AnthropicClient) { c.retry = rc }, - applyOpenAIFn: func(c *OpenAIClient) { c.retry = rc }, - } -} +func WithRetry(rc RetryConfig) ClientOption { return core.WithRetry(rc) } // WithLogger sets the logger. -func WithLogger(l *slog.Logger) ClientOption { - return ClientOption{ - applyFn: func(c *AnthropicClient) { c.logger = l }, - applyOpenAIFn: func(c *OpenAIClient) { c.logger = l }, - } -} +func WithLogger(l *slog.Logger) ClientOption { return core.WithLogger(l) } // WithAPIKey sets the API key. -func WithAPIKey(key string) ClientOption { - return ClientOption{ - applyFn: func(c *AnthropicClient) { c.apiKey = key }, - applyOpenAIFn: func(c *OpenAIClient) { c.apiKey = key }, - } -} +func WithAPIKey(key string) ClientOption { return core.WithAPIKey(key) } // WithBaseURL sets the base URL. -func WithBaseURL(url string) ClientOption { - return ClientOption{ - applyFn: func(c *AnthropicClient) { c.baseURL = url }, - applyOpenAIFn: func(c *OpenAIClient) { c.baseURL = url }, - } -} +func WithBaseURL(url string) ClientOption { return core.WithBaseURL(url) } // WithModel sets the default model for requests. -func WithModel(model string) ClientOption { - return ClientOption{ - applyFn: func(c *AnthropicClient) { c.defaultModel = model }, - applyOpenAIFn: func(c *OpenAIClient) { c.defaultModel = model }, - } -} +func WithModel(model string) ClientOption { return core.WithModel(model) } // WithMaxTokens sets the default max tokens for requests. -func WithMaxTokens(n int) ClientOption { - return ClientOption{ - applyFn: func(c *AnthropicClient) { c.defaultMaxTokens = n }, - applyOpenAIFn: func(c *OpenAIClient) { c.defaultMaxTokens = n }, - } -} +func WithMaxTokens(n int) ClientOption { return core.WithMaxTokens(n) } // WithTemperature sets the default temperature for requests. -func WithTemperature(t float64) ClientOption { - return ClientOption{ - applyFn: func(c *AnthropicClient) { c.defaultTemperature = &t }, - applyOpenAIFn: func(c *OpenAIClient) { c.defaultTemperature = &t }, - } -} - -// WithCoalescing enables request coalescing for identical concurrent requests. -// When enabled, multiple goroutines sending identical requests (same provider, -// model, messages, temperature, max_tokens) will be deduplicated into a single -// API call, with the result broadcast to all waiters. -// -// The ttl parameter controls how long completed requests remain in the coalescer -// for potential reuse. A typical value is 100-500ms. -func WithCoalescing(ttl time.Duration) ClientOption { - return ClientOption{ - applyEyrieFn: func(c *EyrieClient) { - c.coalescer = NewCoalescer(ttl) - }, - } -} +func WithTemperature(t float64) ClientOption { return core.WithTemperature(t) } // WithGuardrails attaches output guardrails to the client. Guardrails run // after the LLM response but before returning to the caller. Blocked // responses are replaced with an error; redacted responses have matches // replaced with asterisks. -func WithGuardrails(rules ...GuardrailRule) ClientOption { - g := NewGuardrails(rules...) - return ClientOption{ - applyFn: func(c *AnthropicClient) { c.guardrails = g }, - applyOpenAIFn: func(c *OpenAIClient) { c.guardrails = g }, - } -} +func WithGuardrails(rules ...GuardrailRule) ClientOption { return core.WithGuardrails(rules...) } // WithGuardrailType attaches output guardrails using built-in rules for the // specified types. For example, WithGuardrailType(GuardrailPII, GuardrailSecretLeak) // enables PII redaction and secret leak blocking with default patterns. -func WithGuardrailType(types ...GuardrailType) ClientOption { - var rules []GuardrailRule - for _, t := range types { - rules = append(rules, RulesForType(t)...) - } - return WithGuardrails(rules...) +func WithGuardrailType(types ...GuardrailType) ClientOption { return core.WithGuardrailType(types...) } + +// WithProviderName sets the OpenAI client provider name for errors/logging. +// No-op for the Anthropic adapter, which reports a fixed provider name. +func WithProviderName(name string) ClientOption { return core.WithProviderName(name) } + +// WithMimoAuth uses api-key header per MiMo documentation (OpenAI + Anthropic compat). +func WithMimoAuth() ClientOption { return core.WithMimoAuth() } + +// WithCoalescing enables request coalescing for identical concurrent requests. +// When enabled, multiple goroutines sending identical requests (same provider, +// model, messages, temperature, max_tokens) will be deduplicated into a single +// API call, with the result broadcast to all waiters. +// +// The ttl parameter controls how long completed requests remain in the coalescer +// for potential reuse. A typical value is 100-500ms. +func WithCoalescing(ttl time.Duration) ClientOption { + return core.NewEyrieOption(func(e core.EyrieConfigurable) { e.SetCoalescingTTL(ttl) }) } diff --git a/client/options_test.go b/client/options_test.go index 4ecfe3f..20f70d1 100644 --- a/client/options_test.go +++ b/client/options_test.go @@ -291,46 +291,48 @@ func TestOpenAIDefaultValues(t *testing.T) { // --- Provider-specific option tests --- -func TestOptionsAppliedToAnthropicOnly(t *testing.T) { +func TestOptionsApplyToBothAdapters(t *testing.T) { t.Parallel() - opt := ClientOption{ - applyFn: func(c *AnthropicClient) { c.apiKey = "anthropic-only" }, + // Options built through the clientConfigurable interface reach both + // adapter types with a single constructor. + opt := WithAPIKey("shared-key") + a := NewAnthropicClient("key", "", opt) + if a.apiKey != "shared-key" { + t.Errorf("anthropic: expected apiKey 'shared-key', got %q", a.apiKey) } - c := NewAnthropicClient("key", "", opt) - if c.apiKey != "anthropic-only" { - t.Errorf("expected apiKey 'anthropic-only', got %q", c.apiKey) + o := NewOpenAIClient("key", "", nil, opt) + if o.apiKey != "shared-key" { + t.Errorf("openai: expected apiKey 'shared-key', got %q", o.apiKey) } } -func TestOptionsAppliedToOpenAIOnly(t *testing.T) { +func TestProviderNameOptionIsOpenAIOnly(t *testing.T) { t.Parallel() - opt := ClientOption{ - applyOpenAIFn: func(c *OpenAIClient) { c.apiKey = "openai-only" }, + // WithProviderName mutates the OpenAI adapter and is a documented no-op + // on the Anthropic adapter (fixed provider name). + opt := WithProviderName("custom") + o := NewOpenAIClient("key", "", nil, opt) + if o.providerName != "custom" { + t.Errorf("openai: expected providerName 'custom', got %q", o.providerName) } - c := NewOpenAIClient("key", "", nil, opt) - if c.apiKey != "openai-only" { - t.Errorf("expected apiKey 'openai-only', got %q", c.apiKey) + a := NewAnthropicClient("original", "", opt) + if a.apiKey != "original" { + t.Errorf("anthropic: expected apiKey unchanged 'original', got %q", a.apiKey) } } -func TestProviderSpecificNilFnNoPanic(t *testing.T) { +func TestZeroOptionNoPanic(t *testing.T) { t.Parallel() - // An option with only applyFn should not panic when applied to OpenAI client. - opt := ClientOption{ - applyFn: func(c *AnthropicClient) { c.apiKey = "anthropic" }, - } + // A zero ClientOption (nil applyConfigurable) must not panic on either + // constructor and must leave the client unchanged. + var opt ClientOption c := NewOpenAIClient("original", "", nil, opt) if c.apiKey != "original" { - t.Errorf("expected apiKey unchanged 'original', got %q", c.apiKey) - } - - // An option with only applyOpenAIFn should not panic when applied to Anthropic client. - opt2 := ClientOption{ - applyOpenAIFn: func(c *OpenAIClient) { c.apiKey = "openai" }, + t.Errorf("openai: expected apiKey unchanged 'original', got %q", c.apiKey) } - c2 := NewAnthropicClient("original", "", opt2) + c2 := NewAnthropicClient("original", "", opt) if c2.apiKey != "original" { - t.Errorf("expected apiKey unchanged 'original', got %q", c2.apiKey) + t.Errorf("anthropic: expected apiKey unchanged 'original', got %q", c2.apiKey) } } diff --git a/client/provider_policy.go b/client/provider_policy.go new file mode 100644 index 0000000..f619835 --- /dev/null +++ b/client/provider_policy.go @@ -0,0 +1,54 @@ +package client + +import ( + "errors" + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" + "github.com/GrayCodeAI/eyrie/config" +) + +// ApplyProviderChatDefaults applies provider policy that host applications +// should not need to encode themselves. +func ApplyProviderChatDefaults(provider string, opts ChatOptions) ChatOptions { + if strings.EqualFold(strings.TrimSpace(provider), "anthropic") { + opts.EnableCaching = true + } + if !config.IsZAIProvider(provider) { + opts.GLMThinkingEnabled = nil + } + return opts +} + +// IsContextOverflow reports whether an error means the request exceeded the +// selected model's context window. +func IsContextOverflow(err error) bool { + if err == nil { + return false + } + var providerErr *core.EyrieError + if errors.As(err, &providerErr) { + message := strings.ToLower(providerErr.Message) + if containsContextOverflowSignal(message) { + return true + } + } + return containsContextOverflowSignal(strings.ToLower(err.Error())) +} + +func containsContextOverflowSignal(message string) bool { + for _, signal := range []string{ + "input exceeds the model's context window", + "context_length_exceeded", + "context length", + "maximum context", + "too many tokens", + "prompt is too long", + "reduce the length", + } { + if strings.Contains(message, signal) { + return true + } + } + return false +} diff --git a/client/provider_policy_test.go b/client/provider_policy_test.go new file mode 100644 index 0000000..f3df322 --- /dev/null +++ b/client/provider_policy_test.go @@ -0,0 +1,37 @@ +package client + +import ( + "errors" + "testing" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestApplyProviderChatDefaults(t *testing.T) { + if opts := ApplyProviderChatDefaults("anthropic", ChatOptions{}); !opts.EnableCaching { + t.Fatal("Anthropic caching default was not applied") + } + if opts := ApplyProviderChatDefaults("openai", ChatOptions{}); opts.EnableCaching { + t.Fatal("non-Anthropic provider enabled caching") + } + enabled := true + if opts := ApplyProviderChatDefaults("zai_payg", ChatOptions{GLMThinkingEnabled: &enabled}); opts.GLMThinkingEnabled == nil { + t.Fatal("Z.AI thinking preference was removed") + } + if opts := ApplyProviderChatDefaults("openai", ChatOptions{GLMThinkingEnabled: &enabled}); opts.GLMThinkingEnabled != nil { + t.Fatal("non-Z.AI thinking preference was retained") + } +} + +func TestIsContextOverflow(t *testing.T) { + typed := &core.EyrieError{Provider: "openai", Message: "input exceeds the model's context window"} + if !IsContextOverflow(typed) { + t.Fatal("typed context error was not detected") + } + if !IsContextOverflow(errors.New("context_length_exceeded")) { + t.Fatal("unstructured context error was not detected") + } + if IsContextOverflow(errors.New("unauthorized")) { + t.Fatal("unrelated error was classified as context overflow") + } +} diff --git a/client/provider_registry.go b/client/provider_registry.go index 6b7162c..cfaa135 100644 --- a/client/provider_registry.go +++ b/client/provider_registry.go @@ -6,6 +6,7 @@ import ( "log/slog" "time" + "github.com/GrayCodeAI/eyrie/catalog/registry" "github.com/GrayCodeAI/eyrie/config" "github.com/GrayCodeAI/eyrie/credentials" ) @@ -40,36 +41,37 @@ type ProviderRegistryConfig struct { Compat *OpenAICompatConfig `json:"compat,omitempty"` } -// CoreProviders are providers with dedicated SDKs. -// This map is populated at package init time and must not be written to -// afterward. All reads are safe for concurrent use without locking. -var CoreProviders = map[string]ProviderRegistryConfig{ - "anthropic": {Name: "anthropic", Type: ProviderTypeAnthropic, EnvKey: "ANTHROPIC_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "openai": {Name: "openai", Type: ProviderTypeOpenAI, BaseURL: "https://api.openai.com/v1", EnvKey: "OPENAI_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "azure": {Name: "azure", Type: ProviderTypeAzure, EnvKey: "AZURE_OPENAI_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "bedrock": {Name: "bedrock", Type: ProviderTypeBedrock, EnvKey: "AWS_SECRET_ACCESS_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "vertex": {Name: "vertex", Type: ProviderTypeVertex, EnvKey: "VERTEX_ACCESS_TOKEN", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, -} +// CoreProviders and OpenAICompatibleProviders are derived from the canonical +// catalog registry. Dynamic providers are added only to the compatible map. +var CoreProviders, OpenAICompatibleProviders = staticProviderMaps() -// OpenAICompatibleProviders use the OpenAI SDK with custom baseUrl. -var OpenAICompatibleProviders = map[string]ProviderRegistryConfig{ - "deepseek": {Name: "deepseek", Type: ProviderTypeOpenAICompatible, BaseURL: "https://api.deepseek.com/v1", EnvKey: "DEEPSEEK_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "grok": {Name: "grok", Type: ProviderTypeOpenAICompatible, BaseURL: "https://api.x.ai/v1", EnvKey: "XAI_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "openrouter": {Name: "openrouter", Type: ProviderTypeOpenAICompatible, BaseURL: "https://openrouter.ai/api/v1", EnvKey: "OPENROUTER_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "zai_payg": {Name: "zai_payg", Type: ProviderTypeOpenAICompatible, BaseURL: "https://api.z.ai/api/paas/v4", EnvKey: "ZAI_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "zai_coding": {Name: "zai_coding", Type: ProviderTypeOpenAICompatible, BaseURL: "https://api.z.ai/api/coding/paas/v4", EnvKey: "ZAI_CODING_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "canopywave": {Name: "canopywave", Type: ProviderTypeOpenAICompatible, BaseURL: "https://inference.canopywave.io/v1", EnvKey: "CANOPYWAVE_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "poolside": {Name: "poolside", Type: ProviderTypeOpenAICompatible, BaseURL: config.DefaultPoolsideOpenAIBaseURL, EnvKey: "POOLSIDE_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "groq": {Name: "groq", Type: ProviderTypeOpenAICompatible, BaseURL: config.DefaultGroqOpenAIBaseURL, EnvKey: "GROQ_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "clinepass": {Name: "clinepass", Type: ProviderTypeOpenAICompatible, BaseURL: config.DefaultClinePassOpenAIBaseURL, EnvKey: "CLINE_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "gemini": {Name: "gemini", Type: ProviderTypeOpenAICompatible, BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", EnvKey: "GEMINI_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "ollama": {Name: "ollama", Type: ProviderTypeOpenAICompatible, BaseURL: "http://localhost:11434/v1", EnvKey: "OLLAMA_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: false}, - "opencodego": {Name: "opencodego", Type: ProviderTypeOpenAICompatible, BaseURL: config.DefaultOpenCodeGoBaseURL, EnvKey: "OPENCODEGO_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "kimi": {Name: "kimi", Type: ProviderTypeOpenAICompatible, BaseURL: config.DefaultKimiOpenAIBaseURL, EnvKey: "MOONSHOT_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "xiaomi_mimo_payg": {Name: "xiaomi_mimo_payg", Type: ProviderTypeOpenAICompatible, BaseURL: config.DefaultXiaomiOpenAIBaseURL, EnvKey: config.EnvXiaomiPaygAPIKey, SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "xiaomi_mimo_token_plan": {Name: "xiaomi_mimo_token_plan", Type: ProviderTypeOpenAICompatible, BaseURL: "", EnvKey: config.EnvXiaomiTokenPlanAPIKey, SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "minimax_token_plan": {Name: "minimax_token_plan", Type: ProviderTypeOpenAICompatible, BaseURL: "https://api.minimax.io/v1", EnvKey: "MINIMAX_TOKEN_PLAN_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, - "minimax_payg": {Name: "minimax_payg", Type: ProviderTypeOpenAICompatible, BaseURL: "https://api.minimax.io/v1", EnvKey: "MINIMAX_PAYG_API_KEY", SupportsStreaming: true, SupportsTools: true, SupportsReasoning: true}, +func staticProviderMaps() (map[string]ProviderRegistryConfig, map[string]ProviderRegistryConfig) { + core := make(map[string]ProviderRegistryConfig) + compatible := make(map[string]ProviderRegistryConfig) + for _, spec := range registry.All() { + providerType := ProviderTypeOpenAICompatible + if spec.TransportKind != "" { + providerType = ProviderType(spec.TransportKind) + } + baseURL := spec.RuntimeBaseURL + if baseURL == "" { + baseURL = spec.ProbeBaseURL + } + envKey := spec.RuntimeCredentialEnv + if envKey == "" { + envKey = spec.CredentialEnv + } + provider := ProviderRegistryConfig{ + Name: spec.ProviderID, Type: providerType, BaseURL: baseURL, EnvKey: envKey, + SupportsStreaming: true, SupportsTools: true, SupportsReasoning: !spec.IsLocal, + } + if providerType == ProviderTypeOpenAICompatible { + compatible[spec.ProviderID] = provider + } else { + core[spec.ProviderID] = provider + } + } + return core, compatible } // GetProviders lists all available providers. diff --git a/client/provider_registry_derived_test.go b/client/provider_registry_derived_test.go new file mode 100644 index 0000000..e2c3633 --- /dev/null +++ b/client/provider_registry_derived_test.go @@ -0,0 +1,50 @@ +package client + +import "testing" + +func TestDerivedProviderRegistryDedicatedTransports(t *testing.T) { + want := map[string]ProviderType{ + "anthropic": ProviderTypeAnthropic, + "openai": ProviderTypeOpenAI, + "azure": ProviderTypeAzure, + "bedrock": ProviderTypeBedrock, + "vertex": ProviderTypeVertex, + } + if len(CoreProviders) != len(want) { + t.Fatalf("CoreProviders has %d entries, want %d: %v", len(CoreProviders), len(want), CoreProviders) + } + for provider, providerType := range want { + got, ok := CoreProviders[provider] + if !ok { + t.Fatalf("CoreProviders missing %q", provider) + } + if got.Type != providerType { + t.Fatalf("CoreProviders[%q].Type = %q, want %q", provider, got.Type, providerType) + } + } +} + +func TestDerivedProviderRegistryRuntimeOverrides(t *testing.T) { + tests := []struct { + provider string + baseURL string + envKey string + }{ + {"gemini", "https://generativelanguage.googleapis.com/v1beta/openai", "GEMINI_API_KEY"}, + {"clinepass", "https://api.cline.bot/api/v1", "CLINE_API_KEY"}, + {"ollama", "http://localhost:11434/v1", "OLLAMA_API_KEY"}, + } + client := Client(nil) + for _, tt := range tests { + info := client.GetProviderInfo(tt.provider) + if info == nil { + t.Fatalf("GetProviderInfo(%q) returned nil", tt.provider) + } + if info.BaseURL != tt.baseURL { + t.Errorf("GetProviderInfo(%q).BaseURL = %q, want %q", tt.provider, info.BaseURL, tt.baseURL) + } + if info.EnvKey != tt.envKey { + t.Errorf("GetProviderInfo(%q).EnvKey = %q, want %q", tt.provider, info.EnvKey, tt.envKey) + } + } +} diff --git a/client/recorder.go b/client/recorder.go index 52bae06..98adc27 100644 --- a/client/recorder.go +++ b/client/recorder.go @@ -273,7 +273,7 @@ func (r *RecorderProvider) syntheticStream(ctx context.Context, resp *EyrieRespo } }() - return &StreamResult{Events: ch, cancel: cancel} + return NewStreamResult(ch, cancel) } // recordStream drains the real stream, accumulates data, saves the interaction, and @@ -337,7 +337,7 @@ func (r *RecorderProvider) recordStream(ctx context.Context, result *StreamResul r.mu.Unlock() }() - return &StreamResult{Events: ch, cancel: cancel} + return NewStreamResult(ch, cancel) } // redact applies the redactor function if set. diff --git a/client/semantic_cache.go b/client/semantic_cache.go index f50ae75..beea5bb 100644 --- a/client/semantic_cache.go +++ b/client/semantic_cache.go @@ -337,56 +337,5 @@ func buildCacheKey(messages []EyrieMessage, opts ChatOptions) string { return hex.EncodeToString(h.Sum(nil)) } -// copyResponse returns a deep copy of an EyrieResponse so that callers -// cannot mutate the cached version. -func copyResponse(resp *EyrieResponse) *EyrieResponse { - if resp == nil { - return nil - } - cp := *resp - if resp.Usage != nil { - u := *resp.Usage - cp.Usage = &u - } - if len(resp.ToolCalls) > 0 { - cp.ToolCalls = make([]ToolCall, len(resp.ToolCalls)) - for i, tc := range resp.ToolCalls { - cp.ToolCalls[i] = tc - if tc.Arguments != nil { - cp.ToolCalls[i].Arguments = deepCopyMap(tc.Arguments) - } - } - } - return &cp -} - -// deepCopyMap returns a deep copy of a map[string]interface{}. -func deepCopyMap(m map[string]interface{}) map[string]interface{} { - cp := make(map[string]interface{}, len(m)) - for k, v := range m { - switch val := v.(type) { - case map[string]interface{}: - cp[k] = deepCopyMap(val) - case []interface{}: - cp[k] = deepCopySlice(val) - default: - cp[k] = v - } - } - return cp -} - -func deepCopySlice(s []interface{}) []interface{} { - cp := make([]interface{}, len(s)) - for i, v := range s { - switch val := v.(type) { - case map[string]interface{}: - cp[i] = deepCopyMap(val) - case []interface{}: - cp[i] = deepCopySlice(val) - default: - cp[i] = v - } - } - return cp -} +// copyResponse and the deep-copy helpers live in client/core (CopyResponse); +// the package-local name is bridged in aliases.go. diff --git a/client/structured.go b/client/structured.go index 37dd714..c392c46 100644 --- a/client/structured.go +++ b/client/structured.go @@ -200,24 +200,15 @@ Important: return result } -// WithStructuredOutput returns a ClientOption that configures structured JSON output. -// For OpenAI, it sets response_format to json_schema with the given schema. -// For Anthropic, it enables structured output via the prefill technique. +// WithStructuredOutput returns a ClientOption for structured JSON output. +// +// The option itself is inert: neither adapter is mutated at construction +// time. Anthropic uses the prefill technique and OpenAI sets response_format, +// both handled per-call in ChatWithStructuredOutput (which receives the +// schema through its SchemaValidation parameter — the fields this option +// previously carried were never read). Kept for API compatibility. func WithStructuredOutput(schema map[string]interface{}, maxRetries int) ClientOption { - schemaJSON, _ := json.Marshal(schema) - - return ClientOption{ - applyFn: func(c *AnthropicClient) { - // Anthropic uses prefill technique - handled in ChatWithStructuredOutput - }, - applyOpenAIFn: func(c *OpenAIClient) { - // OpenAI uses response_format - handled in ChatWithStructuredOutput - }, - // Store schema for use by ChatWithStructuredOutput - structuredSchema: schema, - structuredMaxRetries: maxRetries, - structuredSchemaJSON: string(schemaJSON), - } + return ClientOption{} } // ChatWithStructuredOutput sends a chat request with structured output validation. diff --git a/client/testhelpers_shared_test.go b/client/testhelpers_shared_test.go new file mode 100644 index 0000000..dd54a42 --- /dev/null +++ b/client/testhelpers_shared_test.go @@ -0,0 +1,6 @@ +package client + +// userMsg builds a single-user-message conversation. Shared by tests that +// previously relied on the helper defined in the (since-moved) embedding +// cache tests. +func userMsg(s string) []EyrieMessage { return []EyrieMessage{{Role: "user", Content: s}} } diff --git a/credentials/store.go b/credentials/store.go index 946918f..c17b796 100644 --- a/credentials/store.go +++ b/credentials/store.go @@ -8,9 +8,21 @@ import ( "github.com/GrayCodeAI/eyrie/catalog" ) -const ( - ServiceName = "hawk" -) +// ServiceName is the OS secret-store service under which credentials are +// filed. It defaults to "hawk" — the original (and primary) consumer — and +// must stay stable for existing keychain entries to remain readable. Other +// embedders can rebrand via SetServiceName before any store access. +var ServiceName = "hawk" + +// SetServiceName overrides the secret-store service name. Call it once at +// startup, before the first credential read or write; changing it later +// orphans previously stored secrets. +func SetServiceName(name string) { + name = strings.TrimSpace(name) + if name != "" { + ServiceName = name + } +} // Store persists provider API secrets outside provider.json. type Store interface { @@ -53,37 +65,10 @@ func AccountForEnv(envKey string) string { } // EnvForAccount is a best-effort reverse map for loading into process env. +// Accounts are stored as lowercased env names (see AccountForEnv), so the +// uppercase transform recovers the env key for every registered provider. func EnvForAccount(account string) string { - switch strings.ToLower(account) { - case "anthropic_api_key": - return "ANTHROPIC_API_KEY" - case "openai_api_key": - return "OPENAI_API_KEY" - case "openrouter_api_key": - return "OPENROUTER_API_KEY" - case "gemini_api_key": - return "GEMINI_API_KEY" - case "xai_api_key": - return "XAI_API_KEY" - case "zai_api_key": - return "ZAI_API_KEY" - case "canopywave_api_key": - return "CANOPYWAVE_API_KEY" - case "opencodego_api_key": - return "OPENCODEGO_API_KEY" - case "moonshot_api_key": - return "MOONSHOT_API_KEY" - case "xiaomi_mimo_api_key": - return "XIAOMI_MIMO_API_KEY" - case "xiaomi_mimo_payg_api_key": - return "XIAOMI_MIMO_PAYG_API_KEY" - case "xiaomi_mimo_token_plan_api_key": - return "XIAOMI_MIMO_TOKEN_PLAN_API_KEY" - case "ollama_base_url": - return "OLLAMA_BASE_URL" - default: - return strings.ToUpper(strings.ReplaceAll(account, "-", "_")) - } + return strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(account), "-", "_")) } // APIKeysMap returns env-keyed secrets for catalog discovery. diff --git a/plans/client-package-decomposition.md b/plans/client-package-decomposition.md new file mode 100644 index 0000000..92e4982 --- /dev/null +++ b/plans/client-package-decomposition.md @@ -0,0 +1,206 @@ +# Feature Specification: `client` Package Decomposition + +**Status:** In Progress — Phases 1–2 implemented 2026-07-12; layering guard live +**Author:** Claude (architecture review session) +**Date:** 2026-07-12 +**Repos affected:** eyrie (all changes), hawk (no code changes required; re-pin external/eyrie) + +## Problem Statement + +`eyrie/client` is a 63-file, ~14k-line (source, excluding tests) single package that +mixes at least six distinct concerns: + +1. **Core contract & types** — `Provider` interface, `EyrieMessage`, `EyrieResponse`, + `EyrieStreamEvent`, `StreamResult`, `ChatOptions`, `EyrieClient` (`client.go`, + `options.go`, `chat.go`, `errors.go`, `retry.go`, `transport.go`, `stream.go`, + `continuation.go`, `roles.go`, `merge.go`, `extract.go`) +2. **Protocol adapters** — `anthropic.go`, `openai.go`, `gemini.go`, `azure.go`, + `bedrock.go`, `vertex.go`, `deepseek.go`, `zai.go`, `mimo.go`, `opencodego.go`, + `dynamic.go`, `compat.go`, `protocol_router.go`, `provider_registry.go` +3. **Middleware decorators** (all wrap `Provider`) — `adaptive_ratelimit.go`, + `budget_provider.go`, `callbacks.go`, `condenser.go`, `fallback.go`, + `guardrails.go`, `stream_guardrails.go`, `lazy_provider.go`, `ratelimit.go`, + `weighted.go`, `usage_limit.go`, `repeat_detector.go`, `response_health.go`, + `coalesce.go`, `tracing.go`, `recorder.go`, `cassette.go`, `mock.go` +4. **Caching** — `cache.go`, `semantic_cache.go`, `cache_analytics.go` +5. **Embeddings** — `embedding.go`, `embedding_cache.go`, `embedding_client.go`, + `embedding_defaults.go` +6. **Auxiliary capabilities** — `batch.go`, `image.go`, `moderation.go`, + `structured.go`, `features.go`, `token_utils.go`, `cost_estimator.go`, + `usage_tracker.go`, `call_metrics.go`, `sanitize.go`, `provider_health.go` + +Consequences: no compiler-enforced boundaries (an adapter can reach into cache +internals), slow test cycles (one package = one test binary), unclear ownership, +and a public API surface far larger than what consumers use. + +### Measured coupling (2026-07-12) + +- Embeddings cluster uses only **4** unexported helpers from the rest of the + package: `copyResponse`, `doWithRetry`, `formatAPIError`, `parseProviderError`. +- Adapter cluster uses **13**: the above plus `applyGuardrails`, + `buildAnthropicCachedRequest`, `defaultTimeout`, `emit`, `openAIImageURL`, + `parseImageString`, `parseSSEStream`, `processAnthropicStream`, + `processOpenAIStream`, `userAgent`. +- hawk (the primary consumer) accesses `eyrie/client` from **4 files only** — + it maintains its own DTO layer (`hawk/internal/types/client.go`) and converts + at the boundary. Entry points consumed: `Client`, `EyrieClient` methods + (`Chat`, `StreamChat`, `StreamChatContinue`, `SetAPIKey`, `Ping`, + `GetProviders`), `StreamChatWithContinuation`, `ParseInlineToolCalls`, + `DetectProvider`, `RegisterDynamicProvider`, `DefaultContinuationConfig`, + `NewMockProvider`/`MockModeFixed`, `Provider`, and the DTO types + (`EyrieMessage`, `EyrieResponse`, `EyrieStreamEvent`, `EyrieUsage`, + `StreamResult`, `ChatOptions`, `ContinuationConfig`, `ContentPart`, + `ImageURLPart`, `InputAudioPart`, `ToolCall`, `ToolResult`, `EyrieTool`, + `EyrieConfig`, `ResponseFormat`, `ToolChoiceOption`). + +The narrow consumed surface makes a **non-breaking, alias-based decomposition** +practical. + +## Proposed Solution + +Extract a leaf `core` package holding the contract and wire-level plumbing, then +move each concern into its own subpackage that imports `core`. The `client` +package remains as a compatibility facade: every moved type becomes a Go type +alias (`type EyrieMessage = core.EyrieMessage`), every moved function a thin +wrapper or function variable. Type aliases preserve type identity, so **no +consumer code changes and no version bump semantics change**. + +Target layout: + +``` +eyrie/ + client/ // facade: aliases + wrappers (shrinks each phase) + core/ // Provider, messages, options, errors, retry, transport, SSE + adapters/ // one file per protocol family; imports core only + middleware/ // decorators over core.Provider + llmcache/ // response + semantic caches + embeddings/ // embedding client + cache + defaults + aux/ // batch, image, moderation, structured, tokens, cost +``` + +Dependency rule (CI-enforced): `core` imports none of the siblings; +`adapters`/`middleware`/`llmcache`/`embeddings`/`aux` import `core` only; +the `client` facade imports all of them; nothing imports the facade from +inside the tree. + +## Alternatives Considered + +- **Big-bang rename (`client/v2`)** — breaks every consumer including examples + and SDK bindings; rejected. +- **Move whole package to `internal/`** — hawk and examples import it; rejected. +- **Split without a core package** (e.g., extract embeddings directly) — impossible + without import cycles: subpackages need client types while the facade re-exports + subpackage API. The core extraction is the unlock; everything else follows. +- **Do nothing, document layers in comments** — no compiler enforcement; the + package grew to 63 files precisely because nothing pushes back. + +## Implementation Plan + +### Phase 1: core extraction (the unlock) +Phase 1 is DONE (2026-07-12): +- [x] Created `client/core` with `Provider`, message/response/stream/usage/tool + types, `ChatOptions`, `ResponseFormat`, `ToolChoiceOption`, + `ContinuationConfig`, `EyrieConfig`, `EyrieError`, `RetryConfig` + + `DoWithRetry`, `ParseProviderError`/`FormatAPIError`, `CopyResponse`. + (SSE parsing, transport, `userAgent`, `defaultTimeout` deferred to the + adapters phase — embeddings did not need them.) +- [x] In `client`, every moved name is aliased (`client/aliases.go`); internal + call sites bridge through unexported vars (`doWithRetry = core.DoWithRetry`). +- [x] `go test ./...` green in eyrie; hawk builds + tests green. + +### Phase 2: embeddings (smallest proven cluster, 4 deps) + +Phase 2 is DONE (2026-07-12): +- [x] Moved embedding DTOs, `Embedder`, defaults, and `EmbeddingCachedProvider` + to `client/embeddings` (imports `core` only). +- [x] `OpenAIClient.CreateEmbedding` / `EyrieClient.CreateEmbedding` stayed in + `client` (`embedding_methods.go`) — methods must live with their + receiver's package; they implement `embeddings.Embedder`. +- [x] Facade aliases for the full embedding API in `client/aliases.go`. +- [x] Layering guard live early: `scripts/check-client-layering.sh`, wired + into `make boundaries`. + +Learned in Phases 1–2 (apply to later phases): +- BSD sed has no `\b`; use perl for identifier renames. +- Unexported fields (`StreamResult.cancel`) force constructor use at move + time — added `core.NewStreamResultWithRequestID`. +- Tests that exercise facade types (e.g. `NewMockProvider`) cannot move with + the cluster; give the subpackage a local test double instead. + +### Phase 3a: wire layer to core — DONE 2026-07-12 +- [x] Moved to `client/core` with exported names + facade bridges: + `stream.go` (`ParseSSEStream`, `ProcessAnthropicStream[WithOpts]`, + `ProcessOpenAIStream[WithOpts]`, `Emit`, `ParseInlineToolCalls`, + `StreamChannelBuffer`), `transport.go` (`NewPooledHTTPClient`, + `CloseIdleConnections`, `DefaultTimeout`, `Version`/`SetVersion`/ + `UserAgent`), `repeat_detector.go`, `image.go` (`OpenAIImageURL`, + `ParseImageString`, `NormalizeImageSource`), `response_health.go` + (`DetectResponseHealth`, `ResponseHasContent`, health constants). + Seven wire-layer test files moved with them. +- [x] `client.SetVersion` forwards to `core.SetVersion` (root package wiring + unchanged); `client.Version` kept in sync for back-compat readers. + +### Phase 3b-i: options decoupled from adapter types — DONE 2026-07-12 +- [x] `ClientOption` no longer holds `applyFn func(*AnthropicClient)` / + `applyOpenAIFn func(*OpenAIClient)`. It applies through the unexported + `clientConfigurable` interface (exported `Set*` methods, implemented by + both adapters in `adapter_config.go`). All `With*` constructors, + `WithProviderName`/`WithMimoAuth` (mimo.go), and + `WithStructuredOutput` (structured.go) rewritten; behavior identical. + +### Phase 3b-ii: guardrails engine to core — DONE 2026-07-12 +- [x] Guardrail engine (rule types/constants, `Guardrails`, `Check`, + `ApplyRedactions`, default rule sets, `ApplyGuardrails`, and the + incremental `StreamGuardrails` scanner) moved to `core`; the + `GuardrailProvider` middleware wrapper stays in the facade + (`client/guardrails.go`). Full public API aliased. + +### Phase 3b-iii: adapter file move — remaining +- [ ] Both structural blockers are now cleared. What's left is the physical + move of the 14 adapter files to `client/adapters` plus their in-package + tests (large: adapter tests read unexported fields and share helpers + like `newTestOpenAIClient` with facade tests — split the helpers first). +- [ ] Decide: `OpenAIClient.CreateEmbedding` implements `embeddings.Embedder`, + so either allow `adapters` → `embeddings` in the layering guard or move + the embedding DTOs into `core`. (Recommended: DTOs into `core`, + keeping the strict "siblings import core only" rule.) +- [ ] `buildAnthropicCachedRequest` moves with the Anthropic adapter. +- [ ] `provider_registry.go` and `protocol_router.go` move last; the registry + keyed by `AdapterID` stays the only construction path. + +### Phase 4: middleware, cache, aux +- [ ] One sub-move per PR, same alias recipe. + +### Phase 5: enforcement + deprecation +- [ ] Add `scripts/check-client-layering.sh` (mirror of hawk's + `check-eyrie-client-imports.sh`) to CI: fail on any sibling→sibling import + that bypasses `core`, and on any in-tree import of the facade. +- [ ] Mark facade aliases `// Deprecated:` pointing at the subpackage; migrate + eyrie-internal callers (`conversation`, `router`, `runtime`, `setup`, + examples) to the subpackages; leave external aliases indefinitely. + +## Testing Strategy + +- Unit tests: move with their files; each phase must keep `go test ./...` green + with zero test-logic edits (rename-only diffs). +- Integration tests: `catalogtest` + hawk `internal/engine` suite against the + branch via `go.work` replace. +- E2E tests: `hawk path` smoke + one live streamed chat per protocol family + (anthropic-messages, openai-chat-completions, gemini-generate-content) before + each merge. + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Hidden unexported coupling beyond the measured sets | med | Phases are one-cluster-at-a-time; the compiler finds every missed reference at move time; abort/expand `core` rather than weaken boundaries | +| Type identity breakage for consumers doing type switches | high | Use aliases (`=`), never new named types, for everything that already exists | +| Method sets split from their types | high | Methods move with their receiver's file into the same subpackage — never leave methods behind | +| external/eyrie pin drift in hawk during the refactor | low | Land phases as individual PRs; re-pin hawk after each; `make sync-external` reports drift | +| Facade grows stale re-exports | low | Phase 5 CI check + deprecation comments | + +## References + +- hawk's boundary script: `hawk/scripts/check-eyrie-client-imports.sh` +- hawk's DTO layer (proof the consumer surface is narrow): `hawk/internal/types/client.go` +- Session decomposition precedent: `hawk/docs/session-decomposition.md` diff --git a/router/router.go b/router/router.go index 47c9631..84404e7 100644 --- a/router/router.go +++ b/router/router.go @@ -136,10 +136,10 @@ func (r *Router) Chat(ctx context.Context, messages []client.EyrieMessage, opts } func (r *Router) StreamChat(ctx context.Context, messages []client.EyrieMessage, opts client.ChatOptions) (*client.StreamResult, error) { - provider, _ := r.selectProvider() + provider, retry := r.selectProvider() r.stratState.beginInFlight(provider.Name()) start := time.Now() - sr, err := provider.StreamChat(ctx, messages, opts) + sr, err := r.streamWithRetry(ctx, provider, messages, opts, retry) r.stratState.recordLatency(provider.Name(), float64(time.Since(start).Milliseconds())) r.stratState.endInFlight(provider.Name()) if err == nil { @@ -223,6 +223,38 @@ func (r *Router) chatWithRetry(ctx context.Context, p client.Provider, messages return nil, lastErr } +// streamWithRetry mirrors chatWithRetry for the streaming path: transient +// errors on stream setup are retried with backoff. Errors that surface +// mid-stream (after a successful setup) are not retried here — the caller +// owns the event channel by then. +func (r *Router) streamWithRetry(ctx context.Context, p client.Provider, messages []client.EyrieMessage, opts client.ChatOptions, cfg RetryConfig) (*client.StreamResult, error) { + var lastErr error + for attempt := 0; attempt <= cfg.MaxRetries; attempt++ { + sr, err := p.StreamChat(ctx, messages, opts) + if err == nil { + return sr, nil + } + lastErr = err + if !IsTransient(err) { + return nil, err + } + if attempt < cfg.MaxRetries { + delay := BackoffDelay(attempt, cfg) + if cfg.OnRetry != nil { + cfg.OnRetry(RetryEvent{Err: err, Attempt: attempt + 1, MaxRetries: cfg.MaxRetries, Delay: delay}) + } + timer := newTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return nil, ctx.Err() + case <-timer.C: + } + } + } + return nil, lastErr +} + func (r *Router) recordSuccess(name string) { r.mu.RLock() s, ok := r.stats[name] diff --git a/runtime/configure_provider.go b/runtime/configure_provider.go new file mode 100644 index 0000000..2c0dcd7 --- /dev/null +++ b/runtime/configure_provider.go @@ -0,0 +1,69 @@ +package runtime + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/GrayCodeAI/eyrie/catalog/registry" +) + +// ConfigureProviderOpts is the complete engine input for provider setup. +type ConfigureProviderOpts struct { + ProviderID string + Secret string +} + +// ConfigureProviderResult is the normalized provider setup result for hosts. +type ConfigureProviderResult struct { + ProviderID string + DeploymentID string + Summary string + Models []ModelEntry +} + +// ConfigureProvider prepares provider environment, saves and probes its +// credential, refreshes its catalog, and returns normalized model rows. +func ConfigureProvider(ctx context.Context, opts ConfigureProviderOpts) (*ConfigureProviderResult, error) { + if ctx == nil { + ctx = context.Background() + } + providerID := SetupGatewayID(opts.ProviderID) + spec, ok := registry.SpecByProviderID(providerID) + if !ok { + return nil, fmt.Errorf("runtime: unknown setup provider %q", opts.ProviderID) + } + ctx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + + ApplyGatewayEnv(ctx, providerID) + var inference CredentialInference + var err error + if spec.RequiresKey { + inference, err = InferenceForProvider(providerID) + } else { + inference, err = LocalCredentialInference(providerID) + } + if err != nil { + return nil, err + } + if strings.TrimSpace(opts.Secret) == "" { + return nil, fmt.Errorf("runtime: credential value required for %s", providerID) + } + if err := SaveCredential(ctx, inference, opts.Secret); err != nil { + return nil, err + } + applyResult, err := ApplyCredentialsForProvider(ctx, providerID) + if err != nil { + return nil, err + } + models, err := ListModels(ctx, ListModelsOpts{ProviderID: providerID, Source: ListSourceAuto}) + if err != nil { + return nil, err + } + return &ConfigureProviderResult{ + ProviderID: providerID, DeploymentID: inference.DeploymentID, + Summary: FormatApplyCredentialsSummary(applyResult), Models: models, + }, nil +} diff --git a/runtime/configure_provider_test.go b/runtime/configure_provider_test.go new file mode 100644 index 0000000..3cfe46c --- /dev/null +++ b/runtime/configure_provider_test.go @@ -0,0 +1,21 @@ +package runtime + +import ( + "context" + "strings" + "testing" +) + +func TestConfigureProviderRejectsUnknownProvider(t *testing.T) { + _, err := ConfigureProvider(context.Background(), ConfigureProviderOpts{ProviderID: "missing-provider", Secret: "secret-value"}) + if err == nil || !strings.Contains(err.Error(), "unknown setup provider") { + t.Fatalf("ConfigureProvider() error = %v", err) + } +} + +func TestConfigureProviderRejectsEmptyValueBeforeNetwork(t *testing.T) { + _, err := ConfigureProvider(context.Background(), ConfigureProviderOpts{ProviderID: "openai"}) + if err == nil || !strings.Contains(err.Error(), "credential value required") { + t.Fatalf("ConfigureProvider() error = %v", err) + } +} diff --git a/runtime/list_models.go b/runtime/list_models.go index 8f0dbf9..7652b54 100644 --- a/runtime/list_models.go +++ b/runtime/list_models.go @@ -2,6 +2,7 @@ package runtime import ( "context" + "encoding/json" "fmt" "strings" @@ -29,15 +30,25 @@ type ListModelsOpts struct { // ModelEntry is one row for host model pickers. type ModelEntry struct { - ID string `json:"id"` - DisplayName string `json:"display_name"` - Owner string `json:"owner,omitempty"` - ProviderID string `json:"provider_id"` - ContextWindow int `json:"context_window,omitempty"` - InputPricePer1M float64 `json:"input_price_per_1m,omitempty"` - OutputPricePer1M float64 `json:"output_price_per_1m,omitempty"` - Source string `json:"source"` - Installed bool `json:"installed,omitempty"` + ID string `json:"id"` + DisplayName string `json:"display_name"` + Owner string `json:"owner,omitempty"` + ProviderID string `json:"provider_id"` + ContextWindow int `json:"context_window,omitempty"` + MaxOutput int `json:"max_output,omitempty"` + InputPricePer1M float64 `json:"input_price_per_1m,omitempty"` + OutputPricePer1M float64 `json:"output_price_per_1m,omitempty"` + LiveMetadata json.RawMessage `json:"live_metadata,omitempty"` + Source string `json:"source"` + Installed bool `json:"installed,omitempty"` +} + +// ProviderSupportsLiveModels reports whether a provider has a registered live +// model-list fetcher. Host applications use this as presentation metadata and +// do not need to inspect the catalog registry directly. +func ProviderSupportsLiveModels(providerID string) bool { + spec, ok := registry.SpecByProviderID(strings.TrimSpace(providerID)) + return ok && spec.LiveFetcherKey != "" } // ListModels returns models for a provider using registry-driven source selection. @@ -107,6 +118,7 @@ func liveEntriesToModelList(entries []live.Entry, providerID, source string, ins ID: e.ID, DisplayName: e.DisplayName, Owner: e.OwnedBy, ContextWindow: e.ContextWindow, MaxOutput: e.MaxOutput, InputPricePer1M: e.InputPricePer1M, OutputPricePer1M: e.OutputPricePer1M, + LiveMetadata: e.RawJSON, } } return entriesToModelList(catalogEntries, providerID, source, installed) @@ -131,8 +143,10 @@ func entriesToModelList(entries []catalog.ModelCatalogEntry, providerID, source Owner: catalog.DisplayModelOwner(catalog.ModelOwner(e), id, e.LiveMetadata), ProviderID: providerID, ContextWindow: e.ContextWindow, + MaxOutput: e.MaxOutput, InputPricePer1M: e.InputPricePer1M, OutputPricePer1M: e.OutputPricePer1M, + LiveMetadata: append(json.RawMessage(nil), e.LiveMetadata...), Source: source, Installed: installed, }) diff --git a/runtime/list_models_test.go b/runtime/list_models_test.go index 228dd96..c461cf5 100644 --- a/runtime/list_models_test.go +++ b/runtime/list_models_test.go @@ -2,6 +2,7 @@ package runtime_test import ( "context" + "encoding/json" "os" "path/filepath" "testing" @@ -113,7 +114,7 @@ func TestListModels_CacheEntriesHaveCorrectFields(t *testing.T) { Offerings: []catalog.ModelOffering{{ ID: "anthropic-direct:claude-opus-4-6", CanonicalModelID: "anthropic/claude-opus-4-6", DeploymentID: "anthropic-direct", NativeModelID: "claude-opus-4-6", - Pricing: catalog.Pricing{Status: catalog.PricingUnknown}, + Pricing: catalog.Pricing{Status: catalog.PricingUnknown}, LiveMetadata: []byte(`{"id":"claude-opus-4-6"}`), }}, } if err := catalog.WriteCatalogCache(cachePath, &c); err != nil { @@ -144,11 +145,27 @@ func TestListModels_CacheEntriesHaveCorrectFields(t *testing.T) { if e.ContextWindow != 200000 { t.Fatalf("expected context window 200000, got %d", e.ContextWindow) } + if e.MaxOutput != 32000 { + t.Fatalf("expected max output 32000, got %d", e.MaxOutput) + } + var metadata map[string]any + if err := json.Unmarshal(e.LiveMetadata, &metadata); err != nil || metadata["id"] != "claude-opus-4-6" { + t.Fatalf("unexpected live metadata: %s (err=%v)", e.LiveMetadata, err) + } if e.Installed { t.Fatal("expected installed=false for cache source") } } +func TestProviderSupportsLiveModels(t *testing.T) { + if !runtime.ProviderSupportsLiveModels("openai") { + t.Fatal("expected OpenAI to support live model listing") + } + if runtime.ProviderSupportsLiveModels("unknown-provider") { + t.Fatal("unknown provider must not report live model support") + } +} + func TestListModels_CacheMultipleModels(t *testing.T) { cachePath := filepath.Join(t.TempDir(), "model_catalog.json") now := time.Now().UTC().Truncate(time.Second) diff --git a/runtime/native_compaction.go b/runtime/native_compaction.go new file mode 100644 index 0000000..7d0846b --- /dev/null +++ b/runtime/native_compaction.go @@ -0,0 +1,201 @@ +package runtime + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/GrayCodeAI/eyrie/client" + "github.com/GrayCodeAI/eyrie/credentials" +) + +const ( + anthropicCompactionURL = "https://api.anthropic.com/v1/messages" + anthropicCompactionBeta = "compact-2026-01-12" + anthropicCompactionEdit = "compact_20260112" + anthropicMinCompactTrigger = 50_000 +) + +// NativeCompactionOpts describes a provider-native conversation compaction request. +type NativeCompactionOpts struct { + Provider string + Model string + Messages []client.EyrieMessage + ContextWindow int + ThresholdPct int + MaxOutputTokens int +} + +// NativeCompactionResult is provider-neutral output from native compaction. +type NativeCompactionResult struct { + Summary string +} + +// SupportsNativeCompaction reports whether Eyrie can compact this selection +// with a configured provider credential. +func SupportsNativeCompaction(ctx context.Context, provider, model string) bool { + if !supportsAnthropicCompactionSelection(provider, model) { + return false + } + return credentials.LookupSecret(ctx, "ANTHROPIC_API_KEY") != "" +} + +// CompactNativeConversation invokes the provider-native compaction protocol. +func CompactNativeConversation(ctx context.Context, opts NativeCompactionOpts) (*NativeCompactionResult, error) { + if !supportsAnthropicCompactionSelection(opts.Provider, opts.Model) { + return nil, fmt.Errorf("runtime: provider native compaction unavailable for %s/%s", opts.Provider, opts.Model) + } + apiKey := credentials.LookupSecret(ctx, "ANTHROPIC_API_KEY") + if apiKey == "" { + return nil, fmt.Errorf("runtime: Anthropic credential required for native compaction") + } + trigger := opts.ContextWindow * opts.ThresholdPct / 100 + if trigger < anthropicMinCompactTrigger { + trigger = anthropicMinCompactTrigger + } + maxOutput := opts.MaxOutputTokens + if maxOutput <= 0 { + maxOutput = 8192 + } + messages, system := anthropicCompactionMessages(opts.Messages) + body := map[string]any{ + "model": opts.Model, "max_tokens": maxOutput, "messages": messages, + "context_management": map[string]any{"edits": []map[string]any{{ + "type": anthropicCompactionEdit, + "trigger": map[string]any{"type": "input_tokens", "value": trigger}, + "pause_after_compaction": true, + }}}, + } + if system != "" { + body["system"] = system + } + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, anthropicCompactionURL, bytes.NewReader(raw)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Api-Key", apiKey) + req.Header.Set("Anthropic-Version", "2023-06-01") + req.Header.Set("Anthropic-Beta", anthropicCompactionBeta) + + httpClient := &http.Client{Timeout: 120 * time.Second} + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("runtime: Anthropic compaction HTTP %d: %s", resp.StatusCode, truncateNativeCompactionError(respBody)) + } + var parsed anthropicNativeCompactionResponse + if err := json.Unmarshal(respBody, &parsed); err != nil { + return nil, fmt.Errorf("runtime: parse Anthropic compaction response: %w", err) + } + summary := parsed.summary() + if summary == "" { + return nil, fmt.Errorf("runtime: Anthropic compaction produced no summary (stop=%s)", parsed.StopReason) + } + return &NativeCompactionResult{Summary: summary}, nil +} + +func supportsAnthropicCompactionSelection(provider, model string) bool { + provider = strings.ToLower(strings.TrimSpace(provider)) + model = strings.ToLower(strings.TrimSpace(model)) + if provider != "anthropic" && !strings.Contains(provider, "anthropic") && !strings.HasPrefix(model, "claude-") { + return false + } + for _, prefix := range []string{"claude-opus-4", "claude-sonnet-4", "claude-mythos"} { + if strings.HasPrefix(model, prefix) { + return true + } + } + return false +} + +func anthropicCompactionMessages(messages []client.EyrieMessage) ([]map[string]any, string) { + var system string + out := make([]map[string]any, 0, len(messages)) + for _, message := range messages { + if message.Role == "system" { + system = message.Content + continue + } + if message.Role == "assistant" && len(message.ToolUse) > 0 { + content := make([]map[string]any, 0, len(message.ToolUse)+1) + if message.Content != "" { + content = append(content, map[string]any{"type": "text", "text": message.Content}) + } + for _, call := range message.ToolUse { + input := call.Arguments + if input == nil { + input = map[string]any{} + } + content = append(content, map[string]any{"type": "tool_use", "id": call.ID, "name": call.Name, "input": input}) + } + out = append(out, map[string]any{"role": "assistant", "content": content}) + continue + } + if message.Role == "user" && len(message.ToolResults) > 0 { + content := make([]map[string]any, 0, len(message.ToolResults)+1) + if message.Content != "" { + content = append(content, map[string]any{"type": "text", "text": message.Content}) + } + for _, result := range message.ToolResults { + block := map[string]any{"type": "tool_result", "tool_use_id": result.ToolUseID, "content": result.Content} + if result.IsError { + block["is_error"] = true + } + content = append(content, block) + } + out = append(out, map[string]any{"role": "user", "content": content}) + continue + } + out = append(out, map[string]any{"role": message.Role, "content": message.Content}) + } + return out, system +} + +type anthropicNativeCompactionResponse struct { + StopReason string `json:"stop_reason"` + Content []struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Summary string `json:"summary,omitempty"` + } `json:"content"` +} + +func (response *anthropicNativeCompactionResponse) summary() string { + for _, block := range response.Content { + if block.Type != "compaction" && block.Type != "text" { + continue + } + if summary := strings.TrimSpace(block.Summary); summary != "" { + return summary + } + if text := strings.TrimSpace(block.Text); text != "" { + return text + } + } + return "" +} + +func truncateNativeCompactionError(body []byte) string { + text := strings.TrimSpace(string(body)) + if len(text) > 200 { + return text[:200] + "..." + } + return text +} diff --git a/runtime/native_compaction_test.go b/runtime/native_compaction_test.go new file mode 100644 index 0000000..e992fc8 --- /dev/null +++ b/runtime/native_compaction_test.go @@ -0,0 +1,47 @@ +package runtime + +import ( + "testing" + + "github.com/GrayCodeAI/eyrie/client" +) + +func TestSupportsAnthropicCompactionSelection(t *testing.T) { + tests := []struct { + provider string + model string + want bool + }{ + {"anthropic", "claude-sonnet-4-6", true}, + {"openrouter", "claude-opus-4-6", true}, + {"anthropic", "claude-haiku-3", false}, + {"openai", "gpt-5", false}, + } + for _, tt := range tests { + if got := supportsAnthropicCompactionSelection(tt.provider, tt.model); got != tt.want { + t.Errorf("supportsAnthropicCompactionSelection(%q, %q) = %v, want %v", tt.provider, tt.model, got, tt.want) + } + } +} + +func TestAnthropicCompactionMessagesPreservesTools(t *testing.T) { + messages, system := anthropicCompactionMessages([]client.EyrieMessage{ + {Role: "system", Content: "system prompt"}, + {Role: "assistant", Content: "calling", ToolUse: []client.ToolCall{{ID: "tool-1", Name: "read"}}}, + {Role: "user", ToolResults: []client.ToolResult{{ToolUseID: "tool-1", Content: "result", IsError: true}}}, + }) + if system != "system prompt" { + t.Fatalf("system = %q", system) + } + if len(messages) != 2 { + t.Fatalf("messages = %#v", messages) + } + assistant, ok := messages[0]["content"].([]map[string]any) + if !ok || len(assistant) != 2 || assistant[1]["type"] != "tool_use" { + t.Fatalf("assistant content = %#v", messages[0]["content"]) + } + results, ok := messages[1]["content"].([]map[string]any) + if !ok || len(results) != 1 || results[0]["is_error"] != true { + t.Fatalf("tool results = %#v", messages[1]["content"]) + } +} diff --git a/runtime/runtime.go b/runtime/runtime.go index ad15d6b..19d5618 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -26,10 +26,12 @@ import ( "context" "encoding/json" "fmt" + "sort" "strings" "time" "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/catalog/registry" "github.com/GrayCodeAI/eyrie/client" "github.com/GrayCodeAI/eyrie/config" "github.com/GrayCodeAI/eyrie/credentials" @@ -121,9 +123,25 @@ func ChatProvider(ctx context.Context) (client.Provider, error) { return setup.DeploymentProvider(ctx, cfg) } -// AvailableProviders lists the currently registered provider IDs. +// AvailableProviders lists engine-owned provider IDs. Built-ins come from the +// canonical catalog registry; client-only entries are dynamically registered +// providers and are included for backwards compatibility. func AvailableProviders() []string { - return client.Client(nil).GetProviders() + seen := make(map[string]struct{}) + providers := make([]string, 0, len(registry.All())) + for _, spec := range registry.All() { + seen[spec.ProviderID] = struct{}{} + providers = append(providers, spec.ProviderID) + } + for _, provider := range client.Client(nil).GetProviders() { + if _, ok := seen[provider]; ok { + continue + } + seen[provider] = struct{}{} + providers = append(providers, provider) + } + sort.Strings(providers) + return providers } // RoutingPreviewJSON returns effective routing for a model ID. diff --git a/runtime/runtime_test.go b/runtime/runtime_test.go index 05f06f7..8ae760f 100644 --- a/runtime/runtime_test.go +++ b/runtime/runtime_test.go @@ -5,10 +5,12 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "sort" "testing" "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/catalog/registry" "github.com/GrayCodeAI/eyrie/config" ) @@ -648,6 +650,26 @@ func TestListProviderSetupOptions(t *testing.T) { } } +func TestAvailableProvidersIncludesCanonicalRegistry(t *testing.T) { + providers := AvailableProviders() + want := make([]string, 0, len(registry.All())) + for _, spec := range registry.All() { + want = append(want, spec.ProviderID) + } + sort.Strings(want) + + if !reflect.DeepEqual(providers, want) { + t.Fatalf("AvailableProviders() = %v, want canonical registry %v", providers, want) + } +} + +func TestAvailableProvidersIsSorted(t *testing.T) { + providers := AvailableProviders() + if !sort.StringsAreSorted(providers) { + t.Fatalf("AvailableProviders() is not sorted: %v", providers) + } +} + // --- helpers --- func mustCompileTestCatalog(t *testing.T) *catalog.CompiledCatalog { diff --git a/scripts/check-client-layering.sh b/scripts/check-client-layering.sh new file mode 100755 index 0000000..c828a6e --- /dev/null +++ b/scripts/check-client-layering.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Enforce the client package decomposition layering +# (plans/client-package-decomposition.md): +# - client/core is a leaf: it must not import any eyrie/client package. +# - client subpackages (embeddings, ...) may import client/core only — +# never the client facade or a sibling subpackage. +set -euo pipefail +cd "$(dirname "$0")/.." + +fail=0 + +# core must not import any eyrie/client package. +if grep -rn --include='*.go' '"github.com/GrayCodeAI/eyrie/client' client/core/ | grep -v '/client/core"'; then + echo "FAIL: client/core must not import other eyrie/client packages" >&2 + fail=1 +fi + +# Subpackages (all dirs under client/ except core) may import only client/core. +for dir in client/*/; do + name=$(basename "$dir") + [ "$name" = "core" ] && continue + if grep -rn --include='*.go' '"github.com/GrayCodeAI/eyrie/client' "$dir" | grep -v "/client/core\"" ; then + echo "FAIL: client/$name may import client/core only (no facade, no siblings)" >&2 + fail=1 + fi +done + +if [ "$fail" -ne 0 ]; then exit 1; fi +echo "client layering guard passed" From bbe18ec3317933c7df92c4cd7050c9e1967f40e5 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 12 Jul 2026 19:17:57 +0530 Subject: [PATCH 03/24] feat: add host-neutral engine facade --- config/provider_env.go | 9 +- config/provider_env_test.go | 8 + docs/architecture/HOST-ENGINE-BOUNDARY.md | 97 +++++++ engine/catalog.go | 114 ++++++++ engine/classify.go | 57 ++++ engine/convert.go | 75 +++++ engine/credentials.go | 82 ++++++ engine/doc.go | 10 + engine/engine.go | 338 ++++++++++++++++++++++ engine/engine_test.go | 229 +++++++++++++++ engine/errors.go | 65 +++++ engine/selection.go | 62 ++++ engine/state.go | 102 +++++++ engine/stream.go | 161 +++++++++++ engine/types.go | 192 ++++++++++++ setup/deployment.go | 15 +- 16 files changed, 1611 insertions(+), 5 deletions(-) create mode 100644 docs/architecture/HOST-ENGINE-BOUNDARY.md create mode 100644 engine/catalog.go create mode 100644 engine/classify.go create mode 100644 engine/convert.go create mode 100644 engine/credentials.go create mode 100644 engine/doc.go create mode 100644 engine/engine.go create mode 100644 engine/engine_test.go create mode 100644 engine/errors.go create mode 100644 engine/selection.go create mode 100644 engine/state.go create mode 100644 engine/stream.go create mode 100644 engine/types.go diff --git a/config/provider_env.go b/config/provider_env.go index d31c587..d746828 100644 --- a/config/provider_env.go +++ b/config/provider_env.go @@ -335,13 +335,20 @@ var ProviderDetectionOrder = APIProviderDetectionOrder // GetProviderConfigDir returns the config directory path. func GetProviderConfigDir() string { + if d := strings.TrimSpace(os.Getenv("EYRIE_CONFIG_DIR")); d != "" { + return d + } + // HAWK_CONFIG_DIR is retained as a compatibility fallback for hosts that + // predate Eyrie's host-neutral configuration namespace. if d := os.Getenv("HAWK_CONFIG_DIR"); d != "" { return d } if d, err := os.UserConfigDir(); err == nil && d != "" { + // Preserve the historical default until the host-neutral Engine path + // migration can copy existing provider state safely. return filepath.Join(d, "hawk") } - panic("hawk provider config: user config directory unavailable") + panic("eyrie provider config: user config directory unavailable") } // GetProviderConfigPath returns the full path to provider.json. diff --git a/config/provider_env_test.go b/config/provider_env_test.go index 32df2c8..30a41d4 100644 --- a/config/provider_env_test.go +++ b/config/provider_env_test.go @@ -133,6 +133,14 @@ func TestGetProviderConfigDir(t *testing.T) { } } +func TestGetProviderConfigDirPrefersEyrieNamespace(t *testing.T) { + t.Setenv("EYRIE_CONFIG_DIR", "/tmp/eyrie-config") + t.Setenv("HAWK_CONFIG_DIR", "/tmp/legacy-hawk-config") + if got := GetProviderConfigDir(); got != "/tmp/eyrie-config" { + t.Fatalf("GetProviderConfigDir() = %q, want EYRIE_CONFIG_DIR", got) + } +} + func TestGetProviderConfigPath(t *testing.T) { dir := t.TempDir() os.Setenv("HAWK_CONFIG_DIR", dir) diff --git a/docs/architecture/HOST-ENGINE-BOUNDARY.md b/docs/architecture/HOST-ENGINE-BOUNDARY.md new file mode 100644 index 0000000..a8ed58f --- /dev/null +++ b/docs/architecture/HOST-ENGINE-BOUNDARY.md @@ -0,0 +1,97 @@ +# Host–Eyrie Engine Boundary + +Status: accepted and being migrated incrementally. + +## Decision + +Eyrie is a host-neutral LLM runtime. A host such as Hawk owns its product UI, +agent loop, tool execution, permissions, conversation history, checkpoints, and +task semantics. Eyrie owns credentials, model discovery, capability matching, +provider/deployment routing, request and stream normalization, resilience, +usage, and provider telemetry. + +```text +Host product ──► eyrie/engine ──► provider runtime ──► model APIs +``` + +The dependency is one-way. Eyrie must not import a host product. + +## Stable host API + +New host integrations should import `github.com/GrayCodeAI/eyrie/engine`. +Lower-level packages remain public during the compatibility migration, but a +host should not need to assemble `client`, `catalog`, `config`, `credentials`, +`router`, `runtime`, or `setup` directly. + +The facade owns these stable concepts: + +- `Engine` +- `GenerateRequest` and `GenerateResponse` +- `Message`, `Tool`, `ToolCall`, and `ToolResult` +- `Requirements` and `Preference` +- `Route` +- `Stream` and typed `Event` +- `CatalogSnapshot` and `Model` +- `CredentialStatus` +- typed `Error` and `ErrorCode` + +## Selection contract + +Hosts express requirements rather than provider-specific assumptions: + +```text +requirements: streaming, tools, vision, structured JSON, reasoning, context +preference: fast, balanced, reasoning, economical, provider/model override +``` + +An explicitly requested model is a hard constraint. Eyrie returns a capability +error when it is incompatible unless the host explicitly enables fallback. +Persisted/default selections may be replaced by a compatible catalog route. + +## Conversation contract + +Eyrie generation is stateless from the host's point of view: + +```text +Host owns Eyrie owns +---------- ----------- +conversation history route decision +tool execution provider request +permissions retries and fallback +checkpoints and replay stream normalization +product memory usage and telemetry +``` + +Eyrie's generic `conversation` package remains available to other consumers, +but hosts with a product session model should not create two authoritative +conversation stores. + +## Streaming contract + +`Stream` is pull-based, cancellable, and must be closed. It emits a route event +before provider events and normalizes content, thinking, tool calls, usage, +TTFT, and completion. Unknown future event types are additive and must be +safely ignored by hosts. + +Eyrie never executes a host tool. It emits a normalized tool request; the host +performs permission checks, executes it, appends the result to its history, and +starts the next generation. + +## Credential contract + +The facade accepts an injected `credentials.Store`. Secrets never belong in +provider configuration, model requests, logs, telemetry, or host tool +environments. Credential status values contain identifiers and booleans only. + +## Compatibility migration + +1. Add the facade without removing existing APIs. +2. Move host credential, catalog, selection, and transport calls to the facade. +3. Enforce new import boundaries while listing temporary compatibility + exceptions. +4. Remove duplicated host provider routing and mirrored transport types. +5. Remove deprecated lower-level host entry points only at a semantic-version + boundary. + +Standalone Eyrie development is committed and tested first. Hosts then update +their pinned Eyrie submodule revision and run their integration suites. diff --git a/engine/catalog.go b/engine/catalog.go new file mode 100644 index 0000000..739d3dc --- /dev/null +++ b/engine/catalog.go @@ -0,0 +1,114 @@ +package engine + +import ( + "context" + "sort" + "strings" + "time" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/catalog/discover" + "github.com/GrayCodeAI/eyrie/catalog/registry" +) + +// Catalog returns the current cached catalog through a stable snapshot. +func (e *Engine) Catalog(ctx context.Context) (CatalogSnapshot, error) { + ctx = nonNilContext(ctx) + compiled, err := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{CachePath: e.catalogPath, RequireCache: true}) + if err != nil { + return CatalogSnapshot{}, &Error{Code: ErrorCatalogUnavailable, Operation: "catalog", Message: err.Error(), Cause: err} + } + snapshot := snapshotFromCompiled(compiled) + snapshot.CachePath = e.catalogPath + return snapshot, nil +} + +// RefreshCatalog refreshes one provider when providerID is non-empty, or all +// configured providers otherwise. Credentials are read from this Engine's +// injected store rather than a package-global store. +func (e *Engine) RefreshCatalog(ctx context.Context, providerID string) (CatalogSnapshot, error) { + ctx = nonNilContext(ctx) + compiled, _ := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{CachePath: e.catalogPath}) + creds := catalog.Credentials{APIKeys: e.credentialEnv(ctx, compiled)} + var ( + result *catalog.RefreshResult + err error + ) + if providerID = strings.TrimSpace(providerID); providerID != "" { + if _, ok := registry.SpecByProviderID(providerID); !ok { + return CatalogSnapshot{}, &Error{Code: ErrorInvalidRequest, Operation: "refresh_catalog", Provider: providerID, Message: "eyrie engine: unknown provider"} + } + // The refresh shares one compiled-cache transaction so custom host paths + // remain isolated. Credential presence limits live discovery to configured + // providers; providerID is retained for host status/error attribution. + } + result, err = discover.Run(ctx, discover.Options{ + LoadCatalogOptions: catalog.LoadCatalogOptions{CachePath: e.catalogPath, RefreshRemote: true}, + Credentials: creds, + }) + if err != nil { + return CatalogSnapshot{}, &Error{Code: ErrorCatalogUnavailable, Operation: "refresh_catalog", Provider: providerID, Message: err.Error(), Cause: err} + } + if result == nil || result.Compiled == nil { + return CatalogSnapshot{}, &Error{Code: ErrorCatalogUnavailable, Operation: "refresh_catalog", Provider: providerID, Message: "eyrie engine: catalog refresh returned no compiled catalog"} + } + snapshot := snapshotFromCompiled(result.Compiled) + snapshot.CachePath = e.catalogPath + return snapshot, nil +} + +func snapshotFromCompiled(compiled *catalog.CompiledCatalog) CatalogSnapshot { + snapshot := CatalogSnapshot{CachePath: catalog.DefaultCachePath(), LoadedAt: time.Now().UTC()} + if compiled == nil { + return snapshot + } + ids := make([]string, 0, len(compiled.ModelsByID)) + for id := range compiled.ModelsByID { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + model := compiled.ModelsByID[id] + offering := firstOffering(compiled.OfferingsByCanonicalModel[id]) + inputPrice, outputPrice := 0.0, 0.0 + if offering.Pricing.RatesPer1M != nil { + inputPrice = offering.Pricing.RatesPer1M["input_tokens"] + outputPrice = offering.Pricing.RatesPer1M["output_tokens"] + } + snapshot.Models = append(snapshot.Models, Model{ + ID: id, DisplayName: model.Name, ProviderID: model.ProviderID, + ContextWindow: model.ContextWindow, MaxOutputTokens: model.MaxOutput, + InputPricePer1M: inputPrice, OutputPricePer1M: outputPrice, + Capabilities: capabilityNames(offering.Capabilities), Source: "catalog", + }) + } + if compiled.Catalog != nil && !compiled.Catalog.StaleAfter.IsZero() { + snapshot.Stale = time.Now().UTC().After(compiled.Catalog.StaleAfter) + } + return snapshot +} + +func firstOffering(offerings []catalog.ModelOffering) catalog.ModelOffering { + if len(offerings) == 0 { + return catalog.ModelOffering{} + } + return offerings[0] +} + +func capabilityNames(caps catalog.CapabilitySet) []string { + var out []string + if caps.FunctionCalling == catalog.CapabilitySupported { + out = append(out, "tools") + } + if caps.ImageInput == catalog.CapabilitySupported { + out = append(out, "vision") + } + if caps.StructuredOutput == catalog.CapabilitySupported { + out = append(out, "structured_json") + } + if caps.ExplicitThinkingBudget == catalog.CapabilitySupported || caps.AdaptiveThinking == catalog.CapabilitySupported || caps.Effort == catalog.CapabilitySupported { + out = append(out, "reasoning") + } + sort.Strings(out) + return out +} diff --git a/engine/classify.go b/engine/classify.go new file mode 100644 index 0000000..6f588cd --- /dev/null +++ b/engine/classify.go @@ -0,0 +1,57 @@ +package engine + +import ( + "context" + "errors" + "strings" + + "github.com/GrayCodeAI/eyrie/client" +) + +func classify(operation string, route Route, err error) error { + if err == nil { + return nil + } + var existing *Error + if errors.As(err, &existing) { + return err + } + code := ErrorInternal + retryable := false + switch { + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + code = ErrorCancelled + default: + var providerErr *client.EyrieError + if errors.As(err, &providerErr) { + retryable = providerErr.IsRetriable() + switch { + case providerErr.IsAuthError(): + code = ErrorAuthentication + case providerErr.IsRateLimited(): + code = ErrorRateLimited + case retryable: + code = ErrorProviderUnavailable + default: + code = ErrorInvalidRequest + } + } else { + message := strings.ToLower(err.Error()) + switch { + case strings.Contains(message, "context") && (strings.Contains(message, "long") || strings.Contains(message, "token")): + code = ErrorContextExceeded + case strings.Contains(message, "credential") || strings.Contains(message, "api key"): + code = ErrorCredentialMissing + case strings.Contains(message, "rate limit") || strings.Contains(message, "429"): + code = ErrorRateLimited + retryable = true + case strings.Contains(message, "provider") || strings.Contains(message, "transport"): + code = ErrorProviderUnavailable + } + } + } + return &Error{ + Code: code, Operation: operation, Provider: route.Provider, Model: route.Model, + Message: err.Error(), Retryable: retryable, Cause: err, + } +} diff --git a/engine/convert.go b/engine/convert.go new file mode 100644 index 0000000..3c91e46 --- /dev/null +++ b/engine/convert.go @@ -0,0 +1,75 @@ +package engine + +import "github.com/GrayCodeAI/eyrie/client" + +func toClientMessages(in []Message) []client.EyrieMessage { + out := make([]client.EyrieMessage, 0, len(in)) + for _, message := range in { + parts := make([]client.ContentPart, 0, len(message.ContentParts)) + for _, part := range message.ContentParts { + converted := client.ContentPart{Type: part.Type, Text: part.Text} + if part.URL != "" { + converted.ImageURL = &client.ImageURLPart{URL: part.URL, Detail: part.Detail} + } + if part.AudioData != "" { + converted.InputAudio = &client.InputAudioPart{Data: part.AudioData, Format: part.AudioFormat} + } + parts = append(parts, converted) + } + calls := make([]client.ToolCall, 0, len(message.ToolCalls)) + for _, call := range message.ToolCalls { + calls = append(calls, client.ToolCall{ID: call.ID, Name: call.Name, Arguments: call.Arguments}) + } + results := make([]client.ToolResult, 0, len(message.ToolResults)) + for _, result := range message.ToolResults { + results = append(results, client.ToolResult{ToolUseID: result.ToolUseID, Content: result.Content, IsError: result.IsError}) + } + out = append(out, client.EyrieMessage{ + Role: message.Role, Content: message.Content, Thinking: message.Thinking, + ContentParts: parts, ToolUse: calls, ToolResults: results, + }) + } + return out +} + +func toClientOptions(req GenerateRequest, route Route, stream bool) client.ChatOptions { + tools := make([]client.EyrieTool, 0, len(req.Tools)) + for _, tool := range req.Tools { + tools = append(tools, client.EyrieTool{Name: tool.Name, Description: tool.Description, Parameters: tool.Parameters}) + } + opts := client.ChatOptions{ + Provider: route.Provider, Model: route.Model, Stream: stream, + System: req.SystemPrompt, Tools: tools, Temperature: req.Temperature, + MaxTokens: req.Limits.MaxOutputTokens, MetadataUserID: req.Metadata.UserID, + } + if req.OutputSchema != "" { + opts.ResponseFormat = &client.ResponseFormat{Type: "json_schema", Schema: req.OutputSchema} + } + return opts +} + +func fromClientResponse(resp *client.EyrieResponse, route Route) *GenerateResponse { + if resp == nil { + return &GenerateResponse{Route: route} + } + calls := make([]ToolCall, 0, len(resp.ToolCalls)) + for _, call := range resp.ToolCalls { + calls = append(calls, ToolCall{ID: call.ID, Name: call.Name, Arguments: call.Arguments}) + } + return &GenerateResponse{ + Content: resp.Content, Thinking: resp.Thinking, ToolCalls: calls, + FinishReason: resp.FinishReason, RequestID: resp.RequestID, + Usage: fromClientUsage(resp.Usage), Route: route, + } +} + +func fromClientUsage(usage *client.EyrieUsage) *Usage { + if usage == nil { + return nil + } + return &Usage{ + InputTokens: usage.PromptTokens, OutputTokens: usage.CompletionTokens, + TotalTokens: usage.TotalTokens, CacheCreationTokens: usage.CacheCreationTokens, + CacheReadTokens: usage.CacheReadTokens, ThinkingTokens: usage.ThinkingTokens, + } +} diff --git a/engine/credentials.go b/engine/credentials.go new file mode 100644 index 0000000..0054d82 --- /dev/null +++ b/engine/credentials.go @@ -0,0 +1,82 @@ +package engine + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/GrayCodeAI/eyrie/catalog/registry" + "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" +) + +// SaveCredential validates, stores, and probes a provider credential. The +// secret is persisted before the probe so a transient network failure does not +// discard user input. The returned error states that persistence occurred. +func (e *Engine) SaveCredential(ctx context.Context, providerID, secret string) (CredentialStatus, error) { + ctx = nonNilContext(ctx) + providerID = strings.TrimSpace(providerID) + if providerID == "" { + return CredentialStatus{}, invalid("save_credential", "eyrie engine: provider id is required") + } + inference, err := config.InferenceForProvider(providerID) + if err != nil { + return CredentialStatus{}, &Error{Code: ErrorInvalidRequest, Operation: "save_credential", Provider: providerID, Message: err.Error(), Cause: err} + } + prepared, err := config.PrepareCredentialForSave(inference, secret) + if err != nil { + return CredentialStatus{}, &Error{Code: ErrorInvalidRequest, Operation: "save_credential", Provider: providerID, Message: err.Error(), Cause: err} + } + envKey := strings.TrimSpace(inference.EnvVar) + if envKey == "" { + return CredentialStatus{}, invalid("save_credential", "eyrie engine: provider has no credential target") + } + if err := e.secretStore.Set(ctx, credentials.AccountForEnv(envKey), prepared); err != nil { + return CredentialStatus{}, &Error{Code: ErrorInternal, Operation: "save_credential", Provider: providerID, Message: "eyrie engine: could not save credential", Cause: err} + } + status := CredentialStatus{ProviderID: providerID, EnvVar: envKey, Configured: true} + if spec, ok := registry.DefaultRegistry.Get(providerID); ok && !spec.RequiresKey { + if err := config.ProbeLocalCredential(ctx, envKey, prepared); err != nil { + return status, &Error{Code: ErrorProviderUnavailable, Operation: "probe_credential", Provider: providerID, Message: fmt.Sprintf("%v (credential saved)", err), Cause: err} + } + status.Verified = true + return status, nil + } + if err := config.ProbeCredential(ctx, envKey, prepared); err != nil { + return status, &Error{Code: ErrorAuthentication, Operation: "probe_credential", Provider: providerID, Message: fmt.Sprintf("%v (credential saved)", err), Cause: err} + } + status.Verified = true + return status, nil +} + +// RemoveCredential deletes a provider credential from the configured store. +func (e *Engine) RemoveCredential(ctx context.Context, providerID string) error { + ctx = nonNilContext(ctx) + inference, err := config.InferenceForProvider(strings.TrimSpace(providerID)) + if err != nil { + return &Error{Code: ErrorInvalidRequest, Operation: "remove_credential", Provider: providerID, Message: err.Error(), Cause: err} + } + if err := e.secretStore.Delete(ctx, credentials.AccountForEnv(inference.EnvVar)); err != nil && !errors.Is(err, credentials.ErrNotFound) { + return &Error{Code: ErrorInternal, Operation: "remove_credential", Provider: providerID, Message: "eyrie engine: could not remove credential", Cause: err} + } + return nil +} + +// CredentialStatus reports whether a provider credential is configured. +func (e *Engine) CredentialStatus(ctx context.Context, providerID string) (CredentialStatus, error) { + ctx = nonNilContext(ctx) + providerID = strings.TrimSpace(providerID) + inference, err := config.InferenceForProvider(providerID) + if err != nil { + return CredentialStatus{}, &Error{Code: ErrorInvalidRequest, Operation: "credential_status", Provider: providerID, Message: err.Error(), Cause: err} + } + _, err = e.secretStore.Get(ctx, credentials.AccountForEnv(inference.EnvVar)) + if err == nil { + return CredentialStatus{ProviderID: providerID, EnvVar: inference.EnvVar, Configured: true}, nil + } + if errors.Is(err, credentials.ErrNotFound) { + return CredentialStatus{ProviderID: providerID, EnvVar: inference.EnvVar}, nil + } + return CredentialStatus{}, &Error{Code: ErrorInternal, Operation: "credential_status", Provider: providerID, Message: "eyrie engine: could not read credential status", Cause: err} +} diff --git a/engine/doc.go b/engine/doc.go new file mode 100644 index 0000000..d77ff76 --- /dev/null +++ b/engine/doc.go @@ -0,0 +1,10 @@ +// Package engine is the stable, host-facing Eyrie API. +// +// Hosts should prefer this package over assembling client, catalog, config, +// credentials, runtime, and setup packages directly. The lower-level packages +// remain public for backward compatibility and advanced integrations. +// +// 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. +package engine diff --git a/engine/engine.go b/engine/engine.go new file mode 100644 index 0000000..4a72364 --- /dev/null +++ b/engine/engine.go @@ -0,0 +1,338 @@ +package engine + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/client" + "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/eyrie/setup" +) + +// ContractVersion is the compatibility version of the host-facing API. +const ContractVersion = "1" + +// Options supplies host-owned dependencies. A zero value uses Eyrie's safe +// defaults. Product-specific paths are deliberately not inferred here. +type Options struct { + SecretStore credentials.Store + StateDir string + CatalogPath string + ProviderConfigPath string +} + +// Engine is Eyrie's narrow host facade. It is safe for concurrent use when +// the configured SecretStore is safe for concurrent use. +type Engine struct { + secretStore credentials.Store + catalogPath string + providerConfigPath string +} + +// New constructs a host-facing Eyrie engine. +func New(opts Options) (*Engine, error) { + store := opts.SecretStore + if store == nil { + store = credentials.DefaultStore() + } + if store == nil { + return nil, &Error{Code: ErrorInternal, Operation: "new", Message: "eyrie engine: credential store unavailable"} + } + stateDir := strings.TrimSpace(opts.StateDir) + catalogPath := strings.TrimSpace(opts.CatalogPath) + providerPath := strings.TrimSpace(opts.ProviderConfigPath) + if catalogPath == "" && stateDir != "" { + catalogPath = filepath.Join(stateDir, "model_catalog.json") + } + if providerPath == "" && stateDir != "" { + providerPath = filepath.Join(stateDir, "provider.json") + } + if catalogPath == "" { + catalogPath = catalog.DefaultCachePath() + } + if providerPath == "" { + providerPath = config.GetProviderConfigPath() + } + return &Engine{secretStore: store, catalogPath: catalogPath, providerConfigPath: providerPath}, nil +} + +// SelectionRequest asks Eyrie to resolve a concrete provider/model route. +type SelectionRequest struct { + Requirements Requirements + Preference Preference +} + +// Resolve selects a concrete provider/model and verifies known catalog +// requirements before transport construction. +func (e *Engine) Resolve(ctx context.Context, req SelectionRequest) (Route, error) { + ctx = nonNilContext(ctx) + selection, err := e.resolveSelection(ctx, req) + if err != nil { + return Route{}, err + } + return selection, nil +} + +// Generate performs a blocking generation through Eyrie's resolved transport. +func (e *Engine) Generate(ctx context.Context, req GenerateRequest) (*GenerateResponse, error) { + ctx = nonNilContext(ctx) + if err := validateGenerateRequest(req); err != nil { + return nil, err + } + route, provider, err := e.resolveProvider(ctx, req) + if err != nil { + return nil, err + } + callCtx, cancel := requestContext(ctx, req.Limits.Timeout) + defer cancel() + resp, err := provider.Chat(callCtx, toClientMessages(req.Messages), toClientOptions(req, route, false)) + if err != nil { + return nil, classify("generate", route, err) + } + return fromClientResponse(resp, route), nil +} + +// 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) { + ctx = nonNilContext(ctx) + if err := validateGenerateRequest(req); err != nil { + return nil, err + } + route, provider, err := e.resolveProvider(ctx, req) + if err != nil { + return nil, err + } + callCtx, cancel := requestContext(ctx, req.Limits.Timeout) + source, err := provider.StreamChat(callCtx, toClientMessages(req.Messages), toClientOptions(req, route, true)) + if err != nil { + cancel() + return nil, classify("stream", route, err) + } + return newStream(callCtx, cancel, source, route), nil +} + +// ListModels returns provider models through the stable facade. +func (e *Engine) ListModels(ctx context.Context, providerID string, refresh bool) ([]Model, error) { + ctx = nonNilContext(ctx) + var snapshot CatalogSnapshot + var err error + if refresh { + snapshot, err = e.RefreshCatalog(ctx, providerID) + } else { + snapshot, err = e.Catalog(ctx) + } + if err != nil { + return nil, &Error{Code: ErrorCatalogUnavailable, Operation: "list_models", Provider: providerID, Message: err.Error(), Cause: err} + } + providerID = catalog.CanonicalProviderID(strings.TrimSpace(providerID)) + out := make([]Model, 0, len(snapshot.Models)) + for _, model := range snapshot.Models { + if providerID == "" || catalog.CanonicalProviderID(model.ProviderID) == providerID { + out = append(out, model) + } + } + return out, nil +} + +func (e *Engine) resolveProvider(ctx context.Context, req GenerateRequest) (Route, client.Provider, error) { + route, err := e.resolveSelection(ctx, SelectionRequest{Requirements: req.Requirements, Preference: req.Preference}) + if err != nil { + return Route{}, nil, err + } + compiled, cfg, err := e.loadRuntimeState(ctx) + if err != nil { + return Route{}, nil, err + } + provider, err := setup.DeploymentProviderFromCatalog(cfg, compiled) + if err != nil { + return Route{}, nil, classify("resolve_transport", route, err) + } + return route, provider, nil +} + +func (e *Engine) resolveSelection(ctx context.Context, req SelectionRequest) (Route, error) { + compiled, cfg, err := e.loadRuntimeState(ctx) + if err != nil { + return Route{}, err + } + selection := Route{ + Provider: strings.TrimSpace(req.Preference.PreferredProvider), + Model: strings.TrimSpace(req.Preference.PreferredModelID), + DeploymentRouting: true, + } + if selection.Provider == "" { + selection.Provider = config.ActiveProvider(cfg) + } + if selection.Model == "" { + selection.Model = config.ActiveModel(cfg) + } + if selection.Provider == "" && selection.Model != "" { + selection.Provider = catalog.GatewayForModel(compiled, selection.Model) + if selection.Provider == "" { + selection.Provider = catalog.ProviderForModel(compiled, selection.Model) + } + } + if strings.TrimSpace(selection.Model) != "" { + err := validateRequirementsFromCatalog(compiled, selection.Model, req.Requirements) + if err == nil { + return selection, nil + } + // A user-selected exact model is a hard constraint unless fallback was + // explicitly permitted. Persisted/default selections may be replaced by + // a capability-compatible route. + if req.Preference.PreferredModelID != "" && !req.Preference.AllowFallback { + return Route{}, err + } + } + modelID, providerID := selectCompatibleModel(compiled, req) + if modelID == "" { + return Route{}, &Error{Code: ErrorCapabilityMismatch, Operation: "resolve", Message: "eyrie engine: no catalog model satisfies the requested capabilities"} + } + selection.Model = modelID + selection.Provider = providerID + return selection, nil +} + +type modelCandidate struct { + model catalog.Model + offering catalog.ModelOffering + provider string + cost float64 +} + +func selectCompatibleModel(compiled *catalog.CompiledCatalog, req SelectionRequest) (string, string) { + if compiled == nil { + return "", "" + } + preferredProvider := catalog.CanonicalProviderID(req.Preference.PreferredProvider) + var candidates []modelCandidate + for id, model := range compiled.ModelsByID { + if req.Requirements.MinimumContext > 0 && model.ContextWindow < req.Requirements.MinimumContext { + continue + } + if !offeringSupports(compiled, id, req.Requirements) { + continue + } + provider := catalog.CanonicalProviderID(catalog.GatewayForModel(compiled, id)) + if provider == "" { + provider = catalog.CanonicalProviderID(model.ProviderID) + } + if preferredProvider != "" && provider != preferredProvider { + continue + } + offering := firstOffering(compiled.OfferingsByCanonicalModel[id]) + cost := offering.Pricing.RatesPer1M["input_tokens"] + offering.Pricing.RatesPer1M["output_tokens"] + candidates = append(candidates, modelCandidate{model: model, offering: offering, provider: provider, cost: cost}) + } + if len(candidates) == 0 { + return "", "" + } + sort.SliceStable(candidates, func(i, j int) bool { + a, b := candidates[i], candidates[j] + switch req.Preference.Intent { + case IntentEconomical: + if a.cost != b.cost { + return a.cost < b.cost + } + case IntentReasoning: + if a.model.ContextWindow != b.model.ContextWindow { + return a.model.ContextWindow > b.model.ContextWindow + } + case IntentFast: + if a.model.MaxOutput != b.model.MaxOutput { + return a.model.MaxOutput < b.model.MaxOutput + } + } + return a.model.ID < b.model.ID + }) + return candidates[0].model.ID, candidates[0].provider +} + +func validateGenerateRequest(req GenerateRequest) error { + if len(req.Messages) == 0 { + return invalid("generate", "eyrie engine: at least one message is required") + } + for _, message := range req.Messages { + if strings.TrimSpace(message.Role) == "" { + return invalid("generate", "eyrie engine: every message requires a role") + } + } + if req.Limits.MaxOutputTokens < 0 { + return invalid("generate", "eyrie engine: max output tokens cannot be negative") + } + if req.Requirements.MinimumContext < 0 { + return invalid("generate", "eyrie engine: minimum context cannot be negative") + } + return nil +} + +func validateRequirementsFromCatalog(compiled *catalog.CompiledCatalog, modelID string, req Requirements) error { + if req.MinimumContext <= 0 && !req.Tools && !req.Vision && !req.StructuredJSON && !req.Reasoning { + return nil + } + if compiled == nil { + return &Error{Code: ErrorCatalogUnavailable, Operation: "validate_capabilities", Model: modelID, Message: "eyrie engine: catalog unavailable for capability validation"} + } + canonical, ok := compiled.CanonicalModelForAliasOrID(modelID) + if !ok { + canonical = modelID + } + model, ok := compiled.ModelsByID[canonical] + if !ok { + return &Error{Code: ErrorModelUnavailable, Operation: "validate_capabilities", Model: modelID, Message: fmt.Sprintf("eyrie engine: model %q is not in the catalog", modelID)} + } + if req.MinimumContext > 0 && model.ContextWindow < req.MinimumContext { + return &Error{Code: ErrorCapabilityMismatch, Operation: "validate_capabilities", Model: canonical, Message: fmt.Sprintf("eyrie engine: model %q has context window %d, need at least %d", canonical, model.ContextWindow, req.MinimumContext)} + } + if req.Tools || req.Vision || req.StructuredJSON || req.Reasoning { + if !offeringSupports(compiled, canonical, req) { + return &Error{Code: ErrorCapabilityMismatch, Operation: "validate_capabilities", Model: canonical, Message: fmt.Sprintf("eyrie engine: model %q does not satisfy requested capabilities", canonical)} + } + } + return nil +} + +func offeringSupports(compiled *catalog.CompiledCatalog, modelID string, req Requirements) bool { + offerings := compiled.OfferingsByCanonicalModel[modelID] + if len(offerings) == 0 { + return !req.Tools && !req.Vision && !req.StructuredJSON && !req.Reasoning + } + for _, offering := range offerings { + caps := offering.Capabilities + if req.Tools && caps.FunctionCalling != catalog.CapabilitySupported { + continue + } + if req.Vision && caps.ImageInput != catalog.CapabilitySupported { + continue + } + if req.StructuredJSON && caps.StructuredOutput != catalog.CapabilitySupported { + continue + } + if req.Reasoning && caps.ExplicitThinkingBudget != catalog.CapabilitySupported && caps.AdaptiveThinking != catalog.CapabilitySupported && caps.Effort != catalog.CapabilitySupported { + continue + } + return true + } + return false +} + +func requestContext(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + if timeout > 0 { + return context.WithTimeout(ctx, timeout) + } + return context.WithCancel(ctx) +} + +func nonNilContext(ctx context.Context) context.Context { + if ctx == nil { + return context.Background() + } + return ctx +} diff --git a/engine/engine_test.go b/engine/engine_test.go new file mode 100644 index 0000000..c3e0963 --- /dev/null +++ b/engine/engine_test.go @@ -0,0 +1,229 @@ +package engine + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/client" + "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestNewUsesInjectedCredentialStore(t *testing.T) { + store := &credentials.MapStore{} + eng, err := New(Options{SecretStore: store}) + if err != nil { + t.Fatal(err) + } + if eng.secretStore != store { + t.Fatal("engine did not retain injected credential store") + } +} + +func TestNewDerivesHostNeutralPathsFromStateDir(t *testing.T) { + dir := t.TempDir() + eng, err := New(Options{SecretStore: &credentials.MapStore{}, StateDir: dir}) + if err != nil { + t.Fatal(err) + } + if eng.catalogPath != filepath.Join(dir, "model_catalog.json") { + t.Fatalf("catalog path = %q", eng.catalogPath) + } + if eng.providerConfigPath != filepath.Join(dir, "provider.json") { + t.Fatalf("provider path = %q", eng.providerConfigPath) + } +} + +func TestCredentialStatusAndRemoveUseInjectedStore(t *testing.T) { + ctx := context.Background() + store := &credentials.MapStore{} + if err := store.Set(ctx, credentials.AccountForEnv("OPENAI_API_KEY"), "sk-test-value"); err != nil { + t.Fatal(err) + } + eng, err := New(Options{SecretStore: store}) + if err != nil { + t.Fatal(err) + } + status, err := eng.CredentialStatus(ctx, "openai") + if err != nil { + t.Fatal(err) + } + if !status.Configured || status.EnvVar != "OPENAI_API_KEY" { + t.Fatalf("unexpected status: %+v", status) + } + if err := eng.RemoveCredential(ctx, "openai"); err != nil { + t.Fatal(err) + } + status, err = eng.CredentialStatus(ctx, "openai") + if err != nil { + t.Fatal(err) + } + if status.Configured { + t.Fatalf("credential still configured: %+v", status) + } +} + +func TestValidateGenerateRequest(t *testing.T) { + tests := []struct { + name string + req GenerateRequest + }{ + {name: "no messages", req: GenerateRequest{}}, + {name: "missing role", req: GenerateRequest{Messages: []Message{{Content: "hello"}}}}, + {name: "negative output", req: GenerateRequest{Messages: []Message{{Role: "user"}}, Limits: Limits{MaxOutputTokens: -1}}}, + {name: "negative context", req: GenerateRequest{Messages: []Message{{Role: "user"}}, Requirements: Requirements{MinimumContext: -1}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateGenerateRequest(tt.req) + if !IsCode(err, ErrorInvalidRequest) { + t.Fatalf("error = %v, want invalid request", err) + } + }) + } +} + +func TestMessageConversionPreservesToolsAndMultimodalParts(t *testing.T) { + messages := toClientMessages([]Message{{ + Role: "user", Content: "inspect", ContentParts: []ContentPart{{Type: "image_url", URL: "https://example.test/image.png", Detail: "high"}}, + ToolCalls: []ToolCall{{ID: "call-1", Name: "read", Arguments: map[string]interface{}{"path": "a.go"}}}, + ToolResults: []ToolResult{{ToolUseID: "call-1", Content: "ok"}}, + }}) + if len(messages) != 1 || len(messages[0].ContentParts) != 1 || messages[0].ContentParts[0].ImageURL == nil { + t.Fatalf("multimodal conversion lost data: %+v", messages) + } + if len(messages[0].ToolUse) != 1 || len(messages[0].ToolResults) != 1 { + t.Fatalf("tool conversion lost data: %+v", messages[0]) + } +} + +func TestNormalizedStreamContract(t *testing.T) { + sourceEvents := make(chan client.EyrieStreamEvent, 3) + sourceEvents <- client.EyrieStreamEvent{Type: "content", Content: "hello"} + sourceEvents <- client.EyrieStreamEvent{Type: "tool_call", ToolCall: &client.ToolCall{ID: "1", Name: "read"}} + sourceEvents <- client.EyrieStreamEvent{Type: "done", StopReason: "end_turn", Usage: &client.EyrieUsage{PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5}} + close(sourceEvents) + + ctx, cancel := context.WithCancel(context.Background()) + stream := newStream(ctx, cancel, client.NewStreamResult(sourceEvents, nil), Route{Provider: "mock", Model: "mock/model"}) + defer stream.Close() + + var events []Event + for stream.Next() { + events = append(events, stream.Event()) + } + if err := stream.Err(); err != nil { + t.Fatal(err) + } + if len(events) != 4 { + t.Fatalf("events = %d, want 4: %+v", len(events), events) + } + if events[0].Type != EventRouteSelected || events[1].Type != EventContentDelta || events[2].Type != EventToolCallDone || events[3].Type != EventDone { + t.Fatalf("unexpected event sequence: %+v", events) + } + if events[3].Usage == nil || events[3].Usage.TotalTokens != 5 { + t.Fatalf("usage not normalized: %+v", events[3]) + } +} + +func TestSnapshotPublishesCapabilities(t *testing.T) { + compiled := &catalog.CompiledCatalog{ + ModelsByID: map[string]catalog.Model{ + "vendor/model": {ID: "vendor/model", ProviderID: "vendor", Name: "Model", ContextWindow: 100_000}, + }, + OfferingsByCanonicalModel: map[string][]catalog.ModelOffering{ + "vendor/model": {{CanonicalModelID: "vendor/model", Capabilities: catalog.CapabilitySet{ + FunctionCalling: catalog.CapabilitySupported, + ImageInput: catalog.CapabilitySupported, + }}}, + }, + } + snapshot := snapshotFromCompiled(compiled) + if len(snapshot.Models) != 1 { + t.Fatalf("models = %d, want 1", len(snapshot.Models)) + } + if got := snapshot.Models[0].Capabilities; len(got) != 2 || got[0] != "tools" || got[1] != "vision" { + t.Fatalf("capabilities = %v", got) + } +} + +func TestSelectCompatibleModelUsesCapabilitiesAndIntent(t *testing.T) { + compiled := &catalog.CompiledCatalog{ + ModelsByID: map[string]catalog.Model{ + "vendor/cheap": {ID: "vendor/cheap", ProviderID: "vendor", Name: "Cheap", ContextWindow: 128_000}, + "vendor/rich": {ID: "vendor/rich", ProviderID: "vendor", Name: "Rich", ContextWindow: 200_000}, + "vendor/text": {ID: "vendor/text", ProviderID: "vendor", Name: "Text", ContextWindow: 300_000}, + }, + OfferingsByCanonicalModel: map[string][]catalog.ModelOffering{ + "vendor/cheap": {{CanonicalModelID: "vendor/cheap", Capabilities: catalog.CapabilitySet{FunctionCalling: catalog.CapabilitySupported}, Pricing: catalog.Pricing{RatesPer1M: map[string]float64{"input_tokens": 1, "output_tokens": 2}}}}, + "vendor/rich": {{CanonicalModelID: "vendor/rich", Capabilities: catalog.CapabilitySet{FunctionCalling: catalog.CapabilitySupported}, Pricing: catalog.Pricing{RatesPer1M: map[string]float64{"input_tokens": 5, "output_tokens": 10}}}}, + "vendor/text": {{CanonicalModelID: "vendor/text", Capabilities: catalog.CapabilitySet{}, Pricing: catalog.Pricing{RatesPer1M: map[string]float64{"input_tokens": 0.1, "output_tokens": 0.1}}}}, + }, + } + + model, provider := selectCompatibleModel(compiled, SelectionRequest{ + Requirements: Requirements{Tools: true, MinimumContext: 100_000}, + Preference: Preference{Intent: IntentEconomical}, + }) + if model != "vendor/cheap" || provider != "vendor" { + t.Fatalf("selected %q via %q, want vendor/cheap via vendor", model, provider) + } + + model, _ = selectCompatibleModel(compiled, SelectionRequest{ + Requirements: Requirements{Tools: true, MinimumContext: 150_000}, + Preference: Preference{Intent: IntentReasoning}, + }) + if model != "vendor/rich" { + t.Fatalf("selected %q, want vendor/rich", model) + } +} + +func TestTypedErrorUnwrapAndCode(t *testing.T) { + cause := errors.New("provider down") + err := classify("stream", Route{Provider: "mock", Model: "m"}, cause) + if !errors.Is(err, cause) { + t.Fatal("typed error does not unwrap cause") + } + var typed *Error + if !errors.As(err, &typed) || typed.Operation != "stream" { + t.Fatalf("unexpected typed error: %#v", err) + } +} + +func TestSelectionUsesEngineStateDir(t *testing.T) { + dir := t.TempDir() + cachePath := filepath.Join(dir, "model_catalog.json") + bootstrap := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(cachePath, &bootstrap); err != nil { + t.Fatal(err) + } + eng, err := New(Options{SecretStore: &credentials.MapStore{}, StateDir: dir}) + if err != nil { + t.Fatal(err) + } + var modelID string + for id := range bootstrap.Models { + modelID = id + break + } + if modelID == "" { + t.Fatal("bootstrap catalog has no model") + } + if err := eng.SetSelection(context.Background(), "", modelID); err != nil { + t.Fatal(err) + } + saved := config.LoadProviderConfig(eng.providerConfigPath) + if saved == nil || saved.ActiveModel != modelID { + t.Fatalf("selection not saved to engine path: %+v", saved) + } + if err := eng.ClearSelection(context.Background()); err != nil { + t.Fatal(err) + } + saved = config.LoadProviderConfig(eng.providerConfigPath) + if saved == nil || saved.ActiveModel != "" || saved.ActiveProvider != "" { + t.Fatalf("selection not cleared: %+v", saved) + } +} diff --git a/engine/errors.go b/engine/errors.go new file mode 100644 index 0000000..5e7d436 --- /dev/null +++ b/engine/errors.go @@ -0,0 +1,65 @@ +package engine + +import ( + "errors" + "fmt" +) + +// ErrorCode is a stable, machine-readable engine failure category. +type ErrorCode string + +const ( + ErrorInvalidRequest ErrorCode = "invalid_request" + ErrorCredentialMissing ErrorCode = "credential_missing" + ErrorAuthentication ErrorCode = "authentication_failed" + ErrorCatalogUnavailable ErrorCode = "catalog_unavailable" + ErrorModelUnavailable ErrorCode = "model_unavailable" + ErrorCapabilityMismatch ErrorCode = "capability_mismatch" + ErrorContextExceeded ErrorCode = "context_exceeded" + ErrorRateLimited ErrorCode = "rate_limited" + ErrorBudgetExceeded ErrorCode = "budget_exceeded" + ErrorProviderUnavailable ErrorCode = "provider_unavailable" + ErrorCancelled ErrorCode = "cancelled" + ErrorInternal ErrorCode = "internal" +) + +// 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 +} + +func (e *Error) Error() string { + if e == nil { + return "" + } + if e.Message != "" { + return e.Message + } + if e.Operation != "" { + return fmt.Sprintf("eyrie engine: %s failed", e.Operation) + } + return "eyrie engine error" +} + +func (e *Error) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// IsCode reports whether err or an error in its chain has the supplied code. +func IsCode(err error, code ErrorCode) bool { + var target *Error + return errors.As(err, &target) && target.Code == code +} + +func invalid(operation, message string) error { + return &Error{Code: ErrorInvalidRequest, Operation: operation, Message: message} +} diff --git a/engine/selection.go b/engine/selection.go new file mode 100644 index 0000000..7ad2053 --- /dev/null +++ b/engine/selection.go @@ -0,0 +1,62 @@ +package engine + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/config" +) + +// SetSelection persists the host/user's provider and model choice in this +// Engine's configured state path. It does not persist credentials. +func (e *Engine) SetSelection(ctx context.Context, providerID, modelID string) error { + ctx = nonNilContext(ctx) + _ = ctx + providerID = catalog.CanonicalProviderID(strings.TrimSpace(providerID)) + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return invalid("set_selection", "eyrie engine: model id is required") + } + compiled, err := catalog.LoadCatalog(context.Background(), catalog.LoadCatalogOptions{CachePath: e.catalogPath, RequireCache: true}) + if err != nil { + return &Error{Code: ErrorCatalogUnavailable, Operation: "set_selection", Message: err.Error(), Cause: err} + } + canonical, ok := compiled.CanonicalModelForAliasOrID(modelID) + if ok { + modelID = canonical + } + if providerID == "" { + providerID = catalog.GatewayForModel(compiled, modelID) + if providerID == "" { + providerID = catalog.ProviderForModel(compiled, modelID) + } + } + if providerID == "" { + return &Error{Code: ErrorModelUnavailable, Operation: "set_selection", Model: modelID, Message: "eyrie engine: could not determine provider for model"} + } + cfg := config.LoadProviderConfig(e.providerConfigPath) + if cfg == nil { + cfg = &config.ProviderConfig{} + } + config.SetProviderModel(cfg, providerID, modelID) + if err := config.SaveProviderConfig(cfg, e.providerConfigPath); err != nil { + return &Error{Code: ErrorInternal, Operation: "set_selection", Provider: providerID, Model: modelID, Message: err.Error(), Cause: err} + } + return nil +} + +// ClearSelection removes active provider/model state without modifying +// credentials, deployments, or routing. +func (e *Engine) ClearSelection(ctx context.Context) error { + _ = nonNilContext(ctx) + cfg := config.LoadProviderConfig(e.providerConfigPath) + if cfg == nil { + return nil + } + config.ClearActiveSelection(cfg) + if err := config.SaveProviderConfig(cfg, e.providerConfigPath); err != nil { + return &Error{Code: ErrorInternal, Operation: "clear_selection", Message: err.Error(), Cause: err} + } + return nil +} diff --git a/engine/state.go b/engine/state.go new file mode 100644 index 0000000..51262dc --- /dev/null +++ b/engine/state.go @@ -0,0 +1,102 @@ +package engine + +import ( + "context" + "strings" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" +) + +func (e *Engine) loadRuntimeState(ctx context.Context) (*catalog.CompiledCatalog, *config.ProviderConfig, error) { + compiled, err := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{CachePath: e.catalogPath, RequireCache: true}) + if err != nil { + return nil, nil, &Error{Code: ErrorCatalogUnavailable, Operation: "load_state", Message: err.Error(), Cause: err} + } + persisted := config.LoadProviderConfig(e.providerConfigPath) + if persisted == nil { + persisted = &config.ProviderConfig{} + } + cfg := *persisted + cfg.Deployments = buildDeployments(compiled, persisted.Deployments, e.credentialEnv(ctx, compiled)) + if cfg.Routing == nil { + cfg.Routing = config.BuildRoutingPolicyFromDeployments(cfg.Deployments) + } + if len(cfg.Deployments) > 0 && cfg.ConfigVersion < 2 { + cfg.ConfigVersion = 2 + } + return compiled, &cfg, nil +} + +func (e *Engine) credentialEnv(ctx context.Context, compiled *catalog.CompiledCatalog) map[string]string { + out := make(map[string]string) + if compiled == nil { + return out + } + for _, envKey := range catalog.DiscoveryEnvKeysFromCatalog(compiled) { + secret, err := e.secretStore.Get(ctx, credentials.AccountForEnv(envKey)) + if err == nil && strings.TrimSpace(secret) != "" { + out[envKey] = secret + } + } + return out +} + +func buildDeployments(compiled *catalog.CompiledCatalog, persisted map[string]config.DeploymentConfig, env map[string]string) map[string]config.DeploymentConfig { + out := make(map[string]config.DeploymentConfig) + if compiled == nil || compiled.Catalog == nil { + return out + } + for id, deployment := range compiled.Catalog.Deployments { + derived := config.DeploymentConfigFromEnv(deployment, env) + if existing, ok := persisted[id]; ok { + derived = mergeDeployment(existing, derived) + } + if config.DeploymentConfigured(id, deployment, derived) { + out[id] = derived + } + } + return out +} + +// mergeDeployment keeps non-secret routing fields from disk while filling +// credential fields from the injected store. Values derived from the store or +// current environment take precedence when present. +func mergeDeployment(persisted, derived config.DeploymentConfig) config.DeploymentConfig { + out := persisted + if derived.APIKey != "" { + out.APIKey = derived.APIKey + } + if derived.BaseURL != "" { + out.BaseURL = derived.BaseURL + } + if derived.Endpoint != "" { + out.Endpoint = derived.Endpoint + } + if derived.APIVersion != "" { + out.APIVersion = derived.APIVersion + } + if derived.ProjectID != "" { + out.ProjectID = derived.ProjectID + } + if derived.Region != "" { + out.Region = derived.Region + } + if derived.Token != "" { + out.Token = derived.Token + } + if derived.AccessKeyID != "" { + out.AccessKeyID = derived.AccessKeyID + } + if derived.SecretAccessKey != "" { + out.SecretAccessKey = derived.SecretAccessKey + } + if derived.SessionToken != "" { + out.SessionToken = derived.SessionToken + } + if len(derived.ModelMappings) > 0 { + out.ModelMappings = derived.ModelMappings + } + return out +} diff --git a/engine/stream.go b/engine/stream.go new file mode 100644 index 0000000..f29e820 --- /dev/null +++ b/engine/stream.go @@ -0,0 +1,161 @@ +package engine + +import ( + "context" + "sync" + + "github.com/GrayCodeAI/eyrie/client" +) + +// Stream is a normalized, pull-based event stream. Next must not be called +// concurrently. Close is idempotent and may be called from another goroutine. +type Stream struct { + ctx context.Context + cancel context.CancelFunc + source *client.StreamResult + route Route + events chan Event + + mu sync.Mutex + current Event + err error + once sync.Once +} + +func newStream(ctx context.Context, cancel context.CancelFunc, source *client.StreamResult, route Route) *Stream { + s := &Stream{ctx: ctx, cancel: cancel, source: source, route: route, events: make(chan Event, 32)} + go s.forward() + return s +} + +// Next advances to the next event. +func (s *Stream) Next() bool { + if s == nil { + return false + } + event, ok := <-s.events + if !ok { + return false + } + s.mu.Lock() + s.current = event + s.mu.Unlock() + return true +} + +// Event returns the most recent event produced by Next. +func (s *Stream) Event() Event { + if s == nil { + return Event{} + } + s.mu.Lock() + defer s.mu.Unlock() + return s.current +} + +// Err returns the terminal stream error, if any. +func (s *Stream) Err() error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return s.err +} + +// Close cancels generation and releases provider resources. +func (s *Stream) Close() error { + if s == nil { + return nil + } + s.once.Do(func() { + if s.cancel != nil { + s.cancel() + } + if s.source != nil { + s.source.Close() + } + }) + return nil +} + +func (s *Stream) forward() { + defer close(s.events) + defer s.Close() + if !s.emit(Event{Type: EventRouteSelected, Route: &s.route}) { + return + } + for { + select { + case <-s.ctx.Done(): + s.setError(classify("stream", s.route, s.ctx.Err())) + return + case event, ok := <-s.source.Events: + if !ok { + return + } + normalized, err := normalizeEvent(event) + if err != nil { + s.setError(classify("stream", s.route, err)) + return + } + if !s.emit(normalized) { + return + } + } + } +} + +func (s *Stream) emit(event Event) bool { + select { + case s.events <- event: + return true + case <-s.ctx.Done(): + return false + } +} + +func (s *Stream) setError(err error) { + s.mu.Lock() + s.err = err + s.mu.Unlock() +} + +func normalizeEvent(event client.EyrieStreamEvent) (Event, error) { + out := Event{ + Content: event.Content, Thinking: event.Thinking, RequestID: event.RequestID, + Usage: fromClientUsage(event.Usage), StopReason: event.StopReason, + TTFTMillis: event.TTFTms, + } + if out.TTFTMillis == 0 { + out.TTFTMillis = event.TTFT + } + switch event.Type { + case "content": + out.Type = EventContentDelta + case "thinking": + out.Type = EventThinkingDelta + case "tool_call": + out.Type = EventToolCallDone + case "tool_input_delta": + out.Type = EventToolCallDelta + case "done": + if out.Usage != nil { + // Usage remains attached to done for backward-friendly single-event + // accounting; future providers may also emit EventUsage separately. + out.Type = EventDone + } else { + out.Type = EventDone + } + case "ttft": + out.Type = EventTTFT + case "error": + return Event{}, &Error{Code: ErrorProviderUnavailable, Operation: "stream", Message: event.Error} + default: + out.Type = EventType(event.Type) + } + if event.ToolCall != nil { + out.ToolCall = &ToolCall{ID: event.ToolCall.ID, Name: event.ToolCall.Name, Arguments: event.ToolCall.Arguments} + } + return out, nil +} diff --git a/engine/types.go b/engine/types.go new file mode 100644 index 0000000..fa1ba3e --- /dev/null +++ b/engine/types.go @@ -0,0 +1,192 @@ +package engine + +import "time" + +// Intent expresses a host's semantic preference without naming a provider. +type Intent string + +const ( + IntentFast Intent = "fast" + IntentBalanced Intent = "balanced" + IntentReasoning Intent = "reasoning" + IntentEconomical Intent = "economical" +) + +// Requirements describes capabilities that a resolved model must support. +type Requirements struct { + Streaming bool `json:"streaming,omitempty"` + Tools bool `json:"tools,omitempty"` + Vision bool `json:"vision,omitempty"` + StructuredJSON bool `json:"structured_json,omitempty"` + Reasoning bool `json:"reasoning,omitempty"` + MinimumContext int `json:"minimum_context,omitempty"` +} + +// Preference contains optional selection policy supplied by the host/user. +type Preference struct { + Intent Intent `json:"intent,omitempty"` + PreferredModelID string `json:"preferred_model_id,omitempty"` + PreferredProvider string `json:"preferred_provider,omitempty"` + AllowFallback bool `json:"allow_fallback,omitempty"` + MaximumCostUSD float64 `json:"maximum_cost_usd,omitempty"` +} + +// ContentPart is a provider-neutral multimodal message part. +type ContentPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + URL string `json:"url,omitempty"` + Detail string `json:"detail,omitempty"` + AudioData string `json:"audio_data,omitempty"` + AudioFormat string `json:"audio_format,omitempty"` +} + +// ToolCall is a normalized tool invocation. +type ToolCall struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments"` +} + +// ToolResult is a host-executed tool result sent back to a model. +type ToolResult struct { + ToolUseID string `json:"tool_use_id"` + Content string `json:"content"` + IsError bool `json:"is_error,omitempty"` +} + +// Message is the stable conversation DTO at the host boundary. +type Message struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + Thinking string `json:"thinking,omitempty"` + ContentParts []ContentPart `json:"content_parts,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolResults []ToolResult `json:"tool_results,omitempty"` +} + +// Tool is a model-visible tool definition. Eyrie emits requests for these +// tools; the host remains responsible for permission checks and execution. +type Tool struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` +} + +// Limits bounds a generation request. +type Limits struct { + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + Timeout time.Duration `json:"timeout,omitempty"` +} + +// Metadata provides stable correlation identifiers. Providers receive only +// fields that their adapter explicitly supports. +type Metadata struct { + SessionID string `json:"session_id,omitempty"` + TurnID string `json:"turn_id,omitempty"` + UserID string `json:"user_id,omitempty"` + ProjectID string `json:"project_id,omitempty"` +} + +// GenerateRequest is the provider-neutral request accepted by Engine. +type GenerateRequest struct { + Messages []Message `json:"messages"` + SystemPrompt string `json:"system_prompt,omitempty"` + Tools []Tool `json:"tools,omitempty"` + Requirements Requirements `json:"requirements,omitempty"` + Preference Preference `json:"preference,omitempty"` + Limits Limits `json:"limits,omitempty"` + Metadata Metadata `json:"metadata,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + OutputSchema string `json:"output_schema,omitempty"` +} + +// Route is the concrete model/deployment decision made by Eyrie. +type Route struct { + Provider string `json:"provider"` + Model string `json:"model"` + DeploymentRouting bool `json:"deployment_routing"` +} + +// Usage is normalized token accounting. +type Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` + CacheCreationTokens int `json:"cache_creation_tokens,omitempty"` + CacheReadTokens int `json:"cache_read_tokens,omitempty"` + ThinkingTokens int `json:"thinking_tokens,omitempty"` +} + +// GenerateResponse is a normalized blocking response. +type GenerateResponse struct { + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` + RequestID string `json:"request_id,omitempty"` + Usage *Usage `json:"usage,omitempty"` + Route Route `json:"route"` +} + +// EventType is a stable stream event name. +type EventType string + +const ( + EventRouteSelected EventType = "route_selected" + EventContentDelta EventType = "content_delta" + EventThinkingDelta EventType = "thinking_delta" + EventToolCallStart EventType = "tool_call_start" + EventToolCallDelta EventType = "tool_call_delta" + EventToolCallDone EventType = "tool_call_done" + EventUsage EventType = "usage" + EventRetry EventType = "retry" + EventRouteChanged EventType = "route_changed" + EventWarning EventType = "warning" + EventTTFT EventType = "ttft" + EventDone EventType = "done" +) + +// Event is a normalized model stream event. New optional fields and event +// types are additive; hosts must safely ignore unknown event types. +type Event struct { + Type EventType `json:"type"` + Content string `json:"content,omitempty"` + Thinking string `json:"thinking,omitempty"` + ToolCall *ToolCall `json:"tool_call,omitempty"` + Usage *Usage `json:"usage,omitempty"` + Route *Route `json:"route,omitempty"` + Warning string `json:"warning,omitempty"` + RequestID string `json:"request_id,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + TTFTMillis int `json:"ttft_ms,omitempty"` +} + +// Model is a host-facing catalog row. +type Model struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + ProviderID string `json:"provider_id"` + ContextWindow int `json:"context_window,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + InputPricePer1M float64 `json:"input_price_per_1m,omitempty"` + OutputPricePer1M float64 `json:"output_price_per_1m,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Source string `json:"source,omitempty"` +} + +// CatalogSnapshot is an immutable host-facing view of a loaded catalog. +type CatalogSnapshot struct { + Models []Model `json:"models"` + CachePath string `json:"cache_path,omitempty"` + Stale bool `json:"stale,omitempty"` + LoadedAt time.Time `json:"loaded_at"` +} + +// CredentialStatus is safe to render or log; it never contains a secret. +type CredentialStatus struct { + ProviderID string `json:"provider_id"` + EnvVar string `json:"env_var,omitempty"` + Configured bool `json:"configured"` + Verified bool `json:"verified,omitempty"` +} diff --git a/setup/deployment.go b/setup/deployment.go index de39254..419fae3 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -5,7 +5,6 @@ import ( "context" "fmt" "os" - "path/filepath" "strings" "github.com/GrayCodeAI/eyrie/catalog" @@ -59,15 +58,23 @@ func UseDeploymentRouting(cfg *config.ProviderConfig) bool { // DeploymentProvider builds a catalog-aware router over configured deployments. func DeploymentProvider(ctx context.Context, cfg *config.ProviderConfig) (client.Provider, error) { - home, _ := os.UserHomeDir() - cachePath := filepath.Join(home, ".eyrie", "model_catalog.json") compiled, err := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{ - CachePath: cachePath, + CachePath: catalog.DefaultCachePath(), RefreshRemote: strings.EqualFold(os.Getenv("EYRIE_MODEL_CATALOG_REFRESH"), "true"), }) if err != nil { return nil, err } + return DeploymentProviderFromCatalog(cfg, compiled) +} + +// DeploymentProviderFromCatalog builds a deployment router from explicit +// host-owned state. It is the host-neutral alternative to DeploymentProvider, +// which loads Eyrie's process-default catalog path. +func DeploymentProviderFromCatalog(cfg *config.ProviderConfig, compiled *catalog.CompiledCatalog) (client.Provider, error) { + if compiled == nil { + return nil, fmt.Errorf("deployment provider: catalog is nil") + } deployments := ConfiguredDeploymentAdapters(cfg) if len(deployments) == 0 { return nil, fmt.Errorf("no deployment credentials configured") From 01d725961f519e1c712508755aa15639b5e805c6 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 12 Jul 2026 19:22:32 +0530 Subject: [PATCH 04/24] feat: complete engine state and continuation support --- engine/catalog.go | 32 ++++++++++++++++++++++++++++++++ engine/engine.go | 12 +++++++++++- engine/engine_test.go | 1 + engine/types.go | 6 ++++-- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/engine/catalog.go b/engine/catalog.go index 739d3dc..7f50335 100644 --- a/engine/catalog.go +++ b/engine/catalog.go @@ -9,6 +9,7 @@ import ( "github.com/GrayCodeAI/eyrie/catalog" "github.com/GrayCodeAI/eyrie/catalog/discover" "github.com/GrayCodeAI/eyrie/catalog/registry" + "github.com/GrayCodeAI/eyrie/config" ) // Catalog returns the current cached catalog through a stable snapshot. @@ -57,6 +58,37 @@ func (e *Engine) RefreshCatalog(ctx context.Context, providerID string) (Catalog return snapshot, nil } +// ApplyCredentials refreshes catalog state and persists sanitized deployment +// routing derived from the Engine's credential store. Secret fields are never +// written to provider.json. +func (e *Engine) ApplyCredentials(ctx context.Context, providerID string) (CatalogSnapshot, error) { + ctx = nonNilContext(ctx) + snapshot, err := e.RefreshCatalog(ctx, providerID) + if err != nil { + return CatalogSnapshot{}, err + } + compiled, err := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{CachePath: e.catalogPath, RequireCache: true}) + if err != nil { + return CatalogSnapshot{}, &Error{Code: ErrorCatalogUnavailable, Operation: "apply_credentials", Provider: providerID, Message: err.Error(), Cause: err} + } + persisted := config.LoadProviderConfig(e.providerConfigPath) + if persisted == nil { + persisted = &config.ProviderConfig{} + } + deployments := buildDeployments(compiled, persisted.Deployments, e.credentialEnv(ctx, compiled)) + sanitized := make(map[string]config.DeploymentConfig, len(deployments)) + for id, deployment := range deployments { + sanitized[id] = config.SanitizeDeploymentConfigForDisk(deployment) + } + persisted.ConfigVersion = 2 + persisted.Deployments = sanitized + persisted.Routing = config.BuildRoutingPolicyFromDeployments(sanitized) + if err := config.SaveProviderConfig(persisted, e.providerConfigPath); err != nil { + return CatalogSnapshot{}, &Error{Code: ErrorInternal, Operation: "apply_credentials", Provider: providerID, Message: err.Error(), Cause: err} + } + return snapshot, nil +} + func snapshotFromCompiled(compiled *catalog.CompiledCatalog) CatalogSnapshot { snapshot := CatalogSnapshot{CachePath: catalog.DefaultCachePath(), LoadedAt: time.Now().UTC()} if compiled == nil { diff --git a/engine/engine.go b/engine/engine.go index 4a72364..bd45e3e 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -110,7 +110,14 @@ func (e *Engine) Stream(ctx context.Context, req GenerateRequest) (*Stream, erro return nil, err } callCtx, cancel := requestContext(ctx, req.Limits.Timeout) - source, err := provider.StreamChat(callCtx, toClientMessages(req.Messages), toClientOptions(req, route, true)) + continuation := client.DefaultContinuationConfig() + if req.Limits.MaxContinuations > 0 { + continuation.MaxContinuations = req.Limits.MaxContinuations + } + if req.Limits.MaxTotalOutputTokens > 0 { + continuation.MaxTotalTokens = req.Limits.MaxTotalOutputTokens + } + source, err := client.StreamChatWithContinuation(callCtx, provider, toClientMessages(req.Messages), toClientOptions(req, route, true), continuation) if err != nil { cancel() return nil, classify("stream", route, err) @@ -267,6 +274,9 @@ func validateGenerateRequest(req GenerateRequest) error { if req.Limits.MaxOutputTokens < 0 { return invalid("generate", "eyrie engine: max output tokens cannot be negative") } + if req.Limits.MaxContinuations < 0 || req.Limits.MaxTotalOutputTokens < 0 { + return invalid("generate", "eyrie engine: continuation limits cannot be negative") + } if req.Requirements.MinimumContext < 0 { return invalid("generate", "eyrie engine: minimum context cannot be negative") } diff --git a/engine/engine_test.go b/engine/engine_test.go index c3e0963..3df71f4 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -74,6 +74,7 @@ func TestValidateGenerateRequest(t *testing.T) { {name: "no messages", req: GenerateRequest{}}, {name: "missing role", req: GenerateRequest{Messages: []Message{{Content: "hello"}}}}, {name: "negative output", req: GenerateRequest{Messages: []Message{{Role: "user"}}, Limits: Limits{MaxOutputTokens: -1}}}, + {name: "negative continuations", req: GenerateRequest{Messages: []Message{{Role: "user"}}, Limits: Limits{MaxContinuations: -1}}}, {name: "negative context", req: GenerateRequest{Messages: []Message{{Role: "user"}}, Requirements: Requirements{MinimumContext: -1}}}, } for _, tt := range tests { diff --git a/engine/types.go b/engine/types.go index fa1ba3e..338e6f7 100644 --- a/engine/types.go +++ b/engine/types.go @@ -75,8 +75,10 @@ type Tool struct { // Limits bounds a generation request. type Limits struct { - MaxOutputTokens int `json:"max_output_tokens,omitempty"` - Timeout time.Duration `json:"timeout,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + MaxContinuations int `json:"max_continuations,omitempty"` + MaxTotalOutputTokens int `json:"max_total_output_tokens,omitempty"` + Timeout time.Duration `json:"timeout,omitempty"` } // Metadata provides stable correlation identifiers. Providers receive only From 02960a1463058affaaff71d2f91e639bca20b4d1 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 12 Jul 2026 19:23:16 +0530 Subject: [PATCH 05/24] build: enforce host-neutral eyrie boundary --- scripts/check-ecosystem-boundaries.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/check-ecosystem-boundaries.sh b/scripts/check-ecosystem-boundaries.sh index a7b98b4..af7ddc4 100755 --- a/scripts/check-ecosystem-boundaries.sh +++ b/scripts/check-ecosystem-boundaries.sh @@ -4,7 +4,10 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" -FORBIDDEN_HAWK='github\.com/GrayCodeAI/hawk/(internal/|shared/types)' +# Eyrie is host-neutral: it must not depend on any Hawk package. Shared +# ecosystem vocabulary belongs in hawk-core-contracts, whose module path does +# not match this expression. +FORBIDDEN_HAWK='github\.com/GrayCodeAI/hawk(/|")' FORBIDDEN_ENGINES='github\.com/GrayCodeAI/(yaad|tok|trace|sight|inspect)(/|")' exit_code=0 @@ -21,7 +24,7 @@ if [[ -n "${violations}" ]]; then echo "forbidden Hawk imports found:" echo "${violations}" echo - echo "support repos must use hawk-core-contracts or local contracts, not hawk/internal or removed hawk/shared/types" + echo "eyrie must use hawk-core-contracts or local contracts, never the Hawk product module" exit_code=1 fi From ff4c04d763ea3cf8a2158eb6318f5eab6f38cb8d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 12 Jul 2026 19:29:12 +0530 Subject: [PATCH 06/24] feat: complete engine generation contract --- engine/continuation.go | 102 ++++++++++++++++++++ engine/contract_e2e_test.go | 182 ++++++++++++++++++++++++++++++++++++ engine/convert.go | 60 ++++++++++++ engine/engine.go | 28 +++--- engine/stream.go | 2 + engine/types.go | 60 ++++++++++-- 6 files changed, 411 insertions(+), 23 deletions(-) create mode 100644 engine/continuation.go create mode 100644 engine/contract_e2e_test.go diff --git a/engine/continuation.go b/engine/continuation.go new file mode 100644 index 0000000..450168d --- /dev/null +++ b/engine/continuation.go @@ -0,0 +1,102 @@ +package engine + +import ( + "context" + "strconv" + "strings" + + "github.com/GrayCodeAI/eyrie/client" +) + +// streamWithContinuation implements continuation at the stable engine layer. +// It deliberately does not depend on client.StreamChatWithContinuation, which +// is a deprecated compatibility helper scheduled for removal in Eyrie v0.3. +func streamWithContinuation(ctx context.Context, provider client.Provider, messages []client.EyrieMessage, opts client.ChatOptions, limits Limits) (*client.StreamResult, error) { + maxContinuations := limits.MaxContinuations + if maxContinuations <= 0 { + maxContinuations = client.DefaultContinuationConfig().MaxContinuations + } + maxTotalTokens := limits.MaxTotalOutputTokens + if maxTotalTokens <= 0 { + maxTotalTokens = client.DefaultContinuationConfig().MaxTotalTokens + } + streamCtx, cancel := context.WithCancel(ctx) + first, err := provider.StreamChat(streamCtx, messages, opts) + if err != nil { + cancel() + return nil, err + } + out := make(chan client.EyrieStreamEvent, 64) + go func() { + defer close(out) + defer cancel() + current := first + requestID := first.RequestID + msgs := append([]client.EyrieMessage(nil), messages...) + totalOutput := 0 + + for attempt := 0; ; attempt++ { + var segment strings.Builder + hadToolCall := false + var terminal client.EyrieStreamEvent + for event := range current.Events { + switch event.Type { + case "content": + segment.WriteString(event.Content) + case "tool_call": + hadToolCall = true + case "usage": + if event.Usage != nil { + totalOutput += event.Usage.CompletionTokens + } + case "done": + terminal = event + continue + } + if !emitEngineEvent(streamCtx, out, event) { + current.Close() + return + } + } + current.Close() + + needsContinuation := terminal.StopReason == "max_tokens" || terminal.StopReason == "length" + if !needsContinuation || hadToolCall || totalOutput >= maxTotalTokens || attempt >= maxContinuations { + if terminal.Type == "" { + terminal = client.EyrieStreamEvent{Type: "done", StopReason: terminal.StopReason, RequestID: requestID} + } + _ = emitEngineEvent(streamCtx, out, terminal) + return + } + + if !emitEngineEvent(streamCtx, out, client.EyrieStreamEvent{ + Type: "continuation", Content: requestID, StopReason: strconv.Itoa(attempt + 1), + }) { + return + } + msgs = append(msgs, + client.EyrieMessage{Role: "assistant", Content: segment.String()}, + client.EyrieMessage{Role: "user", Content: "Continue."}, + ) + next, err := provider.StreamChat(streamCtx, msgs, opts) + if err != nil { + _ = emitEngineEvent(streamCtx, out, client.EyrieStreamEvent{Type: "error", Error: err.Error(), RequestID: requestID}) + return + } + current = next + if next.RequestID != "" { + requestID = next.RequestID + } + } + }() + return client.NewStreamResultWithRequestID(out, first.RequestID, cancel), nil +} + +func emitEngineEvent(ctx context.Context, out chan<- client.EyrieStreamEvent, event client.EyrieStreamEvent) bool { + select { + case out <- event: + return true + case <-ctx.Done(): + return false + } +} diff --git a/engine/contract_e2e_test.go b/engine/contract_e2e_test.go new file mode 100644 index 0000000..d35c169 --- /dev/null +++ b/engine/contract_e2e_test.go @@ -0,0 +1,182 @@ +package engine + +import ( + "context" + "path/filepath" + "testing" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/client" + "github.com/GrayCodeAI/eyrie/credentials" +) + +type contractProvider struct { + chatMessages []client.EyrieMessage + chatOptions client.ChatOptions + streamMessages []client.EyrieMessage + streamOptions client.ChatOptions +} + +func (p *contractProvider) Name() string { return "contract" } +func (p *contractProvider) Ping(context.Context) error { return nil } + +func (p *contractProvider) Chat(_ context.Context, messages []client.EyrieMessage, opts client.ChatOptions) (*client.EyrieResponse, error) { + p.chatMessages, p.chatOptions = messages, opts + return &client.EyrieResponse{ + Content: "complete", FinishReason: "end_turn", RequestID: "req-blocking", + Usage: &client.EyrieUsage{PromptTokens: 4, CompletionTokens: 2, TotalTokens: 6}, + }, nil +} + +func (p *contractProvider) StreamChat(_ context.Context, messages []client.EyrieMessage, opts client.ChatOptions) (*client.StreamResult, error) { + p.streamMessages, p.streamOptions = messages, opts + events := make(chan client.EyrieStreamEvent, 4) + events <- client.EyrieStreamEvent{Type: "content", Content: "checking"} + events <- client.EyrieStreamEvent{Type: "tool_call", ToolCall: &client.ToolCall{ID: "call-1", Name: "read_file", Arguments: map[string]interface{}{"path": "main.go"}}} + events <- client.EyrieStreamEvent{Type: "usage", Usage: &client.EyrieUsage{PromptTokens: 5, CompletionTokens: 3, TotalTokens: 8}} + events <- client.EyrieStreamEvent{Type: "done", StopReason: "end_turn", RequestID: "req-stream"} + close(events) + return client.NewStreamResultWithRequestID(events, "req-stream", nil), nil +} + +func TestEngineContractEndToEnd(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + cat := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(filepath.Join(dir, "model_catalog.json"), &cat); err != nil { + t.Fatal(err) + } + eng, err := New(Options{StateDir: dir, SecretStore: &credentials.MapStore{}}) + if err != nil { + t.Fatal(err) + } + modelID := firstCatalogModelID(cat) + if modelID == "" { + t.Fatal("seed catalog has no model") + } + if err := eng.SetSelection(ctx, "", modelID); err != nil { + t.Fatal(err) + } + provider := &contractProvider{} + eng.resolveTransport = func(context.Context, Route) (client.Provider, error) { return provider, nil } + + topP := 0.8 + request := GenerateRequest{ + Messages: []Message{{Role: "user", Content: "inspect main.go"}}, + SystemPrompt: "You are a coding agent.", + Tools: []Tool{{Name: "read_file", Description: "Read a file", Parameters: map[string]interface{}{"type": "object"}}}, + Preference: Preference{PreferredModelID: modelID}, + Limits: Limits{MaxOutputTokens: 1024, MaxContinuations: 2, MaxTotalOutputTokens: 4096}, + Metadata: Metadata{SessionID: "session-1", TurnID: "turn-1", UserID: "user-1"}, + Options: GenerationOptions{EnableCaching: true, ReasoningEffort: "high", TopP: &topP, ServiceTier: "priority"}, + } + + response, err := eng.Generate(ctx, request) + if err != nil { + t.Fatal(err) + } + if response.Content != "complete" || response.Usage == nil || response.Usage.TotalTokens != 6 { + t.Fatalf("blocking response not normalized: %+v", response) + } + if provider.chatOptions.Model != modelID || provider.chatOptions.System != request.SystemPrompt || !provider.chatOptions.EnableCaching || provider.chatOptions.ReasoningEffort != "high" || provider.chatOptions.MetadataUserID != "user-1" { + t.Fatalf("blocking options lost at boundary: %+v", provider.chatOptions) + } + if provider.chatOptions.Metadata["session.id"] != "session-1" || provider.chatOptions.Metadata["turn.id"] != "turn-1" { + t.Fatalf("correlation metadata lost at boundary: %+v", provider.chatOptions.Metadata) + } + + stream, err := eng.Stream(ctx, request) + if err != nil { + t.Fatal(err) + } + defer stream.Close() + var events []Event + for stream.Next() { + events = append(events, stream.Event()) + } + if err := stream.Err(); err != nil { + t.Fatal(err) + } + if len(events) != 5 || events[0].Type != EventRouteSelected || events[1].Type != EventContentDelta || events[2].Type != EventToolCallDone || events[3].Type != EventUsage || events[4].Type != EventDone { + t.Fatalf("normalized event sequence: %+v", events) + } + if events[2].ToolCall == nil || events[2].ToolCall.Name != "read_file" { + t.Fatalf("tool call not normalized: %+v", events[2]) + } + if events[3].Usage == nil || events[3].Usage.TotalTokens != 8 { + t.Fatalf("stream usage not normalized: %+v", events[3]) + } + if len(provider.streamOptions.Tools) != 1 || provider.streamOptions.MaxTokens != 1024 || provider.streamOptions.ServiceTier != "priority" { + t.Fatalf("stream options lost at boundary: %+v", provider.streamOptions) + } +} + +type continuationProvider struct { + calls int + requests [][]client.EyrieMessage +} + +func (p *continuationProvider) Name() string { return "continuation" } +func (p *continuationProvider) Ping(context.Context) error { return nil } +func (p *continuationProvider) Chat(context.Context, []client.EyrieMessage, client.ChatOptions) (*client.EyrieResponse, error) { + return nil, nil +} +func (p *continuationProvider) StreamChat(_ context.Context, messages []client.EyrieMessage, _ client.ChatOptions) (*client.StreamResult, error) { + p.calls++ + p.requests = append(p.requests, append([]client.EyrieMessage(nil), messages...)) + events := make(chan client.EyrieStreamEvent, 3) + if p.calls == 1 { + events <- client.EyrieStreamEvent{Type: "content", Content: "part one"} + events <- client.EyrieStreamEvent{Type: "usage", Usage: &client.EyrieUsage{CompletionTokens: 2, TotalTokens: 4}} + events <- client.EyrieStreamEvent{Type: "done", StopReason: "max_tokens", RequestID: "request-1"} + } else { + events <- client.EyrieStreamEvent{Type: "content", Content: "part two"} + events <- client.EyrieStreamEvent{Type: "usage", Usage: &client.EyrieUsage{CompletionTokens: 2, TotalTokens: 5}} + events <- client.EyrieStreamEvent{Type: "done", StopReason: "end_turn", RequestID: "request-2"} + } + close(events) + return client.NewStreamResultWithRequestID(events, "request-id", nil), nil +} + +func TestEngineContinuationPreservesEventsAndConversationShape(t *testing.T) { + provider := &continuationProvider{} + source, err := streamWithContinuation( + context.Background(), provider, + []client.EyrieMessage{{Role: "user", Content: "write a long answer"}}, + client.ChatOptions{Model: "model"}, + Limits{MaxContinuations: 1, MaxTotalOutputTokens: 100}, + ) + if err != nil { + t.Fatal(err) + } + defer source.Close() + var events []client.EyrieStreamEvent + for event := range source.Events { + events = append(events, event) + } + if provider.calls != 2 { + t.Fatalf("provider calls = %d, want 2", provider.calls) + } + if len(provider.requests[1]) != 3 || provider.requests[1][1].Role != "assistant" || provider.requests[1][1].Content != "part one" || provider.requests[1][2].Content != "Continue." { + t.Fatalf("continuation conversation shape: %+v", provider.requests[1]) + } + var sawContinuation, sawFinal bool + for _, event := range events { + if event.Type == "continuation" { + sawContinuation = true + } + if event.Type == "done" && event.StopReason == "end_turn" && event.RequestID == "request-2" { + sawFinal = true + } + } + if !sawContinuation || !sawFinal { + t.Fatalf("continuation metadata missing: %+v", events) + } +} + +func firstCatalogModelID(cat catalog.Catalog) string { + for id := range cat.Models { + return id + } + return "" +} diff --git a/engine/convert.go b/engine/convert.go index 3c91e46..e809eb0 100644 --- a/engine/convert.go +++ b/engine/convert.go @@ -42,12 +42,72 @@ func toClientOptions(req GenerateRequest, route Route, stream bool) client.ChatO System: req.SystemPrompt, Tools: tools, Temperature: req.Temperature, MaxTokens: req.Limits.MaxOutputTokens, MetadataUserID: req.Metadata.UserID, } + advanced := req.Options + opts.EnableCaching = advanced.EnableCaching + opts.ReasoningEffort = advanced.ReasoningEffort + opts.ThinkingBudgetTokens = advanced.ThinkingBudgetTokens + opts.ThinkingMode = advanced.ThinkingMode + opts.ThinkingDisplay = advanced.ThinkingDisplay + opts.GLMThinkingEnabled = advanced.GLMThinkingEnabled + opts.VirtualKeyID = advanced.VirtualKeyID + opts.KimiContextCacheID = advanced.KimiContextCacheID + opts.KimiCacheResetTTL = advanced.KimiCacheResetTTL + opts.TopP = advanced.TopP + opts.TopK = advanced.TopK + opts.StopSequences = append([]string(nil), advanced.StopSequences...) + if advanced.ToolChoice != nil { + opts.ToolChoice = &client.ToolChoiceOption{ + Type: advanced.ToolChoice.Type, Name: advanced.ToolChoice.Name, + DisableParallelToolUse: advanced.ToolChoice.DisableParallelToolUse, + } + } + opts.ServiceTier = advanced.ServiceTier + opts.OutputEffort = advanced.OutputEffort + opts.PresencePenalty = advanced.PresencePenalty + opts.FrequencyPenalty = advanced.FrequencyPenalty + opts.N = advanced.N + opts.LogProbs = advanced.LogProbs + opts.TopLogProbs = advanced.TopLogProbs + opts.Seed = advanced.Seed + opts.Store = advanced.Store + opts.Metadata = cloneStringMap(advanced.Metadata) + if opts.Metadata == nil { + opts.Metadata = make(map[string]string) + } + setMetadataIfPresent(opts.Metadata, "session.id", req.Metadata.SessionID) + setMetadataIfPresent(opts.Metadata, "turn.id", req.Metadata.TurnID) + setMetadataIfPresent(opts.Metadata, "project.id", req.Metadata.ProjectID) + if len(opts.Metadata) == 0 { + opts.Metadata = nil + } + opts.Modalities = append([]string(nil), advanced.Modalities...) + opts.AudioConfig = advanced.AudioConfig + opts.Prediction = advanced.Prediction + opts.WebSearchOptions = advanced.WebSearchOptions if req.OutputSchema != "" { opts.ResponseFormat = &client.ResponseFormat{Type: "json_schema", Schema: req.OutputSchema} + opts.OutputSchema = req.OutputSchema } return opts } +func setMetadataIfPresent(metadata map[string]string, key, value string) { + if value != "" { + metadata[key] = value + } +} + +func cloneStringMap(in map[string]string) map[string]string { + if in == nil { + return nil + } + out := make(map[string]string, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + func fromClientResponse(resp *client.EyrieResponse, route Route) *GenerateResponse { if resp == nil { return &GenerateResponse{Route: route} diff --git a/engine/engine.go b/engine/engine.go index bd45e3e..5be9c46 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -33,6 +33,7 @@ type Engine struct { secretStore credentials.Store catalogPath string providerConfigPath string + resolveTransport func(context.Context, Route) (client.Provider, error) } // New constructs a host-facing Eyrie engine. @@ -59,7 +60,9 @@ func New(opts Options) (*Engine, error) { if providerPath == "" { providerPath = config.GetProviderConfigPath() } - return &Engine{secretStore: store, catalogPath: catalogPath, providerConfigPath: providerPath}, nil + engine := &Engine{secretStore: store, catalogPath: catalogPath, providerConfigPath: providerPath} + engine.resolveTransport = engine.defaultTransport + return engine, nil } // SelectionRequest asks Eyrie to resolve a concrete provider/model route. @@ -110,14 +113,7 @@ func (e *Engine) Stream(ctx context.Context, req GenerateRequest) (*Stream, erro return nil, err } callCtx, cancel := requestContext(ctx, req.Limits.Timeout) - continuation := client.DefaultContinuationConfig() - if req.Limits.MaxContinuations > 0 { - continuation.MaxContinuations = req.Limits.MaxContinuations - } - if req.Limits.MaxTotalOutputTokens > 0 { - continuation.MaxTotalTokens = req.Limits.MaxTotalOutputTokens - } - source, err := client.StreamChatWithContinuation(callCtx, provider, toClientMessages(req.Messages), toClientOptions(req, route, true), continuation) + source, err := streamWithContinuation(callCtx, provider, toClientMessages(req.Messages), toClientOptions(req, route, true), req.Limits) if err != nil { cancel() return nil, classify("stream", route, err) @@ -153,17 +149,21 @@ func (e *Engine) resolveProvider(ctx context.Context, req GenerateRequest) (Rout if err != nil { return Route{}, nil, err } - compiled, cfg, err := e.loadRuntimeState(ctx) - if err != nil { - return Route{}, nil, err - } - provider, err := setup.DeploymentProviderFromCatalog(cfg, compiled) + provider, err := e.resolveTransport(ctx, route) if err != nil { return Route{}, nil, classify("resolve_transport", route, err) } return route, provider, nil } +func (e *Engine) defaultTransport(ctx context.Context, _ Route) (client.Provider, error) { + compiled, cfg, err := e.loadRuntimeState(ctx) + if err != nil { + return nil, err + } + return setup.DeploymentProviderFromCatalog(cfg, compiled) +} + func (e *Engine) resolveSelection(ctx context.Context, req SelectionRequest) (Route, error) { compiled, cfg, err := e.loadRuntimeState(ctx) if err != nil { diff --git a/engine/stream.go b/engine/stream.go index f29e820..c4a76a5 100644 --- a/engine/stream.go +++ b/engine/stream.go @@ -149,6 +149,8 @@ func normalizeEvent(event client.EyrieStreamEvent) (Event, error) { } case "ttft": out.Type = EventTTFT + case "continuation": + out.Type = EventContinuation case "error": return Event{}, &Error{Code: ErrorProviderUnavailable, Operation: "stream", Message: event.Error} default: diff --git a/engine/types.go b/engine/types.go index 338e6f7..ed779d8 100644 --- a/engine/types.go +++ b/engine/types.go @@ -73,6 +73,46 @@ type Tool struct { Parameters map[string]interface{} `json:"parameters"` } +// ToolChoice controls whether and how the model may request tools. +type ToolChoice struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + DisableParallelToolUse bool `json:"disable_parallel_tool_use,omitempty"` +} + +// GenerationOptions contains model-generation controls that have equivalent +// semantics across one or more provider adapters. Provider-specific wire +// formats remain internal to Eyrie. +type GenerationOptions struct { + EnableCaching bool `json:"enable_caching,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + ThinkingBudgetTokens int `json:"thinking_budget_tokens,omitempty"` + ThinkingMode string `json:"thinking_mode,omitempty"` + ThinkingDisplay string `json:"thinking_display,omitempty"` + GLMThinkingEnabled *bool `json:"glm_thinking_enabled,omitempty"` + VirtualKeyID string `json:"virtual_key_id,omitempty"` + KimiContextCacheID string `json:"kimi_context_cache_id,omitempty"` + KimiCacheResetTTL bool `json:"kimi_cache_reset_ttl,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + ToolChoice *ToolChoice `json:"tool_choice,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + OutputEffort string `json:"output_effort,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` + N *int `json:"n,omitempty"` + LogProbs *bool `json:"logprobs,omitempty"` + TopLogProbs *int `json:"top_logprobs,omitempty"` + Seed *int `json:"seed,omitempty"` + Store *bool `json:"store,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Modalities []string `json:"modalities,omitempty"` + AudioConfig string `json:"audio_config,omitempty"` + Prediction string `json:"prediction,omitempty"` + WebSearchOptions string `json:"web_search_options,omitempty"` +} + // Limits bounds a generation request. type Limits struct { MaxOutputTokens int `json:"max_output_tokens,omitempty"` @@ -92,15 +132,16 @@ type Metadata struct { // GenerateRequest is the provider-neutral request accepted by Engine. type GenerateRequest struct { - Messages []Message `json:"messages"` - SystemPrompt string `json:"system_prompt,omitempty"` - Tools []Tool `json:"tools,omitempty"` - Requirements Requirements `json:"requirements,omitempty"` - Preference Preference `json:"preference,omitempty"` - Limits Limits `json:"limits,omitempty"` - Metadata Metadata `json:"metadata,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - OutputSchema string `json:"output_schema,omitempty"` + Messages []Message `json:"messages"` + SystemPrompt string `json:"system_prompt,omitempty"` + Tools []Tool `json:"tools,omitempty"` + Requirements Requirements `json:"requirements,omitempty"` + Preference Preference `json:"preference,omitempty"` + Limits Limits `json:"limits,omitempty"` + Metadata Metadata `json:"metadata,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + OutputSchema string `json:"output_schema,omitempty"` + Options GenerationOptions `json:"options,omitempty"` } // Route is the concrete model/deployment decision made by Eyrie. @@ -143,6 +184,7 @@ const ( EventToolCallDone EventType = "tool_call_done" EventUsage EventType = "usage" EventRetry EventType = "retry" + EventContinuation EventType = "continuation" EventRouteChanged EventType = "route_changed" EventWarning EventType = "warning" EventTTFT EventType = "ttft" From afefd9ba1a421e5bbc7c929eb9cc9915757919a4 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 12 Jul 2026 19:38:10 +0530 Subject: [PATCH 07/24] fix: list models by serving gateway --- engine/catalog.go | 2 +- engine/engine.go | 37 +++++++++++++++++++++++++++++++++---- engine/engine_test.go | 37 +++++++++++++++++++++++++++++++++++++ engine/types.go | 1 + 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/engine/catalog.go b/engine/catalog.go index 7f50335..1887d65 100644 --- a/engine/catalog.go +++ b/engine/catalog.go @@ -108,7 +108,7 @@ func snapshotFromCompiled(compiled *catalog.CompiledCatalog) CatalogSnapshot { outputPrice = offering.Pricing.RatesPer1M["output_tokens"] } snapshot.Models = append(snapshot.Models, Model{ - ID: id, DisplayName: model.Name, ProviderID: model.ProviderID, + ID: id, DisplayName: model.Name, Owner: model.ProviderID, ProviderID: model.ProviderID, ContextWindow: model.ContextWindow, MaxOutputTokens: model.MaxOutput, InputPricePer1M: inputPrice, OutputPricePer1M: outputPrice, Capabilities: capabilityNames(offering.Capabilities), Source: "catalog", diff --git a/engine/engine.go b/engine/engine.go index 5be9c46..4587b09 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -9,6 +9,7 @@ import ( "time" "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/catalog/registry" "github.com/GrayCodeAI/eyrie/client" "github.com/GrayCodeAI/eyrie/config" "github.com/GrayCodeAI/eyrie/credentials" @@ -135,15 +136,43 @@ func (e *Engine) ListModels(ctx context.Context, providerID string, refresh bool return nil, &Error{Code: ErrorCatalogUnavailable, Operation: "list_models", Provider: providerID, Message: err.Error(), Cause: err} } providerID = catalog.CanonicalProviderID(strings.TrimSpace(providerID)) - out := make([]Model, 0, len(snapshot.Models)) - for _, model := range snapshot.Models { - if providerID == "" || catalog.CanonicalProviderID(model.ProviderID) == providerID { - out = append(out, model) + if providerID == "" { + return snapshot.Models, nil + } + compiled, loadErr := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{CachePath: e.catalogPath, RequireCache: true}) + if loadErr != nil { + return nil, &Error{Code: ErrorCatalogUnavailable, Operation: "list_models", Provider: providerID, Message: loadErr.Error(), Cause: loadErr} + } + entries := catalog.ModelEntriesForProvider(compiled, providerID) + out := make([]Model, 0, len(entries)) + for _, entry := range entries { + capabilities := append([]string(nil), entry.ServerTools...) + if canonical, ok := catalog.CanonicalModelForProviderNative(compiled, providerID, entry.ID); ok { + capabilities = capabilityNames(offeringForProvider(compiled, providerID, canonical, entry.ID).Capabilities) } + out = append(out, Model{ + ID: entry.ID, DisplayName: entry.DisplayName, Owner: entry.Owner, ProviderID: providerID, + ContextWindow: entry.ContextWindow, MaxOutputTokens: entry.MaxOutput, + InputPricePer1M: entry.InputPricePer1M, OutputPricePer1M: entry.OutputPricePer1M, + Capabilities: capabilities, Source: "cache", + }) } return out, nil } +func offeringForProvider(compiled *catalog.CompiledCatalog, providerID, canonicalID, nativeID string) catalog.ModelOffering { + spec, ok := registry.SpecByProviderID(providerID) + if !ok { + return firstOffering(compiled.OfferingsByCanonicalModel[canonicalID]) + } + for _, offering := range compiled.OfferingsByDeployment[spec.DeploymentID] { + if offering.CanonicalModelID == canonicalID && (nativeID == "" || offering.NativeModelID == nativeID) { + return offering + } + } + return firstOffering(compiled.OfferingsByCanonicalModel[canonicalID]) +} + func (e *Engine) resolveProvider(ctx context.Context, req GenerateRequest) (Route, client.Provider, error) { route, err := e.resolveSelection(ctx, SelectionRequest{Requirements: req.Requirements, Preference: req.Preference}) if err != nil { diff --git a/engine/engine_test.go b/engine/engine_test.go index 3df71f4..936be49 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -228,3 +228,40 @@ func TestSelectionUsesEngineStateDir(t *testing.T) { t.Fatalf("selection not cleared: %+v", saved) } } + +func TestListModelsUsesGatewayOfferings(t *testing.T) { + dir := t.TempDir() + seed := catalog.SeedCatalog() + compiled, err := catalog.CompileCatalog(&seed) + if err != nil { + t.Fatal(err) + } + providerID := "" + var expected []catalog.ModelCatalogEntry + for _, candidate := range []string{"openrouter", "openai", "anthropic", "gemini"} { + if entries := catalog.ModelEntriesForProvider(compiled, candidate); len(entries) > 0 { + providerID, expected = candidate, entries + break + } + } + if providerID == "" { + t.Skip("seed catalog has no gateway offerings") + } + if err := catalog.WriteCatalogCache(filepath.Join(dir, "model_catalog.json"), &seed); err != nil { + t.Fatal(err) + } + eng, err := New(Options{StateDir: dir, SecretStore: &credentials.MapStore{}}) + if err != nil { + t.Fatal(err) + } + models, err := eng.ListModels(context.Background(), providerID, false) + if err != nil { + t.Fatal(err) + } + if len(models) != len(expected) || len(models) == 0 { + t.Fatalf("models = %d, want %d", len(models), len(expected)) + } + if models[0].ProviderID != providerID || models[0].ID != expected[0].ID { + t.Fatalf("gateway model mismatch: got %+v want %+v", models[0], expected[0]) + } +} diff --git a/engine/types.go b/engine/types.go index ed779d8..71f0b09 100644 --- a/engine/types.go +++ b/engine/types.go @@ -210,6 +210,7 @@ type Event struct { type Model struct { ID string `json:"id"` DisplayName string `json:"display_name"` + Owner string `json:"owner,omitempty"` ProviderID string `json:"provider_id"` ContextWindow int `json:"context_window,omitempty"` MaxOutputTokens int `json:"max_output_tokens,omitempty"` From 21f23df127ec8603fffa8e34b20875a6a3cf3159 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 12 Jul 2026 19:40:56 +0530 Subject: [PATCH 08/24] fix: preserve credential save reassurance --- engine/credentials.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engine/credentials.go b/engine/credentials.go index 0054d82..0a33db8 100644 --- a/engine/credentials.go +++ b/engine/credentials.go @@ -38,13 +38,13 @@ func (e *Engine) SaveCredential(ctx context.Context, providerID, secret string) status := CredentialStatus{ProviderID: providerID, EnvVar: envKey, Configured: true} if spec, ok := registry.DefaultRegistry.Get(providerID); ok && !spec.RequiresKey { if err := config.ProbeLocalCredential(ctx, envKey, prepared); err != nil { - return status, &Error{Code: ErrorProviderUnavailable, Operation: "probe_credential", Provider: providerID, Message: fmt.Sprintf("%v (credential saved)", err), Cause: err} + return status, &Error{Code: ErrorProviderUnavailable, Operation: "probe_credential", Provider: providerID, Message: fmt.Sprintf("%v (value saved in keychain)", err), Cause: err} } status.Verified = true return status, nil } if err := config.ProbeCredential(ctx, envKey, prepared); err != nil { - return status, &Error{Code: ErrorAuthentication, Operation: "probe_credential", Provider: providerID, Message: fmt.Sprintf("%v (credential saved)", err), Cause: err} + return status, &Error{Code: ErrorAuthentication, Operation: "probe_credential", Provider: providerID, Message: fmt.Sprintf("%v (key saved in keychain)", err), Cause: err} } status.Verified = true return status, nil From 3e78f086aadf808f7174336b725f0a9c8189d3b8 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 12 Jul 2026 19:42:41 +0530 Subject: [PATCH 09/24] feat: expose engine selection lifecycle --- engine/engine_test.go | 4 ++++ engine/selection.go | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/engine/engine_test.go b/engine/engine_test.go index 936be49..a9cd5fc 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -216,6 +216,10 @@ func TestSelectionUsesEngineStateDir(t *testing.T) { if err := eng.SetSelection(context.Background(), "", modelID); err != nil { t.Fatal(err) } + active := eng.ActiveSelection(context.Background()) + if active.Model != modelID || active.Provider == "" { + t.Fatalf("active selection = %+v", active) + } saved := config.LoadProviderConfig(eng.providerConfigPath) if saved == nil || saved.ActiveModel != modelID { t.Fatalf("selection not saved to engine path: %+v", saved) diff --git a/engine/selection.go b/engine/selection.go index 7ad2053..464d7ac 100644 --- a/engine/selection.go +++ b/engine/selection.go @@ -6,8 +6,49 @@ import ( "github.com/GrayCodeAI/eyrie/catalog" "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/runtime" ) +// NormalizeProviderID resolves provider aliases to Eyrie's runtime identifier. +func NormalizeProviderID(providerID string) string { + return runtime.NormalizeProviderID(providerID) +} + +// ActiveSelection reads the persisted host selection from this Engine's state. +func (e *Engine) ActiveSelection(ctx context.Context) Route { + _ = nonNilContext(ctx) + cfg := config.LoadProviderConfig(e.providerConfigPath) + return Route{ + Provider: NormalizeProviderID(config.ActiveProvider(cfg)), + Model: config.ActiveModel(cfg), + DeploymentRouting: cfg != nil && (cfg.ConfigVersion >= 2 || len(cfg.Deployments) > 0 || cfg.Routing != nil), + } +} + +// SetActiveProvider persists a provider choice without requiring a model. +func (e *Engine) SetActiveProvider(ctx context.Context, providerID string) error { + _ = nonNilContext(ctx) + providerID = NormalizeProviderID(providerID) + if providerID == "" { + return invalid("set_active_provider", "eyrie engine: provider id is required") + } + cfg := config.LoadProviderConfig(e.providerConfigPath) + if cfg == nil { + cfg = &config.ProviderConfig{} + } + config.SetActiveProvider(cfg, providerID) + if err := config.SaveProviderConfig(cfg, e.providerConfigPath); err != nil { + return &Error{Code: ErrorInternal, Operation: "set_active_provider", Provider: providerID, Message: err.Error(), Cause: err} + } + return nil +} + +// SetActiveModel persists a model choice and infers its serving provider from +// the configured catalog. +func (e *Engine) SetActiveModel(ctx context.Context, modelID string) error { + return e.SetSelection(ctx, "", modelID) +} + // SetSelection persists the host/user's provider and model choice in this // Engine's configured state path. It does not persist credentials. func (e *Engine) SetSelection(ctx context.Context, providerID, modelID string) error { From 2188c98f1b1174aadade4e455c8ba2e0842cb09d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 12 Jul 2026 19:44:04 +0530 Subject: [PATCH 10/24] fix: preserve model-only selection compatibility --- engine/selection.go | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/engine/selection.go b/engine/selection.go index 464d7ac..a00fbfd 100644 --- a/engine/selection.go +++ b/engine/selection.go @@ -59,26 +59,25 @@ func (e *Engine) SetSelection(ctx context.Context, providerID, modelID string) e if modelID == "" { return invalid("set_selection", "eyrie engine: model id is required") } - compiled, err := catalog.LoadCatalog(context.Background(), catalog.LoadCatalogOptions{CachePath: e.catalogPath, RequireCache: true}) - if err != nil { - return &Error{Code: ErrorCatalogUnavailable, Operation: "set_selection", Message: err.Error(), Cause: err} - } - canonical, ok := compiled.CanonicalModelForAliasOrID(modelID) - if ok { - modelID = canonical + cfg := config.LoadProviderConfig(e.providerConfigPath) + if cfg == nil { + cfg = &config.ProviderConfig{} } - if providerID == "" { - providerID = catalog.GatewayForModel(compiled, modelID) + compiled, err := catalog.LoadCatalog(context.Background(), catalog.LoadCatalogOptions{CachePath: e.catalogPath, RequireCache: true}) + if err == nil && compiled != nil { + canonical, ok := compiled.CanonicalModelForAliasOrID(modelID) + if ok { + modelID = canonical + } if providerID == "" { - providerID = catalog.ProviderForModel(compiled, modelID) + providerID = catalog.GatewayForModel(compiled, modelID) + if providerID == "" { + providerID = catalog.ProviderForModel(compiled, modelID) + } } } if providerID == "" { - return &Error{Code: ErrorModelUnavailable, Operation: "set_selection", Model: modelID, Message: "eyrie engine: could not determine provider for model"} - } - cfg := config.LoadProviderConfig(e.providerConfigPath) - if cfg == nil { - cfg = &config.ProviderConfig{} + providerID = config.ActiveProvider(cfg) } config.SetProviderModel(cfg, providerID, modelID) if err := config.SaveProviderConfig(cfg, e.providerConfigPath); err != nil { From ae09dfb20a535b2afe27689766c81b51409668d6 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 12 Jul 2026 20:47:47 +0530 Subject: [PATCH 11/24] feat: move native compaction behind engine --- engine/compaction.go | 37 ++++++++++++++++++++++++++++++++++++ engine/compaction_test.go | 26 +++++++++++++++++++++++++ runtime/native_compaction.go | 25 +++++++++++++++++++++--- 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 engine/compaction.go create mode 100644 engine/compaction_test.go diff --git a/engine/compaction.go b/engine/compaction.go new file mode 100644 index 0000000..f8b5c55 --- /dev/null +++ b/engine/compaction.go @@ -0,0 +1,37 @@ +package engine + +import ( + "context" + + "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 { + return runtime.SupportsNativeCompactionWithStore(nonNilContext(ctx), provider, model, e.secretStore) +} + +// CompactNative performs provider-native compaction and returns a normalized +// summary. Conversation mutation remains the host's responsibility. +func (e *Engine) CompactNative(ctx context.Context, req NativeCompactionRequest) (string, error) { + result, err := runtime.CompactNativeConversationWithStore(nonNilContext(ctx), runtime.NativeCompactionOpts{ + Provider: req.Provider, Model: req.Model, Messages: toClientMessages(req.Messages), + ContextWindow: req.ContextWindow, ThresholdPct: req.ThresholdPct, MaxOutputTokens: req.MaxOutputTokens, + }, e.secretStore) + if err != nil { + return "", classify("native_compaction", Route{Provider: req.Provider, Model: req.Model}, err) + } + return result.Summary, nil +} diff --git a/engine/compaction_test.go b/engine/compaction_test.go new file mode 100644 index 0000000..518ace6 --- /dev/null +++ b/engine/compaction_test.go @@ -0,0 +1,26 @@ +package engine + +import ( + "context" + "testing" + + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestNativeCompactionUsesInjectedCredentialStore(t *testing.T) { + ctx := context.Background() + store := &credentials.MapStore{} + eng, err := New(Options{SecretStore: store}) + if err != nil { + t.Fatal(err) + } + if eng.SupportsNativeCompaction(ctx, "anthropic", "claude-sonnet-4-6") { + t.Fatal("compaction reported available without injected credential") + } + if err := store.Set(ctx, credentials.AccountForEnv("ANTHROPIC_API_KEY"), "sk-test"); err != nil { + t.Fatal(err) + } + if !eng.SupportsNativeCompaction(ctx, "anthropic", "claude-sonnet-4-6") { + t.Fatal("compaction did not use injected credential") + } +} diff --git a/runtime/native_compaction.go b/runtime/native_compaction.go index 7d0846b..84239dd 100644 --- a/runtime/native_compaction.go +++ b/runtime/native_compaction.go @@ -39,19 +39,38 @@ type NativeCompactionResult struct { // SupportsNativeCompaction reports whether Eyrie can compact this selection // with a configured provider credential. func SupportsNativeCompaction(ctx context.Context, provider, model string) bool { + return SupportsNativeCompactionWithStore(ctx, provider, model, credentials.DefaultStore()) +} + +// SupportsNativeCompactionWithStore is the host-neutral form using an explicit +// credential store. +func SupportsNativeCompactionWithStore(ctx context.Context, provider, model string, store credentials.Store) bool { if !supportsAnthropicCompactionSelection(provider, model) { return false } - return credentials.LookupSecret(ctx, "ANTHROPIC_API_KEY") != "" + if store == nil { + store = credentials.DefaultStore() + } + secret, err := store.Get(ctx, credentials.AccountForEnv("ANTHROPIC_API_KEY")) + return err == nil && strings.TrimSpace(secret) != "" } // CompactNativeConversation invokes the provider-native compaction protocol. func CompactNativeConversation(ctx context.Context, opts NativeCompactionOpts) (*NativeCompactionResult, error) { + return CompactNativeConversationWithStore(ctx, opts, credentials.DefaultStore()) +} + +// CompactNativeConversationWithStore invokes provider-native compaction using +// an explicit host credential store. +func CompactNativeConversationWithStore(ctx context.Context, opts NativeCompactionOpts, store credentials.Store) (*NativeCompactionResult, error) { if !supportsAnthropicCompactionSelection(opts.Provider, opts.Model) { return nil, fmt.Errorf("runtime: provider native compaction unavailable for %s/%s", opts.Provider, opts.Model) } - apiKey := credentials.LookupSecret(ctx, "ANTHROPIC_API_KEY") - if apiKey == "" { + if store == nil { + store = credentials.DefaultStore() + } + apiKey, err := store.Get(ctx, credentials.AccountForEnv("ANTHROPIC_API_KEY")) + if err != nil || strings.TrimSpace(apiKey) == "" { return nil, fmt.Errorf("runtime: Anthropic credential required for native compaction") } trigger := opts.ContextWindow * opts.ThresholdPct / 100 From a46f5d93cbda74579b476e2533cc3b9b25305e44 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 12 Jul 2026 21:15:59 +0530 Subject: [PATCH 12/24] feat: expose provider control plane --- engine/control_plane.go | 114 +++++++++++++++++++++++++++++++++++ engine/control_plane_test.go | 60 ++++++++++++++++++ engine/credentials.go | 8 +-- engine/types.go | 34 +++++++++++ 4 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 engine/control_plane.go create mode 100644 engine/control_plane_test.go diff --git a/engine/control_plane.go b/engine/control_plane.go new file mode 100644 index 0000000..f988148 --- /dev/null +++ b/engine/control_plane.go @@ -0,0 +1,114 @@ +package engine + +import ( + "context" + "errors" + "sort" + "strings" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/catalog/registry" + "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" +) + +// ResolveCredential validates credential input and returns safe provider +// choices. Eyrie does not retain or return the supplied secret. +func (e *Engine) ResolveCredential(ctx context.Context, secret string) CredentialResolution { + resolved := config.ResolveCredential(nonNilContext(ctx), secret) + out := CredentialResolution{ + FormatOK: resolved.FormatOK, FormatError: resolved.FormatError, + ProbeDisambiguationUsed: resolved.ProbeDisambiguationUsed, + Providers: make([]CredentialProvider, len(resolved.Providers)), + } + for i, provider := range resolved.Providers { + out.Providers[i] = CredentialProvider{ + ProviderID: provider.ProviderID, DeploymentID: provider.DeploymentID, + EnvVar: provider.EnvVar, DisplayName: provider.DisplayName, + RequiresKey: provider.RequiresKey, Rank: provider.Rank, + } + } + return out +} + +// CredentialProviders lists safe provider choices for setup UIs. +func (e *Engine) CredentialProviders(context.Context) []CredentialProvider { + providers := config.ListCredentialProviders() + out := make([]CredentialProvider, len(providers)) + for i, provider := range providers { + out[i] = CredentialProvider{ + ProviderID: provider.ProviderID, DeploymentID: provider.DeploymentID, + EnvVar: provider.EnvVar, DisplayName: provider.DisplayName, + RequiresKey: provider.RequiresKey, Rank: provider.Rank, + } + } + return out +} + +// Gateways returns safe configuration status using this Engine's injected +// credential store, catalog path, and provider state path. +func (e *Engine) Gateways(ctx context.Context) []Gateway { + ctx = nonNilContext(ctx) + selection := e.ActiveSelection(ctx) + providerConfig := config.LoadProviderConfig(e.providerConfigPath) + compiled, _ := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{CachePath: e.catalogPath}) + + specs := registry.CredentialRegistry() + out := make([]Gateway, 0, len(specs)) + for _, spec := range specs { + providerSpec, _ := registry.SpecByProviderID(spec.ProviderID) + envVars := append([]string{spec.EnvVar}, providerSpec.CredentialEnvFallbacks...) + envVars = append(envVars, providerSpec.CredentialAliases...) + configured := e.hasCredential(ctx, envVars) + deploymentConfigured := providerConfig != nil && spec.DeploymentID != "" + if deploymentConfigured { + _, deploymentConfigured = providerConfig.Deployments[spec.DeploymentID] + } + modelCount := 0 + if compiled != nil { + modelCount = len(catalog.ModelEntriesForProvider(compiled, spec.ProviderID)) + } + out = append(out, Gateway{ + ID: spec.ProviderID, DisplayName: spec.DisplayName, + DeploymentID: spec.DeploymentID, CredentialEnv: spec.EnvVar, + RequiresKey: spec.RequiresKey, CredentialConfigured: configured, + DeploymentConfigured: deploymentConfigured, ModelCount: modelCount, + Active: NormalizeProviderID(selection.Provider) == NormalizeProviderID(spec.ProviderID), + }) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +func (e *Engine) hasCredential(ctx context.Context, envVars []string) bool { + for _, envVar := range envVars { + envVar = strings.TrimSpace(envVar) + if envVar == "" { + continue + } + secret, err := e.secretStore.Get(ctx, credentials.AccountForEnv(envVar)) + if err == nil && strings.TrimSpace(secret) != "" { + return true + } + } + return false +} + +func maskedCredential(secret string) string { + secret = strings.TrimSpace(secret) + if secret == "" { + return "" + } + if len(secret) <= 8 { + return strings.Repeat("•", len(secret)) + } + return strings.Repeat("•", 8) + secret[len(secret)-4:] +} + +func (e *Engine) credentialValue(ctx context.Context, envVar string) (string, error) { + secret, err := e.secretStore.Get(nonNilContext(ctx), credentials.AccountForEnv(envVar)) + if errors.Is(err, credentials.ErrNotFound) { + return "", nil + } + return secret, err +} diff --git a/engine/control_plane_test.go b/engine/control_plane_test.go new file mode 100644 index 0000000..0fdbeb6 --- /dev/null +++ b/engine/control_plane_test.go @@ -0,0 +1,60 @@ +package engine + +import ( + "context" + "path/filepath" + "testing" + + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestControlPlaneUsesInjectedCredentialStore(t *testing.T) { + ctx := context.Background() + store := &credentials.MapStore{} + eng, err := New(Options{ + SecretStore: store, + StateDir: t.TempDir(), + }) + if err != nil { + t.Fatal(err) + } + + providers := eng.CredentialProviders(ctx) + if len(providers) == 0 { + t.Fatal("expected registry-backed credential providers") + } + resolved := eng.ResolveCredential(ctx, "short") + if resolved.FormatOK { + t.Fatal("expected invalid credential format") + } + + if err := store.Set(ctx, credentials.AccountForEnv("OPENAI_API_KEY"), "sk-injected-secret"); err != nil { + t.Fatal(err) + } + status, err := eng.CredentialStatus(ctx, "openai") + if err != nil { + t.Fatal(err) + } + if !status.Configured || status.Masked != "••••••••cret" { + t.Fatalf("unexpected safe status: %+v", status) + } + for _, gateway := range eng.Gateways(ctx) { + if gateway.ID == "openai" && !gateway.CredentialConfigured { + t.Fatal("gateway status ignored injected credential store") + } + } + + if eng.catalogPath != filepath.Join(filepath.Dir(eng.providerConfigPath), "model_catalog.json") { + // Both paths must derive from the injected StateDir. The exact assertion + // catches accidental fallback to process-global Hawk paths. + t.Fatalf("control-plane paths escaped injected state dir: catalog=%q provider=%q", eng.catalogPath, eng.providerConfigPath) + } +} + +func TestMaskedCredentialNeverReturnsFullSecret(t *testing.T) { + secret := "sk-sensitive-value" + masked := maskedCredential(secret) + if masked == secret || masked == "" { + t.Fatalf("unsafe masked value %q", masked) + } +} diff --git a/engine/credentials.go b/engine/credentials.go index 0a33db8..b8d9d52 100644 --- a/engine/credentials.go +++ b/engine/credentials.go @@ -71,12 +71,10 @@ func (e *Engine) CredentialStatus(ctx context.Context, providerID string) (Crede if err != nil { return CredentialStatus{}, &Error{Code: ErrorInvalidRequest, Operation: "credential_status", Provider: providerID, Message: err.Error(), Cause: err} } - _, err = e.secretStore.Get(ctx, credentials.AccountForEnv(inference.EnvVar)) + secret, err := e.credentialValue(ctx, inference.EnvVar) if err == nil { - return CredentialStatus{ProviderID: providerID, EnvVar: inference.EnvVar, Configured: true}, nil - } - if errors.Is(err, credentials.ErrNotFound) { - return CredentialStatus{ProviderID: providerID, EnvVar: inference.EnvVar}, nil + configured := strings.TrimSpace(secret) != "" + return CredentialStatus{ProviderID: providerID, EnvVar: inference.EnvVar, Configured: configured, Masked: maskedCredential(secret)}, nil } return CredentialStatus{}, &Error{Code: ErrorInternal, Operation: "credential_status", Provider: providerID, Message: "eyrie engine: could not read credential status", Cause: err} } diff --git a/engine/types.go b/engine/types.go index 71f0b09..dab3f9a 100644 --- a/engine/types.go +++ b/engine/types.go @@ -234,4 +234,38 @@ type CredentialStatus struct { EnvVar string `json:"env_var,omitempty"` Configured bool `json:"configured"` Verified bool `json:"verified,omitempty"` + Masked string `json:"masked,omitempty"` +} + +// CredentialProvider is safe setup metadata for a configurable provider. +// It contains identifiers and labels only, never credential material. +type CredentialProvider struct { + ProviderID string `json:"provider_id"` + DeploymentID string `json:"deployment_id,omitempty"` + EnvVar string `json:"env_var,omitempty"` + DisplayName string `json:"display_name"` + RequiresKey bool `json:"requires_key"` + Rank int `json:"rank,omitempty"` +} + +// CredentialResolution validates pasted input and returns provider choices. +// The input secret is never retained in this value. +type CredentialResolution struct { + FormatOK bool `json:"format_ok"` + FormatError string `json:"format_error,omitempty"` + Providers []CredentialProvider `json:"providers"` + ProbeDisambiguationUsed bool `json:"probe_disambiguation_used,omitempty"` +} + +// Gateway is one host-facing provider/deployment configuration row. +type Gateway struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + DeploymentID string `json:"deployment_id,omitempty"` + CredentialEnv string `json:"credential_env,omitempty"` + RequiresKey bool `json:"requires_key"` + CredentialConfigured bool `json:"credential_configured"` + DeploymentConfigured bool `json:"deployment_configured"` + ModelCount int `json:"model_count"` + Active bool `json:"active"` } From 706724534ac08f4016d2b3852a71fd6f8d596651 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 12 Jul 2026 21:27:31 +0530 Subject: [PATCH 13/24] feat: expose effective engine selection --- engine/control_plane_test.go | 24 +++++++++++++ engine/selection.go | 66 ++++++++++++++++++++++++++++++++++++ engine/types.go | 15 ++++++++ 3 files changed, 105 insertions(+) diff --git a/engine/control_plane_test.go b/engine/control_plane_test.go index 0fdbeb6..65642b8 100644 --- a/engine/control_plane_test.go +++ b/engine/control_plane_test.go @@ -58,3 +58,27 @@ func TestMaskedCredentialNeverReturnsFullSecret(t *testing.T) { t.Fatalf("unsafe masked value %q", masked) } } + +func TestEffectiveSelectionUsesEngineOwnedState(t *testing.T) { + ctx := context.Background() + store := &credentials.MapStore{} + stateDir := t.TempDir() + eng, err := New(Options{SecretStore: store, StateDir: stateDir}) + if err != nil { + t.Fatal(err) + } + if err := store.Set(ctx, credentials.AccountForEnv("OPENAI_API_KEY"), "sk-injected-secret"); err != nil { + t.Fatal(err) + } + if err := eng.SetActiveProvider(ctx, "openai"); err != nil { + t.Fatal(err) + } + + selection := eng.EffectiveSelection(ctx, SelectionOptions{ModelOverride: "gpt-test"}) + if selection.Provider != "openai" || selection.Model != "gpt-test" { + t.Fatalf("unexpected selection: %+v", selection) + } + if !selection.HasConfiguredDeployment { + t.Fatal("selection ignored injected credential store") + } +} diff --git a/engine/selection.go b/engine/selection.go index a00fbfd..ca3101a 100644 --- a/engine/selection.go +++ b/engine/selection.go @@ -14,6 +14,72 @@ func NormalizeProviderID(providerID string) string { return runtime.NormalizeProviderID(providerID) } +// EffectiveSelection resolves persisted state plus optional host overrides +// using this Engine's injected catalog, provider config, and credential store. +func (e *Engine) EffectiveSelection(ctx context.Context, opts SelectionOptions) Selection { + ctx = nonNilContext(ctx) + active := e.ActiveSelection(ctx) + provider := NormalizeProviderID(active.Provider) + model := strings.TrimSpace(active.Model) + if override := NormalizeProviderID(opts.ProviderOverride); override != "" { + provider = override + } + if override := strings.TrimSpace(opts.ModelOverride); override != "" { + model = override + } + + compiled, _ := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{CachePath: e.catalogPath}) + if provider == "" && model != "" && compiled != nil { + provider = NormalizeProviderID(catalog.GatewayForModel(compiled, model)) + if provider == "" { + provider = NormalizeProviderID(catalog.ProviderForModel(compiled, model)) + } + } + + gateways := e.Gateways(ctx) + hasConfigured := false + configured := make(map[string]bool, len(gateways)) + for _, gateway := range gateways { + ready := gateway.CredentialConfigured || gateway.DeploymentConfigured || !gateway.RequiresKey + configured[NormalizeProviderID(gateway.ID)] = ready + if ready { + hasConfigured = true + if provider == "" { + provider = NormalizeProviderID(gateway.ID) + } + } + } + if provider != "" && hasConfigured && !configured[provider] { + for _, gateway := range gateways { + if configured[NormalizeProviderID(gateway.ID)] { + provider = NormalizeProviderID(gateway.ID) + model = "" + break + } + } + } + if provider != "" && model == "" { + if models, err := e.ListModels(ctx, provider, false); err == nil && len(models) > 0 { + model = models[0].ID + } + } + if compiled != nil && model != "" { + if canonical, ok := compiled.CanonicalModelForAliasOrID(model); ok { + model = canonical + } + } + + routing := active.DeploymentRouting + if opts.DeploymentRoutingOverride != nil { + routing = *opts.DeploymentRoutingOverride + } + return Selection{ + Provider: provider, Model: model, + HasConfiguredDeployment: hasConfigured, + DeploymentRouting: routing, + } +} + // ActiveSelection reads the persisted host selection from this Engine's state. func (e *Engine) ActiveSelection(ctx context.Context) Route { _ = nonNilContext(ctx) diff --git a/engine/types.go b/engine/types.go index dab3f9a..f89c591 100644 --- a/engine/types.go +++ b/engine/types.go @@ -269,3 +269,18 @@ type Gateway struct { ModelCount int `json:"model_count"` Active bool `json:"active"` } + +// Selection is the effective provider/model state supplied to a host session. +type Selection struct { + Provider string `json:"provider"` + Model string `json:"model"` + HasConfiguredDeployment bool `json:"has_configured_deployment"` + DeploymentRouting bool `json:"deployment_routing"` +} + +// SelectionOptions contains optional user or command-line overrides. +type SelectionOptions struct { + ProviderOverride string `json:"provider_override,omitempty"` + ModelOverride string `json:"model_override,omitempty"` + DeploymentRoutingOverride *bool `json:"-"` +} From 205b5f9e6901efe5e47f03c6f89a1712468fab65 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 12 Jul 2026 21:29:28 +0530 Subject: [PATCH 14/24] fix: require configured local gateway --- engine/selection.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/selection.go b/engine/selection.go index ca3101a..d4f79df 100644 --- a/engine/selection.go +++ b/engine/selection.go @@ -40,7 +40,7 @@ func (e *Engine) EffectiveSelection(ctx context.Context, opts SelectionOptions) hasConfigured := false configured := make(map[string]bool, len(gateways)) for _, gateway := range gateways { - ready := gateway.CredentialConfigured || gateway.DeploymentConfigured || !gateway.RequiresKey + ready := gateway.CredentialConfigured || gateway.DeploymentConfigured configured[NormalizeProviderID(gateway.ID)] = ready if ready { hasConfigured = true From 5a55a6721c49cf09d2ed514aa4ae1ef65c251130 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 12 Jul 2026 21:36:06 +0530 Subject: [PATCH 15/24] feat: complete host model metadata --- engine/catalog.go | 5 ++++- engine/engine.go | 16 +++++++++++++++- engine/engine_test.go | 12 ++++++++++++ engine/types.go | 3 +++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/engine/catalog.go b/engine/catalog.go index 1887d65..5b5a899 100644 --- a/engine/catalog.go +++ b/engine/catalog.go @@ -108,9 +108,12 @@ func snapshotFromCompiled(compiled *catalog.CompiledCatalog) CatalogSnapshot { outputPrice = offering.Pricing.RatesPer1M["output_tokens"] } snapshot.Models = append(snapshot.Models, Model{ - ID: id, DisplayName: model.Name, Owner: model.ProviderID, ProviderID: model.ProviderID, + ID: id, DisplayName: catalog.DisplayModelLabel(id, model.Name), + Owner: catalog.DisplayModelOwner(model.ProviderID, id), ProviderID: model.ProviderID, + GatewayID: catalog.GatewayForModel(compiled, id), ContextWindow: model.ContextWindow, MaxOutputTokens: model.MaxOutput, InputPricePer1M: inputPrice, OutputPricePer1M: outputPrice, + PriceKnown: modelPriceKnown(id, model.Name, inputPrice, outputPrice, model.ContextWindow), Capabilities: capabilityNames(offering.Capabilities), Source: "catalog", }) } diff --git a/engine/engine.go b/engine/engine.go index 4587b09..abc339e 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -151,15 +151,29 @@ func (e *Engine) ListModels(ctx context.Context, providerID string, refresh bool capabilities = capabilityNames(offeringForProvider(compiled, providerID, canonical, entry.ID).Capabilities) } out = append(out, Model{ - ID: entry.ID, DisplayName: entry.DisplayName, Owner: entry.Owner, ProviderID: providerID, + ID: entry.ID, DisplayName: entry.DisplayName, Description: entry.Description, + Owner: entry.Owner, ProviderID: providerID, GatewayID: providerID, ContextWindow: entry.ContextWindow, MaxOutputTokens: entry.MaxOutput, InputPricePer1M: entry.InputPricePer1M, OutputPricePer1M: entry.OutputPricePer1M, + PriceKnown: modelPriceKnown(entry.ID, entry.DisplayName, entry.InputPricePer1M, entry.OutputPricePer1M, entry.ContextWindow), Capabilities: capabilities, Source: "cache", }) } return out, nil } +func modelPriceKnown(id, displayName string, input, output float64, contextWindow int) bool { + if input > 0 || output > 0 { + return true + } + text := strings.ToLower(strings.TrimSpace(id) + " " + strings.TrimSpace(displayName)) + if strings.Contains(text, ":free") || strings.Contains(text, "/free") || + strings.HasSuffix(text, "-free") || strings.Contains(text, " free") { + return true + } + return contextWindow > 0 +} + func offeringForProvider(compiled *catalog.CompiledCatalog, providerID, canonicalID, nativeID string) catalog.ModelOffering { spec, ok := registry.SpecByProviderID(providerID) if !ok { diff --git a/engine/engine_test.go b/engine/engine_test.go index a9cd5fc..00d0ca0 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -87,6 +87,18 @@ func TestValidateGenerateRequest(t *testing.T) { } } +func TestModelPriceKnownContract(t *testing.T) { + if !modelPriceKnown("openrouter/model:free", "", 0, 0, 0) { + t.Fatal("explicitly free model should have known zero price") + } + if !modelPriceKnown("model", "Model", 0, 0, 128000) { + t.Fatal("catalog model with context metadata should have known price") + } + if modelPriceKnown("model", "Model", 0, 0, 0) { + t.Fatal("bare live model row should keep price unknown") + } +} + func TestMessageConversionPreservesToolsAndMultimodalParts(t *testing.T) { messages := toClientMessages([]Message{{ Role: "user", Content: "inspect", ContentParts: []ContentPart{{Type: "image_url", URL: "https://example.test/image.png", Detail: "high"}}, diff --git a/engine/types.go b/engine/types.go index f89c591..80a0b90 100644 --- a/engine/types.go +++ b/engine/types.go @@ -210,12 +210,15 @@ type Event struct { type Model struct { ID string `json:"id"` DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` Owner string `json:"owner,omitempty"` ProviderID string `json:"provider_id"` + GatewayID string `json:"gateway_id,omitempty"` ContextWindow int `json:"context_window,omitempty"` MaxOutputTokens int `json:"max_output_tokens,omitempty"` InputPricePer1M float64 `json:"input_price_per_1m,omitempty"` OutputPricePer1M float64 `json:"output_price_per_1m,omitempty"` + PriceKnown bool `json:"price_known"` Capabilities []string `json:"capabilities,omitempty"` Source string `json:"source,omitempty"` } From 3257aead41439effddbbda4723ed3c4b21d9c7bf Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 13 Jul 2026 00:23:23 +0530 Subject: [PATCH 16/24] feat: expose host-neutral model policy --- engine/engine_test.go | 25 ++++++++ engine/model_policy.go | 136 +++++++++++++++++++++++++++++++++++++++++ engine/types.go | 9 +++ 3 files changed, 170 insertions(+) create mode 100644 engine/model_policy.go diff --git a/engine/engine_test.go b/engine/engine_test.go index 00d0ca0..f4ef377 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -281,3 +281,28 @@ func TestListModelsUsesGatewayOfferings(t *testing.T) { t.Fatalf("gateway model mismatch: got %+v want %+v", models[0], expected[0]) } } + +func TestModelPolicyQueriesUseEngineCatalog(t *testing.T) { + dir := t.TempDir() + seed := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(filepath.Join(dir, "model_catalog.json"), &seed); err != nil { + t.Fatal(err) + } + eng, err := New(Options{SecretStore: &credentials.MapStore{}, StateDir: dir}) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + names := eng.ModelNames(ctx) + providers, err := eng.ModelProviders(ctx) + if err != nil || len(names) == 0 || len(providers) == 0 { + t.Fatalf("policy catalog unavailable: names=%d providers=%v err=%v", len(names), providers, err) + } + modelID := firstCatalogModelID(seed) + if model, ok, err := eng.ModelInfo(ctx, modelID); err != nil || !ok || model.ID == "" { + t.Fatalf("ModelInfo(%q) = %+v, %v, %v", modelID, model, ok, err) + } + if got := eng.ModelClassOf(ctx, modelID); got == "" { + t.Fatal("empty model class") + } +} diff --git a/engine/model_policy.go b/engine/model_policy.go new file mode 100644 index 0000000..32a6b5b --- /dev/null +++ b/engine/model_policy.go @@ -0,0 +1,136 @@ +package engine + +import ( + "context" + "sort" + "strings" + + "github.com/GrayCodeAI/eyrie/catalog" +) + +func (e *Engine) policyCatalog(ctx context.Context) (*catalog.CompiledCatalog, error) { + compiled, err := catalog.LoadCatalog(nonNilContext(ctx), catalog.LoadCatalogOptions{CachePath: e.catalogPath}) + if err != nil { + return nil, &Error{Code: ErrorCatalogUnavailable, Operation: "model_policy", Message: err.Error(), Cause: err} + } + return compiled, nil +} + +// ModelInfo returns normalized metadata for a model id or alias. +func (e *Engine) ModelInfo(ctx context.Context, modelID string) (Model, bool, error) { + compiled, err := e.policyCatalog(ctx) + if err != nil { + return Model{}, false, err + } + canonical, ok := compiled.CanonicalModelForAliasOrID(strings.TrimSpace(modelID)) + if !ok { + return Model{}, false, nil + } + for _, model := range snapshotFromCompiled(compiled).Models { + if model.ID == canonical { + return model, true, nil + } + } + return Model{}, false, nil +} + +// ModelProviders lists canonical catalog model owners. +func (e *Engine) ModelProviders(ctx context.Context) ([]string, error) { + compiled, err := e.policyCatalog(ctx) + if err != nil { + return nil, err + } + return catalog.AllModelProviders(compiled), nil +} + +// DefaultModel returns the catalog default for a provider. +func (e *Engine) DefaultModel(ctx context.Context, provider, fallback string) string { + compiled, err := e.policyCatalog(ctx) + if err != nil { + return fallback + } + return catalog.ProviderDefaultModel(compiled, provider, fallback) +} + +// PreferredModel returns a provider model in the requested relative class. +func (e *Engine) PreferredModel(ctx context.Context, provider string, class ModelClass, fallback string) string { + compiled, err := e.policyCatalog(ctx) + if err != nil { + return fallback + } + return catalog.PreferredProviderModel(compiled, provider, catalogTier(class), fallback) +} + +// PreferredModels returns unique cross-provider candidates in preference order. +func (e *Engine) PreferredModels(ctx context.Context, primaryProvider string, class ModelClass, limit int) []string { + compiled, err := e.policyCatalog(ctx) + if err != nil { + return nil + } + return catalog.PreferredModelsForTier(compiled, primaryProvider, catalogTier(class), limit) +} + +// ModelClassOf resolves a model's relative catalog cost band. +func (e *Engine) ModelClassOf(ctx context.Context, modelID string) ModelClass { + compiled, _ := e.policyCatalog(ctx) + switch catalog.ModelCostTierOf(compiled, modelID) { + case catalog.CostTierCheap: + return ModelClassEconomical + case catalog.CostTierExpensive: + return ModelClassPremium + default: + return ModelClassBalanced + } +} + +// ProviderForModel returns the canonical catalog owner for a model. +func (e *Engine) ProviderForModel(ctx context.Context, modelID string) string { + compiled, _ := e.policyCatalog(ctx) + return catalog.ProviderForModel(compiled, modelID) +} + +// PrimaryModel returns Eyrie's stable best-effort catalog primary model. +func (e *Engine) PrimaryModel(ctx context.Context) string { + compiled, _ := e.policyCatalog(ctx) + return catalog.PrimaryModel(compiled) +} + +// ModelNames returns canonical ids, display names, and aliases for completion. +func (e *Engine) ModelNames(ctx context.Context) []string { + compiled, err := e.policyCatalog(ctx) + if err != nil || compiled == nil { + return nil + } + seen := map[string]bool{} + var out []string + add := func(value string) { + value = strings.TrimSpace(value) + if value != "" && !seen[value] { + seen[value] = true + out = append(out, value) + } + } + for id, model := range compiled.ModelsByID { + add(id) + add(model.Name) + } + if compiled.Catalog != nil { + for alias, canonical := range compiled.Catalog.Aliases { + add(alias) + add(canonical) + } + } + sort.Strings(out) + return out +} + +func catalogTier(class ModelClass) catalog.ModelTier { + switch class { + case ModelClassEconomical: + return catalog.TierHaiku + case ModelClassPremium: + return catalog.TierOpus + default: + return catalog.TierSonnet + } +} diff --git a/engine/types.go b/engine/types.go index 80a0b90..c85daaa 100644 --- a/engine/types.go +++ b/engine/types.go @@ -12,6 +12,15 @@ const ( IntentEconomical Intent = "economical" ) +// ModelClass is a provider-neutral relative model cost/capability band. +type ModelClass string + +const ( + ModelClassEconomical ModelClass = "economical" + ModelClassBalanced ModelClass = "balanced" + ModelClassPremium ModelClass = "premium" +) + // Requirements describes capabilities that a resolved model must support. type Requirements struct { Streaming bool `json:"streaming,omitempty"` From e057b52350bfa1bd6009fa0df168bdf16fe2d1d5 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 13 Jul 2026 00:28:02 +0530 Subject: [PATCH 17/24] fix: preserve deterministic model metadata --- engine/catalog.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/engine/catalog.go b/engine/catalog.go index 5b5a899..dda959a 100644 --- a/engine/catalog.go +++ b/engine/catalog.go @@ -109,7 +109,8 @@ func snapshotFromCompiled(compiled *catalog.CompiledCatalog) CatalogSnapshot { } snapshot.Models = append(snapshot.Models, Model{ ID: id, DisplayName: catalog.DisplayModelLabel(id, model.Name), - Owner: catalog.DisplayModelOwner(model.ProviderID, id), ProviderID: model.ProviderID, + Description: model.Name, + Owner: catalog.DisplayModelOwner(model.ProviderID, id), ProviderID: model.ProviderID, GatewayID: catalog.GatewayForModel(compiled, id), ContextWindow: model.ContextWindow, MaxOutputTokens: model.MaxOutput, InputPricePer1M: inputPrice, OutputPricePer1M: outputPrice, @@ -127,7 +128,9 @@ func firstOffering(offerings []catalog.ModelOffering) catalog.ModelOffering { if len(offerings) == 0 { return catalog.ModelOffering{} } - return offerings[0] + ordered := append([]catalog.ModelOffering(nil), offerings...) + sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].DeploymentID < ordered[j].DeploymentID }) + return ordered[0] } func capabilityNames(caps catalog.CapabilitySet) []string { From bb1f2bed2a5b8e3bda4ac305cf698a532683f064 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 13 Jul 2026 00:40:14 +0530 Subject: [PATCH 18/24] feat: expose host control diagnostics --- engine/control_plane.go | 4 +- engine/control_plane_test.go | 22 +++ engine/host_control.go | 319 +++++++++++++++++++++++++++++++++++ engine/types.go | 2 + 4 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 engine/host_control.go diff --git a/engine/control_plane.go b/engine/control_plane.go index f988148..70e8402 100644 --- a/engine/control_plane.go +++ b/engine/control_plane.go @@ -68,12 +68,14 @@ func (e *Engine) Gateways(ctx context.Context) []Gateway { if compiled != nil { modelCount = len(catalog.ModelEntriesForProvider(compiled, spec.ProviderID)) } + regionLabel, regionRequired := e.GatewayRegion(spec.ProviderID) out = append(out, Gateway{ ID: spec.ProviderID, DisplayName: spec.DisplayName, DeploymentID: spec.DeploymentID, CredentialEnv: spec.EnvVar, RequiresKey: spec.RequiresKey, CredentialConfigured: configured, DeploymentConfigured: deploymentConfigured, ModelCount: modelCount, - Active: NormalizeProviderID(selection.Provider) == NormalizeProviderID(spec.ProviderID), + Active: NormalizeProviderID(selection.Provider) == NormalizeProviderID(spec.ProviderID), + RegionLabel: regionLabel, RegionRequired: regionRequired, }) } sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID }) diff --git a/engine/control_plane_test.go b/engine/control_plane_test.go index 65642b8..139d180 100644 --- a/engine/control_plane_test.go +++ b/engine/control_plane_test.go @@ -82,3 +82,25 @@ func TestEffectiveSelectionUsesEngineOwnedState(t *testing.T) { t.Fatal("selection ignored injected credential store") } } + +func TestHostControlUsesInjectedStoreAndPaths(t *testing.T) { + ctx := context.Background() + store := &credentials.MapStore{} + eng, err := New(Options{SecretStore: store, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + if err := eng.SaveCredentialEnv(ctx, "OPENAI_API_KEY", "sk-injected-value"); err != nil { + t.Fatal(err) + } + if !eng.HasCredentialEnv(ctx, "OPENAI_API_KEY") { + t.Fatal("host control ignored injected credential store") + } + if err := eng.SetGatewayRegion(ctx, "zai_coding", "international"); err != nil { + t.Fatal(err) + } + label, required := eng.GatewayRegion("zai_coding") + if label == "" || required { + t.Fatalf("unexpected gateway region label=%q required=%v", label, required) + } +} diff --git a/engine/host_control.go b/engine/host_control.go new file mode 100644 index 0000000..70a87fb --- /dev/null +++ b/engine/host_control.go @@ -0,0 +1,319 @@ +package engine + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/catalog/xiaomi" + "github.com/GrayCodeAI/eyrie/catalog/zai" + "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/eyrie/runtime" + "github.com/GrayCodeAI/eyrie/setup" +) + +// SecretStoreName is safe presentation metadata for host UIs. +func SecretStoreName() string { return credentials.PlatformSecretStoreName() } + +// CredentialStorageReport is safe to print and never includes secrets. +type CredentialStorageReport struct { + PlatformStore string `json:"platform_store"` + Writable bool `json:"writable"` + Detail string `json:"detail,omitempty"` + Formatted string `json:"formatted,omitempty"` +} + +func CredentialStorage(ctx context.Context) CredentialStorageReport { + ctx = nonNilContext(ctx) + report := credentials.StorageReportFor(ctx) + ok, detail := credentials.KeychainWriteAvailable(ctx) + return CredentialStorageReport{ + PlatformStore: report.PlatformStore, Writable: ok, Detail: detail, + Formatted: credentials.FormatStorageReport(report), + } +} + +func MigrateLegacyCredentials(ctx context.Context) (int, error) { + return credentials.MigrateLegacyEnvFile(nonNilContext(ctx)) +} + +// HasCredentialEnv reports presence without exposing the credential value. +func (e *Engine) HasCredentialEnv(ctx context.Context, envVar string) bool { + return e.hasCredential(nonNilContext(ctx), []string{envVar}) +} + +// SaveCredentialEnv validates and stores a legacy env-addressed credential. +// New callers should prefer SaveCredential(providerID, secret). +func (e *Engine) SaveCredentialEnv(ctx context.Context, envVar, secret string) error { + envVar, secret = strings.TrimSpace(envVar), strings.TrimSpace(secret) + if envVar == "" || secret == "" { + return invalid("save_credential_env", "eyrie engine: credential env and value are required") + } + if err := config.ValidateCredentialSecret(envVar, secret); err != nil { + return &Error{Code: ErrorInvalidRequest, Operation: "save_credential_env", Message: err.Error(), Cause: err} + } + if err := e.secretStore.Set(nonNilContext(ctx), credentials.AccountForEnv(envVar), secret); err != nil { + return &Error{Code: ErrorInternal, Operation: "save_credential_env", Message: err.Error(), Cause: err} + } + return nil +} + +func (e *Engine) CredentialEnvKeys(providerID string) []string { + for _, provider := range e.CredentialProviders(context.Background()) { + if NormalizeProviderID(provider.ProviderID) == NormalizeProviderID(providerID) { + return []string{provider.EnvVar} + } + } + return nil +} + +// GatewayRegion returns normalized region presentation for regional gateways. +func (e *Engine) GatewayRegion(providerID string) (label string, required bool) { + cfg := config.LoadProviderConfig(e.providerConfigPath) + providerID = NormalizeProviderID(providerID) + switch providerID { + case runtime.GatewayXiaomiTokenPlan: + if cfg == nil { + return "", true + } + region, err := xiaomi.NormalizeRegion(cfg.XiaomiMimoTokenPlanRegion) + return string(region), err != nil + case "zai_coding": + if cfg == nil { + return "", true + } + region, err := zai.NormalizeRegion(cfg.ZAICodingRegion) + return string(region), err != nil + default: + return "", false + } +} + +// SetGatewayRegion persists regional gateway configuration at the Engine path. +func (e *Engine) SetGatewayRegion(ctx context.Context, providerID, value string) error { + providerID = NormalizeProviderID(providerID) + cfg := config.LoadProviderConfig(e.providerConfigPath) + if cfg == nil { + cfg = &config.ProviderConfig{} + } + switch providerID { + case runtime.GatewayXiaomiTokenPlan: + region, err := xiaomi.NormalizeRegion(value) + if err != nil { + return err + } + cfg.XiaomiMimoTokenPlanRegion = string(region) + if base, err := config.ResolveXiaomiOpenAIBase(providerID, cfg); err == nil { + cfg.XiaomiMimoTokenPlanBaseURL = base + } + case "zai_payg", "zai_coding": + region, err := zai.NormalizeRegion(value) + if err != nil { + return err + } + if providerID == "zai_coding" { + cfg.ZAICodingRegion = string(region) + if base, err := zai.ResolveOpenAIBase(zai.PlanCoding, region, cfg.ZAICodingBaseURL); err == nil { + cfg.ZAICodingBaseURL = base + } + } else { + cfg.ZAIRegion = string(region) + if base, err := zai.ResolveOpenAIBase(zai.PlanGeneral, region, cfg.ZAIBaseURL); err == nil { + cfg.ZAIBaseURL = base + } + } + default: + return invalid("set_gateway_region", "eyrie engine: gateway does not support regions") + } + if err := config.SaveProviderConfig(cfg, e.providerConfigPath); err != nil { + return err + } + e.ApplyGatewayEnvironment(ctx, providerID) + return nil +} + +// ApplyGatewayEnvironment derives process-local regional base URLs. +func (e *Engine) ApplyGatewayEnvironment(_ context.Context, providerID string) { + cfg := config.LoadProviderConfig(e.providerConfigPath) + if cfg == nil { + return + } + switch NormalizeProviderID(providerID) { + case runtime.GatewayXiaomiTokenPlan: + if cfg.XiaomiMimoTokenPlanRegion != "" { + _ = os.Setenv(config.EnvXiaomiTokenPlanRegion, cfg.XiaomiMimoTokenPlanRegion) + } + if cfg.XiaomiMimoTokenPlanBaseURL != "" { + _ = os.Setenv(config.EnvXiaomiTokenPlanBaseURL, cfg.XiaomiMimoTokenPlanBaseURL) + } + case "zai_payg": + if cfg.ZAIRegion != "" { + _ = os.Setenv("ZAI_REGION", cfg.ZAIRegion) + } + if cfg.ZAIBaseURL != "" { + _ = os.Setenv("ZAI_BASE_URL", cfg.ZAIBaseURL) + } + case "zai_coding": + if cfg.ZAICodingRegion != "" { + _ = os.Setenv("ZAI_CODING_REGION", cfg.ZAICodingRegion) + } + if cfg.ZAICodingBaseURL != "" { + _ = os.Setenv("ZAI_CODING_BASE_URL", cfg.ZAICodingBaseURL) + } + } +} + +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"` + Stale bool `json:"stale,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) + health.Exists, health.ModifiedAt, health.Size = exists, modified, size + if err != nil { + health.Error = err.Error() + return health + } + compiled, err := catalog.LoadCatalog(nonNilContext(ctx), catalog.LoadCatalogOptions{CachePath: e.catalogPath}) + if err != nil { + health.Error = err.Error() + return health + } + health.Models = len(compiled.ModelsByID) + health.Stale = compiled.Catalog != nil && time.Now().UTC().After(compiled.Catalog.StaleAfter) + return health +} + +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 { + return canonical + } + } + return strings.TrimSpace(modelID) +} + +func (e *Engine) GatewayForModel(ctx context.Context, modelID string) string { + compiled, _ := e.policyCatalog(ctx) + return catalog.GatewayForModel(compiled, modelID) +} + +func (e *Engine) DeploymentRoutingEnabled(override *bool) bool { + if override != nil { + return *override + } + cfg := config.LoadProviderConfig(e.providerConfigPath) + return setup.UseDeploymentRouting(cfg) +} + +func (e *Engine) DeploymentStatus(ctx context.Context, activeModel string) (string, error) { + // Compatibility formatter remains Eyrie-owned while setup internals migrate + // to injected paths. Host code receives presentation text only. + report, err := setup.DeploymentStatus(nonNilContext(ctx), activeModel) + if err != nil { + return "", err + } + return setup.FormatStatus(report), nil +} + +func (e *Engine) RoutingPreview(ctx context.Context, modelID string) (string, error) { + return setup.RoutingPreview(nonNilContext(ctx), modelID) +} + +// FormatSetupError returns a safe provider setup hint. +func FormatSetupError(providerID string, err error) string { + if err == nil { + return "" + } + return runtime.FormatSetupError(providerID, err).Error() +} + +func (e *Engine) DefaultProviderFilter(ctx context.Context) string { + selection := e.EffectiveSelection(ctx, SelectionOptions{}) + return selection.Provider +} + +func (e *Engine) Preflight(ctx context.Context) PreflightReport { + ctx = nonNilContext(ctx) + var checks []PreflightCheck + health := e.CatalogHealth(ctx) + if health.Error != "" || health.Models == 0 { + checks = append(checks, PreflightCheck{Name: "catalog", Status: CheckWarn, Detail: fmt.Sprintf("catalog unavailable at %s", health.Path)}) + } else { + checks = append(checks, PreflightCheck{Name: "catalog", Status: CheckOK, Detail: fmt.Sprintf("%d models cached at %s", health.Models, health.Path)}) + } + storage := CredentialStorage(ctx) + status := CheckWarn + if storage.Writable { + status = CheckOK + } + checks = append(checks, PreflightCheck{Name: "credentials_store", Status: status, Detail: storage.Detail}) + selection := e.EffectiveSelection(ctx, SelectionOptions{}) + if selection.HasConfiguredDeployment { + checks = append(checks, PreflightCheck{Name: "credentials", Status: CheckOK, Detail: "at least one provider credential is configured in " + SecretStoreName()}) + } else { + checks = append(checks, PreflightCheck{Name: "credentials", Status: CheckFail, Detail: "no provider credentials configured"}) + } + if selection.Model == "" { + checks = append(checks, PreflightCheck{Name: "model", Status: CheckFail, Detail: "no model selected"}) + } else { + checks = append(checks, PreflightCheck{Name: "model", Status: CheckOK, Detail: selection.Model}) + } + ready := true + for _, check := range checks { + if check.Status == CheckFail { + ready = false + } + } + return PreflightReport{Ready: ready, Checks: checks} +} + +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"` + Checks []PreflightCheck `json:"checks"` +} + +func FormatPreflight(report PreflightReport) string { + var b strings.Builder + if report.Ready { + b.WriteString("Preflight: ready to chat\n") + } else { + b.WriteString("Preflight: setup incomplete\n") + } + for _, check := range report.Checks { + icon := "+" + if check.Status == CheckWarn { + icon = "!" + } else if check.Status == CheckFail { + icon = "x" + } + fmt.Fprintf(&b, " %s %s: %s\n", icon, check.Name, check.Detail) + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/engine/types.go b/engine/types.go index c85daaa..815365f 100644 --- a/engine/types.go +++ b/engine/types.go @@ -280,6 +280,8 @@ type Gateway struct { DeploymentConfigured bool `json:"deployment_configured"` ModelCount int `json:"model_count"` Active bool `json:"active"` + RegionLabel string `json:"region_label,omitempty"` + RegionRequired bool `json:"region_required,omitempty"` } // Selection is the effective provider/model state supplied to a host session. From 1d827422f7df7770d8a0f1b20d01ebb8e898baf5 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 13 Jul 2026 00:49:00 +0530 Subject: [PATCH 19/24] feat: report complete catalog health --- engine/host_control.go | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/engine/host_control.go b/engine/host_control.go index 70a87fb..57970a0 100644 --- a/engine/host_control.go +++ b/engine/host_control.go @@ -168,13 +168,17 @@ 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"` - Stale bool `json:"stale,omitempty"` - Error string `json:"error,omitempty"` + 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 { @@ -191,7 +195,15 @@ func (e *Engine) CatalogHealth(ctx context.Context) CatalogHealth { return health } health.Models = len(compiled.ModelsByID) - health.Stale = compiled.Catalog != nil && time.Now().UTC().After(compiled.Catalog.StaleAfter) + health.Deployments = len(compiled.DeploymentsByID) + health.Offerings = len(compiled.OfferingsByID) + if compiled.Catalog != nil { + health.StaleAfter = compiled.Catalog.StaleAfter + health.Stale = time.Now().UTC().After(compiled.Catalog.StaleAfter) + if compiled.Catalog.Provenance != nil { + health.Source = compiled.Catalog.Provenance.Source + } + } return health } From a47e56b2fd7674435a0de26b36456f640993bfc8 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 13 Jul 2026 00:51:31 +0530 Subject: [PATCH 20/24] fix: expose credential env aliases --- engine/host_control.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/engine/host_control.go b/engine/host_control.go index 57970a0..865d1be 100644 --- a/engine/host_control.go +++ b/engine/host_control.go @@ -8,6 +8,7 @@ import ( "time" "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/catalog/registry" "github.com/GrayCodeAI/eyrie/catalog/xiaomi" "github.com/GrayCodeAI/eyrie/catalog/zai" "github.com/GrayCodeAI/eyrie/config" @@ -63,12 +64,20 @@ func (e *Engine) SaveCredentialEnv(ctx context.Context, envVar, secret string) e } func (e *Engine) CredentialEnvKeys(providerID string) []string { - for _, provider := range e.CredentialProviders(context.Background()) { - if NormalizeProviderID(provider.ProviderID) == NormalizeProviderID(providerID) { - return []string{provider.EnvVar} + spec, ok := registry.SpecByProviderID(NormalizeProviderID(providerID)) + if !ok { + return nil + } + seen := map[string]bool{} + var out []string + for _, envVar := range append(append([]string{spec.CredentialEnv}, spec.CredentialEnvFallbacks...), spec.CredentialAliases...) { + envVar = strings.TrimSpace(envVar) + if envVar != "" && !seen[envVar] { + seen[envVar] = true + out = append(out, envVar) } } - return nil + return out } // GatewayRegion returns normalized region presentation for regional gateways. From 6b394d7a5afeea9611d014350ccb7d058620469a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 13 Jul 2026 01:02:26 +0530 Subject: [PATCH 21/24] feat: complete host control facade --- engine/control_plane.go | 1 + engine/control_plane_test.go | 51 +++++++++++++ engine/host_control.go | 144 ++++++++++++++++++++++++++++++++++- engine/types.go | 23 +++--- setup/status.go | 34 +++++++-- 5 files changed, 235 insertions(+), 18 deletions(-) diff --git a/engine/control_plane.go b/engine/control_plane.go index 70e8402..efb0758 100644 --- a/engine/control_plane.go +++ b/engine/control_plane.go @@ -76,6 +76,7 @@ func (e *Engine) Gateways(ctx context.Context) []Gateway { DeploymentConfigured: deploymentConfigured, ModelCount: modelCount, Active: NormalizeProviderID(selection.Provider) == NormalizeProviderID(spec.ProviderID), RegionLabel: regionLabel, RegionRequired: regionRequired, + SupportsLiveDiscovery: strings.TrimSpace(providerSpec.LiveFetcherKey) != "", }) } sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID }) diff --git a/engine/control_plane_test.go b/engine/control_plane_test.go index 139d180..49b8d2d 100644 --- a/engine/control_plane_test.go +++ b/engine/control_plane_test.go @@ -2,9 +2,12 @@ package engine import ( "context" + "os" "path/filepath" + "strings" "testing" + "github.com/GrayCodeAI/eyrie/config" "github.com/GrayCodeAI/eyrie/credentials" ) @@ -103,4 +106,52 @@ func TestHostControlUsesInjectedStoreAndPaths(t *testing.T) { if label == "" || required { t.Fatalf("unexpected gateway region label=%q required=%v", label, required) } + status, err := eng.DeploymentStatus(ctx, "") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(status, eng.providerConfigPath) || !strings.Contains(status, eng.catalogPath) { + t.Fatalf("deployment status escaped injected paths: %s", status) + } +} + +func TestProviderStateSecurityFailsClosedOnCorruptState(t *testing.T) { + eng, err := New(Options{SecretStore: &credentials.MapStore{}, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(eng.providerConfigPath, []byte("{not-json"), 0o600); err != nil { + t.Fatal(err) + } + status := eng.ProviderStateSecurityStatus() + if status.Error == "" { + t.Fatal("expected corrupt provider state to be reported") + } +} + +func TestProviderSecretMigrationStaysInsideEnginePath(t *testing.T) { + dir := t.TempDir() + eng, err := New(Options{SecretStore: &credentials.MapStore{}, StateDir: dir}) + if err != nil { + t.Fatal(err) + } + cfg := &config.ProviderConfig{Deployments: map[string]config.DeploymentConfig{ + "legacy": {APIKey: "sk-legacy", BaseURL: "https://example.test"}, + }} + if err := config.SaveProviderConfig(cfg, eng.providerConfigPath); err != nil { + t.Fatal(err) + } + if status := eng.ProviderStateSecurityStatus(); !status.HasSecrets { + t.Fatal("expected secret-bearing provider state") + } + if err := eng.MigrateProviderSecrets(); err != nil { + t.Fatal(err) + } + if status := eng.ProviderStateSecurityStatus(); status.HasSecrets { + t.Fatalf("provider state still contains secrets: %+v", status) + } + saved := config.LoadProviderConfig(eng.providerConfigPath) + if saved.Deployments["legacy"].BaseURL != "https://example.test" { + t.Fatal("migration lost non-secret routing metadata") + } } diff --git a/engine/host_control.go b/engine/host_control.go index 865d1be..ea75320 100644 --- a/engine/host_control.go +++ b/engine/host_control.go @@ -2,8 +2,10 @@ package engine import ( "context" + "encoding/json" "fmt" "os" + "path/filepath" "strings" "time" @@ -242,15 +244,32 @@ func (e *Engine) DeploymentRoutingEnabled(override *bool) bool { func (e *Engine) DeploymentStatus(ctx context.Context, activeModel string) (string, error) { // Compatibility formatter remains Eyrie-owned while setup internals migrate // to injected paths. Host code receives presentation text only. - report, err := setup.DeploymentStatus(nonNilContext(ctx), activeModel) + report, err := setup.DeploymentStatusFromPaths(nonNilContext(ctx), activeModel, e.providerConfigPath, e.catalogPath) if err != nil { return "", err } 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 { + return DeploymentSummary{}, err + } + return DeploymentSummary{ + RoutingSource: report.RoutingSource, RoutingStages: report.RoutingStages, + Formatted: setup.FormatStatus(report), + }, nil +} + func (e *Engine) RoutingPreview(ctx context.Context, modelID string) (string, error) { - return setup.RoutingPreview(nonNilContext(ctx), modelID) + return setup.RoutingPreviewFromPaths(nonNilContext(ctx), modelID, e.providerConfigPath, e.catalogPath) } // FormatSetupError returns a safe provider setup hint. @@ -261,6 +280,18 @@ func FormatSetupError(providerID string, err error) string { return runtime.FormatSetupError(providerID, err).Error() } +// CredentialGuidance returns provider-specific, non-secret setup guidance. +func CredentialGuidance(providerID, secret string) string { + switch NormalizeProviderID(providerID) { + case runtime.GatewayXiaomiTokenPlan: + return xiaomi.KeyMismatchHint(xiaomi.BillingTokenPlan, secret) + case xiaomi.ProviderPayAsYouGo: + return xiaomi.KeyMismatchHint(xiaomi.BillingPayAsYouGo, secret) + default: + return "" + } +} + func (e *Engine) DefaultProviderFilter(ctx context.Context) string { selection := e.EffectiveSelection(ctx, SelectionOptions{}) return selection.Provider @@ -338,3 +369,112 @@ 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} + cfg, err := config.LoadProviderConfigWithError(e.providerConfigPath) + if err != nil { + status.Error = err.Error() + return status + } + if cfg == nil { + return status + } + for id, deployment := range cfg.Deployments { + if deploymentContainsSecrets(deployment) { + status.HasSecrets = true + status.Detail = "deployment " + id + " has secret fields on disk" + return status + } + } + return status +} + +// MigrateProviderSecrets atomically strips historical secret fields from +// provider.json while preserving routing and non-secret deployment metadata. +func (e *Engine) MigrateProviderSecrets() error { + path := e.providerConfigPath + marker, backup := path+".secrets-migrated", path+".pre-secret-migrate.bak" + if _, err := os.Stat(marker); err == nil { + _ = os.Remove(backup) + return nil + } + data, err := os.ReadFile(path) // #nosec G304 -- engine-owned state path + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var cfg config.ProviderConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return err + } + changed := false + for id, deployment := range cfg.Deployments { + if deploymentContainsSecrets(deployment) { + changed = true + } + cfg.Deployments[id] = config.SanitizeDeploymentConfigForDisk(deployment) + } + if !changed { + return os.WriteFile(marker, []byte("ok\n"), 0o600) + } + if err := os.WriteFile(backup, data, 0o600); err != nil { + return err + } + if err := writeProviderConfigAtomic(path, &cfg); err != nil { + return err + } + if err := os.WriteFile(marker, []byte("ok\n"), 0o600); err != nil { + return err + } + return os.Remove(backup) +} + +func writeProviderConfigAtomic(path string, cfg *config.ProviderConfig) (err error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".provider-*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + }() + if err := tmp.Chmod(0o600); err != nil { + return err + } + if _, err := tmp.Write(append(data, '\n')); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + +func deploymentContainsSecrets(deployment config.DeploymentConfig) bool { + return strings.TrimSpace(deployment.APIKey) != "" || strings.TrimSpace(deployment.Token) != "" || + strings.TrimSpace(deployment.SecretAccessKey) != "" || strings.TrimSpace(deployment.AccessKeyID) != "" || + strings.TrimSpace(deployment.SessionToken) != "" +} diff --git a/engine/types.go b/engine/types.go index 815365f..f98b055 100644 --- a/engine/types.go +++ b/engine/types.go @@ -271,17 +271,18 @@ type CredentialResolution struct { // Gateway is one host-facing provider/deployment configuration row. type Gateway struct { - ID string `json:"id"` - DisplayName string `json:"display_name"` - DeploymentID string `json:"deployment_id,omitempty"` - CredentialEnv string `json:"credential_env,omitempty"` - RequiresKey bool `json:"requires_key"` - CredentialConfigured bool `json:"credential_configured"` - DeploymentConfigured bool `json:"deployment_configured"` - ModelCount int `json:"model_count"` - Active bool `json:"active"` - RegionLabel string `json:"region_label,omitempty"` - RegionRequired bool `json:"region_required,omitempty"` + ID string `json:"id"` + DisplayName string `json:"display_name"` + DeploymentID string `json:"deployment_id,omitempty"` + CredentialEnv string `json:"credential_env,omitempty"` + RequiresKey bool `json:"requires_key"` + CredentialConfigured bool `json:"credential_configured"` + DeploymentConfigured bool `json:"deployment_configured"` + ModelCount int `json:"model_count"` + Active bool `json:"active"` + RegionLabel string `json:"region_label,omitempty"` + RegionRequired bool `json:"region_required,omitempty"` + SupportsLiveDiscovery bool `json:"supports_live_discovery,omitempty"` } // Selection is the effective provider/model state supplied to a host session. diff --git a/setup/status.go b/setup/status.go index 9cc4ebd..f54e836 100644 --- a/setup/status.go +++ b/setup/status.go @@ -32,9 +32,21 @@ type StatusReport struct { // DeploymentStatus builds a status report for CLI and agent diagnostics. func DeploymentStatus(ctx context.Context, activeModel string) (StatusReport, error) { - cfg := config.LoadProviderConfig("") + return DeploymentStatusFromPaths(ctx, activeModel, config.GetProviderConfigPath(), catalog.DefaultCachePath()) +} + +// DeploymentStatusFromPaths builds a status report from host-owned state. +// Empty paths retain the process defaults for backwards compatibility. +func DeploymentStatusFromPaths(ctx context.Context, activeModel, providerConfigPath, catalogCachePath string) (StatusReport, error) { + if strings.TrimSpace(providerConfigPath) == "" { + providerConfigPath = config.GetProviderConfigPath() + } + if strings.TrimSpace(catalogCachePath) == "" { + catalogCachePath = catalog.DefaultCachePath() + } + cfg := config.LoadProviderConfig(providerConfigPath) report := StatusReport{ - ProviderConfig: config.GetProviderConfigPath(), + ProviderConfig: providerConfigPath, ActiveModel: strings.TrimSpace(activeModel), } if cfg != nil { @@ -47,7 +59,7 @@ func DeploymentStatus(ctx context.Context, activeModel string) (StatusReport, er } sortStrings(report.Configured) - report.CatalogCache = catalog.DefaultCachePath() + report.CatalogCache = catalogCachePath if exists, mod, _, err := catalog.CacheInfo(report.CatalogCache); err == nil && exists { report.CatalogExists = true report.CatalogModified = mod @@ -117,9 +129,21 @@ func FormatStatus(report StatusReport) string { // RoutingPreview returns JSON describing effective routing for a model ID. func RoutingPreview(ctx context.Context, model string) (string, error) { - cfg := config.LoadProviderConfig("") + return RoutingPreviewFromPaths(ctx, model, config.GetProviderConfigPath(), catalog.DefaultCachePath()) +} + +// RoutingPreviewFromPaths resolves routing from host-owned state paths. +// Empty paths retain the process defaults for backwards compatibility. +func RoutingPreviewFromPaths(ctx context.Context, model, providerConfigPath, catalogCachePath string) (string, error) { + if strings.TrimSpace(providerConfigPath) == "" { + providerConfigPath = config.GetProviderConfigPath() + } + if strings.TrimSpace(catalogCachePath) == "" { + catalogCachePath = catalog.DefaultCachePath() + } + cfg := config.LoadProviderConfig(providerConfigPath) compiled, err := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{ - CachePath: catalog.DefaultCachePath(), + CachePath: catalogCachePath, }) if err != nil { return "", err From ed1a154e3ed6599e6d6f9817aba3b824fe5d0bf2 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 13 Jul 2026 01:15:50 +0530 Subject: [PATCH 22/24] feat: close engine runtime boundary --- config/provider_secrets.go | 68 +++++++++ config/provider_secrets_test.go | 27 ++++ engine/control_plane.go | 29 +++- engine/control_plane_test.go | 49 ++++++- engine/engine.go | 24 +++- engine/host_control.go | 37 ++--- engine/host_runtime.go | 241 +++++++++++++++++++++++++++++++ engine/host_runtime_test.go | 246 ++++++++++++++++++++++++++++++++ engine/selection.go | 2 +- engine/state.go | 10 +- 10 files changed, 693 insertions(+), 40 deletions(-) create mode 100644 config/provider_secrets.go create mode 100644 config/provider_secrets_test.go create mode 100644 engine/host_runtime.go create mode 100644 engine/host_runtime_test.go diff --git a/config/provider_secrets.go b/config/provider_secrets.go new file mode 100644 index 0000000..3d36d3e --- /dev/null +++ b/config/provider_secrets.go @@ -0,0 +1,68 @@ +package config + +import "strings" + +// ProviderConfigContainsSecrets reports whether legacy provider state contains +// credential material. It never returns or formats credential values. +func ProviderConfigContainsSecrets(cfg ProviderConfig) bool { + for _, secret := range providerConfigSecrets(cfg) { + if strings.TrimSpace(secret) != "" { + return true + } + } + for _, deployment := range cfg.Deployments { + if deploymentContainsSecrets(deployment) { + return true + } + } + return false +} + +// SanitizeProviderConfigForDisk removes every historical credential field +// while preserving provider selection, routing, endpoints, and model metadata. +func SanitizeProviderConfigForDisk(cfg ProviderConfig) ProviderConfig { + cfg.AnthropicAPIKey = "" + cfg.GrokAPIKey = "" + cfg.XAIAPIKey = "" + cfg.OpenAIAPIKey = "" + cfg.CanopyWaveAPIKey = "" + cfg.DeepSeekAPIKey = "" + cfg.ZAIAPIKey = "" + cfg.ZAICodingAPIKey = "" + cfg.OpenRouterAPIKey = "" + cfg.GeminiAPIKey = "" + cfg.OpenCodeGoAPIKey = "" + cfg.MoonshotAPIKey = "" + cfg.XiaomiMimoPaygAPIKey = "" + cfg.XiaomiMimoTokenPlanAPIKey = "" + cfg.MiniMaxTokenPlanAPIKey = "" + cfg.MiniMaxPaygAPIKey = "" + cfg.PoolsideAPIKey = "" + cfg.GroqAPIKey = "" + cfg.ClinePassAPIKey = "" + if cfg.Deployments != nil { + deployments := make(map[string]DeploymentConfig, len(cfg.Deployments)) + for id, deployment := range cfg.Deployments { + deployments[id] = SanitizeDeploymentConfigForDisk(deployment) + } + cfg.Deployments = deployments + } + return cfg +} + +func providerConfigSecrets(cfg ProviderConfig) []string { + return []string{ + cfg.AnthropicAPIKey, cfg.GrokAPIKey, cfg.XAIAPIKey, cfg.OpenAIAPIKey, + cfg.CanopyWaveAPIKey, cfg.DeepSeekAPIKey, cfg.ZAIAPIKey, cfg.ZAICodingAPIKey, + cfg.OpenRouterAPIKey, cfg.GeminiAPIKey, cfg.OpenCodeGoAPIKey, cfg.MoonshotAPIKey, + cfg.XiaomiMimoPaygAPIKey, cfg.XiaomiMimoTokenPlanAPIKey, + cfg.MiniMaxTokenPlanAPIKey, cfg.MiniMaxPaygAPIKey, + cfg.PoolsideAPIKey, cfg.GroqAPIKey, cfg.ClinePassAPIKey, + } +} + +func deploymentContainsSecrets(deployment DeploymentConfig) bool { + return strings.TrimSpace(deployment.APIKey) != "" || strings.TrimSpace(deployment.Token) != "" || + strings.TrimSpace(deployment.SecretAccessKey) != "" || strings.TrimSpace(deployment.AccessKeyID) != "" || + strings.TrimSpace(deployment.SessionToken) != "" +} diff --git a/config/provider_secrets_test.go b/config/provider_secrets_test.go new file mode 100644 index 0000000..80aaf5f --- /dev/null +++ b/config/provider_secrets_test.go @@ -0,0 +1,27 @@ +package config + +import "testing" + +func TestSanitizeProviderConfigForDiskRemovesLegacyAndDeploymentSecrets(t *testing.T) { + original := ProviderConfig{ + OpenAIAPIKey: "sk-legacy", AnthropicAPIKey: "sk-ant-legacy", + OpenAIBaseURL: "https://example.test", ActiveModel: "custom/model", + Deployments: map[string]DeploymentConfig{ + "openai-direct": {APIKey: "sk-deployment", BaseURL: "https://deployment.test"}, + }, + } + if !ProviderConfigContainsSecrets(original) { + t.Fatal("expected legacy provider state to contain secrets") + } + sanitized := SanitizeProviderConfigForDisk(original) + if ProviderConfigContainsSecrets(sanitized) { + t.Fatalf("sanitized provider state still contains secrets: %+v", sanitized) + } + if sanitized.OpenAIBaseURL != original.OpenAIBaseURL || sanitized.ActiveModel != original.ActiveModel || + sanitized.Deployments["openai-direct"].BaseURL != "https://deployment.test" { + t.Fatalf("sanitization lost routing metadata: %+v", sanitized) + } + if original.OpenAIAPIKey == "" || original.Deployments["openai-direct"].APIKey == "" { + t.Fatal("sanitization mutated its input") + } +} diff --git a/engine/control_plane.go b/engine/control_plane.go index efb0758..fa57a28 100644 --- a/engine/control_plane.go +++ b/engine/control_plane.go @@ -52,6 +52,14 @@ func (e *Engine) Gateways(ctx context.Context) []Gateway { selection := e.ActiveSelection(ctx) providerConfig := config.LoadProviderConfig(e.providerConfigPath) compiled, _ := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{CachePath: e.catalogPath}) + configuredDeployments := map[string]config.DeploymentConfig{} + if compiled != nil { + var persisted map[string]config.DeploymentConfig + if providerConfig != nil { + persisted = providerConfig.Deployments + } + configuredDeployments = buildDeployments(compiled, persisted, e.credentialEnv(ctx, compiled)) + } specs := registry.CredentialRegistry() out := make([]Gateway, 0, len(specs)) @@ -60,10 +68,7 @@ func (e *Engine) Gateways(ctx context.Context) []Gateway { envVars := append([]string{spec.EnvVar}, providerSpec.CredentialEnvFallbacks...) envVars = append(envVars, providerSpec.CredentialAliases...) configured := e.hasCredential(ctx, envVars) - deploymentConfigured := providerConfig != nil && spec.DeploymentID != "" - if deploymentConfigured { - _, deploymentConfigured = providerConfig.Deployments[spec.DeploymentID] - } + _, deploymentConfigured := configuredDeployments[spec.DeploymentID] modelCount := 0 if compiled != nil { modelCount = len(catalog.ModelEntriesForProvider(compiled, spec.ProviderID)) @@ -79,6 +84,20 @@ func (e *Engine) Gateways(ctx context.Context) []Gateway { SupportsLiveDiscovery: strings.TrimSpace(providerSpec.LiveFetcherKey) != "", }) } + for _, gateway := range e.customGateways { + configured := gateway.CredentialEnv == "" || e.hasCredential(ctx, []string{gateway.CredentialEnv}) + modelCount := 0 + if gateway.DefaultModel != "" { + modelCount = 1 + } + out = append(out, Gateway{ + ID: gateway.ID, DisplayName: gateway.DisplayName, + CredentialEnv: gateway.CredentialEnv, RequiresKey: gateway.CredentialEnv != "", + CredentialConfigured: configured, DeploymentConfigured: configured, + ModelCount: modelCount, + Active: NormalizeProviderID(selection.Provider) == gateway.ID, + }) + } sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out } @@ -90,7 +109,7 @@ func (e *Engine) hasCredential(ctx context.Context, envVars []string) bool { continue } secret, err := e.secretStore.Get(ctx, credentials.AccountForEnv(envVar)) - if err == nil && strings.TrimSpace(secret) != "" { + if err == nil && strings.TrimSpace(secret) != "" && !config.LooksLikePlaceholderSecret(secret) { return true } } diff --git a/engine/control_plane_test.go b/engine/control_plane_test.go index 49b8d2d..c8214bb 100644 --- a/engine/control_plane_test.go +++ b/engine/control_plane_test.go @@ -62,6 +62,32 @@ func TestMaskedCredentialNeverReturnsFullSecret(t *testing.T) { } } +func TestGatewayReadinessRejectsPlaceholderAndDiskSecrets(t *testing.T) { + ctx := context.Background() + store := &credentials.MapStore{} + eng, err := New(Options{SecretStore: store, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + if err := store.Set(ctx, credentials.AccountForEnv("OPENAI_API_KEY"), "changeme"); err != nil { + t.Fatal(err) + } + cfg := &config.ProviderConfig{Deployments: map[string]config.DeploymentConfig{ + "openai-direct": {APIKey: "sk-secret-on-disk"}, + }} + if err := config.SaveProviderConfig(cfg, eng.providerConfigPath); err != nil { + t.Fatal(err) + } + for _, gateway := range eng.Gateways(ctx) { + if gateway.ID == "openai" && (gateway.CredentialConfigured || gateway.DeploymentConfigured) { + t.Fatalf("legacy or placeholder secret counted as ready: %+v", gateway) + } + } + if selection := eng.EffectiveSelection(ctx, SelectionOptions{}); selection.HasConfiguredDeployment { + t.Fatalf("legacy disk secret made selection ready: %+v", selection) + } +} + func TestEffectiveSelectionUsesEngineOwnedState(t *testing.T) { ctx := context.Background() store := &credentials.MapStore{} @@ -135,9 +161,12 @@ func TestProviderSecretMigrationStaysInsideEnginePath(t *testing.T) { if err != nil { t.Fatal(err) } - cfg := &config.ProviderConfig{Deployments: map[string]config.DeploymentConfig{ - "legacy": {APIKey: "sk-legacy", BaseURL: "https://example.test"}, - }} + cfg := &config.ProviderConfig{ + OpenAIAPIKey: "sk-top-level-legacy", + Deployments: map[string]config.DeploymentConfig{ + "legacy": {APIKey: "sk-legacy", BaseURL: "https://example.test"}, + }, + } if err := config.SaveProviderConfig(cfg, eng.providerConfigPath); err != nil { t.Fatal(err) } @@ -151,7 +180,21 @@ func TestProviderSecretMigrationStaysInsideEnginePath(t *testing.T) { t.Fatalf("provider state still contains secrets: %+v", status) } saved := config.LoadProviderConfig(eng.providerConfigPath) + if saved.OpenAIAPIKey != "" { + t.Fatal("migration retained a top-level legacy credential") + } if saved.Deployments["legacy"].BaseURL != "https://example.test" { t.Fatal("migration lost non-secret routing metadata") } + // A marker is an audit artifact, not permission to trust later plaintext. + saved.OpenAIAPIKey = "sk-reintroduced" + if err := config.SaveProviderConfig(saved, eng.providerConfigPath); err != nil { + t.Fatal(err) + } + if err := eng.MigrateProviderSecrets(); err != nil { + t.Fatal(err) + } + if config.LoadProviderConfig(eng.providerConfigPath).OpenAIAPIKey != "" { + t.Fatal("migration marker allowed a reintroduced plaintext credential") + } } diff --git a/engine/engine.go b/engine/engine.go index abc339e..5bae835 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -34,6 +34,7 @@ type Engine struct { secretStore credentials.Store catalogPath string providerConfigPath string + customGateways map[string]CustomGateway resolveTransport func(context.Context, Route) (client.Provider, error) } @@ -61,7 +62,10 @@ func New(opts Options) (*Engine, error) { if providerPath == "" { providerPath = config.GetProviderConfigPath() } - engine := &Engine{secretStore: store, catalogPath: catalogPath, providerConfigPath: providerPath} + engine := &Engine{ + secretStore: store, catalogPath: catalogPath, providerConfigPath: providerPath, + customGateways: snapshotCustomGateways(), + } engine.resolveTransport = engine.defaultTransport return engine, nil } @@ -125,6 +129,16 @@ func (e *Engine) Stream(ctx context.Context, req GenerateRequest) (*Stream, erro // ListModels returns provider models through the stable facade. func (e *Engine) ListModels(ctx context.Context, providerID string, refresh bool) ([]Model, error) { ctx = nonNilContext(ctx) + if gateway, ok := e.customGateway(providerID); ok { + if gateway.DefaultModel == "" { + return nil, nil + } + return []Model{{ + ID: gateway.DefaultModel, DisplayName: gateway.DefaultModel, + ProviderID: gateway.ID, GatewayID: gateway.ID, + ContextWindow: gateway.ContextWindow, Capabilities: customGatewayCapabilityNames(gateway), Source: "custom", + }}, nil + } var snapshot CatalogSnapshot var err error if refresh { @@ -199,7 +213,10 @@ func (e *Engine) resolveProvider(ctx context.Context, req GenerateRequest) (Rout return route, provider, nil } -func (e *Engine) defaultTransport(ctx context.Context, _ Route) (client.Provider, error) { +func (e *Engine) defaultTransport(ctx context.Context, route Route) (client.Provider, error) { + if provider, ok, err := e.customGatewayTransport(ctx, route); ok { + return provider, err + } compiled, cfg, err := e.loadRuntimeState(ctx) if err != nil { return nil, err @@ -208,6 +225,9 @@ func (e *Engine) defaultTransport(ctx context.Context, _ Route) (client.Provider } func (e *Engine) resolveSelection(ctx context.Context, req SelectionRequest) (Route, error) { + if route, ok, err := e.resolveCustomSelection(req); ok { + return route, err + } compiled, cfg, err := e.loadRuntimeState(ctx) if err != nil { return Route{}, err diff --git a/engine/host_control.go b/engine/host_control.go index ea75320..3be229c 100644 --- a/engine/host_control.go +++ b/engine/host_control.go @@ -388,12 +388,10 @@ func (e *Engine) ProviderStateSecurityStatus() ProviderStateSecurity { if cfg == nil { return status } - for id, deployment := range cfg.Deployments { - if deploymentContainsSecrets(deployment) { - status.HasSecrets = true - status.Detail = "deployment " + id + " has secret fields on disk" - return status - } + if config.ProviderConfigContainsSecrets(*cfg) { + status.HasSecrets = true + status.Detail = "provider state has credential fields on disk" + return status } return status } @@ -403,10 +401,6 @@ func (e *Engine) ProviderStateSecurityStatus() ProviderStateSecurity { func (e *Engine) MigrateProviderSecrets() error { path := e.providerConfigPath marker, backup := path+".secrets-migrated", path+".pre-secret-migrate.bak" - if _, err := os.Stat(marker); err == nil { - _ = os.Remove(backup) - return nil - } data, err := os.ReadFile(path) // #nosec G304 -- engine-owned state path if err != nil { if os.IsNotExist(err) { @@ -418,15 +412,16 @@ func (e *Engine) MigrateProviderSecrets() error { if err := json.Unmarshal(data, &cfg); err != nil { return err } - changed := false - for id, deployment := range cfg.Deployments { - if deploymentContainsSecrets(deployment) { - changed = true - } - cfg.Deployments[id] = config.SanitizeDeploymentConfigForDisk(deployment) - } + changed := config.ProviderConfigContainsSecrets(cfg) + cfg = config.SanitizeProviderConfigForDisk(cfg) if !changed { - return os.WriteFile(marker, []byte("ok\n"), 0o600) + if err := os.WriteFile(marker, []byte("ok\n"), 0o600); err != nil { + return err + } + if err := os.Remove(backup); err != nil && !os.IsNotExist(err) { + return err + } + return nil } if err := os.WriteFile(backup, data, 0o600); err != nil { return err @@ -472,9 +467,3 @@ func writeProviderConfigAtomic(path string, cfg *config.ProviderConfig) (err err } return os.Rename(tmpPath, path) } - -func deploymentContainsSecrets(deployment config.DeploymentConfig) bool { - return strings.TrimSpace(deployment.APIKey) != "" || strings.TrimSpace(deployment.Token) != "" || - strings.TrimSpace(deployment.SecretAccessKey) != "" || strings.TrimSpace(deployment.AccessKeyID) != "" || - strings.TrimSpace(deployment.SessionToken) != "" -} diff --git a/engine/host_runtime.go b/engine/host_runtime.go new file mode 100644 index 0000000..a148420 --- /dev/null +++ b/engine/host_runtime.go @@ -0,0 +1,241 @@ +package engine + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "sync" + + "github.com/GrayCodeAI/eyrie/catalog/registry" + "github.com/GrayCodeAI/eyrie/client" + "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" +) + +// CustomGatewayCapabilities declares capabilities known to be supported by a +// custom OpenAI-compatible gateway. A nil declaration is permissive for +// backward compatibility: the remote gateway remains the source of truth. +type CustomGatewayCapabilities struct { + Streaming bool `json:"streaming,omitempty"` + Tools bool `json:"tools,omitempty"` + Vision bool `json:"vision,omitempty"` + StructuredJSON bool `json:"structured_json,omitempty"` + Reasoning bool `json:"reasoning,omitempty"` +} + +// CustomGateway describes a user-configured OpenAI-compatible gateway. It +// contains only routing metadata; credential material remains in Eyrie's +// secret store and is discovered by CredentialEnv. +type CustomGateway struct { + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` + BaseURL string `json:"base_url"` + CredentialEnv string `json:"credential_env,omitempty"` + DefaultModel string `json:"default_model,omitempty"` + MaxTokensField string `json:"max_tokens_field,omitempty"` + ContextWindow int `json:"context_window,omitempty"` + Capabilities *CustomGatewayCapabilities `json:"capabilities,omitempty"` +} + +var customGatewayRegistry = struct { + sync.RWMutex + gateways map[string]CustomGateway +}{gateways: make(map[string]CustomGateway)} + +// RegisterCustomGateway registers safe routing metadata for a host-supplied +// OpenAI-compatible gateway. Registration must happen before Engine creation; +// each Engine snapshots the metadata and always resolves credentials through +// its own injected SecretStore. +func RegisterCustomGateway(gateway CustomGateway) error { + id := NormalizeProviderID(gateway.ID) + if id == "" { + return fmt.Errorf("eyrie engine: custom gateway ID is required") + } + if builtIn, ok := registry.SpecByProviderID(id); ok { + return fmt.Errorf("eyrie engine: custom gateway ID %q collides with built-in gateway %q", id, builtIn.ProviderID) + } + baseURL := strings.TrimSpace(gateway.BaseURL) + if baseURL == "" { + return fmt.Errorf("eyrie engine: custom gateway %q base URL is required", id) + } + parsed, err := url.Parse(baseURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return fmt.Errorf("eyrie engine: custom gateway %q has invalid baseURL %q (must be http/https with host)", id, baseURL) + } + if gateway.ContextWindow < 0 { + return fmt.Errorf("eyrie engine: custom gateway %q context window cannot be negative", id) + } + maxTokensField := strings.TrimSpace(gateway.MaxTokensField) + if maxTokensField == "" { + maxTokensField = "max_tokens" + } + if maxTokensField != "max_tokens" && maxTokensField != "max_completion_tokens" { + return fmt.Errorf("eyrie engine: custom gateway %q max tokens field must be max_tokens or max_completion_tokens", id) + } + gateway.ID = id + gateway.BaseURL = strings.TrimRight(baseURL, "/") + gateway.DisplayName = strings.TrimSpace(gateway.DisplayName) + if gateway.DisplayName == "" { + gateway.DisplayName = id + } + gateway.CredentialEnv = strings.TrimSpace(gateway.CredentialEnv) + gateway.DefaultModel = strings.TrimSpace(gateway.DefaultModel) + gateway.MaxTokensField = maxTokensField + customGatewayRegistry.Lock() + customGatewayRegistry.gateways[id] = cloneCustomGateway(gateway) + customGatewayRegistry.Unlock() + return nil +} + +func snapshotCustomGateways() map[string]CustomGateway { + customGatewayRegistry.RLock() + defer customGatewayRegistry.RUnlock() + out := make(map[string]CustomGateway, len(customGatewayRegistry.gateways)) + for id, gateway := range customGatewayRegistry.gateways { + out[id] = cloneCustomGateway(gateway) + } + return out +} + +func cloneCustomGateway(gateway CustomGateway) CustomGateway { + if gateway.Capabilities != nil { + capabilities := *gateway.Capabilities + gateway.Capabilities = &capabilities + } + return gateway +} + +func (e *Engine) customGateway(providerID string) (CustomGateway, bool) { + if e == nil { + return CustomGateway{}, false + } + gateway, ok := e.customGateways[NormalizeProviderID(providerID)] + return cloneCustomGateway(gateway), ok +} + +func (e *Engine) resolveCustomSelection(req SelectionRequest) (Route, bool, error) { + providerID := NormalizeProviderID(req.Preference.PreferredProvider) + modelID := strings.TrimSpace(req.Preference.PreferredModelID) + state := config.LoadProviderConfig(e.providerConfigPath) + if providerID == "" { + providerID = NormalizeProviderID(config.ActiveProvider(state)) + } + gateway, ok := e.customGateway(providerID) + if !ok { + return Route{}, false, nil + } + if modelID == "" { + modelID = strings.TrimSpace(config.ActiveModel(state)) + } + if modelID == "" { + modelID = gateway.DefaultModel + } + if modelID == "" { + return Route{}, true, &Error{ + Code: ErrorModelUnavailable, Operation: "resolve", Provider: gateway.ID, + Message: fmt.Sprintf("eyrie engine: custom gateway %q requires a model", gateway.ID), + } + } + if err := validateCustomGatewayRequirements(gateway, modelID, req.Requirements); err != nil { + return Route{}, true, err + } + return Route{Provider: gateway.ID, Model: modelID, DeploymentRouting: false}, true, nil +} + +func validateCustomGatewayRequirements(gateway CustomGateway, modelID string, req Requirements) error { + if gateway.ContextWindow > 0 && req.MinimumContext > gateway.ContextWindow { + return &Error{ + Code: ErrorCapabilityMismatch, Operation: "resolve", Provider: gateway.ID, Model: modelID, + Message: fmt.Sprintf("eyrie engine: custom gateway %q context window %d is below requested %d", gateway.ID, gateway.ContextWindow, req.MinimumContext), + } + } + capabilities := gateway.Capabilities + if capabilities == nil { + return nil + } + supported := (!req.Streaming || capabilities.Streaming) && + (!req.Tools || capabilities.Tools) && + (!req.Vision || capabilities.Vision) && + (!req.StructuredJSON || capabilities.StructuredJSON) && + (!req.Reasoning || capabilities.Reasoning) + if supported { + return nil + } + return &Error{ + Code: ErrorCapabilityMismatch, Operation: "resolve", Provider: gateway.ID, Model: modelID, + Message: fmt.Sprintf("eyrie engine: custom gateway %q does not satisfy requested capabilities", gateway.ID), + } +} + +func (e *Engine) customGatewayTransport(ctx context.Context, route Route) (client.Provider, bool, error) { + gateway, ok := e.customGateway(route.Provider) + if !ok { + return nil, false, nil + } + secret := "" + if gateway.CredentialEnv != "" { + var err error + secret, err = e.secretStore.Get(nonNilContext(ctx), credentials.AccountForEnv(gateway.CredentialEnv)) + if errors.Is(err, credentials.ErrNotFound) { + return nil, true, &Error{ + Code: ErrorCredentialMissing, Operation: "resolve_transport", Provider: gateway.ID, Model: route.Model, + Message: fmt.Sprintf("eyrie engine: credential for custom gateway %q is not configured", gateway.ID), + } + } + if err != nil { + return nil, true, &Error{ + Code: ErrorInternal, Operation: "resolve_transport", Provider: gateway.ID, Model: route.Model, + Message: fmt.Sprintf("eyrie engine: credential store unavailable for custom gateway %q", gateway.ID), Cause: err, + } + } + if strings.TrimSpace(secret) == "" || config.LooksLikePlaceholderSecret(secret) { + return nil, true, &Error{ + Code: ErrorCredentialMissing, Operation: "resolve_transport", Provider: gateway.ID, Model: route.Model, + Message: fmt.Sprintf("eyrie engine: credential for custom gateway %q is not configured", gateway.ID), + } + } + } + compat := &client.OpenAICompatConfig{MaxTokensField: gateway.MaxTokensField} + provider := client.NewOpenAIClient(secret, gateway.BaseURL, compat, client.WithProviderName(gateway.ID)) + return provider, true, nil +} + +func customGatewayCapabilityNames(gateway CustomGateway) []string { + if gateway.Capabilities == nil { + return nil + } + var out []string + if gateway.Capabilities.Streaming { + out = append(out, "streaming") + } + if gateway.Capabilities.Tools { + out = append(out, "tools") + } + if gateway.Capabilities.Vision { + out = append(out, "vision") + } + if gateway.Capabilities.StructuredJSON { + out = append(out, "structured_json") + } + if gateway.Capabilities.Reasoning { + out = append(out, "reasoning") + } + return out +} + +// ParseInlineToolCalls normalizes provider text-embedded tool calls into the +// stable engine contract. Providers that emit structured tool calls bypass +// this fallback. +func ParseInlineToolCalls(content string) (string, []ToolCall) { + clean, calls := client.ParseInlineToolCalls(content) + if len(calls) == 0 { + return clean, nil + } + out := make([]ToolCall, 0, len(calls)) + for _, call := range calls { + out = append(out, ToolCall{ID: call.ID, Name: call.Name, Arguments: call.Arguments}) + } + return clean, out +} diff --git a/engine/host_runtime_test.go b/engine/host_runtime_test.go new file mode 100644 index 0000000..33775bf --- /dev/null +++ b/engine/host_runtime_test.go @@ -0,0 +1,246 @@ +package engine + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestRegisterCustomGatewayValidatesHostMetadata(t *testing.T) { + for _, test := range []struct { + name string + gateway CustomGateway + want string + }{ + {name: "missing id", gateway: CustomGateway{BaseURL: "https://example.test/v1"}, want: "ID is required"}, + {name: "missing base URL", gateway: CustomGateway{ID: "custom"}, want: "base URL is required"}, + {name: "invalid base URL", gateway: CustomGateway{ID: "custom", BaseURL: "file:///tmp/socket"}, want: "invalid baseURL"}, + {name: "built-in collision", gateway: CustomGateway{ID: "openai", BaseURL: "https://example.test/v1"}, want: "collides with built-in gateway"}, + } { + t.Run(test.name, func(t *testing.T) { + err := RegisterCustomGateway(test.gateway) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("RegisterCustomGateway() error = %v, want containing %q", err, test.want) + } + }) + } +} + +func TestRegisterCustomGatewayAcceptsSafeMetadata(t *testing.T) { + registerCustomGatewayForTest(t, CustomGateway{ + ID: "engine-contract-test", + BaseURL: "https://example.test/v1", + CredentialEnv: "ENGINE_CONTRACT_TEST_API_KEY", + }) +} + +func TestParseInlineToolCallsNormalizesHermesCall(t *testing.T) { + clean, calls := ParseInlineToolCalls(`Before {"name":"read_file","arguments":{"path":"main.go"}} after`) + if clean != "Before after" { + t.Fatalf("clean = %q, want preserved visible text", clean) + } + if len(calls) != 1 || calls[0].Name != "read_file" || calls[0].Arguments["path"] != "main.go" { + t.Fatalf("calls = %#v, want normalized read_file call", calls) + } +} + +func TestParseInlineToolCallsLeavesPlainTextUntouched(t *testing.T) { + const input = "ordinary assistant response" + clean, calls := ParseInlineToolCalls(input) + if clean != input || len(calls) != 0 { + t.Fatalf("ParseInlineToolCalls() = %q, %#v", clean, calls) + } +} + +func TestCustomGatewayUnknownToolModelUsesInjectedStoreForGenerateAndStream(t *testing.T) { + const ( + gatewayID = "custom-parity-gateway" + modelID = "vendor/unknown-tools-model" + envKey = "CUSTOM_PARITY_API_KEY" + ) + t.Setenv(envKey, "global-secret-must-not-be-used") + + var ( + mu sync.Mutex + requests []map[string]interface{} + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Errorf("path = %q, want /chat/completions", r.URL.Path) + } + if auth := r.Header.Get("Authorization"); auth != "Bearer injected-secret" { + t.Errorf("Authorization = %q, want injected SecretStore value", auth) + } + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + mu.Lock() + requests = append(requests, body) + mu.Unlock() + if body["model"] != modelID { + t.Errorf("model = %#v, want %q", body["model"], modelID) + } + tools, ok := body["tools"].([]interface{}) + if !ok || len(tools) != 1 { + t.Errorf("tools = %#v, want one tool", body["tools"]) + } + if streaming, _ := body["stream"].(bool); streaming { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprint(w, "data: {\"id\":\"custom-stream\",\"choices\":[{\"delta\":{\"content\":\"streamed\"},\"finish_reason\":null}]}\n\n") + _, _ = fmt.Fprint(w, "data: {\"id\":\"custom-stream\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n") + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "custom-generate", + "choices": []map[string]interface{}{{ + "message": map[string]string{"role": "assistant", "content": "generated"}, + "finish_reason": "stop", + }}, + "usage": map[string]int{"prompt_tokens": 2, "completion_tokens": 1, "total_tokens": 3}, + }) + })) + defer server.Close() + + registerCustomGatewayForTest(t, CustomGateway{ + ID: gatewayID, BaseURL: server.URL, CredentialEnv: envKey, DefaultModel: modelID, + }) + store := &credentials.MapStore{} + if err := store.Set(context.Background(), credentials.AccountForEnv(envKey), "injected-secret"); err != nil { + t.Fatal(err) + } + eng, err := New(Options{StateDir: t.TempDir(), SecretStore: store}) + if err != nil { + t.Fatal(err) + } + + route, err := eng.Resolve(context.Background(), SelectionRequest{ + Requirements: Requirements{Tools: true}, + Preference: Preference{PreferredProvider: gatewayID, PreferredModelID: modelID}, + }) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if route.Provider != "custom_parity_gateway" || route.Model != modelID || route.DeploymentRouting { + t.Fatalf("route = %+v", route) + } + + request := GenerateRequest{ + Messages: []Message{{Role: "user", Content: "inspect main.go"}}, + Tools: []Tool{{Name: "read_file", Parameters: map[string]interface{}{"type": "object"}}}, + Requirements: Requirements{Tools: true}, + Preference: Preference{PreferredProvider: gatewayID, PreferredModelID: modelID}, + } + response, err := eng.Generate(context.Background(), request) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + if response.Content != "generated" || response.Route.Provider != route.Provider { + t.Fatalf("response = %+v", response) + } + + request.Requirements.Streaming = true + stream, err := eng.Stream(context.Background(), request) + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + defer stream.Close() + var content strings.Builder + for stream.Next() { + if event := stream.Event(); event.Type == EventContentDelta { + content.WriteString(event.Content) + } + } + if err := stream.Err(); err != nil { + t.Fatalf("stream error = %v", err) + } + if content.String() != "streamed" { + t.Fatalf("stream content = %q", content.String()) + } + mu.Lock() + requestCount := len(requests) + mu.Unlock() + if requestCount != 2 { + t.Fatalf("transport requests = %d, want generate + stream", requestCount) + } + + models, err := eng.ListModels(context.Background(), gatewayID, false) + if err != nil || len(models) != 1 || models[0].ID != modelID || models[0].Source != "custom" { + t.Fatalf("custom models = %+v, err = %v", models, err) + } +} + +func TestCustomGatewayDeclaredCapabilitiesAreEnforced(t *testing.T) { + const gatewayID = "custom-capability-contract" + registerCustomGatewayForTest(t, CustomGateway{ + ID: gatewayID, BaseURL: "https://example.test/v1", DefaultModel: "custom/model", + Capabilities: &CustomGatewayCapabilities{Streaming: true, Tools: false}, + }) + eng, err := New(Options{StateDir: t.TempDir(), SecretStore: &credentials.MapStore{}}) + if err != nil { + t.Fatal(err) + } + _, err = eng.Resolve(context.Background(), SelectionRequest{ + Requirements: Requirements{Tools: true}, + Preference: Preference{PreferredProvider: gatewayID, PreferredModelID: "custom/model"}, + }) + if !IsCode(err, ErrorCapabilityMismatch) { + t.Fatalf("Resolve() error = %v, want capability mismatch", err) + } +} + +func TestCustomGatewayRejectsPlaceholderFromInjectedStore(t *testing.T) { + const ( + gatewayID = "custom-placeholder-contract" + envKey = "CUSTOM_PLACEHOLDER_API_KEY" + ) + registerCustomGatewayForTest(t, CustomGateway{ + ID: gatewayID, BaseURL: "https://example.test/v1", CredentialEnv: envKey, DefaultModel: "custom/model", + }) + store := &credentials.MapStore{} + if err := store.Set(context.Background(), credentials.AccountForEnv(envKey), "your-api-key-here"); err != nil { + t.Fatal(err) + } + eng, err := New(Options{StateDir: t.TempDir(), SecretStore: store}) + if err != nil { + t.Fatal(err) + } + _, err = eng.Generate(context.Background(), GenerateRequest{ + Messages: []Message{{Role: "user", Content: "hello"}}, + Preference: Preference{PreferredProvider: gatewayID, PreferredModelID: "custom/model"}, + }) + if !IsCode(err, ErrorCredentialMissing) { + t.Fatalf("Generate() error = %v, want credential missing", err) + } +} + +func registerCustomGatewayForTest(t *testing.T, gateway CustomGateway) { + t.Helper() + id := NormalizeProviderID(gateway.ID) + customGatewayRegistry.RLock() + previous, existed := customGatewayRegistry.gateways[id] + customGatewayRegistry.RUnlock() + if err := RegisterCustomGateway(gateway); err != nil { + t.Fatalf("RegisterCustomGateway() error = %v", err) + } + t.Cleanup(func() { + customGatewayRegistry.Lock() + defer customGatewayRegistry.Unlock() + if existed { + customGatewayRegistry.gateways[id] = previous + return + } + delete(customGatewayRegistry.gateways, id) + }) +} diff --git a/engine/selection.go b/engine/selection.go index d4f79df..b9fdc0d 100644 --- a/engine/selection.go +++ b/engine/selection.go @@ -40,7 +40,7 @@ func (e *Engine) EffectiveSelection(ctx context.Context, opts SelectionOptions) hasConfigured := false configured := make(map[string]bool, len(gateways)) for _, gateway := range gateways { - ready := gateway.CredentialConfigured || gateway.DeploymentConfigured + ready := gateway.DeploymentConfigured configured[NormalizeProviderID(gateway.ID)] = ready if ready { hasConfigured = true diff --git a/engine/state.go b/engine/state.go index 51262dc..d831a1e 100644 --- a/engine/state.go +++ b/engine/state.go @@ -36,7 +36,7 @@ func (e *Engine) credentialEnv(ctx context.Context, compiled *catalog.CompiledCa } for _, envKey := range catalog.DiscoveryEnvKeysFromCatalog(compiled) { secret, err := e.secretStore.Get(ctx, credentials.AccountForEnv(envKey)) - if err == nil && strings.TrimSpace(secret) != "" { + if err == nil && strings.TrimSpace(secret) != "" && !config.LooksLikePlaceholderSecret(secret) { out[envKey] = secret } } @@ -60,11 +60,11 @@ func buildDeployments(compiled *catalog.CompiledCatalog, persisted map[string]co return out } -// mergeDeployment keeps non-secret routing fields from disk while filling -// credential fields from the injected store. Values derived from the store or -// current environment take precedence when present. +// mergeDeployment keeps only non-secret routing fields from disk while filling +// credential fields from the injected store. Legacy secret-bearing provider +// state is never accepted as a runtime credential source. func mergeDeployment(persisted, derived config.DeploymentConfig) config.DeploymentConfig { - out := persisted + out := config.SanitizeDeploymentConfigForDisk(persisted) if derived.APIKey != "" { out.APIKey = derived.APIKey } From 60597f16f181031479d1127751617b167fad8bd9 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 13 Jul 2026 03:00:20 +0530 Subject: [PATCH 23/24] feat: harden host engine boundary --- catalog/discover/discover.go | 18 +- catalog/discover/isolation_test.go | 198 +++++++ catalog/discover/provider_refresh.go | 26 +- catalog/live_enrich.go | 6 +- catalog/registry/derive.go | 54 ++ catalog/registry/derive_test.go | 27 + config/config_package_test.go | 5 - config/credential/probe.go | 20 +- config/credential/probe_strict_test.go | 28 + config/credential/probe_test.go | 2 +- config/credential_export.go | 13 +- config/deployment_env_sync.go | 9 +- config/deployment_readiness_test.go | 32 + config/discovery_credentials_test.go | 2 +- config/discovery_env.go | 76 ++- config/discovery_env_stale_base_test.go | 2 +- config/discovery_state_isolation_test.go | 122 ++++ config/provider_env.go | 65 ++- config/provider_env_test.go | 46 +- config/provider_secrets.go | 101 +++- config/provider_secrets_test.go | 54 ++ config/xiaomi_profile_test.go | 2 +- docs/architecture/HOST-ENGINE-BOUNDARY.md | 221 +++++-- docs/guides/DYNAMIC-MODEL-DISCOVERY.md | 675 ++++++++-------------- engine/catalog.go | 119 +++- engine/control_plane.go | 112 ++-- engine/control_plane_test.go | 38 +- engine/credentials.go | 89 ++- engine/engine.go | 74 ++- engine/engine_test.go | 26 +- engine/host_control.go | 298 ++++++++-- engine/host_control_test.go | 201 +++++++ engine/host_facade_contract_test.go | 227 ++++++++ engine/host_runtime.go | 114 +++- engine/host_runtime_test.go | 60 +- engine/live_models_test.go | 79 +++ engine/model_policy.go | 160 ++++- engine/model_policy_custom_test.go | 111 ++++ engine/preflight_live_test.go | 125 ++++ engine/provider_state.go | 32 + engine/provider_state_test.go | 135 +++++ engine/selection.go | 150 +++-- engine/selection_contract_test.go | 126 ++++ engine/state.go | 118 +++- engine/state_security_test.go | 189 ++++++ engine/types.go | 42 +- setup/deployment.go | 160 +++-- setup/deployment_test.go | 50 +- setup/status.go | 27 +- setup/status_test.go | 40 +- 50 files changed, 3846 insertions(+), 860 deletions(-) create mode 100644 catalog/discover/isolation_test.go create mode 100644 config/credential/probe_strict_test.go create mode 100644 config/deployment_readiness_test.go create mode 100644 config/discovery_state_isolation_test.go create mode 100644 engine/host_control_test.go create mode 100644 engine/host_facade_contract_test.go create mode 100644 engine/live_models_test.go create mode 100644 engine/model_policy_custom_test.go create mode 100644 engine/preflight_live_test.go create mode 100644 engine/provider_state.go create mode 100644 engine/provider_state_test.go create mode 100644 engine/selection_contract_test.go create mode 100644 engine/state_security_test.go diff --git a/catalog/discover/discover.go b/catalog/discover/discover.go index 686f078..e995410 100644 --- a/catalog/discover/discover.go +++ b/catalog/discover/discover.go @@ -37,7 +37,8 @@ func liveDeploymentIDs(v1Catalog catalog.Catalog) []string { // Options configures catalog discovery: published catalog + live provider APIs via API keys. type Options struct { catalog.LoadCatalogOptions - Credentials catalog.Credentials + Credentials catalog.Credentials + DisableCredentialFallback bool } // Run loads the deployment-aware catalog, optionally refreshes the remote catalog, @@ -100,13 +101,13 @@ func run(ctx context.Context, opts Options) (*catalog.RefreshResult, error) { catalog.EnsureCredentialRegistryInCatalog(base) env := opts.Credentials.Env() - if len(env) == 0 { + if len(env) == 0 && !opts.DisableCredentialFallback { env = eyriecfg.DiscoveryCredentials(ctx).Env() } if len(env) > 0 { v1Catalog, enrichment := catalog.FetchLiveProviderCatalog(env) liveProviders = enrichment - if len(v1Catalog.Models) > 0 { + if liveEnrichmentSucceeded(enrichment) { base = MergeCatalogWithPolicy(base, &v1Catalog, MergePolicy{ PreferLive: true, ReplaceDeploymentOfferings: liveDeploymentIDs(v1Catalog), @@ -137,9 +138,18 @@ func run(ctx context.Context, opts Options) (*catalog.RefreshResult, error) { CachePath: opts.CachePath, Source: source, RemoteURL: opts.RemoteURL, - Refreshed: refreshed || len(liveProviders) > 0, + Refreshed: refreshed || liveEnrichmentSucceeded(liveProviders), RemoteRefreshed: remoteRefreshed, LiveProviders: liveProviders, StaleAfter: base.StaleAfter, }, nil } + +func liveEnrichmentSucceeded(enrichment []catalog.LiveProviderEnrichment) bool { + for _, provider := range enrichment { + if provider.Error == "" && provider.ModelCount > 0 { + return true + } + } + return false +} diff --git a/catalog/discover/isolation_test.go b/catalog/discover/isolation_test.go new file mode 100644 index 0000000..3436cac --- /dev/null +++ b/catalog/discover/isolation_test.go @@ -0,0 +1,198 @@ +package discover_test + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/catalog/discover" + "github.com/GrayCodeAI/eyrie/catalog/live" + eyriecfg "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestRun_DisableCredentialFallbackIgnoresAmbientStoreAndConfigPath(t *testing.T) { + var requests atomic.Int32 + stubCanopyFetcher(t, func(map[string]string) ([]live.Entry, error) { + requests.Add(1) + return []live.Entry{{ID: "ambient/model"}}, nil + }) + + t.Setenv("EYRIE_CONFIG_DIR", t.TempDir()) + if err := eyriecfg.SaveProviderConfig(&eyriecfg.ProviderConfig{ + Version: "1", + CanopyWaveBaseURL: "https://ambient-canopy.invalid/v1", + }, ""); err != nil { + t.Fatal(err) + } + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + if err := store.Set(context.Background(), credentials.AccountForEnv("CANOPYWAVE_API_KEY"), "ambient-canopy-key-1234567890"); err != nil { + t.Fatal(err) + } + if eyriecfg.DiscoveryCredentials(context.Background()).APIKeys["CANOPYWAVE_API_KEY"] == "" { + t.Fatal("test setup: ambient fallback credential is not visible") + } + + cachePath := filepath.Join(t.TempDir(), "isolated-catalog.json") + base := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(cachePath, &base); err != nil { + t.Fatal(err) + } + result, err := discover.Run(context.Background(), discover.Options{ + LoadCatalogOptions: catalog.LoadCatalogOptions{CachePath: cachePath}, + DisableCredentialFallback: true, + }) + if err != nil { + t.Fatal(err) + } + if len(result.LiveProviders) != 0 { + t.Fatalf("live providers = %+v, want none without injected credentials", result.LiveProviders) + } + if requests.Load() != 0 { + t.Fatalf("ambient provider endpoint received %d requests", requests.Load()) + } +} + +func TestRunDoesNotReportRefreshWhenEveryLiveProviderFails(t *testing.T) { + stubCanopyFetcher(t, func(map[string]string) ([]live.Entry, error) { + return nil, errors.New("injected provider failure") + }) + cachePath := filepath.Join(t.TempDir(), "catalog.json") + seed := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(cachePath, &seed); err != nil { + t.Fatal(err) + } + result, err := discover.Run(context.Background(), discover.Options{ + LoadCatalogOptions: catalog.LoadCatalogOptions{CachePath: cachePath}, + Credentials: catalog.Credentials{APIKeys: map[string]string{"CANOPYWAVE_API_KEY": "explicit-canopy-key-1234567890"}}, + DisableCredentialFallback: true, + }) + if err != nil { + t.Fatal(err) + } + if result.Refreshed || strings.Contains(result.Source, "providers") { + t.Fatalf("failed live refresh reported success: %+v", result) + } +} + +func TestRefreshProviderWithOptions_DisableCredentialFallback(t *testing.T) { + var requests atomic.Int32 + stubCanopyFetcher(t, func(map[string]string) ([]live.Entry, error) { + requests.Add(1) + return []live.Entry{{ID: "ambient/model"}}, nil + }) + + t.Setenv("EYRIE_CONFIG_DIR", t.TempDir()) + if err := eyriecfg.SaveProviderConfig(&eyriecfg.ProviderConfig{ + Version: "1", + CanopyWaveBaseURL: "https://ambient-canopy.invalid/v1", + }, ""); err != nil { + t.Fatal(err) + } + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + if err := store.Set(context.Background(), credentials.AccountForEnv("CANOPYWAVE_API_KEY"), "ambient-canopy-key-1234567890"); err != nil { + t.Fatal(err) + } + + _, err := discover.RefreshProviderWithOptions(context.Background(), "canopywave", discover.ProviderRefreshOptions{ + CachePath: filepath.Join(t.TempDir(), "catalog.json"), + DisableCredentialFallback: true, + }) + if err == nil || !strings.Contains(err.Error(), "no credentials") { + t.Fatalf("error = %v, want no credentials", err) + } + if requests.Load() != 0 { + t.Fatalf("ambient provider endpoint received %d requests", requests.Load()) + } +} + +func TestRefreshProviderWithOptions_UsesOnlyScopedCachePath(t *testing.T) { + var requests atomic.Int32 + stubCanopyFetcher(t, func(env map[string]string) ([]live.Entry, error) { + requests.Add(1) + if got := env["CANOPYWAVE_API_KEY"]; got != "explicit-canopy-key-1234567890" { + t.Errorf("CANOPYWAVE_API_KEY = %q", got) + } + if got := env["CANOPYWAVE_BASE_URL"]; got != "https://scoped-canopy.example/v1" { + t.Errorf("CANOPYWAVE_BASE_URL = %q", got) + } + return []live.Entry{{ + ID: "scoped/live-model", + ContextWindow: 32768, + RawJSON: []byte(`{"id":"scoped/live-model","context_length":32768}`), + }}, nil + }) + + dir := t.TempDir() + globalPath := filepath.Join(dir, "global-catalog.json") + scopedPath := filepath.Join(dir, "engine-a", "catalog.json") + t.Setenv("EYRIE_MODEL_CATALOG_PATH", globalPath) + + global := catalog.SeedCatalog() + global.Aliases["global-cache-marker"] = "global-only" + if err := catalog.WriteCatalogCache(globalPath, &global); err != nil { + t.Fatal(err) + } + globalBefore, err := os.ReadFile(globalPath) + if err != nil { + t.Fatal(err) + } + + scoped := catalog.SeedCatalog() + scoped.Aliases["scoped-cache-marker"] = "scoped-only" + if err := catalog.WriteCatalogCache(scopedPath, &scoped); err != nil { + t.Fatal(err) + } + + result, err := discover.RefreshProviderWithOptions(context.Background(), "canopywave", discover.ProviderRefreshOptions{ + Credentials: catalog.Credentials{APIKeys: map[string]string{ + "CANOPYWAVE_API_KEY": "explicit-canopy-key-1234567890", + "CANOPYWAVE_BASE_URL": "https://scoped-canopy.example/v1", + }}, + CachePath: scopedPath, + DisableCredentialFallback: true, + }) + if err != nil { + t.Fatal(err) + } + if result.CachePath != scopedPath { + t.Fatalf("result cache path = %q, want %q", result.CachePath, scopedPath) + } + if requests.Load() != 1 { + t.Fatalf("provider endpoint requests = %d, want 1", requests.Load()) + } + if result.Compiled.Catalog.Aliases["scoped-cache-marker"] != "scoped-only" { + t.Fatal("refresh did not load the provider-scoped cache") + } + if _, ok := result.Compiled.Catalog.Aliases["global-cache-marker"]; ok { + t.Fatal("refresh loaded state from the process-global cache path") + } + if len(catalog.ModelEntriesForProvider(result.Compiled, "canopywave")) == 0 { + t.Fatal("provider-scoped cache is missing live CanopyWave models") + } + + globalAfter, err := os.ReadFile(globalPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(globalBefore, globalAfter) { + t.Fatal("provider refresh modified the process-global cache") + } +} + +func stubCanopyFetcher(t *testing.T, fetch live.FetchFunc) { + t.Helper() + previous := live.Registry["canopywave"] + live.Registry["canopywave"] = fetch + t.Cleanup(func() { live.Registry["canopywave"] = previous }) +} diff --git a/catalog/discover/provider_refresh.go b/catalog/discover/provider_refresh.go index 671a5f4..410f268 100644 --- a/catalog/discover/provider_refresh.go +++ b/catalog/discover/provider_refresh.go @@ -15,12 +15,24 @@ import ( // RefreshProvider fetches live models for one provider, merges into the catalog cache, // and returns the compiled catalog. Used after the user saves an API key in /config. func RefreshProvider(ctx context.Context, providerID string, creds catalog.Credentials) (*catalog.RefreshResult, error) { + return RefreshProviderWithOptions(ctx, providerID, ProviderRefreshOptions{Credentials: creds}) +} + +type ProviderRefreshOptions struct { + Credentials catalog.Credentials + CachePath string + DisableCredentialFallback bool +} + +// RefreshProviderWithOptions fetches one provider using explicit host-owned +// credentials and cache state when supplied. +func RefreshProviderWithOptions(ctx context.Context, providerID string, opts ProviderRefreshOptions) (*catalog.RefreshResult, error) { runMu.Lock() defer runMu.Unlock() - return refreshProvider(ctx, providerID, creds) + return refreshProvider(ctx, providerID, opts) } -func refreshProvider(ctx context.Context, providerID string, creds catalog.Credentials) (*catalog.RefreshResult, error) { +func refreshProvider(ctx context.Context, providerID string, opts ProviderRefreshOptions) (*catalog.RefreshResult, error) { spec, ok := registry.SpecByProviderID(providerID) if !ok { return nil, fmt.Errorf("catalog discover: unknown provider %q", providerID) @@ -29,7 +41,10 @@ func refreshProvider(ctx context.Context, providerID string, creds catalog.Crede return nil, fmt.Errorf("catalog discover: provider %q has no live model list API", providerID) } - cachePath := catalog.DefaultCachePath() + cachePath := opts.CachePath + if cachePath == "" { + cachePath = catalog.DefaultCachePath() + } var base *catalog.Catalog source := "cache" if compiled, err := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{ @@ -46,10 +61,11 @@ func refreshProvider(ctx context.Context, providerID string, creds catalog.Crede catalog.EnsureDeploymentEnvFallbacks(base) catalog.EnsureCredentialRegistryInCatalog(base) - env := creds.Env() - if len(env) == 0 { + env := opts.Credentials.Env() + if len(env) == 0 && !opts.DisableCredentialFallback { env = eyriecfg.DiscoveryCredentials(ctx).Env() } + env = registry.ScopedProviderEnv(spec, env) if !registry.CredentialPresent(spec, env) { return nil, fmt.Errorf("catalog discover: no credentials for provider %q", providerID) } diff --git a/catalog/live_enrich.go b/catalog/live_enrich.go index 686be8e..519855c 100644 --- a/catalog/live_enrich.go +++ b/catalog/live_enrich.go @@ -24,7 +24,8 @@ func FetchLiveProviderCatalog(env map[string]string) (Catalog, []LiveProviderEnr } catalogKey := registry.LiveCatalogKeyForFetcher(fetcherKey) start := time.Now() - if !registry.CredentialPresent(spec, env) { + scopedEnv := registry.ScopedProviderEnv(spec, env) + if !registry.CredentialPresent(spec, scopedEnv) { reason := "skipped (no API key)" if !spec.RequiresKey { reason = "skipped (no base URL)" @@ -32,7 +33,7 @@ func FetchLiveProviderCatalog(env map[string]string) (Catalog, []LiveProviderEnr enrichment = append(enrichment, LiveProviderEnrichment{Provider: catalogKey, Error: reason, DurationMs: time.Since(start).Milliseconds()}) continue } - models, err := live.Fetch(fetcherKey, env) + models, err := live.Fetch(fetcherKey, scopedEnv) elapsed := time.Since(start) duration := elapsed.Milliseconds() if err != nil { @@ -121,6 +122,7 @@ func FetchLiveModelEntriesForProvider(env map[string]string, providerID string) if spec.LiveFetcherKey == "" { return nil, fmt.Errorf("catalog: provider %q has no live model list API", providerID) } + env = registry.ScopedProviderEnv(spec, env) if !registry.CredentialPresent(spec, env) { return nil, fmt.Errorf("catalog: set %s for %s", spec.CredentialEnv, providerID) } diff --git a/catalog/registry/derive.go b/catalog/registry/derive.go index c99aec5..2037333 100644 --- a/catalog/registry/derive.go +++ b/catalog/registry/derive.go @@ -173,11 +173,65 @@ func CredentialPresent(spec ProviderSpec, env map[string]string) bool { return true } } + for _, key := range spec.CredentialAliases { + if strings.TrimSpace(env[key]) != "" { + return true + } + } return false } return strings.TrimSpace(env[spec.CredentialEnv]) != "" } +// ScopedProviderEnv returns only credential aliases and routing metadata used +// by one provider. Provider-specific fetchers must never receive unrelated +// credentials from the host's full secret snapshot. +func ScopedProviderEnv(spec ProviderSpec, env map[string]string) map[string]string { + allowed := map[string]bool{} + add := func(keys ...string) { + for _, key := range keys { + if key = strings.TrimSpace(key); key != "" { + allowed[key] = true + } + } + } + add(spec.CredentialEnv) + add(spec.CredentialEnvFallbacks...) + add(spec.CredentialAliases...) + add(spec.BaseURLEnv...) + switch spec.ProviderID { + case "azure": + add("AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_API_VERSION", "AZURE_OPENAI_DEPLOYMENT") + case "bedrock": + add("AWS_REGION", "AWS_DEFAULT_REGION") + case "vertex": + add("VERTEX_PROJECT_ID", "VERTEX_REGION") + case "xiaomi_mimo_token_plan": + add("XIAOMI_MIMO_TOKEN_PLAN_REGION", "XIAOMI_MIMO_PLATFORM_MODELS_URL") + case "xiaomi_mimo_payg": + add("XIAOMI_MIMO_PLATFORM_MODELS_URL") + case "zai_payg": + add("ZAI_REGION") + case "zai_coding": + add("ZAI_CODING_REGION") + } + out := make(map[string]string, len(allowed)) + for key := range allowed { + if value := strings.TrimSpace(env[key]); value != "" { + out[key] = value + } + } + if strings.TrimSpace(out[spec.CredentialEnv]) == "" { + for _, alias := range spec.CredentialAliases { + if value := strings.TrimSpace(out[alias]); value != "" { + out[spec.CredentialEnv] = value + break + } + } + } + return out +} + // SpecForLiveFetcher returns the provider spec for a live fetcher key. func SpecForLiveFetcher(fetcherKey string) (ProviderSpec, bool) { return DefaultRegistry.GetForLiveFetcher(fetcherKey) diff --git a/catalog/registry/derive_test.go b/catalog/registry/derive_test.go index fbf393f..3e9c235 100644 --- a/catalog/registry/derive_test.go +++ b/catalog/registry/derive_test.go @@ -21,3 +21,30 @@ func TestDisplayName_CatalogAlias(t *testing.T) { t.Fatalf("DisplayName(google) = %q", got) } } + +func TestScopedProviderEnvExcludesUnrelatedCredentialsAndCanonicalizesAlias(t *testing.T) { + t.Parallel() + spec := ProviderSpec{ + ProviderID: "example", CredentialEnv: "EXAMPLE_API_KEY", + CredentialEnvFallbacks: []string{"EXAMPLE_FALLBACK_KEY"}, + CredentialAliases: []string{"EXAMPLE_ALIAS_KEY"}, + BaseURLEnv: []string{"EXAMPLE_BASE_URL"}, + } + got := ScopedProviderEnv(spec, map[string]string{ + "EXAMPLE_ALIAS_KEY": "alias-secret", + "EXAMPLE_BASE_URL": "https://example.test/v1", + "OPENAI_API_KEY": "must-not-leak", + "AWS_ACCESS_KEY_ID": "must-not-leak", + }) + if got["EXAMPLE_API_KEY"] != "alias-secret" || got["EXAMPLE_ALIAS_KEY"] != "alias-secret" { + t.Fatalf("provider alias was not scoped and canonicalized: %#v", got) + } + if got["EXAMPLE_BASE_URL"] != "https://example.test/v1" { + t.Fatalf("provider routing metadata missing: %#v", got) + } + for _, key := range []string{"OPENAI_API_KEY", "AWS_ACCESS_KEY_ID"} { + if got[key] != "" { + t.Fatalf("unrelated credential %s leaked into provider scope", key) + } + } +} diff --git a/config/config_package_test.go b/config/config_package_test.go index 97e96cc..34aa69e 100644 --- a/config/config_package_test.go +++ b/config/config_package_test.go @@ -507,9 +507,7 @@ func TestLoadProviderConfig_Roundtrip(t *testing.T) { Version: "1", ConfigVersion: 2, ActiveProvider: "anthropic", - AnthropicAPIKey: "sk-ant-test-key-1234567890", AnthropicModel: "claude-sonnet-4-6", - OpenAIAPIKey: "sk-openai-test-key-1234567890", ActiveModel: "claude-sonnet-4-6", ExplorationModel: "gpt-4o-mini", } @@ -526,9 +524,6 @@ func TestLoadProviderConfig_Roundtrip(t *testing.T) { if loaded.ActiveProvider != original.ActiveProvider { t.Errorf("ActiveProvider = %q, want %q", loaded.ActiveProvider, original.ActiveProvider) } - if loaded.AnthropicAPIKey != original.AnthropicAPIKey { - t.Errorf("AnthropicAPIKey = %q, want %q", loaded.AnthropicAPIKey, original.AnthropicAPIKey) - } if loaded.AnthropicModel != original.AnthropicModel { t.Errorf("AnthropicModel = %q, want %q", loaded.AnthropicModel, original.AnthropicModel) } diff --git a/config/credential/probe.go b/config/credential/probe.go index 80b85c2..1a4e17b 100644 --- a/config/credential/probe.go +++ b/config/credential/probe.go @@ -43,6 +43,16 @@ func ProbeCredential(ctx context.Context, envKey, secret string) error { // ProbeCredentialWithMimo is like ProbeCredential but accepts persisted MiMo routing fields. func ProbeCredentialWithMimo(ctx context.Context, envKey, secret string, mimo MimoProbeConfig) error { + return probeCredentialWithMimo(ctx, envKey, secret, mimo, true) +} + +// ProbeCredentialWithMimoStrict probes with explicitly supplied routing only. +// It never reads process environment for MiMo base URLs or regions. +func ProbeCredentialWithMimoStrict(ctx context.Context, envKey, secret string, mimo MimoProbeConfig) error { + return probeCredentialWithMimo(ctx, envKey, secret, mimo, false) +} + +func probeCredentialWithMimo(ctx context.Context, envKey, secret string, mimo MimoProbeConfig, allowAmbient bool) error { secret = strings.TrimSpace(secret) envKey = strings.TrimSpace(envKey) if secret == "" || envKey == "" { @@ -65,7 +75,7 @@ func ProbeCredentialWithMimo(ctx context.Context, envKey, secret string, mimo Mi case registry.ProbeOpenAIModels: baseURL := spec.ProbeBaseURL if _, ok := xiaomi.BillingForProvider(spec.ProviderID); ok { - baseURL = resolveMimoProbeBaseURL(spec, mimo) + baseURL = resolveMimoProbeBaseURL(spec, mimo, allowAmbient) if baseURL == "" { return fmt.Errorf("credential probe: configure Token Plan region (cn, sgp, or ams) before probing") } @@ -98,23 +108,23 @@ func probeOllama(ctx context.Context, baseURL string) error { return nil } -func resolveMimoProbeBaseURL(spec registry.ProviderSpec, mimo MimoProbeConfig) string { +func resolveMimoProbeBaseURL(spec registry.ProviderSpec, mimo MimoProbeConfig, allowAmbient bool) string { billing, _ := xiaomi.BillingForProvider(spec.ProviderID) override := "" var region xiaomi.Region switch billing { case xiaomi.BillingTokenPlan: override = strings.TrimSpace(mimo.TokenPlanBase) - if override == "" { + if override == "" && allowAmbient { override = strings.TrimSpace(os.Getenv("XIAOMI_MIMO_TOKEN_PLAN_BASE_URL")) } region, _ = xiaomi.NormalizeRegion(mimo.TokenPlanRegion) - if region == "" { + if region == "" && allowAmbient { region, _ = xiaomi.NormalizeRegion(os.Getenv("XIAOMI_MIMO_TOKEN_PLAN_REGION")) } default: override = strings.TrimSpace(mimo.PaygBase) - if override == "" { + if override == "" && allowAmbient { override = strings.TrimSpace(os.Getenv("XIAOMI_MIMO_PAYG_BASE_URL")) } } diff --git a/config/credential/probe_strict_test.go b/config/credential/probe_strict_test.go new file mode 100644 index 0000000..df21d07 --- /dev/null +++ b/config/credential/probe_strict_test.go @@ -0,0 +1,28 @@ +package credential + +import ( + "testing" + + "github.com/GrayCodeAI/eyrie/catalog/registry" +) + +func TestStrictMimoProbeRoutingIgnoresAmbientEnvironment(t *testing.T) { + spec, ok := registry.DefaultRegistry.Get("xiaomi_mimo_token_plan") + if !ok { + t.Fatal("MiMo Token Plan provider spec missing") + } + t.Setenv("XIAOMI_MIMO_TOKEN_PLAN_BASE_URL", "https://hostile.example.test/v1") + t.Setenv("XIAOMI_MIMO_TOKEN_PLAN_REGION", "cn") + + got := resolveMimoProbeBaseURL(spec, MimoProbeConfig{TokenPlanRegion: "sgp"}, false) + want := "https://token-plan-sgp.xiaomimimo.com/v1" + if got != want { + t.Fatalf("strict probe base = %q, want %q", got, want) + } + + t.Setenv("XIAOMI_MIMO_TOKEN_PLAN_REGION", "") + ambient := resolveMimoProbeBaseURL(spec, MimoProbeConfig{}, true) + if ambient != "https://hostile.example.test/v1" { + t.Fatalf("compatibility probe no longer honors explicit ambient override: %q", ambient) + } +} diff --git a/config/credential/probe_test.go b/config/credential/probe_test.go index 9a68df4..7a571d2 100644 --- a/config/credential/probe_test.go +++ b/config/credential/probe_test.go @@ -52,7 +52,7 @@ func TestProbeCredential_XiaomiTokenPlan_ResolvesBaseFromProviderConfig(t *testi t.Setenv("HAWK_CONFIG_DIR", dir) mockBase := strings.TrimRight(srv.URL, "/") + "/v1" cfg := &eyriecfg.ProviderConfig{ - Version: "2", + Version: "1", XiaomiMimoTokenPlanBaseURL: mockBase, } if err := eyriecfg.SaveProviderConfig(cfg, ""); err != nil { diff --git a/config/credential_export.go b/config/credential_export.go index 5307726..e57c02e 100644 --- a/config/credential_export.go +++ b/config/credential_export.go @@ -83,8 +83,19 @@ func ProbeCredential(ctx context.Context, envKey, secret string) error { return credential.ProbeCredentialWithMimo(ctx, envKey, secret, mimoProbeConfigFromProvider()) } +// ProbeCredentialWithProviderConfig verifies a credential using only the +// supplied provider state for region/base routing. Host-facing callers should +// prefer this over ProbeCredential so an Engine never consults the +// process-default provider.json path while probing a scoped credential. +func ProbeCredentialWithProviderConfig(ctx context.Context, envKey, secret string, cfg *ProviderConfig) error { + return credential.ProbeCredentialWithMimoStrict(ctx, envKey, secret, mimoProbeConfig(cfg)) +} + func mimoProbeConfigFromProvider() credential.MimoProbeConfig { - cfg := LoadProviderConfig("") + return mimoProbeConfig(LoadProviderConfig("")) +} + +func mimoProbeConfig(cfg *ProviderConfig) credential.MimoProbeConfig { if cfg == nil { return credential.MimoProbeConfig{} } diff --git a/config/deployment_env_sync.go b/config/deployment_env_sync.go index 1006758..fa3f3cf 100644 --- a/config/deployment_env_sync.go +++ b/config/deployment_env_sync.go @@ -39,9 +39,16 @@ func DeploymentConfigFromEnv(dep catalog.Deployment, env map[string]string) Depl // DeploymentConfigured reports whether env supplies enough credentials for this deployment. func DeploymentConfigured(deploymentID string, dep catalog.Deployment, dc DeploymentConfig) bool { + if dep.ModelMappingsRequired && len(dc.ModelMappings) == 0 { + return false + } switch deploymentID { case "ollama-local": return dc.BaseURL != "" + case "openai-azure": + return dc.APIKey != "" && dc.Endpoint != "" + case "xiaomi_mimo_token_plan-direct": + return dc.APIKey != "" && dc.BaseURL != "" default: return deploymentHasLiveCredentials(deploymentID, dc) } @@ -50,7 +57,7 @@ func DeploymentConfigured(deploymentID string, dep catalog.Deployment, dc Deploy func deploymentHasLiveCredentials(deploymentID string, dc DeploymentConfig) bool { switch deploymentID { case "anthropic-bedrock": - return dc.AccessKeyID != "" && dc.SecretAccessKey != "" + return dc.AccessKeyID != "" && dc.SecretAccessKey != "" && dc.Region != "" case "anthropic-vertex", "gemini-vertex": return dc.ProjectID != "" && dc.Region != "" && (dc.Token != "" || dc.APIKey != "") diff --git a/config/deployment_readiness_test.go b/config/deployment_readiness_test.go new file mode 100644 index 0000000..9223387 --- /dev/null +++ b/config/deployment_readiness_test.go @@ -0,0 +1,32 @@ +package config + +import ( + "testing" + + "github.com/GrayCodeAI/eyrie/catalog" +) + +func TestDeploymentConfiguredMatchesStrictFactoryRequirements(t *testing.T) { + tests := []struct { + name string + id string + dep catalog.Deployment + cfg DeploymentConfig + want bool + }{ + {name: "azure missing endpoint", id: "openai-azure", cfg: DeploymentConfig{APIKey: "secret"}}, + {name: "azure missing mapping", id: "openai-azure", dep: catalog.Deployment{ModelMappingsRequired: true}, cfg: DeploymentConfig{APIKey: "secret", Endpoint: "https://azure.example.test"}}, + {name: "azure ready", id: "openai-azure", dep: catalog.Deployment{ModelMappingsRequired: true}, cfg: DeploymentConfig{APIKey: "secret", Endpoint: "https://azure.example.test", ModelMappings: map[string]string{"openai/gpt": "deployment"}}, want: true}, + {name: "bedrock missing region", id: "anthropic-bedrock", cfg: DeploymentConfig{AccessKeyID: "access", SecretAccessKey: "secret"}}, + {name: "bedrock ready", id: "anthropic-bedrock", cfg: DeploymentConfig{AccessKeyID: "access", SecretAccessKey: "secret", Region: "us-east-1"}, want: true}, + {name: "token plan missing routed base", id: "xiaomi_mimo_token_plan-direct", cfg: DeploymentConfig{APIKey: "secret"}}, + {name: "token plan ready", id: "xiaomi_mimo_token_plan-direct", cfg: DeploymentConfig{APIKey: "secret", BaseURL: "https://api.xiaomimimo.com/v1"}, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := DeploymentConfigured(tt.id, tt.dep, tt.cfg); got != tt.want { + t.Fatalf("DeploymentConfigured() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/config/discovery_credentials_test.go b/config/discovery_credentials_test.go index c5e8a43..4cf3405 100644 --- a/config/discovery_credentials_test.go +++ b/config/discovery_credentials_test.go @@ -28,7 +28,7 @@ func TestDiscoveryCredentials_IncludesTokenPlanRegionFromProviderConfig(t *testi dir := t.TempDir() t.Setenv("HAWK_CONFIG_DIR", dir) - cfg := &ProviderConfig{Version: "2", XiaomiMimoTokenPlanRegion: "sgp"} + cfg := &ProviderConfig{Version: "1", XiaomiMimoTokenPlanRegion: "sgp"} if err := SaveProviderConfig(cfg, ""); err != nil { t.Fatal(err) } diff --git a/config/discovery_env.go b/config/discovery_env.go index d614aa7..a818bb4 100644 --- a/config/discovery_env.go +++ b/config/discovery_env.go @@ -17,22 +17,30 @@ func DiscoveryCredentials(ctx context.Context) catalog.Credentials { ctx = context.Background() } env := filterPlaceholderEnv(credentials.APIKeysMap(ctx, credentials.DefaultStore())) - mergeDiscoveryEnvFromProviderConfig(env) + mergeDiscoveryEnvFromConfig(env, LoadProviderConfig(""), true) return catalog.Credentials{APIKeys: env} } -// mergeDiscoveryEnvFromProviderConfig adds provider.json fields required for live catalog fetch. -// API keys always come from the secret store; values here must not override stored keys. -func mergeDiscoveryEnvFromProviderConfig(env map[string]string) { +// DiscoveryCredentialsFromState combines injected credential values with +// non-secret provider routing state. It never reads process-global paths, +// environment variables, or the default credential store. +func DiscoveryCredentialsFromState(apiKeys map[string]string, cfg *ProviderConfig) catalog.Credentials { + env := filterPlaceholderEnv(cloneDiscoveryEnv(apiKeys)) + mergeDiscoveryEnvFromConfig(env, cfg, false) + return catalog.Credentials{APIKeys: env} +} + +// mergeDiscoveryEnvFromConfig adds non-secret provider state required for live +// catalog fetch. Credential fields in provider.json are deliberately ignored. +func mergeDiscoveryEnvFromConfig(env map[string]string, cfg *ProviderConfig, allowProcessEnv bool) { if env == nil { return } - cfg := LoadProviderConfig("") if cfg == nil { return } r := strings.TrimSpace(cfg.XiaomiMimoTokenPlanRegion) - if r == "" { + if r == "" && allowProcessEnv { r = strings.TrimSpace(os.Getenv(EnvXiaomiTokenPlanRegion)) } if norm, err := xiaomi.NormalizeRegion(r); err == nil { @@ -44,6 +52,24 @@ func mergeDiscoveryEnvFromProviderConfig(env map[string]string) { if paygBase := strings.TrimSpace(cfg.XiaomiMimoPaygBaseURL); paygBase != "" { env[EnvXiaomiPaygBaseURL] = paygBase } + setDiscoveryEnv(env, "ANTHROPIC_BASE_URL", cfg.AnthropicBaseURL) + setDiscoveryEnv(env, "OPENAI_BASE_URL", cfg.OpenAIBaseURL) + setDiscoveryEnv(env, "CANOPYWAVE_BASE_URL", cfg.CanopyWaveBaseURL) + setDiscoveryEnv(env, "DEEPSEEK_BASE_URL", cfg.DeepSeekBaseURL) + setDiscoveryEnv(env, "ZAI_BASE_URL", cfg.ZAIBaseURL) + setDiscoveryEnv(env, "ZAI_CODING_BASE_URL", cfg.ZAICodingBaseURL) + setDiscoveryEnv(env, "ZAI_REGION", cfg.ZAIRegion) + setDiscoveryEnv(env, "ZAI_CODING_REGION", cfg.ZAICodingRegion) + setDiscoveryEnv(env, "XAI_BASE_URL", firstNonEmpty(cfg.GrokBaseURL, cfg.XAIBaseURL)) + setDiscoveryEnv(env, "OPENROUTER_BASE_URL", cfg.OpenRouterBaseURL) + setDiscoveryEnv(env, "GEMINI_BASE_URL", cfg.GeminiBaseURL) + setDiscoveryEnv(env, "OPENCODEGO_BASE_URL", cfg.OpenCodeGoBaseURL) + setDiscoveryEnv(env, "MOONSHOT_BASE_URL", cfg.MoonshotBaseURL) + setDiscoveryEnv(env, "MINIMAX_TOKEN_PLAN_BASE_URL", cfg.MiniMaxTokenPlanBaseURL) + setDiscoveryEnv(env, "MINIMAX_PAYG_BASE_URL", cfg.MiniMaxPaygBaseURL) + setDiscoveryEnv(env, "POOLSIDE_BASE_URL", cfg.PoolsideBaseURL) + setDiscoveryEnv(env, "GROQ_BASE_URL", cfg.GroqBaseURL) + setDiscoveryEnv(env, "CLINE_API_BASE", cfg.ClinePassBaseURL) if ollamaBase := NormalizeOllamaOpenAIBaseURL(AsNonEmptyString(cfg.OllamaBaseURL)); ollamaBase != "" { env["OLLAMA_BASE_URL"] = ollamaBase } @@ -58,14 +84,48 @@ func mergeDeploymentDiscoveryEnv(env map[string]string, deployments map[string]D setDiscoveryEnv(env, "AZURE_OPENAI_API_VERSION", dep.APIVersion) setDiscoveryEnv(env, "AZURE_OPENAI_DEPLOYMENT", firstNonEmptyDeploymentMapping(dep.ModelMappings)) case "anthropic-bedrock": - setDiscoveryEnv(env, "AWS_ACCESS_KEY_ID", dep.AccessKeyID) - setDiscoveryEnv(env, "AWS_SESSION_TOKEN", dep.SessionToken) setDiscoveryEnv(env, "AWS_REGION", dep.Region) case "anthropic-vertex", "gemini-vertex": setDiscoveryEnv(env, "VERTEX_PROJECT_ID", dep.ProjectID) setDiscoveryEnv(env, "VERTEX_REGION", dep.Region) } + mergeDeploymentBaseURL(env, id, dep.BaseURL) + } +} + +func mergeDeploymentBaseURL(env map[string]string, deploymentID, baseURL string) { + keys := map[string]string{ + "anthropic-direct": "ANTHROPIC_BASE_URL", + "openai-direct": "OPENAI_BASE_URL", + "gemini-direct": "GEMINI_BASE_URL", + "deepseek-direct": "DEEPSEEK_BASE_URL", + "grok-direct": "XAI_BASE_URL", + "kimi-direct": "MOONSHOT_BASE_URL", + "zai_coding-direct": "ZAI_CODING_BASE_URL", + "zai_payg-direct": "ZAI_BASE_URL", + "xiaomi_mimo_token_plan-direct": EnvXiaomiTokenPlanBaseURL, + "xiaomi_mimo_payg-direct": EnvXiaomiPaygBaseURL, + "minimax_token_plan-direct": "MINIMAX_TOKEN_PLAN_BASE_URL", + "minimax_payg-direct": "MINIMAX_PAYG_BASE_URL", + "openrouter": "OPENROUTER_BASE_URL", + "canopywave": "CANOPYWAVE_BASE_URL", + "poolside": "POOLSIDE_BASE_URL", + "groq-direct": "GROQ_BASE_URL", + "clinepass": "CLINE_API_BASE", + "opencodego": "OPENCODEGO_BASE_URL", + "ollama-local": "OLLAMA_BASE_URL", } + if key := keys[deploymentID]; key != "" { + setDiscoveryEnv(env, key, baseURL) + } +} + +func cloneDiscoveryEnv(in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for key, value := range in { + out[key] = value + } + return out } func setDiscoveryEnv(env map[string]string, key, value string) { diff --git a/config/discovery_env_stale_base_test.go b/config/discovery_env_stale_base_test.go index 9ae9c17..f7e94eb 100644 --- a/config/discovery_env_stale_base_test.go +++ b/config/discovery_env_stale_base_test.go @@ -9,7 +9,7 @@ func TestDiscoveryCredentials_StaleBaseURLUsesRegion(t *testing.T) { dir := t.TempDir() t.Setenv("HAWK_CONFIG_DIR", dir) cfg := &ProviderConfig{ - Version: "2", + Version: "1", XiaomiMimoTokenPlanRegion: "sgp", XiaomiMimoTokenPlanBaseURL: "https://token-plan-cn.xiaomimimo.com/v1", } diff --git a/config/discovery_state_isolation_test.go b/config/discovery_state_isolation_test.go new file mode 100644 index 0000000..9f058e6 --- /dev/null +++ b/config/discovery_state_isolation_test.go @@ -0,0 +1,122 @@ +package config + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestDiscoveryCredentialsFromState_IsolatedFromProcessGlobals(t *testing.T) { + ambientDir := t.TempDir() + t.Setenv("EYRIE_CONFIG_DIR", ambientDir) + t.Setenv("HAWK_CONFIG_DIR", t.TempDir()) + t.Setenv("OPENAI_API_KEY", "sk-process-openai-1234567890") + t.Setenv("GROQ_BASE_URL", "https://process-env.invalid/v1") + t.Setenv(EnvXiaomiTokenPlanRegion, "cn") + + // Make both process-global persistence mechanisms contain values that must + // not leak into a host-injected discovery snapshot. + ambientState := &ProviderConfig{ + Version: "1", + OpenAIAPIKey: "sk-path-openai-1234567890", + GroqBaseURL: "https://process-path.invalid/v1", + CanopyWaveBaseURL: "https://process-canopy.invalid/v1", + } + data, err := json.Marshal(ambientState) + if err != nil { + t.Fatal(err) + } + // Deliberately seed a historical plaintext fixture; production writes reject it. + if err := os.WriteFile(filepath.Join(ambientDir, "provider.json"), data, 0o600); err != nil { + t.Fatal(err) + } + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + if err := store.Set(context.Background(), credentials.AccountForEnv("CANOPYWAVE_API_KEY"), "sk-store-canopy-1234567890"); err != nil { + t.Fatal(err) + } + + injected := map[string]string{ + "OPENROUTER_API_KEY": "sk-injected-openrouter-1234567890", + "PLACEHOLDER_API_KEY": "your-api-key", + } + cfg := &ProviderConfig{ + OpenAIAPIKey: "sk-state-openai-must-not-leak", + CanopyWaveAPIKey: "sk-state-canopy-must-not-leak", + CanopyWaveBaseURL: "https://state-canopy.example/v1", + XiaomiMimoTokenPlanRegion: "sgp", + XiaomiMimoTokenPlanAPIKey: "sk-state-xiaomi-must-not-leak", + Deployments: map[string]DeploymentConfig{ + "openai-azure": { + APIKey: "sk-state-azure-must-not-leak", + Endpoint: "https://state-azure.example", + APIVersion: "2026-01-01", + ModelMappings: map[string]string{"gpt": "deployment-gpt"}, + }, + "anthropic-bedrock": { + AccessKeyID: "state-access-key-must-not-leak", + SecretAccessKey: "state-secret-key-must-not-leak", + SessionToken: "state-session-token-must-not-leak", + Region: "ap-south-1", + }, + "anthropic-vertex": { + Token: "state-vertex-token-must-not-leak", + ProjectID: "state-project", + Region: "asia-south1", + }, + "openai-direct": { + APIKey: "sk-state-deployment-must-not-leak", + BaseURL: "https://state-openai.example/v1", + }, + }, + } + + got := DiscoveryCredentialsFromState(injected, cfg).APIKeys + + want := map[string]string{ + "OPENROUTER_API_KEY": "sk-injected-openrouter-1234567890", + "CANOPYWAVE_BASE_URL": "https://state-canopy.example/v1", + EnvXiaomiTokenPlanRegion: "sgp", + EnvXiaomiTokenPlanBaseURL: "https://token-plan-sgp.xiaomimimo.com/v1", + "AZURE_OPENAI_ENDPOINT": "https://state-azure.example", + "AZURE_OPENAI_API_VERSION": "2026-01-01", + "AZURE_OPENAI_DEPLOYMENT": "deployment-gpt", + "AWS_REGION": "ap-south-1", + "VERTEX_PROJECT_ID": "state-project", + "VERTEX_REGION": "asia-south1", + "OPENAI_BASE_URL": "https://state-openai.example/v1", + } + for key, value := range want { + if got[key] != value { + t.Errorf("%s = %q, want %q", key, got[key], value) + } + } + + for _, key := range []string{ + "OPENAI_API_KEY", + "CANOPYWAVE_API_KEY", + EnvXiaomiTokenPlanAPIKey, + "AZURE_OPENAI_API_KEY", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "VERTEX_ACCESS_TOKEN", + "PLACEHOLDER_API_KEY", + "GROQ_BASE_URL", + } { + if value := got[key]; value != "" { + t.Errorf("process-global or persisted secret %s leaked as %q", key, value) + } + } + + // The returned snapshot must not alias the host's injected credential map. + got["OPENROUTER_API_KEY"] = "changed" + if injected["OPENROUTER_API_KEY"] != "sk-injected-openrouter-1234567890" { + t.Fatal("DiscoveryCredentialsFromState mutated the injected credential map") + } +} diff --git a/config/provider_env.go b/config/provider_env.go index d746828..0df4804 100644 --- a/config/provider_env.go +++ b/config/provider_env.go @@ -1,8 +1,10 @@ package config import ( + "bytes" "encoding/json" "fmt" + "io" "net/url" "os" "path/filepath" @@ -388,7 +390,15 @@ func LoadProviderConfigWithError(path string) (*ProviderConfig, error) { return nil, fmt.Errorf("eyrie: failed to read provider config at %s: %w", path, err) } var cfg ProviderConfig - if err := json.Unmarshal(data, &cfg); err != nil { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&cfg); err != nil { + return nil, fmt.Errorf("eyrie: corrupt provider config at %s: %w", path, err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + err = fmt.Errorf("multiple JSON values") + } return nil, fmt.Errorf("eyrie: corrupt provider config at %s: %w", path, err) } if cfg.Version != "" && cfg.Version != "1" { @@ -397,17 +407,62 @@ func LoadProviderConfigWithError(path string) (*ProviderConfig, error) { return &cfg, nil } -// SaveProviderConfig saves provider config to disk. -func SaveProviderConfig(config *ProviderConfig, path string) error { +// SaveProviderConfig atomically persists non-secret provider state. Credential +// material belongs in the credential store; refusing it here makes plaintext +// provider.json writes impossible through the public config API. +func SaveProviderConfig(config *ProviderConfig, path string) (err error) { + if config == nil { + return fmt.Errorf("eyrie: provider config is nil") + } + if ProviderConfigContainsSecrets(*config) { + return fmt.Errorf("eyrie: refusing to persist credential fields in provider config") + } + if config.Version != "" && config.Version != "1" { + return fmt.Errorf("eyrie: refusing to persist unsupported provider config version %q", config.Version) + } if path == "" { path = GetProviderConfigPath() } - _ = os.MkdirAll(filepath.Dir(path), 0o700) + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } data, err := json.MarshalIndent(config, "", " ") if err != nil { return err } - return os.WriteFile(path, append(data, '\n'), 0o600) + tmp, err := os.CreateTemp(dir, ".provider-*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + }() + if err := tmp.Chmod(0o600); err != nil { + return err + } + if _, err := tmp.Write(append(data, '\n')); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return err + } + // The rename is the commit point. Directory fsync is best-effort because a + // post-commit error must not make callers roll back credentials after the + // plaintext source has already been replaced by sanitized state. + if dirHandle, openErr := os.Open(dir); openErr == nil { + _ = dirHandle.Sync() + _ = dirHandle.Close() + } + return nil } // IsProviderConfigured checks if a provider has valid configuration. diff --git a/config/provider_env_test.go b/config/provider_env_test.go index 30a41d4..c70e97e 100644 --- a/config/provider_env_test.go +++ b/config/provider_env_test.go @@ -94,6 +94,17 @@ func TestLoadProviderConfigWithError_InvalidJSON(t *testing.T) { } } +func TestLoadProviderConfigWithErrorRejectsTrailingJSON(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "provider.json") + if err := os.WriteFile(path, []byte(`{} {}`), 0o600); err != nil { + t.Fatal(err) + } + if cfg, err := LoadProviderConfigWithError(path); err == nil || cfg != nil { + t.Fatalf("trailing provider JSON accepted: cfg=%#v err=%v", cfg, err) + } +} + func TestLoadProviderConfigWithError_UnsupportedVersion(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -290,7 +301,6 @@ func TestSaveProviderConfig(t *testing.T) { cfg := &ProviderConfig{ Version: "1", ActiveProvider: "openai", - OpenAIAPIKey: "sk-openai-1234567890", OpenAIModel: "gpt-4o", } @@ -313,10 +323,6 @@ func TestSaveProviderConfig(t *testing.T) { if loaded.ActiveProvider != "openai" { t.Errorf("expected active_provider 'openai', got %q", loaded.ActiveProvider) } - if loaded.OpenAIAPIKey != "sk-openai-1234567890" { - t.Errorf("expected OpenAI key preserved") - } - // Verify file ends with newline if data[len(data)-1] != '\n' { t.Error("expected trailing newline") @@ -330,6 +336,36 @@ func TestSaveProviderConfig(t *testing.T) { } } +func TestSaveProviderConfigRefusesPlaintextCredentials(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "provider.json") + if err := SaveProviderConfig(&ProviderConfig{OpenAIAPIKey: "sk-openai-1234567890"}, path); err == nil { + t.Fatal("SaveProviderConfig accepted a plaintext credential") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("secret-bearing provider config reached disk: %v", err) + } +} + +func TestSaveProviderConfigDoesNotReplaceExistingStateOnRefusal(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "provider.json") + if err := SaveProviderConfig(&ProviderConfig{ActiveProvider: "anthropic"}, path); err != nil { + t.Fatal(err) + } + original, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := SaveProviderConfig(&ProviderConfig{OpenAIAPIKey: "sk-openai-1234567890"}, path); err == nil { + t.Fatal("secret-bearing replacement was accepted") + } + after, err := os.ReadFile(path) + if err != nil || string(after) != string(original) { + t.Fatalf("refused write changed existing provider state: err=%v", err) + } +} + func TestSaveProviderConfig_CreatesDirectory(t *testing.T) { t.Parallel() dir := t.TempDir() diff --git a/config/provider_secrets.go b/config/provider_secrets.go index 3d36d3e..157d43a 100644 --- a/config/provider_secrets.go +++ b/config/provider_secrets.go @@ -1,6 +1,10 @@ package config -import "strings" +import ( + "fmt" + "sort" + "strings" +) // ProviderConfigContainsSecrets reports whether legacy provider state contains // credential material. It never returns or formats credential values. @@ -50,6 +54,101 @@ func SanitizeProviderConfigForDisk(cfg ProviderConfig) ProviderConfig { return cfg } +// LegacyProviderSecrets maps historical provider.json credential fields to +// their canonical secret-store environment names. Placeholder values are +// omitted. Deployment credentials take precedence over older top-level fields. +func LegacyProviderSecrets(cfg ProviderConfig) map[string]string { + out, _ := LegacyProviderSecretsStrict(cfg) + return out +} + +// LegacyProviderSecretsStrict returns every effective legacy credential or an +// error naming fields that cannot be represented by the canonical secret +// store. Callers must not sanitize provider state when this returns an error. +func LegacyProviderSecretsStrict(cfg ProviderConfig) (map[string]string, error) { + out := map[string]string{} + put := func(envKey, secret string) { + secret = strings.TrimSpace(secret) + if envKey != "" && secret != "" && !LooksLikePlaceholderSecret(secret) { + out[envKey] = secret + } + } + put("ANTHROPIC_API_KEY", cfg.AnthropicAPIKey) + put("XAI_API_KEY", firstNonEmpty(cfg.XAIAPIKey, cfg.GrokAPIKey)) + put("OPENAI_API_KEY", cfg.OpenAIAPIKey) + put("CANOPYWAVE_API_KEY", cfg.CanopyWaveAPIKey) + put("DEEPSEEK_API_KEY", cfg.DeepSeekAPIKey) + put("ZAI_API_KEY", cfg.ZAIAPIKey) + put("ZAI_CODING_API_KEY", cfg.ZAICodingAPIKey) + put("OPENROUTER_API_KEY", cfg.OpenRouterAPIKey) + put("GEMINI_API_KEY", cfg.GeminiAPIKey) + put("OPENCODEGO_API_KEY", cfg.OpenCodeGoAPIKey) + put("MOONSHOT_API_KEY", cfg.MoonshotAPIKey) + put("XIAOMI_MIMO_PAYG_API_KEY", cfg.XiaomiMimoPaygAPIKey) + put("XIAOMI_MIMO_TOKEN_PLAN_API_KEY", cfg.XiaomiMimoTokenPlanAPIKey) + put("MINIMAX_TOKEN_PLAN_API_KEY", cfg.MiniMaxTokenPlanAPIKey) + put("MINIMAX_PAYG_API_KEY", cfg.MiniMaxPaygAPIKey) + put("POOLSIDE_API_KEY", cfg.PoolsideAPIKey) + put("GROQ_API_KEY", cfg.GroqAPIKey) + put("CLINE_API_KEY", cfg.ClinePassAPIKey) + + deploymentIDs := make([]string, 0, len(cfg.Deployments)) + for id := range cfg.Deployments { + deploymentIDs = append(deploymentIDs, id) + } + sort.Strings(deploymentIDs) + for _, id := range deploymentIDs { + deployment := cfg.Deployments[id] + switch id { + case "anthropic-bedrock": + put("AWS_ACCESS_KEY_ID", firstNonEmpty(deployment.AccessKeyID, deployment.APIKey)) + put("AWS_SECRET_ACCESS_KEY", firstNonEmpty(deployment.SecretAccessKey, deployment.Token)) + put("AWS_SESSION_TOKEN", deployment.SessionToken) + case "anthropic-vertex", "gemini-vertex": + if strings.TrimSpace(deployment.AccessKeyID) != "" || strings.TrimSpace(deployment.SecretAccessKey) != "" || strings.TrimSpace(deployment.SessionToken) != "" { + return nil, fmt.Errorf("provider deployment %q contains unsupported credential fields", id) + } + put("VERTEX_ACCESS_TOKEN", firstNonEmpty(deployment.Token, deployment.APIKey)) + default: + envKey := legacyDeploymentCredentialEnv(id) + if envKey == "" && deploymentContainsSecrets(deployment) { + return nil, fmt.Errorf("provider deployment %q has no safe credential mapping", id) + } + if strings.TrimSpace(deployment.Token) != "" || strings.TrimSpace(deployment.SecretAccessKey) != "" || + strings.TrimSpace(deployment.AccessKeyID) != "" || strings.TrimSpace(deployment.SessionToken) != "" { + return nil, fmt.Errorf("provider deployment %q contains unsupported credential fields", id) + } + put(envKey, deployment.APIKey) + } + } + return out, nil +} + +func legacyDeploymentCredentialEnv(deploymentID string) string { + return map[string]string{ + "anthropic-direct": "ANTHROPIC_API_KEY", + "openai-direct": "OPENAI_API_KEY", + "openai-azure": "AZURE_OPENAI_API_KEY", + "grok-direct": "XAI_API_KEY", + "gemini-direct": "GEMINI_API_KEY", + "openrouter": "OPENROUTER_API_KEY", + "canopywave": "CANOPYWAVE_API_KEY", + "deepseek-direct": "DEEPSEEK_API_KEY", + "poolside": "POOLSIDE_API_KEY", + "groq-direct": "GROQ_API_KEY", + "clinepass": "CLINE_API_KEY", + "zai_payg-direct": "ZAI_API_KEY", + "zai_coding-direct": "ZAI_CODING_API_KEY", + "opencodego": "OPENCODEGO_API_KEY", + "kimi-direct": "MOONSHOT_API_KEY", + "xiaomi_mimo_payg-direct": "XIAOMI_MIMO_PAYG_API_KEY", + "xiaomi_mimo_token_plan-direct": "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", + "minimax_token_plan-direct": "MINIMAX_TOKEN_PLAN_API_KEY", + "minimax_payg-direct": "MINIMAX_PAYG_API_KEY", + "ollama-local": "OLLAMA_API_KEY", + }[deploymentID] +} + func providerConfigSecrets(cfg ProviderConfig) []string { return []string{ cfg.AnthropicAPIKey, cfg.GrokAPIKey, cfg.XAIAPIKey, cfg.OpenAIAPIKey, diff --git a/config/provider_secrets_test.go b/config/provider_secrets_test.go index 80aaf5f..c6e4ca9 100644 --- a/config/provider_secrets_test.go +++ b/config/provider_secrets_test.go @@ -25,3 +25,57 @@ func TestSanitizeProviderConfigForDiskRemovesLegacyAndDeploymentSecrets(t *testi t.Fatal("sanitization mutated its input") } } + +func TestLegacyProviderSecretsMapsTopLevelAndDeploymentFields(t *testing.T) { + cfg := ProviderConfig{ + OpenAIAPIKey: "sk-legacy-value-1234567890", + Deployments: map[string]DeploymentConfig{ + "openai-direct": {APIKey: "sk-current-value-1234567890"}, + "anthropic-bedrock": {AccessKeyID: "AKIAEXAMPLE12345678", SecretAccessKey: "aws-secret-value-long-enough", SessionToken: "aws-session-token-long-enough"}, + }, + } + secrets := LegacyProviderSecrets(cfg) + if secrets["OPENAI_API_KEY"] != "sk-current-value-1234567890" || + secrets["AWS_ACCESS_KEY_ID"] != "AKIAEXAMPLE12345678" || + secrets["AWS_SECRET_ACCESS_KEY"] != "aws-secret-value-long-enough" || + secrets["AWS_SESSION_TOKEN"] != "aws-session-token-long-enough" { + t.Fatalf("LegacyProviderSecrets() = %#v", secrets) + } +} + +func TestLegacyProviderSecretsStrictRejectsUnmappedDeploymentFields(t *testing.T) { + _, err := LegacyProviderSecretsStrict(ProviderConfig{Deployments: map[string]DeploymentConfig{ + "future-provider": {APIKey: "future-secret-1234567890"}, + }}) + if err == nil { + t.Fatal("unmapped future deployment credential was accepted") + } +} + +func TestLegacyProviderSecretsStrictMapsBedrockCompatibilityFields(t *testing.T) { + secrets, err := LegacyProviderSecretsStrict(ProviderConfig{Deployments: map[string]DeploymentConfig{ + "anthropic-bedrock": {APIKey: "AKIALEGACY123456789", Token: "legacy-secret-1234567890"}, + }}) + if err != nil { + t.Fatal(err) + } + if secrets["AWS_ACCESS_KEY_ID"] != "AKIALEGACY123456789" || secrets["AWS_SECRET_ACCESS_KEY"] != "legacy-secret-1234567890" { + t.Fatalf("Bedrock compatibility secrets = %#v", secrets) + } +} + +func TestLegacyProviderSecretsStrictRejectsAmbiguousFieldsOnDirectDeployment(t *testing.T) { + for name, deployment := range map[string]DeploymentConfig{ + "token": {Token: "ambiguous-token-1234567890"}, + "secret_access": {SecretAccessKey: "ambiguous-secret-1234567890"}, + } { + t.Run(name, func(t *testing.T) { + _, err := LegacyProviderSecretsStrict(ProviderConfig{Deployments: map[string]DeploymentConfig{ + "openai-direct": deployment, + }}) + if err == nil { + t.Fatal("ambiguous credential field was silently remapped") + } + }) + } +} diff --git a/config/xiaomi_profile_test.go b/config/xiaomi_profile_test.go index 776c5be..19c97f7 100644 --- a/config/xiaomi_profile_test.go +++ b/config/xiaomi_profile_test.go @@ -24,7 +24,7 @@ func TestSyncProviderConfigFromCatalog_PreservesTokenPlanRegion(t *testing.T) { dir := t.TempDir() t.Setenv("HAWK_CONFIG_DIR", dir) existing := &ProviderConfig{ - Version: "2", + Version: "1", XiaomiMimoTokenPlanRegion: "ams", } if err := SaveProviderConfig(existing, ""); err != nil { diff --git a/docs/architecture/HOST-ENGINE-BOUNDARY.md b/docs/architecture/HOST-ENGINE-BOUNDARY.md index a8ed58f..e6e122a 100644 --- a/docs/architecture/HOST-ENGINE-BOUNDARY.md +++ b/docs/architecture/HOST-ENGINE-BOUNDARY.md @@ -1,97 +1,194 @@ # Host–Eyrie Engine Boundary -Status: accepted and being migrated incrementally. +Status: accepted. The host-facing compatibility contract is +`engine.ContractVersion == "2"`. ## Decision -Eyrie is a host-neutral LLM runtime. A host such as Hawk owns its product UI, -agent loop, tool execution, permissions, conversation history, checkpoints, and -task semantics. Eyrie owns credentials, model discovery, capability matching, -provider/deployment routing, request and stream normalization, resilience, -usage, and provider telemetry. +Hawk is the product face. It owns the terminal UI, agent loop, tool execution, +permissions, conversation history, checkpoints, and product semantics. Eyrie is +the engine. It owns credentials, provider/deployment metadata, model discovery, +catalog compilation, selection, transport construction, provider request and +stream normalization, resilience, normalized usage, and provider telemetry. ```text -Host product ──► eyrie/engine ──► provider runtime ──► model APIs +User + | + v +Hawk (face: UX, session, tools, permissions) + | + | stable DTOs and methods + v +github.com/GrayCodeAI/eyrie/engine [contract v2] + | + +--> injected secret store + +--> injected state paths + +--> invocation-scoped custom gateways + +--> catalog, routing, and provider adapters + | + v +Provider model APIs ``` -The dependency is one-way. Eyrie must not import a host product. +The dependency is one-way: Eyrie must not import Hawk. Hawk's integration layer +may import `eyrie/engine`; Hawk command, conversation, and UI packages must not +assemble Eyrie's `catalog`, `client`, `config`, `credentials`, `router`, +`runtime`, or `setup` packages. -## Stable host API +## Composition root -New host integrations should import `github.com/GrayCodeAI/eyrie/engine`. -Lower-level packages remain public during the compatibility migration, but a -host should not need to assemble `client`, `catalog`, `config`, `credentials`, -`router`, `runtime`, or `setup` directly. +Hawk constructs one `engine.Engine` from host-owned dependencies: -The facade owns these stable concepts: +```go +e, err := engine.New(engine.Options{ + SecretStore: store, + StateDir: stateDir, + CatalogPath: catalogPath, // optional StateDir override + ProviderConfigPath: providerConfigPath, // optional StateDir override + RemoteCatalogURL: trustedCatalogURL, // optional; compiled HTTPS default + CustomGateways: gateways, // snapshotted for this Engine +}) +``` -- `Engine` -- `GenerateRequest` and `GenerateResponse` -- `Message`, `Tool`, `ToolCall`, and `ToolResult` -- `Requirements` and `Preference` -- `Route` -- `Stream` and typed `Event` -- `CatalogSnapshot` and `Model` -- `CredentialStatus` -- typed `Error` and `ErrorCode` +`StateDir` derives `model_catalog.json` and `provider.json` when explicit paths +are absent. Explicit paths win. The store, paths, remote catalog URL, and custom +gateways belong to the Engine instance; production behavior does not depend on +ambient Hawk paths or a process-global custom-gateway registry. The global +registry remains an opt-in compatibility path through +`UseRegisteredCustomGateways`. -## Selection contract +## Stable contract -Hosts express requirements rather than provider-specific assumptions: +The facade exposes provider-neutral request, response, stream, catalog, +credential, gateway, selection, health, and preflight DTOs. The principal +runtime operations are: ```text -requirements: streaming, tools, vision, structured JSON, reasoning, context -preference: fast, balanced, reasoning, economical, provider/model override +New / StatePaths +Resolve / Generate / Stream +Catalog / RefreshCatalog / ListModels / ListLiveModels / ListPublicModels +ResolveCredential / SaveCredential / RemoveCredential / CredentialStatus +GatewayDefinitions / Gateways / SetGatewayRegion +ActiveSelection / EffectiveSelection / SetSelection / ClearSelection +CatalogHealth / ProviderStateSecurityStatus / PreflightWithOptions +MigrateProviderSecretsContext ``` -An explicitly requested model is a hard constraint. Eyrie returns a capability -error when it is incompatible unless the host explicitly enables fallback. -Persisted/default selections may be replaced by a compatible catalog route. +Provider-specific wire types, authentication headers, retry behavior, and raw +stream events do not cross this boundary. `Model` keeps distinct `Owner`, +`ProviderID`, `GatewayID`, `CanonicalID`, `Source`, and `LiveMetadata` fields so +Hawk does not reconstruct catalog meaning. + +## Credential-to-conversation flow + +```text +paste API key / configure gateway + | + v +Engine.ResolveCredential + | + v +Engine.SaveCredential --> injected credentials.Store + | (secret material only) + v +Engine.ApplyCredentials / ListLiveModels + | + +--> load provider routing from injected provider.json + +--> resolve only the selected provider's credential aliases + +--> pass a provider-scoped environment to its live fetcher + +--> merge/compile catalog and atomically update model_catalog.json + v +Engine.ListModels --> Hawk picker --> Engine.SetSelection + | + v +Hawk builds provider-neutral GenerateRequest from its conversation + | + +--> Engine.Generate + `--> Engine.Stream --> normalized route/content/thinking/tool/usage events +``` + +Secrets never belong in catalog rows, requests, logs, diagnostics, telemetry, +or host tool environments. Credential status and gateway DTOs expose only safe +identifiers, booleans, and masked presentation values. Live discovery receives +only the active provider's credential and routing variables, not the complete +secret-store contents. + +## State and migration contract -## Conversation contract +`provider.json` contains routing metadata, never credential material. Engine +mutations serialize per provider-state path, reject corrupt, unknown-version, +or unknown-field state, sanitize before persistence, and replace files +atomically with restrictive permissions. They fail closed instead of silently +overwriting malformed state. -Eyrie generation is stateless from the host's point of view: +Legacy provider files may contain credential-shaped fields. The explicit +`MigrateProviderSecretsContext` flow maps every recognized secret to the +injected store before writing a sanitized provider file. An unmapped secret or +store failure aborts the migration and restores the original state; no +plaintext backup is created. Hawk should run security status/migration before +normal provider-state writes. + +## Selection and generation + +Hosts express capabilities (`streaming`, `tools`, `vision`, structured JSON, +reasoning, and context size) plus an intent or explicit provider/model. An +explicit model is a hard constraint unless the host enables fallback. Eyrie +owns canonicalization, gateway ownership, deployment routing, and capability +matching. + +Eyrie generation is stateless from Hawk's point of view: ```text -Host owns Eyrie owns +Hawk owns Eyrie owns ---------- ----------- conversation history route decision -tool execution provider request -permissions retries and fallback -checkpoints and replay stream normalization -product memory usage and telemetry +tool permission and execution provider transport +checkpoints and replay retry/fallback policy +product memory stream normalization +session lifecycle normalized usage/telemetry ``` -Eyrie's generic `conversation` package remains available to other consumers, -but hosts with a product session model should not create two authoritative -conversation stores. +`Stream` is pull-based, cancellable, and must be closed. It emits the selected +route before provider events and normalizes content, thinking, tool calls, +usage, retry/continuation, TTFT, and completion. Unknown future event types are +additive and must be ignored safely. Eyrie emits tool requests; Hawk authorizes +and executes tools, appends results to its history, and begins the next model +turn. + +## Readiness -## Streaming contract +Preflight has two explicit modes: -`Stream` is pull-based, cancellable, and must be closed. It emits a route event -before provider events and normalizes content, thinking, tool calls, usage, -TTFT, and completion. Unknown future event types are additive and must be -safely ignored by hosts. +- Local preflight validates injected paths, catalog health, provider-state + integrity, exact selected provider/model, credential presence, deployment + configuration, and local transport construction without requiring network. +- Live preflight additionally probes the selected custom gateway or performs a + provider-scoped live model listing and verifies that the selected model is + actually available. -Eyrie never executes a host tool. It emits a normalized tool request; the host -performs permission checks, executes it, appends the result to its history, and -starts the next generation. +These modes must not be conflated: local readiness is suitable for startup and +diagnostics; live readiness is an explicit network operation. -## Credential contract +## Release and submodule order -The facade accepts an injected `credentials.Store`. Secrets never belong in -provider configuration, model requests, logs, telemetry, or host tool -environments. Credential status values contain identifiers and booleans only. +The boundary is delivered Eyrie-first: + +```text +1. Change and verify standalone Eyrie +2. Commit Eyrie and publish a resolvable release/commit +3. Update Hawk's Eyrie module version when required +4. Advance Hawk's Eyrie submodule pin to that exact commit +5. Verify Hawk integration, boundary checks, and clean-clone/module builds +6. Commit Hawk's code and gitlink update together +``` -## Compatibility migration +Hawk must never point at an uncommitted Eyrie worktree. A submodule pin proves +source identity; a resolvable module version is also required for workflows +that build Hawk with `GOWORK=off`. -1. Add the facade without removing existing APIs. -2. Move host credential, catalog, selection, and transport calls to the facade. -3. Enforce new import boundaries while listing temporary compatibility - exceptions. -4. Remove duplicated host provider routing and mirrored transport types. -5. Remove deprecated lower-level host entry points only at a semantic-version - boundary. +## Compatibility policy -Standalone Eyrie development is committed and tested first. Hosts then update -their pinned Eyrie submodule revision and run their integration suites. +Lower-level Eyrie packages remain public for non-Hawk consumers and staged +migration, but they are not part of Hawk's product boundary. Additive fields +and stream events are allowed within contract v2. Removing or changing stable +DTO semantics requires a contract-version and semantic-version boundary. diff --git a/docs/guides/DYNAMIC-MODEL-DISCOVERY.md b/docs/guides/DYNAMIC-MODEL-DISCOVERY.md index ec68496..ff3340d 100644 --- a/docs/guides/DYNAMIC-MODEL-DISCOVERY.md +++ b/docs/guides/DYNAMIC-MODEL-DISCOVERY.md @@ -1,481 +1,276 @@ -# Dynamic Model Discovery — Architecture Plan - -**Status:** Implemented (2026-05-20) -**Scope:** Eyrie (brain) + Hawk (UI face) -**Goal:** One dynamic, provider-agnostic pipeline for credentials → model discovery → picker → chat — no hawk hardcoding, no per-provider forks in UI code. - ---- - -## 1. Principles - -| Principle | Meaning | -|-----------|---------| -| **Eyrie owns truth** | Providers, deployments, env vars, probes, model sources, catalog merge, routing | -| **Hawk owns UX** | Paste key, hub, pickers, errors display — calls `eyrieclient` only | -| **Catalog-driven** | New provider = data + one fetcher registration, not hawk changes | -| **Three-layer models** | Remote catalog → live API enrichment → compiled cache | -| **Secrets never on disk in routing** | Keychain/env store only; `provider.json` is routing metadata | -| **Fail loud, recover gracefully** | Actionable errors; UI returns to correct step (URL screen, provider list, hub) | -| **Live when configured** | If provider has a list API and credentials, prefer live over stale remote rows | - ---- - -## 2. Current state (honest audit) - -### What works today - -``` -User → Hawk /config - → ResolveCredential / SaveCredential / ApplyCredentials - → catalog.DiscoverCatalog (remote JSON + optional live fetch) - → ~/.eyrie/model_catalog.json (compiled) - → ~/.hawk/provider.json (deployments + routing) - → SetupUI / ListModels → model picker -``` - -### What's fragmented - -| Area | Problem | -|------|---------| -| **Live fetch** | All 15 setup gateways have fetchers; some older gateways still have thin test coverage (z-ai, opencodego, kimi). MiMo split: `xiaomi_mimo_payg`, `xiaomi_mimo_token_plan` (`catalog/live/xiaomi_test.go`, `catalog/xiaomi/`). Anthropic, Gemini, Ollama RawJSON gaps remain | -| **Ollama** | No longer bypasses `ListModels`; RetryConfig moved to ProviderSpec. Remaining: hardcoded `== "ollama"` in validation | -| **Registry drift** | ✅ Fixed — `CredentialProviderRegistry` and `liveDiscoverableDeployments` removed; `DefaultDeploymentEnvFallbacks` consolidated (Item 1) | -| **Layering** | Hawk still has ~112 files with direct eyrie imports (Phase A facade done, B-D remain) | -| **Legacy API** | `FetchModelCatalog` / `providers.go` slices coexist with catalog v1 | -| **Merge policy** | ✅ Live replace — prefer-live providers fully replace models; offerings merge pricing/metadata | -| **Display names** | `BuildSetupUI` has partial hardcoded provider labels | -| **Docs** | ✅ `CREDENTIAL-SETUP-FLOW.md` lists all 15 gateways (incl. MiMo payg + token plan, DeepSeek, MiniMax token plan + payg) with live-only picker | - ---- - -## 3. Target architecture - -### 3.1 High-level diagram - -```mermaid -flowchart TB - subgraph Hawk["Hawk (UI only)"] - Hub["/config hub"] - Picker["Model picker"] - EC["internal/eyrieclient"] - end - - subgraph EyrieRuntime["eyrie/runtime (host API)"] - Apply["Apply(ctx, creds)"] - List["ListModels(ctx, opts)"] - Resolve["ResolveCredential"] - Save["SaveCredential"] - end - - subgraph EyrieCatalog["eyrie/catalog"] - Discover["discover.Discover"] - Registry["registry.ProviderSpecs"] - Live["live.Fetchers"] - Remote["remote.FetchCatalogV1"] - Cache["~/.eyrie/model_catalog.json"] - Compile["CompileCatalogV1"] - end - - subgraph EyrieConfig["eyrie/config"] - Probe["probe.Run"] - Sync["SyncProviderConfigFromCatalog"] - Creds["credentials store"] - end - - Hub --> EC - Picker --> EC - EC --> Apply - EC --> List - EC --> Resolve - EC --> Save - Apply --> Discover - List --> Cache - Discover --> Remote - Discover --> Live - Discover --> Compile - Compile --> Cache - Apply --> Sync - Save --> Probe - Save --> Creds -``` - -### 3.2 Single source of provider metadata - -Replace three scattered maps with **one declarative spec** consumed everywhere: - -```go -// eyrie/catalog/registry/provider_spec.go - -type ProviderSpec struct { - ProviderID string - DisplayName string - DeploymentID string - SortOrder int - - // Credentials - RequiresKey bool - CredentialEnv string // e.g. ANTHROPIC_API_KEY - BaseURLEnv []string // e.g. OLLAMA_BASE_URL (non-secret) - - // Validation - Probe ProbeSpec // kind, base URL, timeout - - // Model discovery - ModelSource ModelSourceSpec // remote | live | hybrid | local-only - LiveListFetcher string // registry key → fetcher func -} - -type ModelSourceSpec struct { - LiveOnly bool // all setup providers: models from live list API only -} -``` - -**Bootstrap:** `ProviderRegistry()` returns specs; code generators / init functions derive: -- `CredentialProviderRegistry` (paste-key subset) -- `DefaultDeploymentEnvFallbacks` -- `liveDiscoverableDeployments` -- `EnsureCredentialRegistryInCatalog` merges into catalog v1 - -No provider-specific `if provider == "ollama"` outside registry + fetcher files. - ---- - -## 4. Folder structure (proposed) - -``` -eyrie/ -├── catalog/ -│ ├── registry/ # NEW — single provider spec source -│ │ ├── provider_spec.go # ProviderSpec types -│ │ ├── providers.go # All registered providers (data) -│ │ ├── derive.go # Build env fallbacks, credential rows from specs -│ │ └── provider_spec_test.go -│ │ -│ ├── discover/ # NEW — orchestration (move from root catalog/) -│ │ ├── discover.go # DiscoverCatalog entry -│ │ ├── merge.go # Merge policy (configurable) -│ │ └── enrich.go # Live enrichment coordinator -│ │ -│ ├── live/ # NEW — all live model list fetchers -│ │ ├── registry.go # deployment/provider → FetchFunc -│ │ ├── openai_compat.go # OpenAI, OpenRouter, Grok, CanopyWave, OpenCode Go -│ │ ├── anthropic.go -│ │ ├── gemini.go -│ │ ├── ollama.go -│ │ └── live_test.go -│ │ -│ ├── remote/ # Remote catalog fetch + cache paths -│ │ ├── fetch.go -│ │ └── cache.go -│ │ -│ ├── v1/ # Schema, compile, validate (from v1.go split) -│ │ ├── schema.go -│ │ ├── compile.go -│ │ └── bootstrap.go -│ │ -│ └── legacy/ # Deprecated — tests/fixtures only -│ ├── model_catalog.go -│ └── providers.go -│ -├── config/ -│ ├── credential/ # NEW — group credential files -│ │ ├── resolve.go -│ │ ├── probe.go -│ │ ├── commit.go -│ │ ├── local.go -│ │ └── errors.go -│ └── ... -│ -├── runtime/ # ONLY package hawk imports -│ ├── runtime.go # Load, Apply, Discover -│ ├── models.go # ListModels (unified) -│ ├── credentials.go # Save, Resolve, ListProviders -│ └── selection.go # Active model/provider -│ -└── setup/ - ├── apply_credentials.go - └── setup_ui.go # Display names from ProviderSpec - -hawk/ -├── internal/ -│ ├── eyrieclient/ # Strict facade — ALL eyrie access -│ │ ├── host.go # Apply, Discover, Save, Resolve -│ │ ├── models.go # ListModels, ListModelsLive, SetupUI -│ │ ├── credentials.go -│ │ └── catalog.go -│ │ -│ └── config/ # Hawk-only settings (no eyrie/catalog imports) -│ ├── settings.go -│ └── startup.go -│ -└── cmd/ - └── chat_config_*.go # UI only → eyrieclient +# Dynamic Model Discovery + +Status: implemented through the `eyrie/engine` contract v2. + +This guide describes the host-facing path used by Hawk. Hawk is the face; Eyrie +is the engine and source of truth for provider metadata, credentials, live +model discovery, catalog compilation, selection, and provider transport. + +## Ownership + +| Hawk owns | Eyrie owns | +|---|---| +| credential and model-picker UX | safe credential resolution and persistence | +| conversation/session state | provider registry and deployment metadata | +| tools and permission prompts | provider-scoped live model fetchers | +| presentation of safe DTOs | catalog merge, compilation, and cache health | +| product settings and lifecycle | model ownership, aliases, selection, and routing | +| normalized request construction | provider adapters, streams, usage, and errors | + +Hawk calls its integration wrapper, which delegates to `eyrie/engine`. Hawk UI, +command, and conversation packages do not call Eyrie's lower-level runtime or +client packages. + +## End-to-end path + +```text +User enters API key or custom-gateway settings in Hawk + | + v +Hawk integration --> Engine.ResolveCredential + | + v +Engine.SaveCredential + | + +--> validate secret/provider + +--> write secret to the injected credentials.Store + `--> probe when the provider contract requires it + | + v +Engine.ApplyCredentials / Engine.ListLiveModels + | + +--> load routing metadata from the injected provider-config path + +--> resolve credential aliases from the injected store + +--> construct a provider-scoped discovery environment + +--> call only that provider's registered live fetcher + +--> merge offerings and live metadata + `--> atomically compile the injected catalog path + | + v +Engine.ListModels --> Hawk picker --> Engine.SetSelection + | + v +Hawk conversation --> Engine.Generate or Engine.Stream --> provider API + | + `--> normalized route, content, thinking, tool-call, usage, and done events ``` ---- - -## 5. Hawk ↔ Eyrie communication contract - -### 5.1 Rule: hawk imports only `eyrie/runtime` via `internal/eyrieclient` +The provider-scoped environment is important: OpenAI discovery cannot receive +Anthropic, Gemini, or another provider's secrets merely because those secrets +exist in the same store. -| Hawk need | Eyrie API | Returns | -|-----------|-----------|---------| -| First-run / refresh | `runtime.Apply(ctx, creds)` | `ApplyResult{Catalog, Provider, Setup}` | -| List models for picker | `runtime.ListModels(ctx, ListModelsOpts)` | `[]ModelEntry` | -| Paste key → providers | `runtime.ResolveCredential(ctx, secret)` | `CredentialResolveResult` | -| Save key / Ollama URL | `runtime.SaveCredential(ctx, inference, value)` | `error` | -| All providers for hub | `runtime.ListProviderSetupOptions(ctx)` | `[]ProviderSetupOption` | -| Active model | `runtime.ActiveModel(ctx)` | `string` | -| Deployment status | `runtime.DeploymentRows(ctx)` | `[]DeploymentRow` | +## Engine construction and isolation -### 5.2 New unified list API +Create the Engine once at Hawk's composition root and inject all host-owned +state: ```go -// eyrie/runtime/models.go - -type ListModelsOpts struct { - ProviderID string // required filter - Source ListModelSource // "auto" | "cache" | "live" - Refresh bool // force Discover before list -} - -type ListModelSource string - -const ( - ListSourceAuto ListModelSource = "auto" // spec-driven: live if configured else cache - ListSourceCache ListModelSource = "cache" - ListSourceLive ListModelSource = "live" // hit provider API; fail if unavailable -) - -type ModelEntry struct { - ID string `json:"id"` - DisplayName string `json:"display_name"` - ProviderID string `json:"provider_id"` - Source string `json:"source"` // "remote" | "live" | "merged" - Installed bool `json:"installed,omitempty"` // ollama: true; cloud: omitempty -} - -func ListModels(ctx context.Context, opts ListModelsOpts) ([]ModelEntry, error) +e, err := engine.New(engine.Options{ + SecretStore: store, + StateDir: stateDir, + CatalogPath: catalogPath, + ProviderConfigPath: providerConfigPath, + RemoteCatalogURL: trustedCatalogURL, + CustomGateways: customGateways, +}) ``` -**Hawk never calls `catalog.FetchOllamaModels` directly** — always `eyrieclient.ListModels(ctx, ListModelsOpts{ProviderID: "ollama", Source: ListSourceAuto})`. - -### 5.3 Setup flow messages (tea) - -Keep async pattern; hawk maps opaque errors via: - -```go -// eyrie/runtime/errors.go -func FormatSetupError(providerID string, err error) string +- `StateDir` derives default catalog and provider paths; explicit paths win. +- An empty `RemoteCatalogURL` selects Eyrie's compiled-in HTTPS seed and does + not consult a process-environment override. +- Custom gateways are normalized, validated, and snapshotted per Engine. +- `UseRegisteredCustomGateways` exists only for callers that deliberately opt + into the deprecated process-global compatibility registry. +- The Engine uses the injected secret store for setup, discovery, transport, + status, removal, compaction, and preflight. + +These rules allow multiple Engine instances with different paths, stores, and +custom gateways to coexist safely in one process. + +## Catalog sources + +Eyrie keeps three complementary sources: + +```text +published remote catalog + | deployment/protocol/bootstrap metadata + v +live provider listing ---------> merge/compile ---------> local catalog cache + provider model IDs, metadata | fast offline reads + v + stable Engine.Model DTOs ``` -Provider-specific friendly text lives in eyrie, not hawk cmd. - ---- +`RefreshCatalog` may refresh the trusted remote source and provider offerings. +`ListLiveModels` performs a direct provider-scoped live listing and does not +mutate the catalog cache. `ListModels` serves stable catalog rows and can ask +for a refresh. `ListPublicModels` is for registered public listing surfaces +that do not require the host to reproduce provider logic. -## 6. Model discovery strategy per provider +The stable model DTO intentionally distinguishes: -| Provider | Credential | Probe | Live API | Edge notes | -|----------|------------|-------|----------|------------| -| **Anthropic** | `ANTHROPIC_API_KEY` | `GET /v1/models` | `/v1/models` fetcher | Rate limits on list | -| **OpenAI** | `OPENAI_API_KEY` | `GET /v1/models` | `/v1/models` | Org-scoped model lists differ | -| **Gemini** | `GEMINI_API_KEY` | `GET /v1beta/models` | Gemini models API | Key in query param | -| **DeepSeek** | `DEEPSEEK_API_KEY` | `GET /models` | OpenAI-compat `/models` | `https://api.deepseek.com/v1` | -| **OpenRouter** | `OPENROUTER_API_KEY` | `GET /models` | Already live | Largest dynamic catalog | -| **Grok/xAI** | `XAI_API_KEY` | `GET /v1/models` | `/v1/models` | OpenAI-compatible | -| **Z.AI** | `ZAI_API_KEY` | `GET /models` | OpenAI-compat `/models` | Base URL env fallbacks | -| **CanopyWave** | `CANOPYWAVE_API_KEY` | `GET /models` | OpenAI-compat `/models` | Aggregator; not `z-ai` owner slug | -| **OpenCode Go** | `OPENCODEGO_API_KEY` | `GET /models` | OpenAI-compat `/models` | Custom base URL env | -| **Kimi (Moonshot)** | `MOONSHOT_API_KEY` | `GET /models` | OpenAI-compat `/models` | Provider id `kimi` | -| **Xiaomi (MiMo) Pay-as-you-go** | `XIAOMI_MIMO_PAYG_API_KEY` | `GET /v1/models` (`api-key`; Bearer on 401) | OpenAI: `api.xiaomimimo.com/v1` · Anthropic: `api.xiaomimimo.com/anthropic` | `xiaomi_mimo_payg`; chat via `MiMoClient` | -| **Xiaomi (MiMo) Token Plan** | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY` | `GET /v1/models` (region host) | OpenAI + Anthropic per region (`token-plan-{cn,sgp,ams}.xiaomimimo.com`) | `xiaomi_mimo_token_plan` + `xiaomi_mimo_token_plan_region` in provider.json | -| **MiniMax (minimax) Token Plan** | `MINIMAX_TOKEN_PLAN_API_KEY` | `GET /v1/models` | OpenAI-compat `/v1/models` | `https://api.minimax.io/v1` | -| **MiniMax (minimax) Pay-as-you-go** | `MINIMAX_PAYG_API_KEY` | `GET /v1/models` | OpenAI-compat `/v1/models` | `https://api.minimax.io/v1` | -| **Ollama** | `OLLAMA_BASE_URL` | `GET /api/tags` | `/api/tags` | Zero models = error; no remote fallback in picker | +- `ID`: provider-native/listed model identifier. +- `CanonicalID`: Eyrie's canonical alias target where known. +- `Owner`: catalog model owner. +- `ProviderID`: provider associated with the offering. +- `GatewayID`: selected/listed gateway or deployment identity. +- `Source` and `LiveMetadata`: origin and provider-native metadata. -### Model discovery (all setup providers) +Hawk should render these fields; it should not infer ownership from model-name +prefixes or parse raw provider responses. -Every registered setup provider lists models from its live API only. Without credentials (or Ollama URL), the picker shows zero models. After key save, discover replaces that deployment’s offerings from the live fetch. Remote catalog JSON still supplies deployment/protocol metadata, not picker model IDs. +## Provider registry ---- +The declarative provider registry is the shared source for safe setup metadata: -## 7. Discover pipeline (detailed) - -``` -DiscoverCatalog(ctx, opts) -│ -├─1─ Load base catalog -│ ├─ RefreshRemote? → GET remote catalog JSON -│ └─ else → ~/.eyrie/model_catalog.json or bootstrap -│ -├─2─ Ensure registry deployments in catalog (from ProviderSpec) -│ -├─3─ Resolve credentials -│ ├─ opts.Credentials (from Apply) -│ └─ fallback: credential store only (never process env in production path) -│ -├─4─ Live enrichment (for each configured deployment with live fetcher) -│ ├─ Skip if no credential env satisfied -│ ├─ Call live.Fetch(deploymentID, env) -│ ├─ Record LiveProviderEnrichment (count or error) -│ └─ Merge per strategy (see §8) -│ -├─5─ Write cache + CompileCatalogV1 -│ -└─6─ Fail if zero models total (unless bootstrap-only dev mode) +```text +ProviderSpec + +--> provider/deployment identity and labels + +--> credential primary name, fallbacks, and aliases + +--> non-secret base URL and regional routing variables + +--> live-discovery capability and fetcher lookup + +--> setup order and chat preference ``` ---- - -## 8. Merge policy - -**Implemented:** `discover.MergeCatalogV1WithPolicy` replaces deployment offerings from live fetch, then **fully replaces** model rows for prefer-live providers (all 15 setup gateways). Offerings merge pricing, capabilities, and `live_metadata` from the live catalog. - -Remote catalog JSON still supplies deployments, protocols, and bootstrap metadata — not picker model IDs for setup gateways. - ---- - -## 9. Edge cases — complete matrix - -### 9.1 Credentials - -| Case | Expected behavior | -|------|-------------------| -| Empty paste | Reject before provider list | -| Placeholder key (`your-api-key`) | Reject with clear message | -| Wrong prefix for chosen provider | `ValidateCredentialSecret` fails on save | -| Valid key, probe 401 | "Authentication failed — check key" | -| Valid key, probe timeout | Retry once; then friendly timeout message | -| Probe OK, discover fails (network) | Save key; show "catalog refresh failed" with retry | -| Key saved, user cancels model pick | Credentials kept; `NeedsSetup` until model selected | -| Switch provider with existing key | Hub → paste new key → discover → picker | -| Ollama URL invalid | Reject at URL validation | -| Ollama down | Return to URL screen with hint (`ollama serve`) | -| Ollama up, zero models | Error: `ollama pull …`; stay on URL screen | -| Ollama remote URL (LAN/VPN) | Same probe/fetch; validate URL scheme/host | -| Secure credentials off | Removed — keychain-only; legacy env files migrated once on startup | - -### 9.2 Model listing - -| Case | Expected behavior | -|------|-------------------| -| Cache stale | Background refresh (`catalog_startup`); picker uses cache until refresh completes | -| Cache empty for provider | Auto-discover once; then list | -| Live returns empty (cloud) | Fall back to remote catalog entries | -| Live returns empty (Ollama) | Error — no remote fallback in picker | -| Provider not in registry | Not shown in paste-key list; may exist in remote catalog for routing | -| Model ID alias vs canonical | `CanonicalModelForAliasOrID` at selection time | -| User picks model from wrong provider | Filter picker by `providerFilter` always after credential apply | -| Concurrent discover | Mutex on cache write; second call waits or returns in-flight result | - -### 9.3 UI / hawk +Provider-specific HTTP parsing remains inside registered fetchers/adapters. +Adding a built-in provider should normally require registry data, its adapter +or live fetcher, and tests—not new branches in Hawk UI code. -| Case | Expected behavior | -|------|-------------------| -| Async save in progress | `configSaving` locks hub/lists | -| Error on save | Return to correct step (URL / provider / hub) per provider type | -| Empty model list | Show notice in picker + esc → hub | -| `/config` with existing creds | Hub: Pick model \| Paste key \| Ollama | -| `/model` quick switch | Model picker; esc → hub | -| First run auto-open | Hub when `NeedsSetup` | +Custom OpenAI-compatible gateways are invocation-scoped Engine options rather +than registry mutations. Their URLs must be HTTP(S) with a host and must not +contain userinfo, query parameters, or fragments. Credentials remain in the +injected store under the declared credential key. -### 9.4 Security +## Discovery behavior -| Case | Expected behavior | -|------|-------------------| -| Secrets in provider.json | Never — sanitize on sync | -| Secrets in process env | Never applied from store (deprecated `ApplyToProcess`) | -| Logs | Never log secret values; probe errors truncate body (512 bytes) | -| Env file fallback | Removed — one-time migration from `~/.hawk/env` into keychain | -| Catalog URL override | `EYRIE_MODEL_CATALOG_URL` — HTTPS only in production builds | +### Cached listing -### 9.5 Performance - -| Case | Expected behavior | -|------|-------------------| -| Cold start | Load cache from disk (<50ms); background refresh if stale | -| After key paste | Single discover (90s timeout); probe parallel where multiple deployments | -| Model picker open | Serve from cache; optional `ListSourceLive` for Ollama refresh button | -| Large OpenRouter list | Virtual scroll (existing `configWindowSize`); cache in memory | -| Repeated /config | `modelCache` per provider in hawk (invalidate on Apply) | - ---- - -## 10. Central reusable modules - -### 10.1 `catalog/live/openai_compat.go` - -One fetcher for all OpenAI-compatible list endpoints: - -```go -func FetchOpenAICompatModels(ctx context.Context, cfg OpenAICompatFetchConfig) ([]ModelCatalogEntry, error) +```text +Engine.ListModels(provider, refresh=false) + --> require/load injected catalog cache + --> select offerings for provider/gateway + --> canonicalize and enrich capabilities + --> return stable []engine.Model ``` -Used by: OpenAI, OpenRouter, Grok, CanopyWave, OpenCode Go, Ollama (separate tags API). +### Direct live listing -### 10.2 `config/credential/probe.go` - -```go -func RunProbe(ctx context.Context, spec ProbeSpec, env map[string]string) error +```text +Engine.ListLiveModels(provider) + --> strict-load injected provider state + --> resolve only provider credential aliases + --> add only provider-owned routing/base URL variables + --> call registered live fetcher + --> return stable []engine.Model (cache unchanged) ``` -Maps `ProbeKind` → HTTP client; shared timeout, retry, error formatting. - -### 10.3 `runtime/models.go` - -Single entry for all hosts (hawk, CLI, SDK): +### Apply and refresh -```go -ListModels(ctx, opts) -DiscoverAndList(ctx, providerID) -SetupUI(ctx, providerFilter) +```text +Engine.ApplyCredentials(provider) + --> strict-load provider state + --> import/sanitize any recognized legacy secret fields + --> build scoped discovery credentials + --> refresh provider catalog data + --> preserve unrelated deployment metadata + --> atomically persist catalog and routing state ``` -### 10.4 `setup/setup_ui.go` +Unknown or unsupported providers fail with a typed Engine error. An empty or +failed live response is not silently represented as successful live readiness. -- Display names from `ProviderSpec.DisplayName` -- Sort from `ProviderSpec.SortOrder` -- No hardcoded switch for provider labels +## Selection and conversation handoff ---- +Hawk persists a choice through `Engine.SetSelection(provider, model)`. Eyrie +validates provider/model ownership, preserves custom model IDs, canonicalizes +built-in aliases, and stores routing metadata at the injected provider path. -## 11. Implementation phases +At generation time Hawk passes provider-neutral messages, tools, +requirements, preferences, limits, and metadata. `Resolve`, `Generate`, and +`Stream` use the same Engine-owned catalog, provider state, credentials, and +custom-gateway snapshot. Hawk neither constructs a provider client nor exports +credentials into the process environment. -### Phase 0 — Hygiene (1–2 days) -- [x] Update `CREDENTIAL-SETUP-FLOW.md` to match code (12 gateways, MiMo payg + token plan, Ollama URL flow) -- [x] Fix `displayNameForProvider` to read registry (removed dead z-ai case) -- [x] Document current API in `hawk/docs/DYNAMIC-MODELS.md` +## Local and live preflight -### Phase 1 — Unify hawk access ✅ -- [x] `eyrieclient` facade package created with catalog/credentials/client/storage wrappers -- [x] Migrated ~112 hawk files from direct eyrie imports to `eyrieclient`/`internal/types` -- [x] Add `runtime.FormatSetupError(provider, err)` +Use `Engine.PreflightWithOptions` explicitly: -### Phase 2 — Provider registry ✅ -- [x] Create `catalog/registry/` with `ProviderSpec` -- [x] Derive credential registry, env fallbacks, live registry from specs -- [x] Delete duplicated maps after migration tests pass +```text +Local (VerifyLive=false) + [x] injected state paths and state-file integrity + [x] real catalog cache health (no bootstrap false positive) + [x] persisted active provider and model + [x] exact provider credential/deployment readiness + [x] local provider transport can be constructed -### Phase 3 — Unified ListModels ✅ -- [x] Implement `runtime.ListModels(ctx, ListModelsOpts)` with `Source: auto` -- [x] Ollama `live_only` enforced inside runtime (remove hawk special case) -- [x] Hawk picker uses single `eyrieclient.ListModels` path +Live (VerifyLive=true) + [x] every local check + [x] selected built-in provider can list models live, or custom gateway probes + [x] persisted selected model is present in that live result +``` -### Phase 4 — Live fetchers for all cloud providers (~80% done) -- [x] All 12 setup gateways have live fetchers (Anthropic, OpenAI, Gemini, Grok, OpenRouter, CanopyWave, z-ai, opencodego, kimi, xiaomi_mimo_payg, xiaomi_mimo_token_plan, ollama) -- [x] OpenCode Go probe + live fetch -- [x] CanopyWave probe (was already `ProbeOpenAIModels`, plan doc was stale) -- [x] Register all in `catalog/live/registry.go` -- [x] RawJSON preserved in Anthropic, Gemini, Ollama; hardcoded context/max removed -- [x] Tests for z-ai, opencodego, kimi; MiMo payg/token plan (`catalog/live/xiaomi_test.go`, `catalog/xiaomi/endpoints_test.go`, `client/mimo.go`) -- [x] Minimize provider-specific branches in `hawk/cmd/chat_config_*.go` (Ollama URL screen is intentional UX) -- [x] Minimize hawk imports of `eyrie/catalog`, `eyrie/setup`, `eyrie/config` outside `eyrieclient` (~30 remain, need interface extraction for circular deps) -- [x] Full `/config` flow tested: hub → credential → discover → picker → chat (script exists at `scripts/test-config-flow.sh`) +Local preflight is safe for normal startup and offline diagnostics. Live +preflight is an explicit network check and should be labeled accordingly in +Hawk. + +## State and secret safety + +Secrets are stored only in `credentials.Store`. `provider.json` contains +routing metadata and `model_catalog.json` contains public catalog data. + +Engine provider-state mutations: + +- serialize by provider-state path, including across Engine instances; +- reject corrupt JSON, unknown fields, and unsupported versions; +- refuse unknown credential-shaped fields; +- import recognized legacy secrets into the injected store before sanitizing; +- preserve unrelated safe deployment metadata; +- write atomically with restrictive permissions; and +- never create a plaintext secret backup. + +`MigrateProviderSecretsContext` is the explicit legacy migration. If mapping or +store persistence fails, it aborts and restores the original provider state. +Hawk diagnostics can use `ProviderStateSecurityStatus`, `CatalogHealth`, and +the safe credential/gateway reports without reading either file directly. + +## Failure handling + +| Failure | Required behavior | +|---|---| +| empty/placeholder secret | reject before persistence | +| credential probe rejects authentication | return an actionable typed error | +| injected store unavailable | fail closed; never fall back to another host path | +| malformed provider state | block mutation and transport construction | +| catalog cache missing/corrupt | report unavailable; do not claim bootstrap readiness | +| live list fails or selected model is absent | fail live preflight | +| custom gateway URL contains embedded data | reject configuration | +| stream caller exits | close/cancel the Engine stream | + +Provider-specific friendly error formatting remains Eyrie-owned; Hawk decides +where and how to display it. + +## Release order for Hawk + +Eyrie is changed and released before Hawk advances its dependency: + +```text +standalone Eyrie change + --> Eyrie tests (two passes) + --> signed Eyrie commit + --> publish a resolvable Eyrie module release/commit + --> update Hawk module dependency when needed + --> pin Hawk's Eyrie submodule to the same commit + --> Hawk integration + boundary + clean-clone verification (two passes) + --> commit Hawk code and gitlink together +``` ---- +The submodule must point to a committed Eyrie object, never working-tree-only +code. The published module and submodule must expose the same Engine contract +so both workspace builds and `GOWORK=off` builds are reproducible. -## 16. Related docs +## Related documentation -- `eyrie/plans/CREDENTIAL-SETUP-FLOW.md` — paste-key wizard (update in Phase 0) -- `hawk/docs/DYNAMIC-MODELS.md` — hawk integration guide (update in Phase 1) -- `eyrie/README.md` — env vars and provider table +- `docs/architecture/HOST-ENGINE-BOUNDARY.md` — ownership and compatibility + policy. +- `CREDENTIAL-SETUP-FLOW.md` — product setup flow. +- Hawk's dynamic-model and architecture docs — host integration and UI behavior. diff --git a/engine/catalog.go b/engine/catalog.go index dda959a..de2276e 100644 --- a/engine/catalog.go +++ b/engine/catalog.go @@ -9,6 +9,7 @@ import ( "github.com/GrayCodeAI/eyrie/catalog" "github.com/GrayCodeAI/eyrie/catalog/discover" "github.com/GrayCodeAI/eyrie/catalog/registry" + "github.com/GrayCodeAI/eyrie/catalog/xiaomi" "github.com/GrayCodeAI/eyrie/config" ) @@ -30,23 +31,35 @@ func (e *Engine) Catalog(ctx context.Context) (CatalogSnapshot, error) { func (e *Engine) RefreshCatalog(ctx context.Context, providerID string) (CatalogSnapshot, error) { ctx = nonNilContext(ctx) compiled, _ := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{CachePath: e.catalogPath}) - creds := catalog.Credentials{APIKeys: e.credentialEnv(ctx, compiled)} + creds, credentialErr := e.discoveryCredentials(ctx, compiled) + if credentialErr != nil { + return CatalogSnapshot{}, &Error{Code: ErrorInternal, Operation: "refresh_catalog", Provider: providerID, Message: credentialErr.Error(), Cause: credentialErr} + } var ( result *catalog.RefreshResult err error ) if providerID = strings.TrimSpace(providerID); providerID != "" { - if _, ok := registry.SpecByProviderID(providerID); !ok { + spec, ok := registry.SpecByProviderID(providerID) + if !ok { return CatalogSnapshot{}, &Error{Code: ErrorInvalidRequest, Operation: "refresh_catalog", Provider: providerID, Message: "eyrie engine: unknown provider"} } - // The refresh shares one compiled-cache transaction so custom host paths - // remain isolated. Credential presence limits live discovery to configured - // providers; providerID is retained for host status/error attribution. - } - result, err = discover.Run(ctx, discover.Options{ - LoadCatalogOptions: catalog.LoadCatalogOptions{CachePath: e.catalogPath, RefreshRemote: true}, - Credentials: creds, - }) + if strings.TrimSpace(spec.LiveFetcherKey) != "" { + result, err = discover.RefreshProviderWithOptions(ctx, providerID, discover.ProviderRefreshOptions{ + Credentials: creds, CachePath: e.catalogPath, DisableCredentialFallback: true, + }) + } else { + result, err = discover.Run(ctx, discover.Options{ + LoadCatalogOptions: catalog.LoadCatalogOptions{CachePath: e.catalogPath, RefreshRemote: true, RemoteURL: e.remoteCatalogURL}, + Credentials: creds, DisableCredentialFallback: true, + }) + } + } else { + result, err = discover.Run(ctx, discover.Options{ + LoadCatalogOptions: catalog.LoadCatalogOptions{CachePath: e.catalogPath, RefreshRemote: true, RemoteURL: e.remoteCatalogURL}, + Credentials: creds, DisableCredentialFallback: true, + }) + } if err != nil { return CatalogSnapshot{}, &Error{Code: ErrorCatalogUnavailable, Operation: "refresh_catalog", Provider: providerID, Message: err.Error(), Cause: err} } @@ -71,19 +84,17 @@ func (e *Engine) ApplyCredentials(ctx context.Context, providerID string) (Catal if err != nil { return CatalogSnapshot{}, &Error{Code: ErrorCatalogUnavailable, Operation: "apply_credentials", Provider: providerID, Message: err.Error(), Cause: err} } - persisted := config.LoadProviderConfig(e.providerConfigPath) - if persisted == nil { - persisted = &config.ProviderConfig{} - } - deployments := buildDeployments(compiled, persisted.Deployments, e.credentialEnv(ctx, compiled)) - sanitized := make(map[string]config.DeploymentConfig, len(deployments)) - for id, deployment := range deployments { - sanitized[id] = config.SanitizeDeploymentConfigForDisk(deployment) + unlock := lockProviderStatePath(e.providerConfigPath) + defer unlock() + persisted, err := e.loadProviderConfigStrict() + if err != nil { + return CatalogSnapshot{}, &Error{Code: ErrorInternal, Operation: "apply_credentials", Provider: providerID, Message: err.Error(), Cause: err} } + deployments := buildDeployments(compiled, persisted.Deployments, e.discoveryCredentialsFromConfig(ctx, compiled, persisted).Env()) persisted.ConfigVersion = 2 - persisted.Deployments = sanitized - persisted.Routing = config.BuildRoutingPolicyFromDeployments(sanitized) - if err := config.SaveProviderConfig(persisted, e.providerConfigPath); err != nil { + persisted.Deployments = deployments + persisted.Routing = config.BuildRoutingPolicyFromDeployments(deployments) + if err := e.saveProviderConfig(ctx, persisted); err != nil { return CatalogSnapshot{}, &Error{Code: ErrorInternal, Operation: "apply_credentials", Provider: providerID, Message: err.Error(), Cause: err} } return snapshot, nil @@ -108,14 +119,15 @@ func snapshotFromCompiled(compiled *catalog.CompiledCatalog) CatalogSnapshot { outputPrice = offering.Pricing.RatesPer1M["output_tokens"] } snapshot.Models = append(snapshot.Models, Model{ - ID: id, DisplayName: catalog.DisplayModelLabel(id, model.Name), + ID: id, CanonicalID: id, DisplayName: catalog.DisplayModelLabel(id, model.Name), Description: model.Name, Owner: catalog.DisplayModelOwner(model.ProviderID, id), ProviderID: model.ProviderID, - GatewayID: catalog.GatewayForModel(compiled, id), + GatewayID: NormalizeProviderID(catalog.GatewayForModel(compiled, id)), ContextWindow: model.ContextWindow, MaxOutputTokens: model.MaxOutput, InputPricePer1M: inputPrice, OutputPricePer1M: outputPrice, PriceKnown: modelPriceKnown(id, model.Name, inputPrice, outputPrice, model.ContextWindow), Capabilities: capabilityNames(offering.Capabilities), Source: "catalog", + LiveMetadata: append([]byte(nil), offering.LiveMetadata...), }) } if compiled.Catalog != nil && !compiled.Catalog.StaleAfter.IsZero() { @@ -124,6 +136,67 @@ func snapshotFromCompiled(compiled *catalog.CompiledCatalog) CatalogSnapshot { return snapshot } +// ListPublicModels returns provider-published model metadata that does not +// require credentials. It never mutates the Engine catalog cache. +func (e *Engine) ListPublicModels(ctx context.Context, providerID string) ([]Model, error) { + return listPublicModels(nonNilContext(ctx), providerID, "") +} + +// ListLiveModels queries one provider directly without reading from or writing +// to the catalog cache for its result set. Credentials and routing metadata +// come exclusively from this Engine's injected state. +func (e *Engine) ListLiveModels(ctx context.Context, providerID string) ([]Model, error) { + ctx = nonNilContext(ctx) + providerID = NormalizeProviderID(providerID) + if providerID == "" { + return nil, invalid("list_live_models", "eyrie engine: provider id is required") + } + if _, custom := e.customGateway(providerID); custom { + return nil, invalid("list_live_models", "eyrie engine: custom gateway live model listing is not supported") + } + compiled, _ := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{CachePath: e.catalogPath}) + creds, err := e.discoveryCredentials(ctx, compiled) + if err != nil { + return nil, &Error{Code: ErrorInternal, Operation: "list_live_models", Provider: providerID, Message: err.Error(), Cause: err} + } + entries, err := catalog.FetchLiveModelEntriesForProvider(creds.Env(), providerID) + if err != nil { + return nil, &Error{Code: ErrorProviderUnavailable, Operation: "list_live_models", Provider: providerID, Message: err.Error(), Cause: err} + } + return modelsFromCatalogEntries(compiled, providerID, entries, "live", false, false), nil +} + +func listPublicModels(ctx context.Context, providerID, catalogURL string) ([]Model, error) { + gatewayID := NormalizeProviderID(providerID) + switch gatewayID { + case "xiaomi_mimo_payg", "xiaomi_mimo_token_plan": + default: + return nil, invalid("list_public_models", "eyrie engine: gateway has no public model metadata source") + } + index, err := xiaomi.FetchPlatformModelsIndex(ctx, catalogURL) + if err != nil { + return nil, &Error{Code: ErrorCatalogUnavailable, Operation: "list_public_models", Provider: gatewayID, Message: err.Error(), Cause: err} + } + ids := make([]string, 0, len(index)) + for id := range index { + ids = append(ids, id) + } + sort.Strings(ids) + out := make([]Model, 0, len(ids)) + for _, id := range ids { + model := index[id] + out = append(out, Model{ + ID: id, CanonicalID: id, DisplayName: model.Name, Description: model.Description, + Owner: "Xiaomi", ProviderID: "xiaomi", GatewayID: gatewayID, + ContextWindow: model.ContextLength, MaxOutputTokens: model.MaxOutputLength, + InputPricePer1M: model.InputPricePer1M, OutputPricePer1M: model.OutputPricePer1M, + PriceKnown: model.InputPricePer1M > 0 || model.OutputPricePer1M > 0, + Source: "public", LiveMetadata: append([]byte(nil), model.Raw...), + }) + } + return out, nil +} + func firstOffering(offerings []catalog.ModelOffering) catalog.ModelOffering { if len(offerings) == 0 { return catalog.ModelOffering{} diff --git a/engine/control_plane.go b/engine/control_plane.go index fa57a28..62870dd 100644 --- a/engine/control_plane.go +++ b/engine/control_plane.go @@ -28,6 +28,17 @@ func (e *Engine) ResolveCredential(ctx context.Context, secret string) Credentia RequiresKey: provider.RequiresKey, Rank: provider.Rank, } } + if out.FormatOK { + for _, gateway := range e.GatewayDefinitions() { + if _, custom := e.customGateway(gateway.ID); !custom || !gateway.RequiresKey { + continue + } + out.Providers = append(out.Providers, CredentialProvider{ + ProviderID: gateway.ID, EnvVar: gateway.CredentialEnv, + DisplayName: gateway.DisplayName, RequiresKey: true, Rank: gateway.SortOrder, + }) + } + } return out } @@ -42,6 +53,45 @@ func (e *Engine) CredentialProviders(context.Context) []CredentialProvider { RequiresKey: provider.RequiresKey, Rank: provider.Rank, } } + for _, gateway := range e.GatewayDefinitions() { + if _, custom := e.customGateway(gateway.ID); !custom { + continue + } + out = append(out, CredentialProvider{ + ProviderID: gateway.ID, EnvVar: gateway.CredentialEnv, + DisplayName: gateway.DisplayName, RequiresKey: gateway.RequiresKey, Rank: gateway.SortOrder, + }) + } + return out +} + +// GatewayDefinitions returns pure registry/custom metadata in setup UI order. +// It does not read credentials, provider state, or the model catalog. +func (e *Engine) GatewayDefinitions() []Gateway { + specs := registry.CredentialRegistry() + out := make([]Gateway, 0, len(specs)+len(e.customGateways)) + for _, spec := range specs { + providerSpec, _ := registry.SpecByProviderID(spec.ProviderID) + out = append(out, Gateway{ + ID: spec.ProviderID, DisplayName: spec.DisplayName, + DeploymentID: spec.DeploymentID, CredentialEnv: spec.EnvVar, + RequiresKey: spec.RequiresKey, SortOrder: spec.SortOrder, ChatPreference: providerSpec.ChatPreference, + SupportsLiveDiscovery: strings.TrimSpace(providerSpec.LiveFetcherKey) != "", + }) + } + for _, gateway := range e.customGateways { + out = append(out, Gateway{ + ID: gateway.ID, DisplayName: gateway.DisplayName, + CredentialEnv: gateway.CredentialEnv, RequiresKey: gateway.CredentialEnv != "", + ModelCount: boolInt(gateway.DefaultModel != ""), SortOrder: gateway.SortOrder, ChatPreference: gateway.ChatPreference, + }) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].SortOrder != out[j].SortOrder { + return out[i].SortOrder < out[j].SortOrder + } + return out[i].ID < out[j].ID + }) return out } @@ -58,50 +108,46 @@ func (e *Engine) Gateways(ctx context.Context) []Gateway { if providerConfig != nil { persisted = providerConfig.Deployments } - configuredDeployments = buildDeployments(compiled, persisted, e.credentialEnv(ctx, compiled)) + configuredDeployments = buildDeployments(compiled, persisted, e.discoveryCredentialsFromConfig(ctx, compiled, providerConfig).Env()) } - specs := registry.CredentialRegistry() - out := make([]Gateway, 0, len(specs)) - for _, spec := range specs { - providerSpec, _ := registry.SpecByProviderID(spec.ProviderID) - envVars := append([]string{spec.EnvVar}, providerSpec.CredentialEnvFallbacks...) + definitions := e.GatewayDefinitions() + out := make([]Gateway, 0, len(definitions)) + for _, definition := range definitions { + if custom, ok := e.customGateway(definition.ID); ok { + configured := custom.CredentialEnv == "" || e.hasCredential(ctx, []string{custom.CredentialEnv}) + definition.CredentialConfigured = configured + definition.DeploymentConfigured = configured + definition.Active = NormalizeProviderID(selection.Provider) == custom.ID + out = append(out, definition) + continue + } + providerSpec, _ := registry.SpecByProviderID(definition.ID) + envVars := append([]string{definition.CredentialEnv}, providerSpec.CredentialEnvFallbacks...) envVars = append(envVars, providerSpec.CredentialAliases...) configured := e.hasCredential(ctx, envVars) - _, deploymentConfigured := configuredDeployments[spec.DeploymentID] + _, deploymentConfigured := configuredDeployments[definition.DeploymentID] modelCount := 0 if compiled != nil { - modelCount = len(catalog.ModelEntriesForProvider(compiled, spec.ProviderID)) + modelCount = len(catalog.ModelEntriesForProvider(compiled, definition.ID)) } - regionLabel, regionRequired := e.GatewayRegion(spec.ProviderID) - out = append(out, Gateway{ - ID: spec.ProviderID, DisplayName: spec.DisplayName, - DeploymentID: spec.DeploymentID, CredentialEnv: spec.EnvVar, - RequiresKey: spec.RequiresKey, CredentialConfigured: configured, - DeploymentConfigured: deploymentConfigured, ModelCount: modelCount, - Active: NormalizeProviderID(selection.Provider) == NormalizeProviderID(spec.ProviderID), - RegionLabel: regionLabel, RegionRequired: regionRequired, - SupportsLiveDiscovery: strings.TrimSpace(providerSpec.LiveFetcherKey) != "", - }) + definition.CredentialConfigured = configured + definition.DeploymentConfigured = deploymentConfigured + definition.ModelCount = modelCount + definition.Active = NormalizeProviderID(selection.Provider) == NormalizeProviderID(definition.ID) + definition.RegionLabel, definition.RegionRequired = gatewayRegionFromConfig(definition.ID, providerConfig) + out = append(out, definition) } - for _, gateway := range e.customGateways { - configured := gateway.CredentialEnv == "" || e.hasCredential(ctx, []string{gateway.CredentialEnv}) - modelCount := 0 - if gateway.DefaultModel != "" { - modelCount = 1 - } - out = append(out, Gateway{ - ID: gateway.ID, DisplayName: gateway.DisplayName, - CredentialEnv: gateway.CredentialEnv, RequiresKey: gateway.CredentialEnv != "", - CredentialConfigured: configured, DeploymentConfigured: configured, - ModelCount: modelCount, - Active: NormalizeProviderID(selection.Provider) == gateway.ID, - }) - } - sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out } +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} + func (e *Engine) hasCredential(ctx context.Context, envVars []string) bool { for _, envVar := range envVars { envVar = strings.TrimSpace(envVar) diff --git a/engine/control_plane_test.go b/engine/control_plane_test.go index c8214bb..3e5db1f 100644 --- a/engine/control_plane_test.go +++ b/engine/control_plane_test.go @@ -75,9 +75,7 @@ func TestGatewayReadinessRejectsPlaceholderAndDiskSecrets(t *testing.T) { cfg := &config.ProviderConfig{Deployments: map[string]config.DeploymentConfig{ "openai-direct": {APIKey: "sk-secret-on-disk"}, }} - if err := config.SaveProviderConfig(cfg, eng.providerConfigPath); err != nil { - t.Fatal(err) - } + writeLegacyProviderConfigFixture(t, eng.providerConfigPath, cfg) for _, gateway := range eng.Gateways(ctx) { if gateway.ID == "openai" && (gateway.CredentialConfigured || gateway.DeploymentConfigured) { t.Fatalf("legacy or placeholder secret counted as ready: %+v", gateway) @@ -112,6 +110,28 @@ func TestEffectiveSelectionUsesEngineOwnedState(t *testing.T) { } } +func TestPreflightRequiresPersistedModelSelection(t *testing.T) { + ctx := context.Background() + store := &credentials.MapStore{} + eng, err := New(Options{SecretStore: store, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + if err := store.Set(ctx, credentials.AccountForEnv("OPENAI_API_KEY"), "sk-injected-secret"); err != nil { + t.Fatal(err) + } + report := eng.Preflight(ctx) + if report.Ready { + t.Fatalf("preflight auto-selected a model instead of requiring user selection: %+v", report) + } + for _, check := range report.Checks { + if check.Name == "model" && check.Status == CheckFail { + return + } + } + t.Fatalf("preflight did not report missing persisted model: %+v", report) +} + func TestHostControlUsesInjectedStoreAndPaths(t *testing.T) { ctx := context.Background() store := &credentials.MapStore{} @@ -164,12 +184,10 @@ func TestProviderSecretMigrationStaysInsideEnginePath(t *testing.T) { cfg := &config.ProviderConfig{ OpenAIAPIKey: "sk-top-level-legacy", Deployments: map[string]config.DeploymentConfig{ - "legacy": {APIKey: "sk-legacy", BaseURL: "https://example.test"}, + "openai-direct": {APIKey: "sk-legacy", BaseURL: "https://example.test"}, }, } - if err := config.SaveProviderConfig(cfg, eng.providerConfigPath); err != nil { - t.Fatal(err) - } + writeLegacyProviderConfigFixture(t, eng.providerConfigPath, cfg) if status := eng.ProviderStateSecurityStatus(); !status.HasSecrets { t.Fatal("expected secret-bearing provider state") } @@ -183,14 +201,12 @@ func TestProviderSecretMigrationStaysInsideEnginePath(t *testing.T) { if saved.OpenAIAPIKey != "" { t.Fatal("migration retained a top-level legacy credential") } - if saved.Deployments["legacy"].BaseURL != "https://example.test" { + if saved.Deployments["openai-direct"].BaseURL != "https://example.test" { t.Fatal("migration lost non-secret routing metadata") } // A marker is an audit artifact, not permission to trust later plaintext. saved.OpenAIAPIKey = "sk-reintroduced" - if err := config.SaveProviderConfig(saved, eng.providerConfigPath); err != nil { - t.Fatal(err) - } + writeLegacyProviderConfigFixture(t, eng.providerConfigPath, saved) if err := eng.MigrateProviderSecrets(); err != nil { t.Fatal(err) } diff --git a/engine/credentials.go b/engine/credentials.go index b8d9d52..44a05e3 100644 --- a/engine/credentials.go +++ b/engine/credentials.go @@ -20,6 +20,13 @@ func (e *Engine) SaveCredential(ctx context.Context, providerID, secret string) if providerID == "" { return CredentialStatus{}, invalid("save_credential", "eyrie engine: provider id is required") } + if gateway, ok := e.customGateway(providerID); ok { + return e.saveCustomGatewayCredential(ctx, gateway, secret) + } + providerCfg, err := e.loadProviderConfigStrict() + if err != nil { + return CredentialStatus{}, &Error{Code: ErrorInternal, Operation: "save_credential", Provider: providerID, Message: err.Error(), Cause: err} + } inference, err := config.InferenceForProvider(providerID) if err != nil { return CredentialStatus{}, &Error{Code: ErrorInvalidRequest, Operation: "save_credential", Provider: providerID, Message: err.Error(), Cause: err} @@ -43,7 +50,7 @@ func (e *Engine) SaveCredential(ctx context.Context, providerID, secret string) status.Verified = true return status, nil } - if err := config.ProbeCredential(ctx, envKey, prepared); err != nil { + if err := config.ProbeCredentialWithProviderConfig(ctx, envKey, prepared, providerCfg); err != nil { return status, &Error{Code: ErrorAuthentication, Operation: "probe_credential", Provider: providerID, Message: fmt.Sprintf("%v (key saved in keychain)", err), Cause: err} } status.Verified = true @@ -53,12 +60,22 @@ func (e *Engine) SaveCredential(ctx context.Context, providerID, secret string) // RemoveCredential deletes a provider credential from the configured store. func (e *Engine) RemoveCredential(ctx context.Context, providerID string) error { ctx = nonNilContext(ctx) - inference, err := config.InferenceForProvider(strings.TrimSpace(providerID)) - if err != nil { + if gateway, ok := e.customGateway(providerID); ok { + if gateway.CredentialEnv == "" { + return nil + } + if err := e.secretStore.Delete(ctx, credentials.AccountForEnv(gateway.CredentialEnv)); err != nil && !errors.Is(err, credentials.ErrNotFound) { + return &Error{Code: ErrorInternal, Operation: "remove_credential", Provider: gateway.ID, Message: "eyrie engine: could not remove credential", Cause: err} + } + return nil + } + if _, err := config.InferenceForProvider(strings.TrimSpace(providerID)); err != nil { return &Error{Code: ErrorInvalidRequest, Operation: "remove_credential", Provider: providerID, Message: err.Error(), Cause: err} } - if err := e.secretStore.Delete(ctx, credentials.AccountForEnv(inference.EnvVar)); err != nil && !errors.Is(err, credentials.ErrNotFound) { - return &Error{Code: ErrorInternal, Operation: "remove_credential", Provider: providerID, Message: "eyrie engine: could not remove credential", Cause: err} + for _, envKey := range e.CredentialEnvKeys(providerID) { + if err := e.secretStore.Delete(ctx, credentials.AccountForEnv(envKey)); err != nil && !errors.Is(err, credentials.ErrNotFound) { + return &Error{Code: ErrorInternal, Operation: "remove_credential", Provider: providerID, Message: "eyrie engine: could not remove credential", Cause: err} + } } return nil } @@ -67,14 +84,66 @@ func (e *Engine) RemoveCredential(ctx context.Context, providerID string) error func (e *Engine) CredentialStatus(ctx context.Context, providerID string) (CredentialStatus, error) { ctx = nonNilContext(ctx) providerID = strings.TrimSpace(providerID) + if gateway, ok := e.customGateway(providerID); ok { + if gateway.CredentialEnv == "" { + return CredentialStatus{ProviderID: gateway.ID, Configured: true}, nil + } + secret, err := e.credentialValue(ctx, gateway.CredentialEnv) + if err != nil { + return CredentialStatus{}, &Error{Code: ErrorInternal, Operation: "credential_status", Provider: gateway.ID, Message: "eyrie engine: could not read credential status", Cause: err} + } + configured := strings.TrimSpace(secret) != "" && !config.LooksLikePlaceholderSecret(secret) + return CredentialStatus{ + ProviderID: gateway.ID, EnvVar: gateway.CredentialEnv, + Configured: configured, Masked: maskedCredentialIf(configured, secret), + }, nil + } inference, err := config.InferenceForProvider(providerID) if err != nil { return CredentialStatus{}, &Error{Code: ErrorInvalidRequest, Operation: "credential_status", Provider: providerID, Message: err.Error(), Cause: err} } - secret, err := e.credentialValue(ctx, inference.EnvVar) - if err == nil { - configured := strings.TrimSpace(secret) != "" - return CredentialStatus{ProviderID: providerID, EnvVar: inference.EnvVar, Configured: configured, Masked: maskedCredential(secret)}, nil + for _, envKey := range e.CredentialEnvKeys(providerID) { + secret, err := e.credentialValue(ctx, envKey) + if err != nil { + return CredentialStatus{}, &Error{Code: ErrorInternal, Operation: "credential_status", Provider: providerID, Message: "eyrie engine: could not read credential status", Cause: err} + } + if strings.TrimSpace(secret) != "" && !config.LooksLikePlaceholderSecret(secret) { + return CredentialStatus{ + ProviderID: providerID, EnvVar: inference.EnvVar, + Configured: true, Masked: maskedCredential(secret), + }, nil + } + } + return CredentialStatus{ProviderID: providerID, EnvVar: inference.EnvVar}, nil +} + +func (e *Engine) saveCustomGatewayCredential(ctx context.Context, gateway CustomGateway, secret string) (CredentialStatus, error) { + if gateway.CredentialEnv == "" { + return CredentialStatus{ProviderID: gateway.ID, Configured: true, Verified: true}, nil + } + secret = strings.TrimSpace(secret) + if err := config.ValidateCredentialSecret(gateway.CredentialEnv, secret); err != nil { + return CredentialStatus{}, &Error{Code: ErrorInvalidRequest, Operation: "save_credential", Provider: gateway.ID, Message: err.Error(), Cause: err} + } + if err := e.secretStore.Set(ctx, credentials.AccountForEnv(gateway.CredentialEnv), secret); err != nil { + return CredentialStatus{}, &Error{Code: ErrorInternal, Operation: "save_credential", Provider: gateway.ID, Message: "eyrie engine: could not save credential", Cause: err} + } + status := CredentialStatus{ProviderID: gateway.ID, EnvVar: gateway.CredentialEnv, Configured: true} + if err := e.probeCustomGateway(ctx, gateway, secret); err != nil { + code := ErrorAuthentication + var typed *Error + if errors.As(err, &typed) && typed.Code != "" { + code = typed.Code + } + return status, &Error{Code: code, Operation: "probe_credential", Provider: gateway.ID, Message: fmt.Sprintf("%v (key saved in keychain)", err), Cause: err} + } + status.Verified = true + return status, nil +} + +func maskedCredentialIf(configured bool, secret string) string { + if !configured { + return "" } - return CredentialStatus{}, &Error{Code: ErrorInternal, Operation: "credential_status", Provider: providerID, Message: "eyrie engine: could not read credential status", Cause: err} + return maskedCredential(secret) } diff --git a/engine/engine.go b/engine/engine.go index 5bae835..ae88c57 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -17,7 +17,7 @@ import ( ) // ContractVersion is the compatibility version of the host-facing API. -const ContractVersion = "1" +const ContractVersion = "2" // Options supplies host-owned dependencies. A zero value uses Eyrie's safe // defaults. Product-specific paths are deliberately not inferred here. @@ -26,20 +26,33 @@ type Options struct { StateDir string CatalogPath string ProviderConfigPath string + // RemoteCatalogURL is the trusted published catalog source used by full + // refreshes. Empty selects Eyrie's compiled-in HTTPS catalog URL, never a + // process-environment override. + RemoteCatalogURL string + // CustomGateways is snapshotted per Engine. A non-nil empty slice + // explicitly declares that the host has no custom gateways. + CustomGateways []CustomGateway + // UseRegisteredCustomGateways opts into the deprecated process-global + // RegisterCustomGateway registry when CustomGateways is nil. + UseRegisteredCustomGateways bool } // Engine is Eyrie's narrow host facade. It is safe for concurrent use when // the configured SecretStore is safe for concurrent use. type Engine struct { secretStore credentials.Store + defaultSecretStore bool catalogPath string providerConfigPath string + remoteCatalogURL string customGateways map[string]CustomGateway resolveTransport func(context.Context, Route) (client.Provider, error) } // New constructs a host-facing Eyrie engine. func New(opts Options) (*Engine, error) { + usesDefaultStore := opts.SecretStore == nil store := opts.SecretStore if store == nil { store = credentials.DefaultStore() @@ -62,9 +75,18 @@ func New(opts Options) (*Engine, error) { if providerPath == "" { providerPath = config.GetProviderConfigPath() } + remoteCatalogURL := strings.TrimSpace(opts.RemoteCatalogURL) + if remoteCatalogURL == "" { + remoteCatalogURL = catalog.SeedCatalogURL + } + customGateways, err := customGatewaysForOptions(opts.CustomGateways, opts.UseRegisteredCustomGateways) + if err != nil { + return nil, err + } engine := &Engine{ - secretStore: store, catalogPath: catalogPath, providerConfigPath: providerPath, - customGateways: snapshotCustomGateways(), + secretStore: store, defaultSecretStore: usesDefaultStore, + catalogPath: catalogPath, providerConfigPath: providerPath, remoteCatalogURL: remoteCatalogURL, + customGateways: customGateways, } engine.resolveTransport = engine.defaultTransport return engine, nil @@ -133,11 +155,7 @@ func (e *Engine) ListModels(ctx context.Context, providerID string, refresh bool if gateway.DefaultModel == "" { return nil, nil } - return []Model{{ - ID: gateway.DefaultModel, DisplayName: gateway.DefaultModel, - ProviderID: gateway.ID, GatewayID: gateway.ID, - ContextWindow: gateway.ContextWindow, Capabilities: customGatewayCapabilityNames(gateway), Source: "custom", - }}, nil + return []Model{customGatewayModel(gateway)}, nil } var snapshot CatalogSnapshot var err error @@ -149,31 +167,51 @@ func (e *Engine) ListModels(ctx context.Context, providerID string, refresh bool if err != nil { return nil, &Error{Code: ErrorCatalogUnavailable, Operation: "list_models", Provider: providerID, Message: err.Error(), Cause: err} } - providerID = catalog.CanonicalProviderID(strings.TrimSpace(providerID)) - if providerID == "" { + requestedProvider := strings.TrimSpace(providerID) + if requestedProvider == "" { return snapshot.Models, nil } compiled, loadErr := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{CachePath: e.catalogPath, RequireCache: true}) if loadErr != nil { return nil, &Error{Code: ErrorCatalogUnavailable, Operation: "list_models", Provider: providerID, Message: loadErr.Error(), Cause: loadErr} } - entries := catalog.ModelEntriesForProvider(compiled, providerID) + entries := catalog.ModelEntriesForProvider(compiled, requestedProvider) + return modelsFromCatalogEntries(compiled, requestedProvider, entries, "cache", refresh, true), nil +} + +func modelsFromCatalogEntries(compiled *catalog.CompiledCatalog, requestedProvider string, entries []catalog.ModelCatalogEntry, defaultSource string, metadataMarksLive, enrichCapabilities bool) []Model { + gatewayID := NormalizeProviderID(requestedProvider) + catalogProviderID := catalog.CanonicalProviderID(requestedProvider) out := make([]Model, 0, len(entries)) for _, entry := range entries { capabilities := append([]string(nil), entry.ServerTools...) - if canonical, ok := catalog.CanonicalModelForProviderNative(compiled, providerID, entry.ID); ok { - capabilities = capabilityNames(offeringForProvider(compiled, providerID, canonical, entry.ID).Capabilities) + owner := catalogProviderID + canonicalID := entry.ID + if canonical, ok := catalog.CanonicalModelForProviderNative(compiled, requestedProvider, entry.ID); ok { + canonicalID = canonical + if enrichCapabilities { + offering := offeringForProvider(compiled, requestedProvider, canonical, entry.ID) + capabilities = capabilityNames(offering.Capabilities) + } + if resolvedOwner := catalog.ProviderForModel(compiled, canonical); resolvedOwner != "" { + owner = resolvedOwner + } + } + source := defaultSource + if metadataMarksLive && len(entry.LiveMetadata) > 0 { + source = "live" } out = append(out, Model{ - ID: entry.ID, DisplayName: entry.DisplayName, Description: entry.Description, - Owner: entry.Owner, ProviderID: providerID, GatewayID: providerID, + ID: entry.ID, CanonicalID: canonicalID, DisplayName: entry.DisplayName, Description: entry.Description, + Owner: entry.Owner, ProviderID: owner, GatewayID: gatewayID, ContextWindow: entry.ContextWindow, MaxOutputTokens: entry.MaxOutput, InputPricePer1M: entry.InputPricePer1M, OutputPricePer1M: entry.OutputPricePer1M, PriceKnown: modelPriceKnown(entry.ID, entry.DisplayName, entry.InputPricePer1M, entry.OutputPricePer1M, entry.ContextWindow), - Capabilities: capabilities, Source: "cache", + Capabilities: capabilities, Source: source, + LiveMetadata: append([]byte(nil), entry.LiveMetadata...), }) } - return out, nil + return out } func modelPriceKnown(id, displayName string, input, output float64, contextWindow int) bool { @@ -221,7 +259,7 @@ func (e *Engine) defaultTransport(ctx context.Context, route Route) (client.Prov if err != nil { return nil, err } - return setup.DeploymentProviderFromCatalog(cfg, compiled) + return setup.DeploymentProviderFromState(cfg, compiled) } func (e *Engine) resolveSelection(ctx context.Context, req SelectionRequest) (Route, error) { diff --git a/engine/engine_test.go b/engine/engine_test.go index f4ef377..98cafc9 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -23,6 +23,30 @@ func TestNewUsesInjectedCredentialStore(t *testing.T) { } } +func TestContractVersionAndRemoteCatalogIsolation(t *testing.T) { + if ContractVersion != "2" { + t.Fatalf("ContractVersion = %q, want 2", ContractVersion) + } + t.Setenv("EYRIE_MODEL_CATALOG_URL", "https://ambient.invalid/catalog.json") + eng, err := New(Options{SecretStore: &credentials.MapStore{}, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + if eng.remoteCatalogURL != catalog.SeedCatalogURL { + t.Fatalf("ambient remote catalog leaked into Engine: %q", eng.remoteCatalogURL) + } + explicit, err := New(Options{ + SecretStore: &credentials.MapStore{}, StateDir: t.TempDir(), + RemoteCatalogURL: "https://host.example.test/catalog.json", + }) + if err != nil { + t.Fatal(err) + } + if explicit.remoteCatalogURL != "https://host.example.test/catalog.json" { + t.Fatalf("explicit remote catalog = %q", explicit.remoteCatalogURL) + } +} + func TestNewDerivesHostNeutralPathsFromStateDir(t *testing.T) { dir := t.TempDir() eng, err := New(Options{SecretStore: &credentials.MapStore{}, StateDir: dir}) @@ -40,7 +64,7 @@ func TestNewDerivesHostNeutralPathsFromStateDir(t *testing.T) { func TestCredentialStatusAndRemoveUseInjectedStore(t *testing.T) { ctx := context.Background() store := &credentials.MapStore{} - if err := store.Set(ctx, credentials.AccountForEnv("OPENAI_API_KEY"), "sk-test-value"); err != nil { + if err := store.Set(ctx, credentials.AccountForEnv("OPENAI_API_KEY"), "sk-live-value-1234567890"); err != nil { t.Fatal(err) } eng, err := New(Options{SecretStore: store}) diff --git a/engine/host_control.go b/engine/host_control.go index 3be229c..d84c616 100644 --- a/engine/host_control.go +++ b/engine/host_control.go @@ -2,7 +2,7 @@ package engine import ( "context" - "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -33,13 +33,27 @@ type CredentialStorageReport struct { func CredentialStorage(ctx context.Context) CredentialStorageReport { ctx = nonNilContext(ctx) report := credentials.StorageReportFor(ctx) - ok, detail := credentials.KeychainWriteAvailable(ctx) return CredentialStorageReport{ - PlatformStore: report.PlatformStore, Writable: ok, Detail: detail, + PlatformStore: report.PlatformStore, Writable: report.KeychainWritable, Detail: report.KeychainDetail, Formatted: credentials.FormatStorageReport(report), } } +func (e *Engine) credentialStorage(ctx context.Context) CredentialStorageReport { + if e.defaultSecretStore { + return CredentialStorage(ctx) + } + return CredentialStorageReport{ + PlatformStore: "host-injected", Writable: false, + Detail: "host-injected credential store configured (writability not probed)", + Formatted: "Credential storage:\n platform store: host-injected\n writability: not probed", + } +} + +func (e *Engine) StatePaths() StatePaths { + return StatePaths{Catalog: e.catalogPath, ProviderConfig: e.providerConfigPath} +} + func MigrateLegacyCredentials(ctx context.Context) (int, error) { return credentials.MigrateLegacyEnvFile(nonNilContext(ctx)) } @@ -66,6 +80,12 @@ func (e *Engine) SaveCredentialEnv(ctx context.Context, envVar, secret string) e } func (e *Engine) CredentialEnvKeys(providerID string) []string { + if gateway, ok := e.customGateway(providerID); ok { + if gateway.CredentialEnv == "" { + return nil + } + return []string{gateway.CredentialEnv} + } spec, ok := registry.SpecByProviderID(NormalizeProviderID(providerID)) if !ok { return nil @@ -85,6 +105,10 @@ func (e *Engine) CredentialEnvKeys(providerID string) []string { // GatewayRegion returns normalized region presentation for regional gateways. func (e *Engine) GatewayRegion(providerID string) (label string, required bool) { cfg := config.LoadProviderConfig(e.providerConfigPath) + return gatewayRegionFromConfig(providerID, cfg) +} + +func gatewayRegionFromConfig(providerID string, cfg *config.ProviderConfig) (label string, required bool) { providerID = NormalizeProviderID(providerID) switch providerID { case runtime.GatewayXiaomiTokenPlan: @@ -106,10 +130,13 @@ func (e *Engine) GatewayRegion(providerID string) (label string, required bool) // SetGatewayRegion persists regional gateway configuration at the Engine path. func (e *Engine) SetGatewayRegion(ctx context.Context, providerID, value string) error { + ctx = nonNilContext(ctx) providerID = NormalizeProviderID(providerID) - cfg := config.LoadProviderConfig(e.providerConfigPath) - if cfg == nil { - cfg = &config.ProviderConfig{} + unlock := lockProviderStatePath(e.providerConfigPath) + defer unlock() + cfg, err := e.loadProviderConfigStrict() + if err != nil { + return &Error{Code: ErrorInternal, Operation: "set_gateway_region", Provider: providerID, Message: err.Error(), Cause: err} } switch providerID { case runtime.GatewayXiaomiTokenPlan: @@ -140,14 +167,15 @@ func (e *Engine) SetGatewayRegion(ctx context.Context, providerID, value string) default: return invalid("set_gateway_region", "eyrie engine: gateway does not support regions") } - if err := config.SaveProviderConfig(cfg, e.providerConfigPath); err != nil { + if err := e.saveProviderConfig(ctx, cfg); err != nil { return err } - e.ApplyGatewayEnvironment(ctx, providerID) return nil } // ApplyGatewayEnvironment derives process-local regional base URLs. +// Deprecated: invocation-scoped hosts must use Engine state directly and must +// not mutate process environment. This remains only for legacy callers. func (e *Engine) ApplyGatewayEnvironment(_ context.Context, providerID string) { cfg := config.LoadProviderConfig(e.providerConfigPath) if cfg == nil { @@ -158,22 +186,26 @@ func (e *Engine) ApplyGatewayEnvironment(_ context.Context, providerID string) { if cfg.XiaomiMimoTokenPlanRegion != "" { _ = os.Setenv(config.EnvXiaomiTokenPlanRegion, cfg.XiaomiMimoTokenPlanRegion) } - if cfg.XiaomiMimoTokenPlanBaseURL != "" { - _ = os.Setenv(config.EnvXiaomiTokenPlanBaseURL, cfg.XiaomiMimoTokenPlanBaseURL) + if base, err := config.ResolveXiaomiOpenAIBase(runtime.GatewayXiaomiTokenPlan, cfg); err == nil && base != "" { + _ = os.Setenv(config.EnvXiaomiTokenPlanBaseURL, base) } case "zai_payg": if cfg.ZAIRegion != "" { _ = os.Setenv("ZAI_REGION", cfg.ZAIRegion) } - if cfg.ZAIBaseURL != "" { - _ = os.Setenv("ZAI_BASE_URL", cfg.ZAIBaseURL) + if region, err := zai.NormalizeRegion(cfg.ZAIRegion); err == nil { + if base, err := zai.ResolveOpenAIBase(zai.PlanGeneral, region, cfg.ZAIBaseURL); err == nil && base != "" { + _ = os.Setenv("ZAI_BASE_URL", base) + } } case "zai_coding": if cfg.ZAICodingRegion != "" { _ = os.Setenv("ZAI_CODING_REGION", cfg.ZAICodingRegion) } - if cfg.ZAICodingBaseURL != "" { - _ = os.Setenv("ZAI_CODING_BASE_URL", cfg.ZAICodingBaseURL) + if region, err := zai.NormalizeRegion(cfg.ZAICodingRegion); err == nil { + if base, err := zai.ResolveOpenAIBase(zai.PlanCoding, region, cfg.ZAICodingBaseURL); err == nil && base != "" { + _ = os.Setenv("ZAI_CODING_BASE_URL", base) + } } } } @@ -200,7 +232,10 @@ func (e *Engine) CatalogHealth(ctx context.Context) CatalogHealth { health.Error = err.Error() return health } - compiled, err := catalog.LoadCatalog(nonNilContext(ctx), catalog.LoadCatalogOptions{CachePath: e.catalogPath}) + if !exists { + return health + } + compiled, err := catalog.LoadCatalog(nonNilContext(ctx), catalog.LoadCatalogOptions{CachePath: e.catalogPath, RequireCache: true}) if err != nil { health.Error = err.Error() return health @@ -210,7 +245,7 @@ func (e *Engine) CatalogHealth(ctx context.Context) CatalogHealth { health.Offerings = len(compiled.OfferingsByID) if compiled.Catalog != nil { health.StaleAfter = compiled.Catalog.StaleAfter - health.Stale = time.Now().UTC().After(compiled.Catalog.StaleAfter) + health.Stale = !compiled.Catalog.StaleAfter.IsZero() && time.Now().UTC().After(compiled.Catalog.StaleAfter) if compiled.Catalog.Provenance != nil { health.Source = compiled.Catalog.Provenance.Source } @@ -219,6 +254,9 @@ func (e *Engine) CatalogHealth(ctx context.Context) CatalogHealth { } func (e *Engine) CanonicalModel(ctx context.Context, modelID string) string { + if _, ok := e.customGatewayForModel(modelID); ok { + return strings.TrimSpace(modelID) + } compiled, err := e.policyCatalog(ctx) if err == nil { if canonical, ok := compiled.CanonicalModelForAliasOrID(strings.TrimSpace(modelID)); ok { @@ -229,16 +267,19 @@ func (e *Engine) CanonicalModel(ctx context.Context, modelID string) string { } func (e *Engine) GatewayForModel(ctx context.Context, modelID string) string { + if gateway, ok := e.customGatewayForModel(modelID); ok { + return gateway.ID + } compiled, _ := e.policyCatalog(ctx) - return catalog.GatewayForModel(compiled, modelID) + return NormalizeProviderID(catalog.GatewayForModel(compiled, modelID)) } func (e *Engine) DeploymentRoutingEnabled(override *bool) bool { if override != nil { return *override } - cfg := config.LoadProviderConfig(e.providerConfigPath) - return setup.UseDeploymentRouting(cfg) + cfg, err := e.loadProviderConfigStrict() + return err == nil && setup.DeploymentRoutingFromState(cfg) } func (e *Engine) DeploymentStatus(ctx context.Context, activeModel string) (string, error) { @@ -298,38 +339,162 @@ func (e *Engine) DefaultProviderFilter(ctx context.Context) string { } 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 - health := e.CatalogHealth(ctx) - if health.Error != "" || health.Models == 0 { - checks = append(checks, PreflightCheck{Name: "catalog", Status: CheckWarn, Detail: fmt.Sprintf("catalog unavailable at %s", health.Path)}) + cfg, stateErr := e.loadProviderConfigStrict() + if stateErr != nil { + checks = append(checks, PreflightCheck{Name: "provider_state", Status: CheckFail, Detail: stateErr.Error()}) + cfg = &config.ProviderConfig{} } else { - checks = append(checks, PreflightCheck{Name: "catalog", Status: CheckOK, Detail: fmt.Sprintf("%d models cached at %s", health.Models, health.Path)}) + checks = append(checks, PreflightCheck{Name: "provider_state", Status: CheckOK, Detail: e.providerConfigPath}) } - storage := CredentialStorage(ctx) + providerID := NormalizeProviderID(config.ActiveProvider(cfg)) + activeModel := strings.TrimSpace(config.ActiveModel(cfg)) + customGateway, custom := e.customGateway(providerID) + + var compiled *catalog.CompiledCatalog + if custom { + checks = append(checks, PreflightCheck{Name: "catalog", Status: CheckOK, Detail: "not required for selected custom gateway"}) + } else { + health := e.CatalogHealth(ctx) + if health.Error != "" || health.Models == 0 { + checks = append(checks, PreflightCheck{Name: "catalog", Status: CheckFail, Detail: fmt.Sprintf("catalog unavailable at %s", health.Path)}) + } else { + var err error + compiled, err = catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{CachePath: e.catalogPath, RequireCache: true}) + if err != nil { + checks = append(checks, PreflightCheck{Name: "catalog", Status: CheckFail, Detail: err.Error()}) + } else { + checks = append(checks, PreflightCheck{Name: "catalog", Status: CheckOK, Detail: fmt.Sprintf("%d models cached at %s", health.Models, health.Path)}) + } + } + } + storage := e.credentialStorage(ctx) status := CheckWarn if storage.Writable { status = CheckOK } checks = append(checks, PreflightCheck{Name: "credentials_store", Status: status, Detail: storage.Detail}) - selection := e.EffectiveSelection(ctx, SelectionOptions{}) - if selection.HasConfiguredDeployment { - checks = append(checks, PreflightCheck{Name: "credentials", Status: CheckOK, Detail: "at least one provider credential is configured in " + SecretStoreName()}) - } else { - checks = append(checks, PreflightCheck{Name: "credentials", Status: CheckFail, Detail: "no provider credentials configured"}) + + credentialReady := false + credentialDetail := "no active provider selected" + if custom { + credentialReady = customGateway.CredentialEnv == "" || e.hasCredential(ctx, []string{customGateway.CredentialEnv}) + credentialDetail = providerID + " credential is not configured" + if credentialReady { + credentialDetail = providerID + " credential is configured" + } + } else if providerID != "" && compiled != nil && stateErr == nil { + if spec, ok := registry.SpecByProviderID(providerID); ok { + env := e.discoveryCredentialsFromConfig(ctx, compiled, cfg).Env() + deployments := buildDeployments(compiled, cfg.Deployments, env) + if deployment, ok := deployments[spec.DeploymentID]; ok { + _, credentialReady = setup.ProviderForDeploymentFromState(spec.DeploymentID, deployment, cfg) + } + credentialDetail = providerID + " deployment is not fully configured" + if credentialReady { + credentialDetail = providerID + " deployment is configured" + } + } else { + credentialDetail = "unknown active provider " + providerID + } } - if selection.Model == "" { + credentialStatus := CheckFail + if credentialReady { + credentialStatus = CheckOK + } + checks = append(checks, PreflightCheck{Name: "credentials", Status: credentialStatus, Detail: credentialDetail}) + + modelDetail := activeModel + if activeModel == "" { checks = append(checks, PreflightCheck{Name: "model", Status: CheckFail, Detail: "no model selected"}) + } else if custom { + checks = append(checks, PreflightCheck{Name: "model", Status: CheckOK, Detail: activeModel}) + } else if compiled != nil && providerModelAvailable(compiled, providerID, activeModel) { + checks = append(checks, PreflightCheck{Name: "model", Status: CheckOK, Detail: activeModel}) } else { - checks = append(checks, PreflightCheck{Name: "model", Status: CheckOK, Detail: selection.Model}) + checks = append(checks, PreflightCheck{Name: "model", Status: CheckFail, Detail: modelDetail + " is not available through " + providerID}) } - ready := true + localReady := true for _, check := range checks { if check.Status == CheckFail { - ready = false + localReady = false + } + } + liveVerified := false + if !opts.VerifyLive { + checks = append(checks, PreflightCheck{Name: "provider_live", Status: CheckWarn, Detail: "not checked (local preflight only)"}) + } else if !localReady { + checks = append(checks, PreflightCheck{Name: "provider_live", Status: CheckWarn, Detail: "skipped until local setup is complete"}) + } else { + var err error + if custom { + err = e.probeCustomGateway(ctx, customGateway, "") + } else { + var liveModels []Model + liveModels, err = e.ListLiveModels(ctx, providerID) + if err == nil && !modelListContains(liveModels, activeModel) { + err = &Error{ + Code: ErrorModelUnavailable, Operation: "preflight", Provider: providerID, Model: activeModel, + Message: fmt.Sprintf("eyrie engine: selected model %q is not present in %s live model list", activeModel, providerID), + } + } + } + if err != nil { + checks = append(checks, PreflightCheck{Name: "provider_live", Status: CheckFail, Detail: fmt.Sprintf("%s verification failed: %v", providerID, err)}) + } else { + liveVerified = true + checks = append(checks, PreflightCheck{Name: "provider_live", Status: CheckOK, Detail: providerID + " connectivity and authentication verified"}) + } + } + ready := localReady + if opts.VerifyLive && !liveVerified { + ready = false + } + return PreflightReport{Ready: ready, LiveVerified: liveVerified, Checks: checks} +} + +func modelListContains(models []Model, modelID string) bool { + modelID = strings.TrimSpace(modelID) + for _, model := range models { + if strings.TrimSpace(model.ID) == modelID || strings.TrimSpace(model.CanonicalID) == modelID { + return true } } - return PreflightReport{Ready: ready, Checks: checks} + return false +} + +func providerModelAvailable(compiled *catalog.CompiledCatalog, providerID, modelID string) bool { + if compiled == nil || providerID == "" || strings.TrimSpace(modelID) == "" { + return false + } + if _, ok := catalog.CanonicalModelForProviderNative(compiled, providerID, modelID); ok { + return true + } + canonical, ok := compiled.CanonicalModelForAliasOrID(modelID) + if !ok { + canonical = strings.TrimSpace(modelID) + } + spec, ok := registry.SpecByProviderID(providerID) + if !ok { + return false + } + for _, offering := range compiled.OfferingsByDeployment[spec.DeploymentID] { + if offering.CanonicalModelID == canonical || offering.NativeModelID == modelID { + return true + } + } + return false } type CheckStatus string @@ -347,14 +512,17 @@ type PreflightCheck struct { } type PreflightReport struct { - Ready bool `json:"ready"` - Checks []PreflightCheck `json:"checks"` + Ready bool `json:"ready"` + LiveVerified bool `json:"live_verified"` + Checks []PreflightCheck `json:"checks"` } func FormatPreflight(report PreflightReport) string { var b strings.Builder - if report.Ready { - b.WriteString("Preflight: ready to chat\n") + if report.Ready && report.LiveVerified { + b.WriteString("Preflight: ready to chat (live verified)\n") + } else if report.Ready { + b.WriteString("Preflight: locally ready to chat\n") } else { b.WriteString("Preflight: setup incomplete\n") } @@ -396,54 +564,56 @@ func (e *Engine) ProviderStateSecurityStatus() ProviderStateSecurity { return status } -// MigrateProviderSecrets atomically strips historical secret fields from -// provider.json while preserving routing and non-secret deployment metadata. +// MigrateProviderSecrets imports historical credential fields into the +// Engine's secret store, then atomically strips them from provider.json. func (e *Engine) MigrateProviderSecrets() error { + return e.MigrateProviderSecretsContext(context.Background()) +} + +func (e *Engine) MigrateProviderSecretsContext(ctx context.Context) error { + ctx = nonNilContext(ctx) path := e.providerConfigPath - marker, backup := path+".secrets-migrated", path+".pre-secret-migrate.bak" - data, err := os.ReadFile(path) // #nosec G304 -- engine-owned state path + unlock := lockProviderStatePath(path) + defer unlock() + marker := path + ".secrets-migrated" + cfgState, err := config.LoadProviderConfigWithError(path) if err != nil { - if os.IsNotExist(err) { - return nil - } return err } - var cfg config.ProviderConfig - if err := json.Unmarshal(data, &cfg); err != nil { - return err + if cfgState == nil { + return nil } + cfg := *cfgState changed := config.ProviderConfigContainsSecrets(cfg) - cfg = config.SanitizeProviderConfigForDisk(cfg) if !changed { - if err := os.WriteFile(marker, []byte("ok\n"), 0o600); err != nil { - return err - } - if err := os.Remove(backup); err != nil && !os.IsNotExist(err) { + if err := writeBytesAtomic(marker, []byte("ok\n")); err != nil { return err } return nil } - if err := os.WriteFile(backup, data, 0o600); err != nil { - return err + writes, err := e.importLegacyProviderSecrets(ctx, cfg) + if err != nil { + return &Error{Code: ErrorInternal, Operation: "migrate_provider_secrets", Message: "eyrie engine: could not import legacy credentials", Cause: err} } - if err := writeProviderConfigAtomic(path, &cfg); err != nil { - return err + sanitized := config.SanitizeProviderConfigForDisk(cfg) + if err := writeProviderConfigAtomic(path, &sanitized); err != nil { + return errors.Join(err, e.rollbackImportedCredentials(ctx, writes)) } - if err := os.WriteFile(marker, []byte("ok\n"), 0o600); err != nil { + if err := writeBytesAtomic(marker, []byte("ok\n")); err != nil { return err } - return os.Remove(backup) + return nil } func writeProviderConfigAtomic(path string, cfg *config.ProviderConfig) (err error) { + return config.SaveProviderConfig(cfg, path) +} + +func writeBytesAtomic(path string, data []byte) (err error) { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o700); err != nil { return err } - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } tmp, err := os.CreateTemp(dir, ".provider-*.tmp") if err != nil { return err @@ -456,7 +626,7 @@ func writeProviderConfigAtomic(path string, cfg *config.ProviderConfig) (err err if err := tmp.Chmod(0o600); err != nil { return err } - if _, err := tmp.Write(append(data, '\n')); err != nil { + if _, err := tmp.Write(data); err != nil { return err } if err := tmp.Sync(); err != nil { diff --git a/engine/host_control_test.go b/engine/host_control_test.go new file mode 100644 index 0000000..36f1232 --- /dev/null +++ b/engine/host_control_test.go @@ -0,0 +1,201 @@ +package engine + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestCatalogHealthMissingCacheDoesNotUseBootstrapFallback(t *testing.T) { + eng := newHostControlTestEngine(t) + + health := eng.CatalogHealth(context.Background()) + if health.Exists { + t.Fatalf("missing cache reported as existing: %+v", health) + } + if health.Error != "" || health.Models != 0 || health.Source != "" || health.Stale { + t.Fatalf("missing cache inherited bootstrap health: %+v", health) + } +} + +func TestCatalogHealthRejectsInvalidAndBootstrapCaches(t *testing.T) { + tests := []struct { + name string + write func(*testing.T, string) + }{ + { + name: "empty file", + write: func(t *testing.T, path string) { + writeHostControlTestFile(t, path, nil) + }, + }, + { + name: "corrupt JSON", + write: func(t *testing.T, path string) { + writeHostControlTestFile(t, path, []byte("{not-json")) + }, + }, + { + name: "empty model catalog", + write: func(t *testing.T, path string) { + seed := catalog.SeedCatalog() + seed.Models = nil + seed.Offerings = nil + data, err := json.Marshal(seed) + if err != nil { + t.Fatal(err) + } + writeHostControlTestFile(t, path, data) + }, + }, + { + name: "bootstrap catalog", + write: func(t *testing.T, path string) { + bootstrap := catalog.BootstrapCatalog() + if err := catalog.WriteCatalogCache(path, &bootstrap); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "zero stale-after", + write: func(t *testing.T, path string) { + seed := catalog.SeedCatalog() + seed.StaleAfter = time.Time{} + data, err := json.Marshal(seed) + if err != nil { + t.Fatal(err) + } + writeHostControlTestFile(t, path, data) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + eng := newHostControlTestEngine(t) + tt.write(t, eng.catalogPath) + + health := eng.CatalogHealth(context.Background()) + if !health.Exists { + t.Fatalf("on-disk cache reported as missing: %+v", health) + } + if health.Error == "" { + t.Fatalf("invalid cache reported healthy: %+v", health) + } + if health.Models != 0 || health.Source != "" || health.Stale || !health.StaleAfter.IsZero() { + t.Fatalf("invalid cache inherited compiled/bootstrap metadata: %+v", health) + } + }) + } +} + +func TestCatalogHealthReportsValidCacheWithoutFalseStaleness(t *testing.T) { + eng := newHostControlTestEngine(t) + seed := catalog.SeedCatalog() + seed.StaleAfter = time.Now().UTC().Add(time.Hour) + if err := catalog.WriteCatalogCache(eng.catalogPath, &seed); err != nil { + t.Fatal(err) + } + + health := eng.CatalogHealth(context.Background()) + if health.Error != "" || !health.Exists || health.Models == 0 { + t.Fatalf("valid cache reported unhealthy: %+v", health) + } + if health.Stale { + t.Fatalf("future stale-after reported stale: %+v", health) + } + if health.Source != "test" { + t.Fatalf("source = %q, want test", health.Source) + } +} + +func TestPreflightRequiresPersistedModelEvenWhenEffectiveSelectionCanChooseOne(t *testing.T) { + ctx := context.Background() + store := &credentials.MapStore{} + eng, err := New(Options{SecretStore: store, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + seed := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(eng.catalogPath, &seed); err != nil { + t.Fatal(err) + } + if err := store.Set(ctx, credentials.AccountForEnv("OPENAI_API_KEY"), "sk-injected-secret"); err != nil { + t.Fatal(err) + } + + effective := eng.EffectiveSelection(ctx, SelectionOptions{}) + if strings.TrimSpace(effective.Model) == "" { + t.Fatal("test setup did not produce an automatic effective model") + } + report := eng.Preflight(ctx) + if report.Ready { + t.Fatalf("preflight accepted an automatic model without persisted user selection: %+v", report) + } + assertPreflightCheck(t, report, "model", CheckFail, "no model selected") +} + +func TestPreflightAcceptsPersistedModelSelection(t *testing.T) { + ctx := context.Background() + store := &credentials.MapStore{} + eng, err := New(Options{SecretStore: store, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + seed := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(eng.catalogPath, &seed); err != nil { + t.Fatal(err) + } + if err := store.Set(ctx, credentials.AccountForEnv("OPENAI_API_KEY"), "sk-injected-secret"); err != nil { + t.Fatal(err) + } + if err := eng.SetSelection(ctx, "openai", catalog.GPT_4o); err != nil { + t.Fatal(err) + } + + report := eng.Preflight(ctx) + if !report.Ready { + t.Fatalf("persisted, credentialed model was not ready: %+v", report) + } + assertPreflightCheck(t, report, "model", CheckOK, "openai/gpt-4o") +} + +func newHostControlTestEngine(t *testing.T) *Engine { + t.Helper() + eng, err := New(Options{SecretStore: &credentials.MapStore{}, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + return eng +} + +func writeHostControlTestFile(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +func assertPreflightCheck(t *testing.T, report PreflightReport, name string, status CheckStatus, detail string) { + t.Helper() + for _, check := range report.Checks { + if check.Name == name { + if check.Status != status || check.Detail != detail { + t.Fatalf("%s check = %+v, want status=%q detail=%q", name, check, status, detail) + } + return + } + } + t.Fatalf("missing %q check in %+v", name, report) +} diff --git a/engine/host_facade_contract_test.go b/engine/host_facade_contract_test.go new file mode 100644 index 0000000..11034d2 --- /dev/null +++ b/engine/host_facade_contract_test.go @@ -0,0 +1,227 @@ +package engine + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestGatewayDefinitionsArePureMetadataWithSeparateRanks(t *testing.T) { + store := &countingStore{inner: &credentials.MapStore{}} + eng, err := New(Options{SecretStore: store, StateDir: t.TempDir(), CustomGateways: []CustomGateway{}}) + if err != nil { + t.Fatal(err) + } + definitions := eng.GatewayDefinitions() + if store.gets != 0 { + t.Fatalf("pure gateway metadata read the credential store %d times", store.gets) + } + if paths := eng.StatePaths(); paths.Catalog != eng.catalogPath || paths.ProviderConfig != eng.providerConfigPath || store.gets != 0 { + t.Fatalf("StatePaths() performed work or returned wrong paths: %+v", paths) + } + var anthropic, openai Gateway + for i, gateway := range definitions { + if i > 0 && definitions[i-1].SortOrder > gateway.SortOrder { + t.Fatalf("gateway definitions are not in UI order: %+v", definitions) + } + switch gateway.ID { + case "anthropic": + anthropic = gateway + case "openai": + openai = gateway + } + } + if anthropic.ID == "" || openai.ID == "" || + anthropic.SortOrder >= openai.SortOrder || openai.ChatPreference >= anthropic.ChatPreference { + t.Fatalf("UI and chat ranks were conflated: anthropic=%+v openai=%+v", anthropic, openai) + } + configured := map[string]bool{"anthropic": true, "openai": true} + if got := preferredConfiguredGateway(definitions, configured); got != "openai" { + t.Fatalf("preferred gateway = %q, want chat-ranked openai", got) + } +} + +func TestCustomGatewayOptionsOverrideProcessGlobalRegistry(t *testing.T) { + registerCustomGatewayForTest(t, CustomGateway{ + ID: "global-only-contract", BaseURL: "https://global.example.test/v1", DefaultModel: "global/model", + }) + store := &credentials.MapStore{} + explicit, err := New(Options{ + SecretStore: store, StateDir: t.TempDir(), + CustomGateways: []CustomGateway{{ + ID: "instance-only-contract", BaseURL: "https://instance.example.test/v1", DefaultModel: "instance/model", + }}, + }) + if err != nil { + t.Fatal(err) + } + if _, ok := explicit.customGateway("instance-only-contract"); !ok { + t.Fatal("per-engine custom gateway missing") + } + if _, ok := explicit.customGateway("global-only-contract"); ok { + t.Fatal("per-engine options leaked process-global compatibility gateway") + } + compat, err := New(Options{SecretStore: store, StateDir: t.TempDir(), UseRegisteredCustomGateways: true}) + if err != nil { + t.Fatal(err) + } + if _, ok := compat.customGateway("global-only-contract"); !ok { + t.Fatal("nil custom options did not preserve compatibility registration") + } +} + +func TestCustomGatewayCredentialStatusAndRemovalUseInjectedStore(t *testing.T) { + ctx := context.Background() + const envKey = "INSTANCE_ONLY_API_KEY" + store := &credentials.MapStore{} + if err := store.Set(ctx, credentials.AccountForEnv(envKey), "instance-live-secret-1234567890"); err != nil { + t.Fatal(err) + } + eng, err := New(Options{ + SecretStore: store, StateDir: t.TempDir(), + CustomGateways: []CustomGateway{{ + ID: "instance-only", BaseURL: "https://instance.example.test/v1", + CredentialEnv: envKey, DefaultModel: "instance/model", + }}, + }) + if err != nil { + t.Fatal(err) + } + status, err := eng.CredentialStatus(ctx, "instance-only") + if err != nil || !status.Configured || status.EnvVar != envKey { + t.Fatalf("custom credential status = %+v, err=%v", status, err) + } + if err := eng.RemoveCredential(ctx, "instance-only"); err != nil { + t.Fatal(err) + } + status, err = eng.CredentialStatus(ctx, "instance-only") + if err != nil || status.Configured { + t.Fatalf("custom credential survived removal: %+v, err=%v", status, err) + } +} + +func TestCredentialAliasesDriveStatusDiscoveryAndRemoval(t *testing.T) { + ctx := context.Background() + for _, test := range []struct { + provider string + primary string + alias string + }{ + {provider: "anthropic", primary: "ANTHROPIC_API_KEY", alias: "CLAUDE_API_KEY"}, + {provider: "gemini", primary: "GEMINI_API_KEY", alias: "GOOGLE_API_KEY"}, + {provider: "xiaomi_mimo_payg", primary: "XIAOMI_MIMO_PAYG_API_KEY", alias: "XIAOMI_MIMO_API_KEY"}, + } { + t.Run(test.provider, func(t *testing.T) { + dir := t.TempDir() + seed := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(filepath.Join(dir, "model_catalog.json"), &seed); err != nil { + t.Fatal(err) + } + compiled, err := catalog.CompileCatalog(&seed) + if err != nil { + t.Fatal(err) + } + store := &credentials.MapStore{} + if err := store.Set(ctx, credentials.AccountForEnv(test.alias), "alias-live-secret-1234567890"); err != nil { + t.Fatal(err) + } + eng, err := New(Options{SecretStore: store, StateDir: dir, CustomGateways: []CustomGateway{}}) + if err != nil { + t.Fatal(err) + } + status, err := eng.CredentialStatus(ctx, test.provider) + if err != nil || !status.Configured || status.EnvVar != test.primary { + t.Fatalf("alias status = %+v, err=%v", status, err) + } + if got := eng.credentialEnv(ctx, compiled)[test.primary]; got != "alias-live-secret-1234567890" { + t.Fatalf("canonical discovery credential = %q", got) + } + if err := eng.RemoveCredential(ctx, test.provider); err != nil { + t.Fatal(err) + } + if _, err := store.Get(ctx, credentials.AccountForEnv(test.alias)); err == nil { + t.Fatal("credential alias survived removal") + } + }) + } +} + +func TestModelRowsKeepOwnerGatewayCanonicalAndLiveMetadataDistinct(t *testing.T) { + dir := t.TempDir() + seed := catalog.SeedCatalog() + var liveCanonical string + for i := range seed.Offerings { + if seed.Offerings[i].DeploymentID == "gemini-direct" { + seed.Offerings[i].LiveMetadata = json.RawMessage(`{"owned_by":"google-live","future_field":true}`) + liveCanonical = seed.Offerings[i].CanonicalModelID + break + } + } + if liveCanonical == "" { + t.Fatal("seed catalog has no Gemini offering") + } + if err := catalog.WriteCatalogCache(filepath.Join(dir, "model_catalog.json"), &seed); err != nil { + t.Fatal(err) + } + eng, err := New(Options{SecretStore: &credentials.MapStore{}, StateDir: dir, CustomGateways: []CustomGateway{}}) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + gateway string + owner string + }{ + {gateway: "gemini", owner: "google"}, + {gateway: "grok", owner: "xai"}, + } { + models, err := eng.ListModels(context.Background(), test.gateway, false) + if err != nil || len(models) == 0 { + t.Fatalf("ListModels(%q) = %+v, err=%v", test.gateway, models, err) + } + for _, model := range models { + if model.ProviderID != test.owner || model.GatewayID != test.gateway || model.CanonicalID == "" { + t.Fatalf("model identity was conflated for %q: %+v", test.gateway, model) + } + } + } + models, err := eng.ListModels(context.Background(), "gemini", false) + if err != nil { + t.Fatal(err) + } + found := false + for _, model := range models { + if model.CanonicalID == liveCanonical { + found = true + var metadata map[string]interface{} + if err := json.Unmarshal(model.LiveMetadata, &metadata); err != nil || + metadata["owned_by"] != "google-live" || metadata["future_field"] != true { + t.Fatalf("live provider metadata was not preserved: %s", model.LiveMetadata) + } + } + } + if !found { + t.Fatalf("live canonical row %q not found: %+v", liveCanonical, models) + } +} + +type countingStore struct { + inner *credentials.MapStore + gets int +} + +func (s *countingStore) Set(ctx context.Context, account, secret string) error { + return s.inner.Set(ctx, account, secret) +} + +func (s *countingStore) Get(ctx context.Context, account string) (string, error) { + s.gets++ + return s.inner.Get(ctx, account) +} + +func (s *countingStore) Delete(ctx context.Context, account string) error { + return s.inner.Delete(ctx, account) +} diff --git a/engine/host_runtime.go b/engine/host_runtime.go index a148420..6b80abd 100644 --- a/engine/host_runtime.go +++ b/engine/host_runtime.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "net/http" "net/url" "strings" "sync" + "time" "github.com/GrayCodeAI/eyrie/catalog/registry" "github.com/GrayCodeAI/eyrie/client" @@ -36,6 +38,8 @@ type CustomGateway struct { DefaultModel string `json:"default_model,omitempty"` MaxTokensField string `json:"max_tokens_field,omitempty"` ContextWindow int `json:"context_window,omitempty"` + SortOrder int `json:"sort_order,omitempty"` + ChatPreference int `json:"chat_preference,omitempty"` Capabilities *CustomGatewayCapabilities `json:"capabilities,omitempty"` } @@ -44,35 +48,56 @@ var customGatewayRegistry = struct { gateways map[string]CustomGateway }{gateways: make(map[string]CustomGateway)} -// RegisterCustomGateway registers safe routing metadata for a host-supplied -// OpenAI-compatible gateway. Registration must happen before Engine creation; +// RegisterCustomGateway registers safe OpenAI-compatible routing metadata for +// compatibility callers. New embedders should pass Options.CustomGateways so +// instances stay isolated. Registration must happen before Engine creation; // each Engine snapshots the metadata and always resolves credentials through // its own injected SecretStore. func RegisterCustomGateway(gateway CustomGateway) error { + gateway, err := normalizeCustomGateway(gateway) + if err != nil { + return err + } + customGatewayRegistry.Lock() + customGatewayRegistry.gateways[gateway.ID] = cloneCustomGateway(gateway) + customGatewayRegistry.Unlock() + return nil +} + +func normalizeCustomGateway(gateway CustomGateway) (CustomGateway, error) { id := NormalizeProviderID(gateway.ID) if id == "" { - return fmt.Errorf("eyrie engine: custom gateway ID is required") + return CustomGateway{}, fmt.Errorf("eyrie engine: custom gateway ID is required") } if builtIn, ok := registry.SpecByProviderID(id); ok { - return fmt.Errorf("eyrie engine: custom gateway ID %q collides with built-in gateway %q", id, builtIn.ProviderID) + return CustomGateway{}, fmt.Errorf("eyrie engine: custom gateway ID %q collides with built-in gateway %q", id, builtIn.ProviderID) } baseURL := strings.TrimSpace(gateway.BaseURL) if baseURL == "" { - return fmt.Errorf("eyrie engine: custom gateway %q base URL is required", id) + return CustomGateway{}, fmt.Errorf("eyrie engine: custom gateway %q base URL is required", id) } parsed, err := url.Parse(baseURL) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { - return fmt.Errorf("eyrie engine: custom gateway %q has invalid baseURL %q (must be http/https with host)", id, baseURL) + return CustomGateway{}, fmt.Errorf("eyrie engine: custom gateway %q has invalid baseURL %q (must be http/https with host)", id, baseURL) + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return CustomGateway{}, fmt.Errorf("eyrie engine: custom gateway %q baseURL must not contain userinfo, query, or fragment", id) } if gateway.ContextWindow < 0 { - return fmt.Errorf("eyrie engine: custom gateway %q context window cannot be negative", id) + return CustomGateway{}, fmt.Errorf("eyrie engine: custom gateway %q context window cannot be negative", id) + } + if gateway.SortOrder <= 0 { + gateway.SortOrder = 10_000 + } + if gateway.ChatPreference <= 0 { + gateway.ChatPreference = gateway.SortOrder + 10_000 } maxTokensField := strings.TrimSpace(gateway.MaxTokensField) if maxTokensField == "" { maxTokensField = "max_tokens" } if maxTokensField != "max_tokens" && maxTokensField != "max_completion_tokens" { - return fmt.Errorf("eyrie engine: custom gateway %q max tokens field must be max_tokens or max_completion_tokens", id) + return CustomGateway{}, fmt.Errorf("eyrie engine: custom gateway %q max tokens field must be max_tokens or max_completion_tokens", id) } gateway.ID = id gateway.BaseURL = strings.TrimRight(baseURL, "/") @@ -83,10 +108,25 @@ func RegisterCustomGateway(gateway CustomGateway) error { gateway.CredentialEnv = strings.TrimSpace(gateway.CredentialEnv) gateway.DefaultModel = strings.TrimSpace(gateway.DefaultModel) gateway.MaxTokensField = maxTokensField - customGatewayRegistry.Lock() - customGatewayRegistry.gateways[id] = cloneCustomGateway(gateway) - customGatewayRegistry.Unlock() - return nil + return gateway, nil +} + +func customGatewaysForOptions(gateways []CustomGateway, useRegistered bool) (map[string]CustomGateway, error) { + if gateways == nil && useRegistered { + return snapshotCustomGateways(), nil + } + out := make(map[string]CustomGateway, len(gateways)) + for _, gateway := range gateways { + normalized, err := normalizeCustomGateway(gateway) + if err != nil { + return nil, &Error{Code: ErrorInvalidRequest, Operation: "new", Message: err.Error(), Cause: err} + } + if _, exists := out[normalized.ID]; exists { + return nil, invalid("new", fmt.Sprintf("eyrie engine: duplicate custom gateway %q", normalized.ID)) + } + out[normalized.ID] = cloneCustomGateway(normalized) + } + return out, nil } func snapshotCustomGateways() map[string]CustomGateway { @@ -118,15 +158,23 @@ func (e *Engine) customGateway(providerID string) (CustomGateway, bool) { func (e *Engine) resolveCustomSelection(req SelectionRequest) (Route, bool, error) { providerID := NormalizeProviderID(req.Preference.PreferredProvider) modelID := strings.TrimSpace(req.Preference.PreferredModelID) - state := config.LoadProviderConfig(e.providerConfigPath) + state, stateErr := e.loadProviderConfigStrict() + if stateErr != nil { + _, preferredCustom := e.customGateway(providerID) + if preferredCustom { + return Route{}, true, &Error{Code: ErrorInternal, Operation: "resolve", Provider: providerID, Message: stateErr.Error(), Cause: stateErr} + } + return Route{}, false, nil + } + activeProvider := NormalizeProviderID(config.ActiveProvider(state)) if providerID == "" { - providerID = NormalizeProviderID(config.ActiveProvider(state)) + providerID = activeProvider } gateway, ok := e.customGateway(providerID) if !ok { return Route{}, false, nil } - if modelID == "" { + if modelID == "" && providerID == activeProvider { modelID = strings.TrimSpace(config.ActiveModel(state)) } if modelID == "" { @@ -202,6 +250,42 @@ func (e *Engine) customGatewayTransport(ctx context.Context, route Route) (clien return provider, true, nil } +var customGatewayProbeClient = &http.Client{Timeout: 10 * time.Second} + +func (e *Engine) probeCustomGateway(ctx context.Context, gateway CustomGateway, secret string) error { + if gateway.CredentialEnv != "" && strings.TrimSpace(secret) == "" { + var err error + secret, err = e.credentialValue(ctx, gateway.CredentialEnv) + if err != nil { + return err + } + } + if gateway.CredentialEnv != "" && (strings.TrimSpace(secret) == "" || config.LooksLikePlaceholderSecret(secret)) { + return &Error{Code: ErrorCredentialMissing, Operation: "probe_credential", Provider: gateway.ID, Message: "eyrie engine: custom gateway credential is not configured"} + } + req, err := http.NewRequestWithContext(nonNilContext(ctx), http.MethodGet, strings.TrimRight(gateway.BaseURL, "/")+"/models", nil) + if err != nil { + return err + } + if strings.TrimSpace(secret) != "" { + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(secret)) + } + req.Header.Set("Accept", "application/json") + resp, err := customGatewayProbeClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + code := ErrorProviderUnavailable + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + code = ErrorAuthentication + } + return &Error{Code: code, Operation: "probe_credential", Provider: gateway.ID, Message: fmt.Sprintf("eyrie engine: custom gateway probe returned HTTP %d", resp.StatusCode)} + } + return nil +} + func customGatewayCapabilityNames(gateway CustomGateway) []string { if gateway.Capabilities == nil { return nil diff --git a/engine/host_runtime_test.go b/engine/host_runtime_test.go index 33775bf..1f08a89 100644 --- a/engine/host_runtime_test.go +++ b/engine/host_runtime_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "strings" @@ -22,6 +23,8 @@ func TestRegisterCustomGatewayValidatesHostMetadata(t *testing.T) { {name: "missing id", gateway: CustomGateway{BaseURL: "https://example.test/v1"}, want: "ID is required"}, {name: "missing base URL", gateway: CustomGateway{ID: "custom"}, want: "base URL is required"}, {name: "invalid base URL", gateway: CustomGateway{ID: "custom", BaseURL: "file:///tmp/socket"}, want: "invalid baseURL"}, + {name: "secret in base URL", gateway: CustomGateway{ID: "custom", BaseURL: "https://user:secret@example.test/v1"}, want: "must not contain userinfo"}, + {name: "query in base URL", gateway: CustomGateway{ID: "custom", BaseURL: "https://example.test/v1?key=secret"}, want: "must not contain userinfo"}, {name: "built-in collision", gateway: CustomGateway{ID: "openai", BaseURL: "https://example.test/v1"}, want: "collides with built-in gateway"}, } { t.Run(test.name, func(t *testing.T) { @@ -120,7 +123,7 @@ func TestCustomGatewayUnknownToolModelUsesInjectedStoreForGenerateAndStream(t *t if err := store.Set(context.Background(), credentials.AccountForEnv(envKey), "injected-secret"); err != nil { t.Fatal(err) } - eng, err := New(Options{StateDir: t.TempDir(), SecretStore: store}) + eng, err := New(Options{StateDir: t.TempDir(), SecretStore: store, UseRegisteredCustomGateways: true}) if err != nil { t.Fatal(err) } @@ -187,7 +190,7 @@ func TestCustomGatewayDeclaredCapabilitiesAreEnforced(t *testing.T) { ID: gatewayID, BaseURL: "https://example.test/v1", DefaultModel: "custom/model", Capabilities: &CustomGatewayCapabilities{Streaming: true, Tools: false}, }) - eng, err := New(Options{StateDir: t.TempDir(), SecretStore: &credentials.MapStore{}}) + eng, err := New(Options{StateDir: t.TempDir(), SecretStore: &credentials.MapStore{}, UseRegisteredCustomGateways: true}) if err != nil { t.Fatal(err) } @@ -212,7 +215,7 @@ func TestCustomGatewayRejectsPlaceholderFromInjectedStore(t *testing.T) { if err := store.Set(context.Background(), credentials.AccountForEnv(envKey), "your-api-key-here"); err != nil { t.Fatal(err) } - eng, err := New(Options{StateDir: t.TempDir(), SecretStore: store}) + eng, err := New(Options{StateDir: t.TempDir(), SecretStore: store, UseRegisteredCustomGateways: true}) if err != nil { t.Fatal(err) } @@ -225,6 +228,57 @@ func TestCustomGatewayRejectsPlaceholderFromInjectedStore(t *testing.T) { } } +func TestSaveCustomGatewayCredentialUsesStrictTwoXXProbe(t *testing.T) { + const envKey = "CUSTOM_PROBE_API_KEY" + previous := customGatewayProbeClient + t.Cleanup(func() { customGatewayProbeClient = previous }) + statusCode := http.StatusNoContent + customGatewayProbeClient = &http.Client{Transport: engineRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.String() != "https://probe.example.test/v1/models" { + t.Fatalf("probe URL = %q", req.URL.String()) + } + if req.Header.Get("Authorization") != "Bearer custom-secret-1234567890" { + t.Fatalf("probe authorization header was not derived from the supplied credential") + } + return &http.Response{ + StatusCode: statusCode, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{}`)), + Request: req, + }, nil + })} + store := &credentials.MapStore{} + eng, err := New(Options{ + SecretStore: store, StateDir: t.TempDir(), + CustomGateways: []CustomGateway{{ + ID: "strict-probe", BaseURL: "https://probe.example.test/v1", CredentialEnv: envKey, + }}, + }) + if err != nil { + t.Fatal(err) + } + status, err := eng.SaveCredential(context.Background(), "strict-probe", "custom-secret-1234567890") + if err != nil || !status.Configured || !status.Verified { + t.Fatalf("2xx custom probe = %+v, err=%v", status, err) + } + statusCode = http.StatusForbidden + status, err = eng.SaveCredential(context.Background(), "strict-probe", "custom-secret-1234567890") + if !status.Configured || status.Verified || !IsCode(err, ErrorAuthentication) { + t.Fatalf("403 custom probe = %+v, err=%v", status, err) + } + statusCode = http.StatusInternalServerError + status, err = eng.SaveCredential(context.Background(), "strict-probe", "custom-secret-1234567890") + if !status.Configured || status.Verified || !IsCode(err, ErrorProviderUnavailable) { + t.Fatalf("500 custom probe = %+v, err=%v", status, err) + } +} + +type engineRoundTripFunc func(*http.Request) (*http.Response, error) + +func (fn engineRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + func registerCustomGatewayForTest(t *testing.T, gateway CustomGateway) { t.Helper() id := NormalizeProviderID(gateway.ID) diff --git a/engine/live_models_test.go b/engine/live_models_test.go new file mode 100644 index 0000000..fa62265 --- /dev/null +++ b/engine/live_models_test.go @@ -0,0 +1,79 @@ +package engine + +import ( + "bytes" + "context" + "encoding/json" + "os" + "testing" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/catalog/live" + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestListLiveModelsUsesInjectedStateWithoutMutatingCache(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + store := &credentials.MapStore{} + if err := store.Set(ctx, credentials.AccountForEnv("CANOPYWAVE_API_KEY"), "injected-live-secret-1234567890"); err != nil { + t.Fatal(err) + } + if err := store.Set(ctx, credentials.AccountForEnv("OPENAI_API_KEY"), "unrelated-openai-secret-1234567890"); err != nil { + t.Fatal(err) + } + eng, err := New(Options{SecretStore: store, StateDir: dir, CustomGateways: []CustomGateway{}}) + if err != nil { + t.Fatal(err) + } + seed := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(eng.catalogPath, &seed); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(eng.catalogPath) + if err != nil { + t.Fatal(err) + } + t.Setenv("CANOPYWAVE_API_KEY", "ambient-secret-must-not-be-used") + + previous := live.Registry["canopywave"] + var captured map[string]string + live.Registry["canopywave"] = func(env map[string]string) ([]live.Entry, error) { + captured = make(map[string]string, len(env)) + for key, value := range env { + captured[key] = value + } + return []live.Entry{{ + ID: "vendor/live-model", DisplayName: "Live Model", OwnedBy: "vendor", + ContextWindow: 256_000, MaxOutput: 16_000, + InputPricePer1M: 1.25, OutputPricePer1M: 5, + Features: []string{"future_tool"}, + RawJSON: json.RawMessage(`{"id":"vendor/live-model","future_field":true}`), + }}, nil + } + t.Cleanup(func() { live.Registry["canopywave"] = previous }) + + models, err := eng.ListLiveModels(ctx, "canopywave") + if err != nil { + t.Fatal(err) + } + if captured["CANOPYWAVE_API_KEY"] != "injected-live-secret-1234567890" { + t.Fatalf("live listing escaped injected credentials: %#v", captured) + } + if _, leaked := captured["OPENAI_API_KEY"]; leaked { + t.Fatalf("provider-scoped fetch received an unrelated credential: %#v", captured) + } + if len(models) != 1 || models[0].ID != "vendor/live-model" || models[0].Source != "live" || + models[0].GatewayID != "canopywave" || models[0].ProviderID != "canopywave" || + len(models[0].Capabilities) != 1 || models[0].Capabilities[0] != "future_tool" || + !json.Valid(models[0].LiveMetadata) { + t.Fatalf("direct live model contract = %+v", models) + } + after, err := os.ReadFile(eng.catalogPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, before) { + t.Fatal("direct live model listing mutated the catalog cache") + } +} diff --git a/engine/model_policy.go b/engine/model_policy.go index 32a6b5b..babeb68 100644 --- a/engine/model_policy.go +++ b/engine/model_policy.go @@ -18,6 +18,9 @@ func (e *Engine) policyCatalog(ctx context.Context) (*catalog.CompiledCatalog, e // ModelInfo returns normalized metadata for a model id or alias. func (e *Engine) ModelInfo(ctx context.Context, modelID string) (Model, bool, error) { + if gateway, ok := e.customGatewayForModel(modelID); ok { + return customGatewayModel(gateway), true, nil + } compiled, err := e.policyCatalog(ctx) if err != nil { return Model{}, false, err @@ -34,17 +37,41 @@ func (e *Engine) ModelInfo(ctx context.Context, modelID string) (Model, bool, er return Model{}, false, nil } -// ModelProviders lists canonical catalog model owners. +// ModelProviders lists canonical catalog model owners and invocation-scoped +// custom gateway owners. func (e *Engine) ModelProviders(ctx context.Context) ([]string, error) { compiled, err := e.policyCatalog(ctx) - if err != nil { + if err != nil && len(e.customGateways) == 0 { return nil, err } - return catalog.AllModelProviders(compiled), nil + seen := map[string]bool{} + catalogProviders := catalog.AllModelProviders(compiled) + providers := make([]string, 0, len(e.customGateways)+len(catalogProviders)) + for _, provider := range catalogProviders { + provider = strings.TrimSpace(provider) + if provider != "" && !seen[provider] { + seen[provider] = true + providers = append(providers, provider) + } + } + for _, gateway := range e.orderedCustomGateways() { + if !seen[gateway.ID] { + seen[gateway.ID] = true + providers = append(providers, gateway.ID) + } + } + sort.Strings(providers) + return providers, nil } // DefaultModel returns the catalog default for a provider. func (e *Engine) DefaultModel(ctx context.Context, provider, fallback string) string { + if gateway, ok := e.customGateway(provider); ok { + if gateway.DefaultModel != "" { + return gateway.DefaultModel + } + return fallback + } compiled, err := e.policyCatalog(ctx) if err != nil { return fallback @@ -54,6 +81,12 @@ func (e *Engine) DefaultModel(ctx context.Context, provider, fallback string) st // PreferredModel returns a provider model in the requested relative class. func (e *Engine) PreferredModel(ctx context.Context, provider string, class ModelClass, fallback string) string { + if gateway, ok := e.customGateway(provider); ok { + if gateway.DefaultModel != "" { + return gateway.DefaultModel + } + return fallback + } compiled, err := e.policyCatalog(ctx) if err != nil { return fallback @@ -65,9 +98,47 @@ func (e *Engine) PreferredModel(ctx context.Context, provider string, class Mode func (e *Engine) PreferredModels(ctx context.Context, primaryProvider string, class ModelClass, limit int) []string { compiled, err := e.policyCatalog(ctx) if err != nil { - return nil + compiled = nil + } + seen := map[string]bool{} + models := make([]string, 0, len(e.customGateways)+8) + add := func(model string) bool { + model = strings.TrimSpace(model) + if model == "" || seen[model] { + return false + } + seen[model] = true + models = append(models, model) + return limit > 0 && len(models) >= limit + } + + primaryCustom, isPrimaryCustom := e.customGateway(primaryProvider) + if isPrimaryCustom && add(primaryCustom.DefaultModel) { + return models + } + if !isPrimaryCustom { + for _, model := range catalog.PreferredModelsForTier(compiled, primaryProvider, catalogTier(class), limit) { + if add(model) { + return models + } + } + } + for _, gateway := range e.orderedCustomGateways() { + if isPrimaryCustom && gateway.ID == primaryCustom.ID { + continue + } + if add(gateway.DefaultModel) { + return models + } + } + if isPrimaryCustom { + for _, model := range catalog.PreferredModelsForTier(compiled, "", catalogTier(class), limit) { + if add(model) { + return models + } + } } - return catalog.PreferredModelsForTier(compiled, primaryProvider, catalogTier(class), limit) + return models } // ModelClassOf resolves a model's relative catalog cost band. @@ -83,8 +154,12 @@ func (e *Engine) ModelClassOf(ctx context.Context, modelID string) ModelClass { } } -// ProviderForModel returns the canonical catalog owner for a model. +// ProviderForModel returns the canonical catalog owner or invocation-scoped +// custom gateway owner for a model. func (e *Engine) ProviderForModel(ctx context.Context, modelID string) string { + if gateway, ok := e.customGatewayForModel(modelID); ok { + return gateway.ID + } compiled, _ := e.policyCatalog(ctx) return catalog.ProviderForModel(compiled, modelID) } @@ -92,15 +167,20 @@ func (e *Engine) ProviderForModel(ctx context.Context, modelID string) string { // PrimaryModel returns Eyrie's stable best-effort catalog primary model. func (e *Engine) PrimaryModel(ctx context.Context) string { compiled, _ := e.policyCatalog(ctx) - return catalog.PrimaryModel(compiled) + if model := catalog.PrimaryModel(compiled); model != "" { + return model + } + for _, gateway := range e.orderedCustomGateways() { + if gateway.DefaultModel != "" { + return gateway.DefaultModel + } + } + return "" } // ModelNames returns canonical ids, display names, and aliases for completion. func (e *Engine) ModelNames(ctx context.Context) []string { compiled, err := e.policyCatalog(ctx) - if err != nil || compiled == nil { - return nil - } seen := map[string]bool{} var out []string add := func(value string) { @@ -110,20 +190,66 @@ func (e *Engine) ModelNames(ctx context.Context) []string { out = append(out, value) } } - for id, model := range compiled.ModelsByID { - add(id) - add(model.Name) + for _, gateway := range e.orderedCustomGateways() { + add(gateway.DefaultModel) } - if compiled.Catalog != nil { - for alias, canonical := range compiled.Catalog.Aliases { - add(alias) - add(canonical) + if err == nil && compiled != nil { + for id, model := range compiled.ModelsByID { + add(id) + add(model.Name) + } + if compiled.Catalog != nil { + for alias, canonical := range compiled.Catalog.Aliases { + add(alias) + add(canonical) + } } } sort.Strings(out) return out } +// orderedCustomGateways returns the immutable per-Engine gateway snapshot in +// routing preference order. Map iteration must never decide which custom +// gateway owns a model ID shared by multiple invocation-scoped gateways. +func (e *Engine) orderedCustomGateways() []CustomGateway { + gateways := make([]CustomGateway, 0, len(e.customGateways)) + for _, gateway := range e.customGateways { + gateways = append(gateways, cloneCustomGateway(gateway)) + } + sort.SliceStable(gateways, func(i, j int) bool { + if gateways[i].ChatPreference != gateways[j].ChatPreference { + return gateways[i].ChatPreference < gateways[j].ChatPreference + } + if gateways[i].SortOrder != gateways[j].SortOrder { + return gateways[i].SortOrder < gateways[j].SortOrder + } + return gateways[i].ID < gateways[j].ID + }) + return gateways +} + +func (e *Engine) customGatewayForModel(modelID string) (CustomGateway, bool) { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return CustomGateway{}, false + } + for _, gateway := range e.orderedCustomGateways() { + if gateway.DefaultModel == modelID { + return gateway, true + } + } + return CustomGateway{}, false +} + +func customGatewayModel(gateway CustomGateway) Model { + return Model{ + ID: gateway.DefaultModel, CanonicalID: gateway.DefaultModel, DisplayName: gateway.DefaultModel, + Owner: gateway.DisplayName, ProviderID: gateway.ID, GatewayID: gateway.ID, + ContextWindow: gateway.ContextWindow, Capabilities: customGatewayCapabilityNames(gateway), Source: "custom", + } +} + func catalogTier(class ModelClass) catalog.ModelTier { switch class { case ModelClassEconomical: diff --git a/engine/model_policy_custom_test.go b/engine/model_policy_custom_test.go new file mode 100644 index 0000000..f1b4503 --- /dev/null +++ b/engine/model_policy_custom_test.go @@ -0,0 +1,111 @@ +package engine + +import ( + "context" + "path/filepath" + "reflect" + "slices" + "testing" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestModelPolicyIncludesInvocationScopedCustomGateways(t *testing.T) { + dir := t.TempDir() + seed := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(filepath.Join(dir, "model_catalog.json"), &seed); err != nil { + t.Fatal(err) + } + eng, err := New(Options{ + SecretStore: &credentials.MapStore{}, StateDir: dir, + CustomGateways: []CustomGateway{ + {ID: "custom-secondary", DisplayName: "Secondary", BaseURL: "https://secondary.example.test/v1", DefaultModel: "custom/secondary", SortOrder: 2, ChatPreference: 20}, + {ID: "custom-primary", DisplayName: "Primary", BaseURL: "https://primary.example.test/v1", DefaultModel: "custom/primary", ContextWindow: 128_000, SortOrder: 1, ChatPreference: 10, Capabilities: &CustomGatewayCapabilities{Streaming: true, Tools: true}}, + }, + }) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + if got := eng.DefaultModel(ctx, "custom-primary", "fallback"); got != "custom/primary" { + t.Fatalf("DefaultModel(custom) = %q", got) + } + if got := eng.PreferredModel(ctx, "custom-primary", ModelClassPremium, "fallback"); got != "custom/primary" { + t.Fatalf("PreferredModel(custom) = %q", got) + } + preferred := eng.PreferredModels(ctx, "custom-primary", ModelClassBalanced, 2) + if !slices.Equal(preferred, []string{"custom/primary", "custom/secondary"}) { + t.Fatalf("PreferredModels(custom) = %v", preferred) + } + if got := eng.ProviderForModel(ctx, "custom/primary"); got != "custom_primary" { + t.Fatalf("ProviderForModel(custom) = %q", got) + } + if got := eng.GatewayForModel(ctx, "custom/primary"); got != "custom_primary" { + t.Fatalf("GatewayForModel(custom) = %q", got) + } + model, ok, err := eng.ModelInfo(ctx, "custom/primary") + if err != nil || !ok { + t.Fatalf("ModelInfo(custom) = %+v, %v, %v", model, ok, err) + } + if model.ID != "custom/primary" || model.CanonicalID != model.ID || model.ProviderID != "custom_primary" || model.GatewayID != "custom_primary" || model.Owner != "Primary" || model.ContextWindow != 128_000 || !slices.Contains(model.Capabilities, "tools") { + t.Fatalf("custom model metadata = %+v", model) + } + listed, err := eng.ListModels(ctx, "custom-primary", false) + if err != nil || len(listed) != 1 || !reflect.DeepEqual(listed[0], model) { + t.Fatalf("ListModels(custom) = %+v, err=%v; ModelInfo = %+v", listed, err, model) + } + if names := eng.ModelNames(ctx); !slices.Contains(names, "custom/primary") || !slices.Contains(names, "custom/secondary") { + t.Fatalf("custom model names missing: %v", names) + } + providers, err := eng.ModelProviders(ctx) + if err != nil || !slices.Contains(providers, "custom_primary") || !slices.Contains(providers, "custom_secondary") || !slices.IsSorted(providers) { + t.Fatalf("custom model providers = %v, err=%v", providers, err) + } +} + +func TestCustomModelOwnershipUsesDeterministicInstancePreference(t *testing.T) { + eng, err := New(Options{ + SecretStore: &credentials.MapStore{}, StateDir: t.TempDir(), + CustomGateways: []CustomGateway{ + {ID: "later", BaseURL: "https://later.example.test/v1", DefaultModel: "shared/model", SortOrder: 1, ChatPreference: 20}, + {ID: "preferred", BaseURL: "https://preferred.example.test/v1", DefaultModel: "shared/model", SortOrder: 99, ChatPreference: 10}, + }, + }) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + for i := 0; i < 100; i++ { + if provider := eng.ProviderForModel(ctx, "shared/model"); provider != "preferred" { + t.Fatalf("iteration %d provider = %q", i, provider) + } + if gateway := eng.GatewayForModel(ctx, "shared/model"); gateway != "preferred" { + t.Fatalf("iteration %d gateway = %q", i, gateway) + } + } +} + +func TestCustomModelInfoAndNamesDoNotRequireCatalog(t *testing.T) { + dir := t.TempDir() + eng, err := New(Options{ + SecretStore: &credentials.MapStore{}, StateDir: dir, + CustomGateways: []CustomGateway{{ID: "isolated", BaseURL: "https://isolated.example.test/v1", DefaultModel: "isolated/model"}}, + }) + if err != nil { + t.Fatal(err) + } + eng.catalogPath = filepath.Join(dir, "missing", "catalog.json") + ctx := context.Background() + if model, ok, err := eng.ModelInfo(ctx, "isolated/model"); err != nil || !ok || model.GatewayID != "isolated" { + t.Fatalf("ModelInfo(custom without catalog) = %+v, %v, %v", model, ok, err) + } + if names := eng.ModelNames(ctx); !slices.Contains(names, "isolated/model") { + t.Fatalf("ModelNames(custom without catalog) = %v", names) + } + providers, err := eng.ModelProviders(ctx) + if err != nil || !slices.Contains(providers, "isolated") { + t.Fatalf("ModelProviders(custom without catalog) = %v, %v", providers, err) + } +} diff --git a/engine/preflight_live_test.go b/engine/preflight_live_test.go new file mode 100644 index 0000000..ebbbd1d --- /dev/null +++ b/engine/preflight_live_test.go @@ -0,0 +1,125 @@ +package engine + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/catalog/live" + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestPreflightDistinguishesLocalAndLiveReadiness(t *testing.T) { + ctx := context.Background() + eng := readyPreflightEngine(t, ctx) + stubLiveProvider(t, "openai", func(map[string]string) ([]live.Entry, error) { + return []live.Entry{{ID: catalog.GPT_4o}}, nil + }) + + local := eng.Preflight(ctx) + if !local.Ready || local.LiveVerified { + t.Fatalf("local preflight = %+v", local) + } + if formatted := FormatPreflight(local); !strings.Contains(formatted, "locally ready") || !strings.Contains(formatted, "not checked") { + t.Fatalf("local readiness label is ambiguous: %q", formatted) + } + + live := eng.PreflightWithOptions(ctx, PreflightOptions{VerifyLive: true}) + if !live.Ready || !live.LiveVerified { + t.Fatalf("live preflight = %+v", live) + } + if formatted := FormatPreflight(live); !strings.Contains(formatted, "live verified") { + t.Fatalf("live readiness label missing: %q", formatted) + } +} + +func TestLivePreflightFailsClosedWhenProviderProbeFails(t *testing.T) { + ctx := context.Background() + eng := readyPreflightEngine(t, ctx) + stubLiveProvider(t, "openai", func(map[string]string) ([]live.Entry, error) { + return nil, errors.New("authentication rejected") + }) + report := eng.PreflightWithOptions(ctx, PreflightOptions{VerifyLive: true}) + if report.Ready || report.LiveVerified { + t.Fatalf("failed provider probe reported ready: %+v", report) + } + for _, check := range report.Checks { + if check.Name == "provider_live" && check.Status == CheckFail && strings.Contains(check.Detail, "verification failed") { + return + } + } + t.Fatalf("live failure check missing: %+v", report) +} + +func TestLivePreflightRequiresSelectedModelInProviderList(t *testing.T) { + ctx := context.Background() + eng := readyPreflightEngine(t, ctx) + stubLiveProvider(t, "openai", func(map[string]string) ([]live.Entry, error) { + return []live.Entry{{ID: "different-live-model"}}, nil + }) + report := eng.PreflightWithOptions(ctx, PreflightOptions{VerifyLive: true}) + if report.Ready || report.LiveVerified { + t.Fatalf("missing selected live model reported ready: %+v", report) + } + for _, check := range report.Checks { + if check.Name == "provider_live" && check.Status == CheckFail && strings.Contains(check.Detail, "not present") { + return + } + } + t.Fatalf("selected-model live failure check missing: %+v", report) +} + +func TestCustomGatewayPreflightDoesNotRequireCatalog(t *testing.T) { + ctx := context.Background() + store := &credentials.MapStore{} + if err := store.Set(ctx, credentials.AccountForEnv("PRIVATE_API_KEY"), "private-live-secret-1234567890"); err != nil { + t.Fatal(err) + } + eng, err := New(Options{ + SecretStore: store, StateDir: t.TempDir(), + CustomGateways: []CustomGateway{{ + ID: "private", BaseURL: "https://private.example.test/v1", + CredentialEnv: "PRIVATE_API_KEY", DefaultModel: "private/model", + }}, + }) + if err != nil { + t.Fatal(err) + } + if err := eng.SetSelection(ctx, "private", "private/model"); err != nil { + t.Fatal(err) + } + report := eng.Preflight(ctx) + if !report.Ready { + t.Fatalf("custom-only local preflight requires catalog: %+v", report) + } + assertPreflightCheck(t, report, "catalog", CheckOK, "not required for selected custom gateway") +} + +func readyPreflightEngine(t *testing.T, ctx context.Context) *Engine { + t.Helper() + store := &credentials.MapStore{} + eng, err := New(Options{SecretStore: store, StateDir: t.TempDir(), CustomGateways: []CustomGateway{}}) + if err != nil { + t.Fatal(err) + } + seed := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(eng.catalogPath, &seed); err != nil { + t.Fatal(err) + } + if err := store.Set(ctx, credentials.AccountForEnv("OPENAI_API_KEY"), "sk-live-preflight-1234567890"); err != nil { + t.Fatal(err) + } + if err := eng.SetSelection(ctx, "openai", catalog.GPT_4o); err != nil { + t.Fatal(err) + } + return eng +} + +func stubLiveProvider(t *testing.T, provider string, fetch live.FetchFunc) { + t.Helper() + previous := live.Registry[provider] + live.Registry[provider] = fetch + t.Cleanup(func() { live.Registry[provider] = previous }) +} diff --git a/engine/provider_state.go b/engine/provider_state.go new file mode 100644 index 0000000..9f32b93 --- /dev/null +++ b/engine/provider_state.go @@ -0,0 +1,32 @@ +package engine + +import ( + "path/filepath" + "sync" + + "github.com/GrayCodeAI/eyrie/config" +) + +var providerStateLocks sync.Map + +func lockProviderStatePath(path string) func() { + key, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + key = filepath.Clean(path) + } + value, _ := providerStateLocks.LoadOrStore(key, &sync.Mutex{}) + mu := value.(*sync.Mutex) + mu.Lock() + return mu.Unlock +} + +func (e *Engine) loadProviderConfigStrict() (*config.ProviderConfig, error) { + cfg, err := config.LoadProviderConfigWithError(e.providerConfigPath) + if err != nil { + return nil, err + } + if cfg == nil { + cfg = &config.ProviderConfig{} + } + return cfg, nil +} diff --git a/engine/provider_state_test.go b/engine/provider_state_test.go new file mode 100644 index 0000000..65efc67 --- /dev/null +++ b/engine/provider_state_test.go @@ -0,0 +1,135 @@ +package engine + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestProviderStateMutationsSerializeAcrossEngineInstances(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + seed := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(filepath.Join(dir, "model_catalog.json"), &seed); err != nil { + t.Fatal(err) + } + one, err := New(Options{SecretStore: &credentials.MapStore{}, StateDir: dir}) + if err != nil { + t.Fatal(err) + } + two, err := New(Options{SecretStore: &credentials.MapStore{}, StateDir: dir}) + if err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + errs := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + <-start + errs <- one.SetGatewayRegion(ctx, "zai_coding", "china") + }() + go func() { + defer wg.Done() + <-start + errs <- two.SetSelection(ctx, "openai", catalog.GPT_4o) + }() + close(start) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + saved, err := config.LoadProviderConfigWithError(one.providerConfigPath) + if err != nil { + t.Fatal(err) + } + if saved == nil || saved.ActiveProvider != "openai" || saved.ActiveModel != "openai/gpt-4o" || saved.ZAICodingRegion != "cn" { + t.Fatalf("concurrent mutation lost state: %#v", saved) + } +} + +func TestProviderStateMutationsRefuseCorruptConfig(t *testing.T) { + ctx := context.Background() + eng, err := New(Options{SecretStore: &credentials.MapStore{}, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + original := []byte(`{"active_provider":`) + if err := os.WriteFile(eng.providerConfigPath, original, 0o600); err != nil { + t.Fatal(err) + } + mutations := []func() error{ + func() error { return eng.SetActiveProvider(ctx, "openai") }, + func() error { return eng.SetSelection(ctx, "openai", "openai/gpt-4o") }, + func() error { return eng.ClearSelection(ctx) }, + func() error { return eng.SetGatewayRegion(ctx, "zai_coding", "china") }, + } + for i, mutate := range mutations { + if err := mutate(); err == nil { + t.Fatalf("mutation %d accepted corrupt provider state", i) + } + after, err := os.ReadFile(eng.providerConfigPath) + if err != nil || !bytes.Equal(after, original) { + t.Fatalf("mutation %d overwrote corrupt provider state: %q err=%v", i, after, err) + } + } +} + +func TestProviderStateRejectsUnknownFieldsAndVersions(t *testing.T) { + for _, raw := range [][]byte{ + []byte(`{"_version":"future","active_provider":"openai"}`), + []byte(`{"_version":"1","future_field":true}`), + } { + eng, err := New(Options{SecretStore: &credentials.MapStore{}, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(eng.providerConfigPath, raw, 0o600); err != nil { + t.Fatal(err) + } + if err := eng.SetActiveProvider(context.Background(), "openai"); err == nil { + t.Fatalf("unsupported provider state was overwritten: %s", raw) + } + } +} + +func TestCorruptProviderStateBlocksCredentialAndRuntimeWrites(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + seed := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(filepath.Join(dir, "model_catalog.json"), &seed); err != nil { + t.Fatal(err) + } + store := &credentials.MapStore{} + eng, err := New(Options{ + SecretStore: store, StateDir: dir, + CustomGateways: []CustomGateway{{ID: "private", BaseURL: "https://private.example.test/v1", DefaultModel: "private/model"}}, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(eng.providerConfigPath, []byte(`{"active_provider":`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := eng.SaveCredential(ctx, "openai", "sk-openai-secret-1234567890"); err == nil { + t.Fatal("credential write accepted corrupt provider state") + } + if _, err := store.Get(ctx, credentials.AccountForEnv("OPENAI_API_KEY")); !errors.Is(err, credentials.ErrNotFound) { + t.Fatalf("credential was persisted before state validation: %v", err) + } + if _, err := eng.Resolve(ctx, SelectionRequest{Preference: Preference{PreferredProvider: "private"}}); err == nil { + t.Fatal("custom runtime accepted corrupt provider state") + } +} diff --git a/engine/selection.go b/engine/selection.go index b9fdc0d..0e0398d 100644 --- a/engine/selection.go +++ b/engine/selection.go @@ -2,6 +2,7 @@ package engine import ( "context" + "fmt" "strings" "github.com/GrayCodeAI/eyrie/catalog" @@ -22,6 +23,9 @@ func (e *Engine) EffectiveSelection(ctx context.Context, opts SelectionOptions) provider := NormalizeProviderID(active.Provider) model := strings.TrimSpace(active.Model) if override := NormalizeProviderID(opts.ProviderOverride); override != "" { + if override != provider && strings.TrimSpace(opts.ModelOverride) == "" { + model = "" + } provider = override } if override := strings.TrimSpace(opts.ModelOverride); override != "" { @@ -44,27 +48,24 @@ func (e *Engine) EffectiveSelection(ctx context.Context, opts SelectionOptions) configured[NormalizeProviderID(gateway.ID)] = ready if ready { hasConfigured = true - if provider == "" { - provider = NormalizeProviderID(gateway.ID) - } } } + if provider == "" && hasConfigured { + provider = preferredConfiguredGateway(gateways, configured) + } if provider != "" && hasConfigured && !configured[provider] { - for _, gateway := range gateways { - if configured[NormalizeProviderID(gateway.ID)] { - provider = NormalizeProviderID(gateway.ID) - model = "" - break - } - } + provider = preferredConfiguredGateway(gateways, configured) + model = "" } if provider != "" && model == "" { if models, err := e.ListModels(ctx, provider, false); err == nil && len(models) > 0 { model = models[0].ID } } - if compiled != nil && model != "" { - if canonical, ok := compiled.CanonicalModelForAliasOrID(model); ok { + if _, custom := e.customGateway(provider); !custom && compiled != nil && model != "" { + if canonical, ok := catalog.CanonicalModelForProviderNative(compiled, provider, model); ok { + model = canonical + } else if canonical, ok := compiled.CanonicalModelForAliasOrID(model); ok { model = canonical } } @@ -80,6 +81,24 @@ func (e *Engine) EffectiveSelection(ctx context.Context, opts SelectionOptions) } } +func preferredConfiguredGateway(gateways []Gateway, configured map[string]bool) string { + bestID, bestRank := "", int(^uint(0)>>1) + for _, gateway := range gateways { + providerID := NormalizeProviderID(gateway.ID) + if !configured[providerID] { + continue + } + rank := gateway.ChatPreference + if rank <= 0 { + rank = gateway.SortOrder + 10_000 + } + if rank < bestRank || (rank == bestRank && (bestID == "" || providerID < bestID)) { + bestID, bestRank = providerID, rank + } + } + return bestID +} + // ActiveSelection reads the persisted host selection from this Engine's state. func (e *Engine) ActiveSelection(ctx context.Context) Route { _ = nonNilContext(ctx) @@ -93,17 +112,19 @@ func (e *Engine) ActiveSelection(ctx context.Context) Route { // SetActiveProvider persists a provider choice without requiring a model. func (e *Engine) SetActiveProvider(ctx context.Context, providerID string) error { - _ = nonNilContext(ctx) + ctx = nonNilContext(ctx) providerID = NormalizeProviderID(providerID) if providerID == "" { return invalid("set_active_provider", "eyrie engine: provider id is required") } - cfg := config.LoadProviderConfig(e.providerConfigPath) - if cfg == nil { - cfg = &config.ProviderConfig{} + unlock := lockProviderStatePath(e.providerConfigPath) + defer unlock() + cfg, err := e.loadProviderConfigStrict() + if err != nil { + return &Error{Code: ErrorInternal, Operation: "set_active_provider", Provider: providerID, Message: err.Error(), Cause: err} } config.SetActiveProvider(cfg, providerID) - if err := config.SaveProviderConfig(cfg, e.providerConfigPath); err != nil { + if err := e.saveProviderConfig(ctx, cfg); err != nil { return &Error{Code: ErrorInternal, Operation: "set_active_provider", Provider: providerID, Message: err.Error(), Cause: err} } return nil @@ -119,49 +140,100 @@ func (e *Engine) SetActiveModel(ctx context.Context, modelID string) error { // Engine's configured state path. It does not persist credentials. func (e *Engine) SetSelection(ctx context.Context, providerID, modelID string) error { ctx = nonNilContext(ctx) - _ = ctx - providerID = catalog.CanonicalProviderID(strings.TrimSpace(providerID)) + providerID = NormalizeProviderID(providerID) modelID = strings.TrimSpace(modelID) if modelID == "" { return invalid("set_selection", "eyrie engine: model id is required") } - cfg := config.LoadProviderConfig(e.providerConfigPath) - if cfg == nil { - cfg = &config.ProviderConfig{} + unlock := lockProviderStatePath(e.providerConfigPath) + defer unlock() + cfg, err := e.loadProviderConfigStrict() + if err != nil { + return &Error{Code: ErrorInternal, Operation: "set_selection", Provider: providerID, Model: modelID, Message: err.Error(), Cause: err} } - compiled, err := catalog.LoadCatalog(context.Background(), catalog.LoadCatalogOptions{CachePath: e.catalogPath, RequireCache: true}) - if err == nil && compiled != nil { - canonical, ok := compiled.CanonicalModelForAliasOrID(modelID) - if ok { - modelID = canonical + activeProvider := NormalizeProviderID(config.ActiveProvider(cfg)) + _, custom := e.customGateway(providerID) + if providerID == "" { + if gateway, ok := e.customGatewayForModel(modelID); ok { + providerID, custom = gateway.ID, true + } else if _, ok := e.customGateway(activeProvider); ok { + providerID, custom = activeProvider, true + } + } + if !custom { + compiled, catalogErr := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{CachePath: e.catalogPath, RequireCache: true}) + if catalogErr != nil { + return &Error{Code: ErrorCatalogUnavailable, Operation: "set_selection", Provider: providerID, Model: modelID, Message: catalogErr.Error(), Cause: catalogErr} } - if providerID == "" { - providerID = catalog.GatewayForModel(compiled, modelID) + if providerID != "" { + canonical, ok := canonicalSelectionModel(compiled, providerID, modelID) + if !ok { + return selectionModelUnavailable(providerID, modelID) + } + modelID = canonical + } else { + if activeProvider != "" { + if canonical, ok := canonicalSelectionModel(compiled, activeProvider, modelID); ok { + providerID, modelID = activeProvider, canonical + } + } if providerID == "" { - providerID = catalog.ProviderForModel(compiled, modelID) + canonical, ok := compiled.CanonicalModelForAliasOrID(modelID) + if !ok { + return selectionModelUnavailable("", modelID) + } + modelID = canonical + providerID = NormalizeProviderID(catalog.GatewayForModel(compiled, modelID)) + if providerID == "" { + providerID = NormalizeProviderID(catalog.ProviderForModel(compiled, modelID)) + } + if providerID == "" { + return selectionModelUnavailable("", modelID) + } } } } - if providerID == "" { - providerID = config.ActiveProvider(cfg) - } config.SetProviderModel(cfg, providerID, modelID) - if err := config.SaveProviderConfig(cfg, e.providerConfigPath); err != nil { + if err := e.saveProviderConfig(ctx, cfg); err != nil { return &Error{Code: ErrorInternal, Operation: "set_selection", Provider: providerID, Model: modelID, Message: err.Error(), Cause: err} } return nil } +func canonicalSelectionModel(compiled *catalog.CompiledCatalog, providerID, modelID string) (string, bool) { + if canonical, ok := catalog.CanonicalModelForProviderNative(compiled, providerID, modelID); ok { + return canonical, true + } + canonical, ok := compiled.CanonicalModelForAliasOrID(modelID) + if !ok { + return "", false + } + if !providerModelAvailable(compiled, providerID, canonical) { + return "", false + } + return canonical, true +} + +func selectionModelUnavailable(providerID, modelID string) error { + message := fmt.Sprintf("eyrie engine: model %q is not available", modelID) + if providerID != "" { + message = fmt.Sprintf("eyrie engine: model %q is not available through %q", modelID, providerID) + } + return &Error{Code: ErrorModelUnavailable, Operation: "set_selection", Provider: providerID, Model: modelID, Message: message} +} + // ClearSelection removes active provider/model state without modifying // credentials, deployments, or routing. func (e *Engine) ClearSelection(ctx context.Context) error { - _ = nonNilContext(ctx) - cfg := config.LoadProviderConfig(e.providerConfigPath) - if cfg == nil { - return nil + ctx = nonNilContext(ctx) + unlock := lockProviderStatePath(e.providerConfigPath) + defer unlock() + cfg, err := e.loadProviderConfigStrict() + if err != nil { + return &Error{Code: ErrorInternal, Operation: "clear_selection", Message: err.Error(), Cause: err} } config.ClearActiveSelection(cfg) - if err := config.SaveProviderConfig(cfg, e.providerConfigPath); err != nil { + if err := e.saveProviderConfig(ctx, cfg); err != nil { return &Error{Code: ErrorInternal, Operation: "clear_selection", Message: err.Error(), Cause: err} } return nil diff --git a/engine/selection_contract_test.go b/engine/selection_contract_test.go new file mode 100644 index 0000000..3f4aea7 --- /dev/null +++ b/engine/selection_contract_test.go @@ -0,0 +1,126 @@ +package engine + +import ( + "context" + "path/filepath" + "testing" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestSetSelectionPreservesCustomModelIDThatMatchesCatalogAlias(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + seed := catalog.SeedCatalog() + seed.Aliases["shared-native-id"] = "openai/gpt-4o" + if err := catalog.WriteCatalogCache(filepath.Join(dir, "model_catalog.json"), &seed); err != nil { + t.Fatal(err) + } + eng, err := New(Options{ + SecretStore: &credentials.MapStore{}, StateDir: dir, + CustomGateways: []CustomGateway{{ID: "private", BaseURL: "https://private.example.test/v1", DefaultModel: "shared-native-id"}}, + }) + if err != nil { + t.Fatal(err) + } + if err := eng.SetSelection(ctx, "private", "shared-native-id"); err != nil { + t.Fatal(err) + } + saved, err := config.LoadProviderConfigWithError(eng.providerConfigPath) + if err != nil || saved == nil || saved.ActiveModel != "shared-native-id" || saved.ActiveProvider != "private" { + t.Fatalf("custom model ID was globally canonicalized: %#v err=%v", saved, err) + } +} + +func TestEffectiveSelectionClearsModelWhenProviderOverrideChanges(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + seed := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(filepath.Join(dir, "model_catalog.json"), &seed); err != nil { + t.Fatal(err) + } + store := &credentials.MapStore{} + for envKey, secret := range map[string]string{ + "ANTHROPIC_API_KEY": "anthropic-live-secret-1234567890", + "OPENAI_API_KEY": "openai-live-secret-1234567890", + } { + if err := store.Set(ctx, credentials.AccountForEnv(envKey), secret); err != nil { + t.Fatal(err) + } + } + eng, err := New(Options{SecretStore: store, StateDir: dir}) + if err != nil { + t.Fatal(err) + } + if err := eng.SetSelection(ctx, "anthropic", catalog.ClaudeOpusV4_6); err != nil { + t.Fatal(err) + } + selection := eng.EffectiveSelection(ctx, SelectionOptions{ProviderOverride: "openai"}) + if selection.Provider != "openai" || selection.Model == "" || selection.Model == "anthropic/"+catalog.ClaudeOpusV4_6 { + t.Fatalf("provider override retained stale model: %+v", selection) + } +} + +func TestCustomProviderOverrideDoesNotReuseOtherCustomModel(t *testing.T) { + ctx := context.Background() + eng, err := New(Options{ + SecretStore: &credentials.MapStore{}, StateDir: t.TempDir(), + CustomGateways: []CustomGateway{ + {ID: "first", BaseURL: "https://first.example.test/v1", DefaultModel: "first/model"}, + {ID: "second", BaseURL: "https://second.example.test/v1", DefaultModel: "second/model"}, + }, + }) + if err != nil { + t.Fatal(err) + } + if err := eng.SetSelection(ctx, "first", "first/model"); err != nil { + t.Fatal(err) + } + route, err := eng.Resolve(ctx, SelectionRequest{Preference: Preference{PreferredProvider: "second"}}) + if err != nil { + t.Fatal(err) + } + if route.Provider != "second" || route.Model != "second/model" { + t.Fatalf("custom override reused persisted model: %+v", route) + } +} + +func TestSetSelectionRejectsModelOwnedByDifferentProvider(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + seed := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(filepath.Join(dir, "model_catalog.json"), &seed); err != nil { + t.Fatal(err) + } + eng, err := New(Options{SecretStore: &credentials.MapStore{}, StateDir: dir}) + if err != nil { + t.Fatal(err) + } + modelID := "anthropic/" + catalog.ClaudeOpusV4_6 + if err := eng.SetSelection(ctx, "openai", modelID); !IsCode(err, ErrorModelUnavailable) { + t.Fatalf("cross-provider selection error = %v, want model unavailable", err) + } + if saved, err := config.LoadProviderConfigWithError(eng.providerConfigPath); err != nil || saved != nil { + t.Fatalf("rejected selection changed provider state: %#v err=%v", saved, err) + } +} + +func TestSetSelectionInfersInvocationScopedCustomGateway(t *testing.T) { + ctx := context.Background() + eng, err := New(Options{ + SecretStore: &credentials.MapStore{}, StateDir: t.TempDir(), + CustomGateways: []CustomGateway{{ID: "private", BaseURL: "https://private.example.test/v1", DefaultModel: "private/model"}}, + }) + if err != nil { + t.Fatal(err) + } + if err := eng.SetSelection(ctx, "", "private/model"); err != nil { + t.Fatal(err) + } + saved, err := config.LoadProviderConfigWithError(eng.providerConfigPath) + if err != nil || saved == nil || saved.ActiveProvider != "private" || saved.ActiveModel != "private/model" { + t.Fatalf("custom gateway ownership was not inferred: %#v err=%v", saved, err) + } +} diff --git a/engine/state.go b/engine/state.go index d831a1e..4003879 100644 --- a/engine/state.go +++ b/engine/state.go @@ -2,24 +2,98 @@ package engine import ( "context" + "errors" + "fmt" + "sort" "strings" + "time" "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/catalog/registry" "github.com/GrayCodeAI/eyrie/config" "github.com/GrayCodeAI/eyrie/credentials" ) +type importedCredential struct { + account string + previous string + hadValue bool +} + +func (e *Engine) importLegacyProviderSecrets(ctx context.Context, cfg config.ProviderConfig) ([]importedCredential, error) { + var writes []importedCredential + secrets, err := config.LegacyProviderSecretsStrict(cfg) + if err != nil { + return nil, err + } + envKeys := make([]string, 0, len(secrets)) + for envKey := range secrets { + envKeys = append(envKeys, envKey) + } + sort.Strings(envKeys) + for _, envKey := range envKeys { + secret := secrets[envKey] + account := credentials.AccountForEnv(envKey) + previous, err := e.secretStore.Get(ctx, account) + if err != nil && !errors.Is(err, credentials.ErrNotFound) { + return nil, errors.Join(err, e.rollbackImportedCredentials(ctx, writes)) + } + if strings.TrimSpace(previous) != "" && !config.LooksLikePlaceholderSecret(previous) { + continue + } + write := importedCredential{account: account, previous: previous, hadValue: strings.TrimSpace(previous) != ""} + if err := e.secretStore.Set(ctx, account, secret); err != nil { + return nil, errors.Join(err, e.rollbackImportedCredentials(ctx, writes)) + } + writes = append(writes, write) + } + return writes, nil +} + +func (e *Engine) rollbackImportedCredentials(ctx context.Context, writes []importedCredential) error { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(nonNilContext(ctx)), 5*time.Second) + defer cancel() + var rollbackErrors []error + for i := len(writes) - 1; i >= 0; i-- { + var err error + if writes[i].hadValue { + err = e.secretStore.Set(cleanupCtx, writes[i].account, writes[i].previous) + } else { + err = e.secretStore.Delete(cleanupCtx, writes[i].account) + } + if err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("rollback credential account %q: %w", writes[i].account, err)) + } + } + return errors.Join(rollbackErrors...) +} + +func (e *Engine) saveProviderConfig(ctx context.Context, cfg *config.ProviderConfig) error { + if cfg == nil { + return nil + } + writes, err := e.importLegacyProviderSecrets(nonNilContext(ctx), *cfg) + if err != nil { + return err + } + sanitized := config.SanitizeProviderConfigForDisk(*cfg) + if err := writeProviderConfigAtomic(e.providerConfigPath, &sanitized); err != nil { + return errors.Join(err, e.rollbackImportedCredentials(ctx, writes)) + } + return nil +} + func (e *Engine) loadRuntimeState(ctx context.Context) (*catalog.CompiledCatalog, *config.ProviderConfig, error) { compiled, err := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{CachePath: e.catalogPath, RequireCache: true}) if err != nil { return nil, nil, &Error{Code: ErrorCatalogUnavailable, Operation: "load_state", Message: err.Error(), Cause: err} } - persisted := config.LoadProviderConfig(e.providerConfigPath) - if persisted == nil { - persisted = &config.ProviderConfig{} + persisted, err := e.loadProviderConfigStrict() + if err != nil { + return nil, nil, &Error{Code: ErrorInternal, Operation: "load_state", Message: err.Error(), Cause: err} } cfg := *persisted - cfg.Deployments = buildDeployments(compiled, persisted.Deployments, e.credentialEnv(ctx, compiled)) + cfg.Deployments = buildDeployments(compiled, persisted.Deployments, e.discoveryCredentialsFromConfig(ctx, compiled, persisted).Env()) if cfg.Routing == nil { cfg.Routing = config.BuildRoutingPolicyFromDeployments(cfg.Deployments) } @@ -40,14 +114,50 @@ func (e *Engine) credentialEnv(ctx context.Context, compiled *catalog.CompiledCa out[envKey] = secret } } + for _, spec := range registry.All() { + primary := strings.TrimSpace(spec.CredentialEnv) + if primary == "" || strings.TrimSpace(out[primary]) != "" { + continue + } + for _, alias := range registry.CredentialAliases(spec.ProviderID) { + secret, err := e.secretStore.Get(ctx, credentials.AccountForEnv(alias)) + if err == nil && strings.TrimSpace(secret) != "" && !config.LooksLikePlaceholderSecret(secret) { + out[primary] = secret + break + } + } + } return out } +func (e *Engine) discoveryCredentials(ctx context.Context, compiled *catalog.CompiledCatalog) (catalog.Credentials, error) { + cfg, err := e.loadProviderConfigStrict() + if err != nil { + return catalog.Credentials{}, err + } + return e.discoveryCredentialsFromConfig(ctx, compiled, cfg), nil +} + +func (e *Engine) discoveryCredentialsFromConfig(ctx context.Context, compiled *catalog.CompiledCatalog, cfg *config.ProviderConfig) catalog.Credentials { + return config.DiscoveryCredentialsFromState( + e.credentialEnv(ctx, compiled), + cfg, + ) +} + func buildDeployments(compiled *catalog.CompiledCatalog, persisted map[string]config.DeploymentConfig, env map[string]string) map[string]config.DeploymentConfig { out := make(map[string]config.DeploymentConfig) if compiled == nil || compiled.Catalog == nil { + for id, deployment := range persisted { + out[id] = deployment + } return out } + for id, deployment := range persisted { + if _, known := compiled.Catalog.Deployments[id]; !known { + out[id] = deployment + } + } for id, deployment := range compiled.Catalog.Deployments { derived := config.DeploymentConfigFromEnv(deployment, env) if existing, ok := persisted[id]; ok { diff --git a/engine/state_security_test.go b/engine/state_security_test.go new file mode 100644 index 0000000..f1f032c --- /dev/null +++ b/engine/state_security_test.go @@ -0,0 +1,189 @@ +package engine + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" +) + +func TestMigrateProviderSecretsImportsBeforeSanitizing(t *testing.T) { + ctx := context.Background() + store := &credentials.MapStore{} + eng, err := New(Options{SecretStore: store, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + cfg := &config.ProviderConfig{ + OpenAIAPIKey: "sk-legacy-top-level-1234567890", + Deployments: map[string]config.DeploymentConfig{ + "openai-direct": { + APIKey: "sk-legacy-deployment-1234567890", + BaseURL: "https://gateway.example.test/v1", + }, + }, + } + writeLegacyProviderConfigFixture(t, eng.providerConfigPath, cfg) + if err := eng.MigrateProviderSecretsContext(ctx); err != nil { + t.Fatal(err) + } + secret, err := store.Get(ctx, credentials.AccountForEnv("OPENAI_API_KEY")) + if err != nil { + t.Fatal(err) + } + if secret != "sk-legacy-deployment-1234567890" { + t.Fatalf("imported credential = %q", secret) + } + saved := config.LoadProviderConfig(eng.providerConfigPath) + if saved == nil || config.ProviderConfigContainsSecrets(*saved) { + t.Fatalf("provider state was not sanitized: %#v", saved) + } + if got := saved.Deployments["openai-direct"].BaseURL; got != "https://gateway.example.test/v1" { + t.Fatalf("non-secret route metadata = %q", got) + } +} + +func TestMigrateProviderSecretsRollsBackOnStoreFailure(t *testing.T) { + ctx := context.Background() + store := &failSecondSetStore{inner: &credentials.MapStore{}} + eng, err := New(Options{SecretStore: store, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + cfg := &config.ProviderConfig{ + OpenAIAPIKey: "sk-openai-legacy-1234567890", + AnthropicAPIKey: "sk-ant-legacy-1234567890", + } + writeLegacyProviderConfigFixture(t, eng.providerConfigPath, cfg) + original, err := os.ReadFile(eng.providerConfigPath) + if err != nil { + t.Fatal(err) + } + if err := eng.MigrateProviderSecretsContext(ctx); err == nil { + t.Fatal("expected injected store failure") + } + after, err := os.ReadFile(eng.providerConfigPath) + if err != nil { + t.Fatal(err) + } + if string(after) != string(original) { + t.Fatal("provider state changed after credential import failure") + } + for _, envKey := range []string{"OPENAI_API_KEY", "ANTHROPIC_API_KEY"} { + if _, err := store.Get(ctx, credentials.AccountForEnv(envKey)); !errors.Is(err, credentials.ErrNotFound) { + t.Fatalf("partial credential %s survived rollback: %v", envKey, err) + } + } + if _, err := os.Stat(eng.providerConfigPath + ".pre-secret-migrate.bak"); !os.IsNotExist(err) { + t.Fatalf("migration backup survived failed import: %v", err) + } +} + +func TestMigrateProviderSecretsRefusesUnmappedCredentialFields(t *testing.T) { + ctx := context.Background() + store := &credentials.MapStore{} + eng, err := New(Options{SecretStore: store, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + cfg := &config.ProviderConfig{Deployments: map[string]config.DeploymentConfig{ + "future-provider": {APIKey: "future-secret-1234567890", BaseURL: "https://future.example.test/v1"}, + }} + writeLegacyProviderConfigFixture(t, eng.providerConfigPath, cfg) + original, err := os.ReadFile(eng.providerConfigPath) + if err != nil { + t.Fatal(err) + } + if err := eng.MigrateProviderSecretsContext(ctx); err == nil { + t.Fatal("expected unmapped credential migration to fail closed") + } + after, err := os.ReadFile(eng.providerConfigPath) + if err != nil || string(after) != string(original) { + t.Fatalf("failed migration changed provider state: err=%v", err) + } + if _, err := store.Get(ctx, credentials.AccountForEnv("FUTURE_PROVIDER_API_KEY")); !errors.Is(err, credentials.ErrNotFound) { + t.Fatalf("unmapped credential reached store: %v", err) + } +} + +func TestEngineProviderWritesImportAndSanitizeLegacySecrets(t *testing.T) { + ctx := context.Background() + store := &credentials.MapStore{} + eng, err := New(Options{SecretStore: store, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + cfg := &config.ProviderConfig{AnthropicAPIKey: "sk-ant-legacy-1234567890"} + writeLegacyProviderConfigFixture(t, eng.providerConfigPath, cfg) + if err := eng.SetActiveProvider(ctx, "anthropic"); err != nil { + t.Fatal(err) + } + secret, err := store.Get(ctx, credentials.AccountForEnv("ANTHROPIC_API_KEY")) + if err != nil || secret != "sk-ant-legacy-1234567890" { + t.Fatalf("legacy credential was not safely imported: value=%q err=%v", secret, err) + } + saved := config.LoadProviderConfig(eng.providerConfigPath) + if saved == nil || saved.ActiveProvider != "anthropic" || config.ProviderConfigContainsSecrets(*saved) { + t.Fatalf("central provider write was not sanitized: %#v", saved) + } +} + +// writeLegacyProviderConfigFixture is deliberately test-only: production +// SaveProviderConfig refuses plaintext credential fields. +func writeLegacyProviderConfigFixture(t *testing.T, path string, cfg *config.ProviderConfig) { + t.Helper() + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestWriteBytesAtomicTightensExistingFilePermissions(t *testing.T) { + path := filepath.Join(t.TempDir(), "migration-artifact") + if err := os.WriteFile(path, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := writeBytesAtomic(path, []byte("new")); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("atomic state mode = %o, want 600", got) + } + data, err := os.ReadFile(path) + if err != nil || string(data) != "new" { + t.Fatalf("atomic state content = %q, err=%v", data, err) + } +} + +type failSecondSetStore struct { + inner *credentials.MapStore + sets int +} + +func (s *failSecondSetStore) Set(ctx context.Context, account, secret string) error { + s.sets++ + if s.sets == 2 { + return errors.New("injected secret-store write failure") + } + return s.inner.Set(ctx, account, secret) +} + +func (s *failSecondSetStore) Get(ctx context.Context, account string) (string, error) { + return s.inner.Get(ctx, account) +} + +func (s *failSecondSetStore) Delete(ctx context.Context, account string) error { + return s.inner.Delete(ctx, account) +} diff --git a/engine/types.go b/engine/types.go index f98b055..43054fa 100644 --- a/engine/types.go +++ b/engine/types.go @@ -1,6 +1,9 @@ package engine -import "time" +import ( + "encoding/json" + "time" +) // Intent expresses a host's semantic preference without naming a provider. type Intent string @@ -217,19 +220,21 @@ type Event struct { // Model is a host-facing catalog row. type Model struct { - ID string `json:"id"` - DisplayName string `json:"display_name"` - Description string `json:"description,omitempty"` - Owner string `json:"owner,omitempty"` - ProviderID string `json:"provider_id"` - GatewayID string `json:"gateway_id,omitempty"` - ContextWindow int `json:"context_window,omitempty"` - MaxOutputTokens int `json:"max_output_tokens,omitempty"` - InputPricePer1M float64 `json:"input_price_per_1m,omitempty"` - OutputPricePer1M float64 `json:"output_price_per_1m,omitempty"` - PriceKnown bool `json:"price_known"` - Capabilities []string `json:"capabilities,omitempty"` - Source string `json:"source,omitempty"` + ID string `json:"id"` + CanonicalID string `json:"canonical_id,omitempty"` + DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` + Owner string `json:"owner,omitempty"` + ProviderID string `json:"provider_id"` + GatewayID string `json:"gateway_id,omitempty"` + ContextWindow int `json:"context_window,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + InputPricePer1M float64 `json:"input_price_per_1m,omitempty"` + OutputPricePer1M float64 `json:"output_price_per_1m,omitempty"` + PriceKnown bool `json:"price_known"` + Capabilities []string `json:"capabilities,omitempty"` + Source string `json:"source,omitempty"` + LiveMetadata json.RawMessage `json:"live_metadata,omitempty"` } // CatalogSnapshot is an immutable host-facing view of a loaded catalog. @@ -283,6 +288,15 @@ type Gateway struct { RegionLabel string `json:"region_label,omitempty"` RegionRequired bool `json:"region_required,omitempty"` SupportsLiveDiscovery bool `json:"supports_live_discovery,omitempty"` + SortOrder int `json:"sort_order,omitempty"` + ChatPreference int `json:"chat_preference,omitempty"` +} + +// StatePaths reports the Engine's host-owned state locations without reading +// or parsing either file. +type StatePaths struct { + Catalog string `json:"catalog"` + ProviderConfig string `json:"provider_config"` } // Selection is the effective provider/model state supplied to a host session. diff --git a/setup/deployment.go b/setup/deployment.go index 419fae3..fada603 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -53,6 +53,12 @@ func UseDeploymentRouting(cfg *config.ProviderConfig) bool { case "0", "false", "no", "off": return false } + return DeploymentRoutingFromState(cfg) +} + +// DeploymentRoutingFromState reports routing state without consulting process +// environment. Host-facing Engine code must use this pure form. +func DeploymentRoutingFromState(cfg *config.ProviderConfig) bool { return cfg != nil && (cfg.ConfigVersion >= 2 || len(cfg.Deployments) > 0 || cfg.Routing != nil) } @@ -68,29 +74,53 @@ func DeploymentProvider(ctx context.Context, cfg *config.ProviderConfig) (client return DeploymentProviderFromCatalog(cfg, compiled) } -// DeploymentProviderFromCatalog builds a deployment router from explicit -// host-owned state. It is the host-neutral alternative to DeploymentProvider, -// which loads Eyrie's process-default catalog path. +// DeploymentProviderFromCatalog is the ambient compatibility constructor. It +// may consult the default store, process environment, and legacy detection. +// Host integrations must use DeploymentProviderFromState instead. func DeploymentProviderFromCatalog(cfg *config.ProviderConfig, compiled *catalog.CompiledCatalog) (client.Provider, error) { + return deploymentProviderFromCatalog(cfg, compiled, true) +} + +// DeploymentProviderFromState builds a router exclusively from the supplied +// provider state. It never reads the default credential store, process +// environment, process-default provider path, or legacy provider detection. +// Host-facing Engine code must use this strict constructor. +func DeploymentProviderFromState(cfg *config.ProviderConfig, compiled *catalog.CompiledCatalog) (client.Provider, error) { + return deploymentProviderFromCatalog(cfg, compiled, false) +} + +func deploymentProviderFromCatalog(cfg *config.ProviderConfig, compiled *catalog.CompiledCatalog, allowAmbient bool) (client.Provider, error) { if compiled == nil { return nil, fmt.Errorf("deployment provider: catalog is nil") } - deployments := ConfiguredDeploymentAdapters(cfg) + deployments := configuredDeploymentAdapters(cfg, allowAmbient) if len(deployments) == 0 { return nil, fmt.Errorf("no deployment credentials configured") } + var routing *config.RoutingPolicy + if cfg != nil { + routing = cfg.Routing + } return router.NewDeploymentRouter(router.DeploymentRouterOptions{ Catalog: compiled, Deployments: deployments, - Routing: RouterRoutingPolicy(cfg.Routing), + Routing: RouterRoutingPolicy(routing), }) } // ConfiguredDeploymentAdapters maps deployment IDs to live provider clients. func ConfiguredDeploymentAdapters(cfg *config.ProviderConfig) map[string]router.DeploymentAdapter { + return configuredDeploymentAdapters(cfg, true) +} + +func configuredDeploymentAdapters(cfg *config.ProviderConfig, allowAmbient bool) map[string]router.DeploymentAdapter { out := map[string]router.DeploymentAdapter{} - for id, deployment := range ConfiguredDeployments(cfg) { - provider, ok := ProviderForDeployment(id, deployment) + configured := explicitDeployments(cfg) + if allowAmbient { + configured = ConfiguredDeployments(cfg) + } + for id, deployment := range configured { + provider, ok := providerForDeployment(id, deployment, cfg, allowAmbient) if !ok { continue } @@ -103,6 +133,16 @@ func ConfiguredDeploymentAdapters(cfg *config.ProviderConfig) map[string]router. return out } +func explicitDeployments(cfg *config.ProviderConfig) map[string]config.DeploymentConfig { + out := map[string]config.DeploymentConfig{} + if cfg != nil { + for id, deployment := range cfg.Deployments { + out[id] = deployment + } + } + return out +} + // ConfiguredDeployments merges explicit deployments with legacy single-provider config. func ConfiguredDeployments(cfg *config.ProviderConfig) map[string]config.DeploymentConfig { out := map[string]config.DeploymentConfig{} @@ -126,38 +166,58 @@ func ConfiguredDeployments(cfg *config.ProviderConfig) map[string]config.Deploym return out } -// ProviderForDeployment constructs the API client for a catalog deployment ID. +// ProviderForDeployment constructs one adapter with ambient compatibility +// fallbacks. Host integrations must use ProviderForDeploymentFromState. func ProviderForDeployment(id string, deployment config.DeploymentConfig) (client.Provider, bool) { + return providerForDeployment(id, deployment, nil, true) +} + +// ProviderForDeploymentFromState constructs exactly one adapter without any +// process-global credential, environment, OIDC, or provider-config fallback. +func ProviderForDeploymentFromState(id string, deployment config.DeploymentConfig, cfg *config.ProviderConfig) (client.Provider, bool) { + return providerForDeployment(id, deployment, cfg, false) +} + +func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *config.ProviderConfig, allowAmbient bool) (client.Provider, bool) { + lookup := func(...string) string { return "" } + getenv := func(string) string { return "" } + if allowAmbient { + lookup = storeSecret + getenv = os.Getenv + if cfg == nil { + cfg = config.LoadProviderConfig("") + } + } switch id { case "anthropic-direct": - apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("ANTHROPIC_API_KEY")) + apiKey := FirstNonEmpty(deployment.APIKey, lookup("ANTHROPIC_API_KEY")) if apiKey == "" { return nil, false } - return client.NewAnthropicClient(apiKey, FirstNonEmpty(deployment.BaseURL, os.Getenv("ANTHROPIC_BASE_URL"))), true + return client.NewAnthropicClient(apiKey, FirstNonEmpty(deployment.BaseURL, getenv("ANTHROPIC_BASE_URL"))), true case "anthropic-vertex": - projectID := FirstNonEmpty(deployment.ProjectID, os.Getenv("VERTEX_PROJECT_ID")) - region := FirstNonEmpty(deployment.Region, os.Getenv("VERTEX_REGION")) + projectID := FirstNonEmpty(deployment.ProjectID, getenv("VERTEX_PROJECT_ID")) + region := FirstNonEmpty(deployment.Region, getenv("VERTEX_REGION")) // Opt-in OIDC keyless auth: only when enabled AND running in GitHub // Actions. On ErrNoOIDC or any failure we fall through to stored token. - if projectID != "" && region != "" && oidcEnabled(deployment) && credentials.DetectGitHubActions() { - audience := FirstNonEmpty(deployment.WIFAudience, os.Getenv("VERTEX_WIF_AUDIENCE")) - sa := FirstNonEmpty(deployment.ServiceAccountEmail, os.Getenv("VERTEX_SERVICE_ACCOUNT_EMAIL")) + if allowAmbient && projectID != "" && region != "" && oidcEnabled(deployment) && credentials.DetectGitHubActions() { + audience := FirstNonEmpty(deployment.WIFAudience, getenv("VERTEX_WIF_AUDIENCE")) + sa := FirstNonEmpty(deployment.ServiceAccountEmail, getenv("VERTEX_SERVICE_ACCOUNT_EMAIL")) if oidcTok, err := oidcVertexToken(context.Background(), audience, sa); err == nil && oidcTok != "" { return client.NewVertexClient(projectID, region, oidcTok), true } } - token := FirstNonEmpty(deployment.Token, deployment.APIKey, storeSecret("VERTEX_ACCESS_TOKEN", "GOOGLE_OAUTH_ACCESS_TOKEN")) + token := FirstNonEmpty(deployment.Token, deployment.APIKey, lookup("VERTEX_ACCESS_TOKEN", "GOOGLE_OAUTH_ACCESS_TOKEN")) if projectID == "" || region == "" || token == "" { return nil, false } return client.NewVertexClient(projectID, region, token), true case "anthropic-bedrock": - region := FirstNonEmpty(deployment.Region, os.Getenv("AWS_REGION"), os.Getenv("AWS_DEFAULT_REGION")) + region := FirstNonEmpty(deployment.Region, getenv("AWS_REGION"), getenv("AWS_DEFAULT_REGION")) // Opt-in OIDC keyless auth: only when enabled AND running in GitHub // Actions. On ErrNoOIDC or any failure we fall through to stored creds. - if oidcEnabled(deployment) && credentials.DetectGitHubActions() { - roleARN := FirstNonEmpty(deployment.RoleARN, os.Getenv("AWS_ROLE_ARN")) + if allowAmbient && oidcEnabled(deployment) && credentials.DetectGitHubActions() { + roleARN := FirstNonEmpty(deployment.RoleARN, getenv("AWS_ROLE_ARN")) if creds, err := oidcBedrockCreds(context.Background(), roleARN, region); err == nil && creds.AccessKeyID != "" && creds.SecretAccessKey != "" { oidcRegion := FirstNonEmpty(creds.Region, region) @@ -166,61 +226,61 @@ func ProviderForDeployment(id string, deployment config.DeploymentConfig) (clien } } } - accessKeyID := FirstNonEmpty(deployment.AccessKeyID, deployment.APIKey, storeSecret("AWS_ACCESS_KEY_ID")) - secretAccessKey := FirstNonEmpty(deployment.SecretAccessKey, deployment.Token, storeSecret("AWS_SECRET_ACCESS_KEY")) - sessionToken := FirstNonEmpty(deployment.SessionToken, storeSecret("AWS_SESSION_TOKEN")) + accessKeyID := FirstNonEmpty(deployment.AccessKeyID, deployment.APIKey, lookup("AWS_ACCESS_KEY_ID")) + secretAccessKey := FirstNonEmpty(deployment.SecretAccessKey, deployment.Token, lookup("AWS_SECRET_ACCESS_KEY")) + sessionToken := FirstNonEmpty(deployment.SessionToken, lookup("AWS_SESSION_TOKEN")) if region == "" || accessKeyID == "" || secretAccessKey == "" { return nil, false } return client.NewBedrockClient(accessKeyID, secretAccessKey, sessionToken, region), true case "openai-direct": - apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("OPENAI_API_KEY")) + apiKey := FirstNonEmpty(deployment.APIKey, lookup("OPENAI_API_KEY")) if apiKey == "" { return nil, false } return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultOpenAIBaseURL), &client.OpenAICompat), true case "openai-azure": - apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("AZURE_OPENAI_API_KEY")) - endpoint := FirstNonEmpty(deployment.Endpoint, os.Getenv("AZURE_OPENAI_ENDPOINT")) - apiVersion := FirstNonEmpty(deployment.APIVersion, os.Getenv("AZURE_OPENAI_API_VERSION")) + apiKey := FirstNonEmpty(deployment.APIKey, lookup("AZURE_OPENAI_API_KEY")) + endpoint := FirstNonEmpty(deployment.Endpoint, getenv("AZURE_OPENAI_ENDPOINT")) + apiVersion := FirstNonEmpty(deployment.APIVersion, getenv("AZURE_OPENAI_API_VERSION")) if apiKey == "" || endpoint == "" { return nil, false } return client.NewAzureClient(apiKey, endpoint, apiVersion), true case "grok-direct": - apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("XAI_API_KEY")) + apiKey := FirstNonEmpty(deployment.APIKey, lookup("XAI_API_KEY")) if apiKey == "" { return nil, false } return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultGrokOpenAIBaseURL), &client.GrokCompat), true case "gemini-direct": - apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("GEMINI_API_KEY")) + apiKey := FirstNonEmpty(deployment.APIKey, lookup("GEMINI_API_KEY")) if apiKey == "" { return nil, false } return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultGeminiOpenAIBaseURL), &client.GeminiCompat), true case "gemini-vertex": - projectID := FirstNonEmpty(deployment.ProjectID, os.Getenv("VERTEX_PROJECT_ID")) - region := FirstNonEmpty(deployment.Region, os.Getenv("VERTEX_REGION")) - token := FirstNonEmpty(deployment.Token, deployment.APIKey, storeSecret("VERTEX_ACCESS_TOKEN", "GOOGLE_OAUTH_ACCESS_TOKEN")) + projectID := FirstNonEmpty(deployment.ProjectID, getenv("VERTEX_PROJECT_ID")) + region := FirstNonEmpty(deployment.Region, getenv("VERTEX_REGION")) + token := FirstNonEmpty(deployment.Token, deployment.APIKey, lookup("VERTEX_ACCESS_TOKEN", "GOOGLE_OAUTH_ACCESS_TOKEN")) if projectID == "" || region == "" || token == "" { return nil, false } return client.NewGeminiClient(token, config.VertexGeminiBaseURL(projectID, region)), true case "openrouter": - apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("OPENROUTER_API_KEY")) + apiKey := FirstNonEmpty(deployment.APIKey, lookup("OPENROUTER_API_KEY")) if apiKey == "" { return nil, false } return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultOpenRouterOpenAIBaseURL), &client.OpenRouterCompat), true case "canopywave": - apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("CANOPYWAVE_API_KEY")) + apiKey := FirstNonEmpty(deployment.APIKey, lookup("CANOPYWAVE_API_KEY")) if apiKey == "" { return nil, false } return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultCanopyWaveOpenAIBaseURL), &client.CanopyWaveCompat), true case "deepseek-direct": - apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("DEEPSEEK_API_KEY")) + apiKey := FirstNonEmpty(deployment.APIKey, lookup("DEEPSEEK_API_KEY")) if apiKey == "" { return nil, false } @@ -228,54 +288,54 @@ func ProviderForDeployment(id string, deployment config.DeploymentConfig) (clien anthropicBase := "https://api.deepseek.com/anthropic" return client.NewDeepSeekClient(apiKey, openBase, anthropicBase, &client.DeepSeekCompat), true case "poolside": - apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("POOLSIDE_API_KEY")) + apiKey := FirstNonEmpty(deployment.APIKey, lookup("POOLSIDE_API_KEY")) if apiKey == "" { return nil, false } return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultPoolsideOpenAIBaseURL), &client.PoolsideCompat), true case "groq-direct": - apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("GROQ_API_KEY")) + apiKey := FirstNonEmpty(deployment.APIKey, lookup("GROQ_API_KEY")) if apiKey == "" { return nil, false } return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultGroqOpenAIBaseURL), &client.GroqCompat), true case "clinepass": - apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("CLINE_API_KEY")) + apiKey := FirstNonEmpty(deployment.APIKey, lookup("CLINE_API_KEY")) if apiKey == "" { return nil, false } return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultClinePassOpenAIBaseURL), &client.ClinePassCompat), true case "zai_payg-direct": - return newZAIDeploymentClient(deployment, "zai_payg", "ZAI_API_KEY", storeSecret) + return newZAIDeploymentClient(deployment, "zai_payg", "ZAI_API_KEY", lookup, cfg) case "zai_coding-direct": - return newZAIDeploymentClient(deployment, "zai_coding", "ZAI_CODING_API_KEY", storeSecret) + return newZAIDeploymentClient(deployment, "zai_coding", "ZAI_CODING_API_KEY", lookup, cfg) case "ollama-local": - baseURL := config.NormalizeOllamaOpenAIBaseURL(FirstNonEmpty(deployment.BaseURL, os.Getenv("OLLAMA_BASE_URL"), config.OllamaDefaultBaseURL)) - return client.NewOpenAIClient(FirstNonEmpty(deployment.APIKey, storeSecret("OLLAMA_API_KEY")), baseURL, &client.OllamaCompat), true + baseURL := config.NormalizeOllamaOpenAIBaseURL(FirstNonEmpty(deployment.BaseURL, getenv("OLLAMA_BASE_URL"), config.OllamaDefaultBaseURL)) + return client.NewOpenAIClient(FirstNonEmpty(deployment.APIKey, lookup("OLLAMA_API_KEY")), baseURL, &client.OllamaCompat), true case "opencodego": - apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("OPENCODEGO_API_KEY")) + apiKey := FirstNonEmpty(deployment.APIKey, lookup("OPENCODEGO_API_KEY")) if apiKey == "" { return nil, false } return client.NewOpenCodeGoClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultOpenCodeGoBaseURL)), true case "kimi-direct": - apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("MOONSHOT_API_KEY")) + apiKey := FirstNonEmpty(deployment.APIKey, lookup("MOONSHOT_API_KEY")) if apiKey == "" { return nil, false } return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultKimiOpenAIBaseURL), &client.KimiCompat), true case "xiaomi_mimo_payg-direct": - return newMiMoDeploymentClient(deployment, config.ProviderXiaomiMimoPayg, "XIAOMI_MIMO_PAYG_API_KEY", storeSecret) + return newMiMoDeploymentClient(deployment, config.ProviderXiaomiMimoPayg, "XIAOMI_MIMO_PAYG_API_KEY", lookup, cfg) case "xiaomi_mimo_token_plan-direct": - return newMiMoDeploymentClient(deployment, config.ProviderXiaomiMimoTokenPlan, "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", storeSecret) + return newMiMoDeploymentClient(deployment, config.ProviderXiaomiMimoTokenPlan, "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", lookup, cfg) case "minimax_token_plan-direct": - apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("MINIMAX_TOKEN_PLAN_API_KEY")) + apiKey := FirstNonEmpty(deployment.APIKey, lookup("MINIMAX_TOKEN_PLAN_API_KEY")) if apiKey == "" { return nil, false } return newMiniMaxDualProtocolClient(apiKey, deployment.BaseURL), true case "minimax_payg-direct": - apiKey := FirstNonEmpty(deployment.APIKey, storeSecret("MINIMAX_PAYG_API_KEY")) + apiKey := FirstNonEmpty(deployment.APIKey, lookup("MINIMAX_PAYG_API_KEY")) if apiKey == "" { return nil, false } @@ -285,12 +345,11 @@ func ProviderForDeployment(id string, deployment config.DeploymentConfig) (clien } } -func newMiMoDeploymentClient(deployment config.DeploymentConfig, providerID, envKey string, lookup func(...string) string) (client.Provider, bool) { +func newMiMoDeploymentClient(deployment config.DeploymentConfig, providerID, envKey string, lookup func(...string) string, cfg *config.ProviderConfig) (client.Provider, bool) { apiKey := FirstNonEmpty(deployment.APIKey, lookup(envKey)) if apiKey == "" { return nil, false } - cfg := config.LoadProviderConfig("") openBase, err := config.ResolveXiaomiOpenAIBase(providerID, cfg) if err != nil || openBase == "" { openBase = FirstNonEmpty(deployment.BaseURL, config.DefaultXiaomiOpenAIBaseURL) @@ -308,7 +367,7 @@ func newMiMoDeploymentClient(deployment config.DeploymentConfig, providerID, env // newZAIDeploymentClient constructs a dual-protocol (OpenAI + Anthropic) Z.AI client // for either the general or Coding Plan gateway, resolving the correct bases // for the plan + region (international or china) per official docs. -func newZAIDeploymentClient(deployment config.DeploymentConfig, providerID, envKey string, lookup func(...string) string) (client.Provider, bool) { +func newZAIDeploymentClient(deployment config.DeploymentConfig, providerID, envKey string, lookup func(...string) string, cfg *config.ProviderConfig) (client.Provider, bool) { apiKey := FirstNonEmpty(deployment.APIKey, lookup(envKey)) if apiKey == "" { return nil, false @@ -316,7 +375,6 @@ func newZAIDeploymentClient(deployment config.DeploymentConfig, providerID, envK plan, _ := zai.PlanForProvider(providerID) - cfg := config.LoadProviderConfig("") openBase, err := resolveZAIOpenAIBaseForDeployment(plan, providerID, cfg, deployment.BaseURL) if err != nil || openBase == "" { if plan == zai.PlanCoding { diff --git a/setup/deployment_test.go b/setup/deployment_test.go index 9dcf763..4b33940 100644 --- a/setup/deployment_test.go +++ b/setup/deployment_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/GrayCodeAI/eyrie/catalog" "github.com/GrayCodeAI/eyrie/client" "github.com/GrayCodeAI/eyrie/config" "github.com/GrayCodeAI/eyrie/credentials" @@ -54,6 +55,43 @@ func TestProviderForDeploymentAnthropicBedrockRequiresCredentials(t *testing.T) } } +func TestDeploymentProviderFromStateRejectsAmbientCredentialsAndLegacyDetection(t *testing.T) { + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + ctx := context.Background() + if err := store.Set(ctx, credentials.AccountForEnv("OPENAI_API_KEY"), "ambient-store-secret-1234567890"); err != nil { + t.Fatal(err) + } + t.Setenv("OPENAI_API_KEY", "ambient-env-secret-1234567890") + compiled, err := catalog.CompileTestCatalog() + if err != nil { + t.Fatal(err) + } + for _, cfg := range []*config.ProviderConfig{ + {}, + {Deployments: map[string]config.DeploymentConfig{"openai-direct": {}}}, + } { + if provider, err := DeploymentProviderFromState(cfg, compiled); err == nil || provider != nil { + t.Fatalf("strict provider used ambient state: provider=%T err=%v", provider, err) + } + } +} + +func TestDeploymentProviderFromStateAcceptsExplicitHydratedDeployment(t *testing.T) { + compiled, err := catalog.CompileTestCatalog() + if err != nil { + t.Fatal(err) + } + cfg := &config.ProviderConfig{Deployments: map[string]config.DeploymentConfig{ + "openai-direct": {APIKey: "injected-explicit-secret-1234567890", BaseURL: "https://explicit.example.test/v1"}, + }} + provider, err := DeploymentProviderFromState(cfg, compiled) + if err != nil || provider == nil { + t.Fatalf("strict provider rejected explicit state: provider=%T err=%v", provider, err) + } +} + // --- UseDeploymentRouting --- func TestUseDeploymentRouting_EnvOverrideTrue(t *testing.T) { @@ -123,6 +161,16 @@ func TestUseDeploymentRouting_LegacyConfig(t *testing.T) { } } +func TestDeploymentRoutingFromStateIgnoresAmbientOverride(t *testing.T) { + t.Setenv("EYRIE_DEPLOYMENT_ROUTING", "true") + if DeploymentRoutingFromState(nil) { + t.Fatal("pure deployment routing read process environment") + } + if !DeploymentRoutingFromState(&config.ProviderConfig{ConfigVersion: 2}) { + t.Fatal("pure deployment routing ignored explicit provider state") + } +} + // --- FirstNonEmpty --- func TestFirstNonEmpty(t *testing.T) { @@ -575,7 +623,7 @@ func TestProviderForDeployment_XiaomiTokenPlanDirect(t *testing.T) { dir := t.TempDir() t.Setenv("HAWK_CONFIG_DIR", dir) cfg := &config.ProviderConfig{ - Version: "2", + Version: "1", XiaomiMimoTokenPlanRegion: "sgp", XiaomiMimoTokenPlanBaseURL: "https://token-plan-cn.xiaomimimo.com/v1", } diff --git a/setup/status.go b/setup/status.go index f54e836..1e03215 100644 --- a/setup/status.go +++ b/setup/status.go @@ -32,19 +32,26 @@ type StatusReport struct { // DeploymentStatus builds a status report for CLI and agent diagnostics. func DeploymentStatus(ctx context.Context, activeModel string) (StatusReport, error) { - return DeploymentStatusFromPaths(ctx, activeModel, config.GetProviderConfigPath(), catalog.DefaultCachePath()) + return deploymentStatusFromPaths(ctx, activeModel, config.GetProviderConfigPath(), catalog.DefaultCachePath(), true) } // DeploymentStatusFromPaths builds a status report from host-owned state. // Empty paths retain the process defaults for backwards compatibility. func DeploymentStatusFromPaths(ctx context.Context, activeModel, providerConfigPath, catalogCachePath string) (StatusReport, error) { + return deploymentStatusFromPaths(ctx, activeModel, providerConfigPath, catalogCachePath, false) +} + +func deploymentStatusFromPaths(ctx context.Context, activeModel, providerConfigPath, catalogCachePath string, allowAmbient bool) (StatusReport, error) { if strings.TrimSpace(providerConfigPath) == "" { providerConfigPath = config.GetProviderConfigPath() } if strings.TrimSpace(catalogCachePath) == "" { catalogCachePath = catalog.DefaultCachePath() } - cfg := config.LoadProviderConfig(providerConfigPath) + cfg, err := config.LoadProviderConfigWithError(providerConfigPath) + if err != nil { + return StatusReport{}, err + } report := StatusReport{ ProviderConfig: providerConfigPath, ActiveModel: strings.TrimSpace(activeModel), @@ -52,9 +59,14 @@ func DeploymentStatusFromPaths(ctx context.Context, activeModel, providerConfigP if cfg != nil { report.ConfigVersion = cfg.ConfigVersion } - report.DeploymentRouting = UseDeploymentRouting(cfg) + report.DeploymentRouting = DeploymentRoutingFromState(cfg) + configured := explicitDeployments(cfg) + if allowAmbient { + report.DeploymentRouting = UseDeploymentRouting(cfg) + configured = ConfiguredDeployments(cfg) + } - for id := range ConfiguredDeployments(cfg) { + for id := range configured { report.Configured = append(report.Configured, id) } sortStrings(report.Configured) @@ -95,7 +107,7 @@ func FormatStatus(report StatusReport) string { if report.DeploymentRouting { b.WriteString("enabled\n") } else { - b.WriteString("disabled (legacy provider client)\n") + b.WriteString("disabled (single-route Eyrie transport)\n") } fmt.Fprintf(&b, "Provider config: %s", report.ProviderConfig) if report.ConfigVersion > 0 { @@ -141,7 +153,10 @@ func RoutingPreviewFromPaths(ctx context.Context, model, providerConfigPath, cat if strings.TrimSpace(catalogCachePath) == "" { catalogCachePath = catalog.DefaultCachePath() } - cfg := config.LoadProviderConfig(providerConfigPath) + cfg, err := config.LoadProviderConfigWithError(providerConfigPath) + if err != nil { + return "", err + } compiled, err := catalog.LoadCatalog(ctx, catalog.LoadCatalogOptions{ CachePath: catalogCachePath, }) diff --git a/setup/status_test.go b/setup/status_test.go index 10d5fd2..515e9bb 100644 --- a/setup/status_test.go +++ b/setup/status_test.go @@ -1,9 +1,15 @@ package setup import ( + "context" + "path/filepath" "strings" "testing" "time" + + "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" ) func TestFormatStatus_DisabledRouting(t *testing.T) { @@ -17,8 +23,11 @@ func TestFormatStatus_DisabledRouting(t *testing.T) { CatalogModels: 10, } out := FormatStatus(report) - if !strings.Contains(out, "disabled (legacy provider client)") { - t.Fatal("expected 'disabled' in output") + if !strings.Contains(out, "disabled (single-route Eyrie transport)") { + t.Fatal("expected engine-owned disabled-routing description") + } + if strings.Contains(out, "legacy provider client") { + t.Fatal("status output still describes the retired host-side provider client") } if !strings.Contains(out, "none (set API keys or deployments in provider.json)") { t.Fatal("expected 'none' deployment message") @@ -150,3 +159,30 @@ func TestSaveProviderConfigV2_Nil(t *testing.T) { t.Fatalf("expected nil error for nil config, got %v", err) } } + +func TestDeploymentStatusFromPathsIgnoresAmbientRoutingAndCredentials(t *testing.T) { + dir := t.TempDir() + providerPath := filepath.Join(dir, "provider.json") + catalogPath := filepath.Join(dir, "model_catalog.json") + if err := config.SaveProviderConfig(&config.ProviderConfig{}, providerPath); err != nil { + t.Fatal(err) + } + seed := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(catalogPath, &seed); err != nil { + t.Fatal(err) + } + t.Setenv("EYRIE_DEPLOYMENT_ROUTING", "true") + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) + if err := store.Set(context.Background(), credentials.AccountForEnv("OPENAI_API_KEY"), "ambient-secret-1234567890"); err != nil { + t.Fatal(err) + } + report, err := DeploymentStatusFromPaths(context.Background(), "", providerPath, catalogPath) + if err != nil { + t.Fatal(err) + } + if report.DeploymentRouting || len(report.Configured) != 0 { + t.Fatalf("host-scoped status used ambient state: %+v", report) + } +} From e60f089abfd409819aa94b7162c42a040219d128 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 13 Jul 2026 07:36:52 +0530 Subject: [PATCH 24/24] refactor: enforce client adapter layering --- .github/workflows/ci.yml | 12 +- .gitignore | 3 + AGENTS.md | 40 ++-- Makefile | 2 +- README.md | 15 +- client/{ => adapters}/adapter_config.go | 41 +++- client/{ => adapters}/anthropic.go | 170 +++++++++------- client/adapters/anthropic_cache.go | 93 +++++++++ client/{ => adapters}/azure.go | 64 +++--- client/{ => adapters}/bedrock.go | 100 +++++++--- client/adapters/compat.go | 172 ++++++++++++++++ client/{ => adapters}/deepseek.go | 22 ++- client/adapters/dynamic.go | 75 +++++++ client/{ => adapters}/gemini.go | 116 ++++++----- client/{ => adapters}/mimo.go | 31 ++- client/adapters/mimo_test.go | 74 +++++++ client/{ => adapters}/openai.go | 92 +++++---- client/adapters/openai_embedding.go | 113 +++++++++++ client/{ => adapters}/opencodego.go | 21 +- client/adapters/opencodego_test.go | 234 ++++++++++++++++++++++ client/{ => adapters}/options_test.go | 23 ++- client/{ => adapters}/protocol_router.go | 56 +++--- client/adapters/protocol_router_test.go | 152 ++++++++++++++ client/adapters/provider_registry.go | 142 ++++++++++++++ client/adapters/test_helpers_test.go | 37 ++++ client/{ => adapters}/vertex.go | 73 ++++--- client/{ => adapters}/zai.go | 20 +- client/aliases.go | 159 +++++++++++++-- client/anthropic_chat_test.go | 10 +- client/anthropic_features_test.go | 22 ++- client/anthropic_response_test.go | 2 +- client/cache.go | 86 -------- client/client.go | 3 +- client/client_test.go | 8 +- client/cloud_providers_bedrock_test.go | 72 +++---- client/cloud_providers_test.go | 20 +- client/cloud_providers_vertex_test.go | 84 ++++---- client/compat.go | 187 ++---------------- client/core/audio.go | 27 +++ client/core/errors.go | 61 +++++- client/core/sanitize.go | 48 +++++ client/dynamic.go | 85 +------- client/embedding_methods.go | 102 +--------- client/embedding_methods_test.go | 1 - client/fallback.go | 57 +----- client/gemini_stream_test.go | 6 +- client/guardrails.go | 1 - client/guardrails_provider_test.go | 34 ++-- client/mimo_test.go | 71 +------ client/multimodal_test.go | 2 +- client/openai_misc_test.go | 4 +- client/openai_test.go | 6 +- client/opencodego_test.go | 239 +---------------------- client/options.go | 2 - client/options_facade_test.go | 74 +++++++ client/protocol_router_test.go | 145 -------------- client/provider_registry.go | 209 ++++---------------- client/provider_registry_test.go | 21 +- client/provider_request_test.go | 14 +- client/sanitize.go | 47 +---- config/provider_env.go | 42 +++- config/provider_env_test.go | 40 ++++ config/runtime_test.go | 28 ++- engine/continuation.go | 3 +- engine/contract_e2e_test.go | 1 + engine/host_control.go | 35 ++-- engine/provider_state.go | 5 +- engine/provider_state_test.go | 2 + engine/state_security_test.go | 36 ++++ engine/stream.go | 10 +- plans/client-package-decomposition.md | 28 +-- 71 files changed, 2426 insertions(+), 1706 deletions(-) rename client/{ => adapters}/adapter_config.go (58%) rename client/{ => adapters}/anthropic.go (78%) create mode 100644 client/adapters/anthropic_cache.go rename client/{ => adapters}/azure.go (67%) rename client/{ => adapters}/bedrock.go (79%) create mode 100644 client/adapters/compat.go rename client/{ => adapters}/deepseek.go (70%) create mode 100644 client/adapters/dynamic.go rename client/{ => adapters}/gemini.go (85%) rename client/{ => adapters}/mimo.go (75%) create mode 100644 client/adapters/mimo_test.go rename client/{ => adapters}/openai.go (83%) create mode 100644 client/adapters/openai_embedding.go rename client/{ => adapters}/opencodego.go (73%) create mode 100644 client/adapters/opencodego_test.go rename client/{ => adapters}/options_test.go (95%) rename client/{ => adapters}/protocol_router.go (70%) create mode 100644 client/adapters/protocol_router_test.go create mode 100644 client/adapters/provider_registry.go create mode 100644 client/adapters/test_helpers_test.go rename client/{ => adapters}/vertex.go (66%) rename client/{ => adapters}/zai.go (83%) create mode 100644 client/core/audio.go create mode 100644 client/core/sanitize.go create mode 100644 client/options_facade_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc1172a..b2acbbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,9 @@ jobs: go-version: ${{ env.GO_VERSION }} cache: true - name: Boundary guard - run: bash ./scripts/check-ecosystem-boundaries.sh + run: | + bash ./scripts/check-ecosystem-boundaries.sh + bash ./scripts/check-client-layering.sh - name: gofumpt run: | go install mvdan.cc/gofumpt@v0.10.0 @@ -121,7 +123,9 @@ jobs: - name: Clone tok (workspace dep) run: git clone --depth=1 https://github.com/GrayCodeAI/tok.git ../tok - name: Boundary guard - run: bash ./scripts/check-ecosystem-boundaries.sh + run: | + bash ./scripts/check-ecosystem-boundaries.sh + bash ./scripts/check-client-layering.sh - name: Run golangci-lint run: | go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.0 @@ -143,7 +147,9 @@ jobs: - name: Clone tok (workspace dep) run: git clone --depth=1 https://github.com/GrayCodeAI/tok.git ../tok - name: Boundary guard - run: bash ./scripts/check-ecosystem-boundaries.sh + run: | + bash ./scripts/check-ecosystem-boundaries.sh + bash ./scripts/check-client-layering.sh - name: Test with race detector run: go test ./... -race -count=1 -shuffle=on -coverprofile=coverage.out -covermode=atomic -timeout=300s - name: Coverage summary diff --git a/.gitignore b/.gitignore index 9e9a9f4..ea37c7d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /basic bin/ *.exe +*.test # Secrets / local config .env @@ -11,6 +12,8 @@ bin/ # Build / cache / seed artifacts .gocache/ +.gomodcache/ +.golangci-cache/ *.seeds.json elrond*.db *.codegraph.db diff --git a/AGENTS.md b/AGENTS.md index f71fee2..36db3ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,8 @@ Universal LLM provider runtime. One interface for every model. Authentication, r ## Design Principles - **Model-agnostic** — single interface for 75+ LLM providers -- **Zero opinions** — consumers control routing, caching, and retry strategies +- **Host-neutral engine** — Eyrie owns provider routing, transport, caching, + retry/fallback, and normalized telemetry; hosts own product UX and semantics - **Streaming-first** — all responses are streamed; blocking is opt-in ## Observability @@ -25,13 +26,13 @@ make ci # Full CI suite ## Architecture -- `provider.go` — Provider interface and registry -- `routing.go` — Model routing and fallback chains -- `streaming.go` — SSE streaming with backpressure -- `auth.go` — API key management and rotation -- `cache.go` — Response caching (optional) -- `retry.go` — Retry with exponential backoff + Retry-After -- `catalog.go` — Model catalog and capability discovery +- `engine/` — stable host-facing provider engine facade and DTO contract +- `client/core/` — provider-neutral wire types, transport, stream, and retry primitives +- `client/adapters/` — provider protocol adapters and construction registry +- `client/` — backwards-compatible public facade, middleware, and caches +- `credentials/` — API key storage, lookup, and safe status projection +- `catalog/` — model catalog, discovery, capabilities, and pricing +- `router/` and `runtime/` — route policy and runtime resolution ## Conventions @@ -44,7 +45,10 @@ make ci # Full CI suite ## Common Pitfalls -- Provider interface is the boundary — keep it stable +- `engine` is Hawk's product boundary; Hawk must not assemble lower-level + `client`, `catalog`, `config`, `credentials`, `router`, or `runtime` packages +- `client.Provider` remains the lower-level compatibility boundary for other + consumers; preserve its method set and the facade's type identity - Streaming tests need careful goroutine management - `go.work` here should stay minimal; hawk's own `go.work` adds an `external/eyrie` replace so hawk can develop against a local eyrie checkout. Do not add extra local `replace` directives here without coordinating with hawk's workspace. @@ -106,14 +110,16 @@ make ci # Full CI suite |---|---| | Provider interface | `client/client.go` (`Provider`, `EyrieConfig`, `EyrieMessage`, `ContentPart`) | | Chat implementation | `client/chat.go` (`Chat()`, `StreamChat()`, `StreamChatContinue()`) | -| Anthropic provider | `client/anthropic.go` | -| OpenAI provider | `client/openai.go` | -| Gemini provider | `client/gemini.go` | -| Bedrock provider | `client/bedrock.go` | -| Vertex provider | `client/vertex.go` | -| Azure provider | `client/azure.go` | -| Provider registry | `client/provider_registry.go` | -| Provider compatibility | `client/compat.go` (`OpenAICompat`, `GrokCompat`, etc.) | +| Host-facing engine facade | `engine/` | +| Provider-neutral core | `client/core/` | +| Anthropic provider | `client/adapters/anthropic.go` | +| OpenAI provider | `client/adapters/openai.go` | +| Gemini provider | `client/adapters/gemini.go` | +| Bedrock provider | `client/adapters/bedrock.go` | +| Vertex provider | `client/adapters/vertex.go` | +| Azure provider | `client/adapters/azure.go` | +| Provider registry | `client/adapters/provider_registry.go` | +| Provider compatibility | `client/adapters/compat.go` (`OpenAICompat`, `GrokCompat`, etc.) | | SSE streaming | `client/stream.go` (`parseSSEStream()`, `SSEEvent`) | | Retry logic | `client/retry.go` (`RetryConfig`, `backoffDelay()`, `shouldRetry()`) | | Rate limiting | `client/ratelimit.go`, `client/adaptive_ratelimit.go` | diff --git a/Makefile b/Makefile index fc52182..ab3e66b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Canonical hawk-eco Makefile for Go LIBRARY repos. -# eyrie is a library consumed by hawk (no standalone binary, no release). +# eyrie is a versioned Go library consumed by Hawk (no standalone binary). # --------------------------------------------------------------------------- # Project metadata diff --git a/README.md b/README.md index 2bd0189..e1da9e5 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,13 @@ When your app calls a model, eyrie figures out which provider to use, how to tal **Your app never talks to an LLM API directly. eyrie does.** +Hawk is the product face: it owns UX, agent orchestration, tools, permissions, +sessions, and product semantics. Eyrie is the provider engine: it owns +credentials, catalog and route resolution, provider transports, normalized +streams, retry/fallback, usage, and provider telemetry. Hawk integrates through +the stable [`engine`](engine/) facade rather than assembling Eyrie's internal +provider packages. + ## Ecosystem Boundaries eyrie is a Hawk support engine. Keep the dependency edge one-way: @@ -240,7 +247,11 @@ config.SaveProviderConfig(cfg, "") // save changes ``` eyrie/ -├── client/ # Provider client & streaming interface +├── engine/ # Stable host-facing facade and provider-neutral DTOs +├── client/ # Backwards-compatible public client facade +│ ├── core/ # Provider-neutral wire, stream, retry, and transport primitives +│ ├── adapters/ # Provider protocol adapters and construction registry +│ └── embeddings/ # Embedding clients, cache, and defaults ├── config/ # Provider configuration & routing │ └── credential/ # Credential file management ├── catalog/ # Model catalog & tier system @@ -253,7 +264,7 @@ eyrie/ ├── credentials/ # Credential management ├── docs/ # Documentation & guides ├── examples/ # Runnable code examples -├── router/ # Weighted provider router +├── router/ # Provider routing strategies ├── runtime/ # Runtime manifest & routing policies ├── storage/ # SQLite conversation DAG store ├── types/ # Branded types & API errors diff --git a/client/adapter_config.go b/client/adapters/adapter_config.go similarity index 58% rename from client/adapter_config.go rename to client/adapters/adapter_config.go index 460fcc3..b4ec0c2 100644 --- a/client/adapter_config.go +++ b/client/adapters/adapter_config.go @@ -1,4 +1,4 @@ -package client +package adapters import ( "log/slog" @@ -24,7 +24,7 @@ func (c *AnthropicClient) SetTimeout(d time.Duration) { c.httpClient.Timeout = d func (c *AnthropicClient) SetHTTPClient(hc *http.Client) { c.httpClient = hc } // SetRetry sets the retry configuration. -func (c *AnthropicClient) SetRetry(rc RetryConfig) { c.retry = rc } +func (c *AnthropicClient) SetRetry(rc core.RetryConfig) { c.retry = rc } // SetLogger sets the logger. func (c *AnthropicClient) SetLogger(l *slog.Logger) { c.logger = l } @@ -45,16 +45,28 @@ func (c *AnthropicClient) SetDefaultMaxTokens(n int) { c.defaultMaxTokens = n } func (c *AnthropicClient) SetDefaultTemperature(t float64) { c.defaultTemperature = &t } // SetGuardrails attaches output guardrails. -func (c *AnthropicClient) SetGuardrails(g *Guardrails) { c.guardrails = g } +func (c *AnthropicClient) SetGuardrails(g *core.Guardrails) { c.guardrails = g } // SetProviderName is a no-op: the Anthropic adapter reports a fixed provider -// name. Present to satisfy core.Configurable; WithProviderName only affects +// name. Present to satisfy core.Configurable; core.WithProviderName only affects // OpenAI-compatible adapters. func (c *AnthropicClient) SetProviderName(string) {} // SetMimoAuth switches authentication to MiMo's api-key header. func (c *AnthropicClient) SetMimoAuth() { c.useMimoAuth = true } +// Inspection methods expose non-secret adapter configuration. + +func (c *AnthropicClient) BaseURL() string { return c.baseURL } +func (c *AnthropicClient) HTTPClient() *http.Client { return c.httpClient } +func (c *AnthropicClient) Retry() core.RetryConfig { return c.retry } +func (c *AnthropicClient) Logger() *slog.Logger { return c.logger } +func (c *AnthropicClient) Guardrails() *core.Guardrails { return c.guardrails } +func (c *AnthropicClient) DefaultModel() string { return c.defaultModel } +func (c *AnthropicClient) DefaultMaxTokens() int { return c.defaultMaxTokens } +func (c *AnthropicClient) DefaultTemperature() *float64 { return c.defaultTemperature } +func (c *AnthropicClient) Version() string { return c.version } + // SetTimeout sets the HTTP client timeout. func (c *OpenAIClient) SetTimeout(d time.Duration) { c.httpClient.Timeout = d } @@ -62,7 +74,7 @@ func (c *OpenAIClient) SetTimeout(d time.Duration) { c.httpClient.Timeout = d } func (c *OpenAIClient) SetHTTPClient(hc *http.Client) { c.httpClient = hc } // SetRetry sets the retry configuration. -func (c *OpenAIClient) SetRetry(rc RetryConfig) { c.retry = rc } +func (c *OpenAIClient) SetRetry(rc core.RetryConfig) { c.retry = rc } // SetLogger sets the logger. func (c *OpenAIClient) SetLogger(l *slog.Logger) { c.logger = l } @@ -73,6 +85,18 @@ func (c *OpenAIClient) SetAPIKey(key string) { c.apiKey = key } // SetBaseURL sets the base URL. func (c *OpenAIClient) SetBaseURL(url string) { c.baseURL = url } +// Inspection methods expose non-secret adapter configuration. +func (c *OpenAIClient) BaseURL() string { return c.baseURL } +func (c *OpenAIClient) HTTPClient() *http.Client { return c.httpClient } +func (c *OpenAIClient) Retry() core.RetryConfig { return c.retry } +func (c *OpenAIClient) ProviderName() string { return c.providerName } +func (c *OpenAIClient) Logger() *slog.Logger { return c.logger } +func (c *OpenAIClient) Guardrails() *core.Guardrails { return c.guardrails } +func (c *OpenAIClient) DefaultModel() string { return c.defaultModel } +func (c *OpenAIClient) DefaultMaxTokens() int { return c.defaultMaxTokens } +func (c *OpenAIClient) DefaultTemperature() *float64 { return c.defaultTemperature } +func (c *OpenAIClient) Compat() *OpenAICompatConfig { return c.compat } + // SetDefaultModel sets the default model for requests. func (c *OpenAIClient) SetDefaultModel(model string) { c.defaultModel = model } @@ -83,10 +107,15 @@ func (c *OpenAIClient) SetDefaultMaxTokens(n int) { c.defaultMaxTokens = n } func (c *OpenAIClient) SetDefaultTemperature(t float64) { c.defaultTemperature = &t } // SetGuardrails attaches output guardrails. -func (c *OpenAIClient) SetGuardrails(g *Guardrails) { c.guardrails = g } +func (c *OpenAIClient) SetGuardrails(g *core.Guardrails) { c.guardrails = g } // SetProviderName sets the provider name used in errors and logging. func (c *OpenAIClient) SetProviderName(name string) { c.providerName = name } // SetMimoAuth switches authentication to MiMo's api-key header. func (c *OpenAIClient) SetMimoAuth() { c.useMimoAuth = true } + +// Bedrock transport configuration. + +func (c *BedrockClient) SetHTTPClient(hc *http.Client) { c.httpClient = hc } +func (c *BedrockClient) SetRetry(rc core.RetryConfig) { c.retry = rc } diff --git a/client/anthropic.go b/client/adapters/anthropic.go similarity index 78% rename from client/anthropic.go rename to client/adapters/anthropic.go index b03ae00..bed1a96 100644 --- a/client/anthropic.go +++ b/client/adapters/anthropic.go @@ -1,4 +1,4 @@ -package client +package adapters import ( "bytes" @@ -9,37 +9,39 @@ import ( "log/slog" "net/http" "strings" + + "github.com/GrayCodeAI/eyrie/client/core" ) // maxAnthropicRequestSize is the maximum request body size for the Messages API (32 MB). const maxAnthropicRequestSize = 32 * 1024 * 1024 -// AnthropicClient implements Provider for the Anthropic Messages API. +// AnthropicClient implements core.Provider for the Anthropic Messages API. type AnthropicClient struct { apiKey string baseURL string version string httpClient *http.Client - retry RetryConfig + retry core.RetryConfig logger *slog.Logger defaultModel string defaultMaxTokens int defaultTemperature *float64 - guardrails *Guardrails + guardrails *core.Guardrails useMimoAuth bool } -// Compile-time check that AnthropicClient implements Provider. -var _ Provider = (*AnthropicClient)(nil) +// Compile-time check that AnthropicClient implements core.Provider. +var _ core.Provider = (*AnthropicClient)(nil) // NewAnthropicClient creates a configured Anthropic client. -func NewAnthropicClient(apiKey, baseURL string, opts ...ClientOption) *AnthropicClient { +func NewAnthropicClient(apiKey, baseURL string, opts ...core.ClientOption) *AnthropicClient { c := &AnthropicClient{ apiKey: apiKey, baseURL: baseURL, version: "2023-06-01", - httpClient: NewPooledHTTPClient(defaultTimeout), - retry: DefaultRetryConfig(), + httpClient: core.NewPooledHTTPClient(core.DefaultTimeout), + retry: core.DefaultRetryConfig(), logger: slog.Default(), } if c.baseURL == "" { @@ -62,7 +64,7 @@ func (c *AnthropicClient) setHeaders(req *http.Request) { req.Header.Set("X-Api-Key", c.apiKey) } req.Header.Set("Anthropic-Version", c.version) - req.Header.Set("User-Agent", userAgent()) + req.Header.Set("User-Agent", core.UserAgent()) } type anthropicRequest struct { @@ -75,14 +77,18 @@ type anthropicRequest struct { TopK *int `json:"top_k,omitempty"` Stream bool `json:"stream,omitempty"` StopSequences []string `json:"stop_sequences,omitempty"` - Tools []anthropicTool `json:"tools,omitempty"` - ToolChoice *anthropicToolChoice `json:"tool_choice,omitempty"` - Thinking *anthropicThinking `json:"thinking,omitempty"` - Metadata *anthropicMetadata `json:"metadata,omitempty"` + Tools []AnthropicTool `json:"tools,omitempty"` + ToolChoice *AnthropicToolChoice `json:"tool_choice,omitempty"` + Thinking *AnthropicThinking `json:"thinking,omitempty"` + Metadata *AnthropicMetadata `json:"metadata,omitempty"` ServiceTier string `json:"service_tier,omitempty"` - OutputConfig *anthropicOutputConfig `json:"output_config,omitempty"` + OutputConfig *AnthropicOutputConfig `json:"output_config,omitempty"` } +type AnthropicRequest = anthropicRequest + +type AnthropicResponse = anthropicResponse + // anthropicThinking enables Anthropic extended thinking. // Type is "enabled" (with budget_tokens), "adaptive" (model decides), or "disabled". type anthropicThinking struct { @@ -91,30 +97,48 @@ type anthropicThinking struct { Display string `json:"display,omitempty"` // "summarized" or "omitted" } -// anthropicToolChoice controls how the model uses tools. +type AnthropicTool = anthropicTool + +type AnthropicToolChoice = anthropicToolChoice + +type AnthropicThinking = anthropicThinking + +type AnthropicMetadata = anthropicMetadata + +type AnthropicOutputConfig = anthropicOutputConfig + +type AnthropicOutputFormat = anthropicOutputFormat + type anthropicToolChoice struct { - Type string `json:"type"` // "auto", "any", "tool", "none" - Name string `json:"name,omitempty"` // required when type="tool" + Type string `json:"type"` + Name string `json:"name,omitempty"` DisableParallelToolUse bool `json:"disable_parallel_tool_use,omitempty"` } -// anthropicMetadata carries request-level metadata. type anthropicMetadata struct { UserID string `json:"user_id,omitempty"` } -// anthropicOutputConfig controls output format and effort. type anthropicOutputConfig struct { - Effort string `json:"effort,omitempty"` // "low","medium","high","xhigh","max" + Effort string `json:"effort,omitempty"` Format *anthropicOutputFormat `json:"format,omitempty"` } -// anthropicOutputFormat specifies structured output format. type anthropicOutputFormat struct { - Type string `json:"type"` // "json_schema" + Type string `json:"type"` Schema map[string]interface{} `json:"schema,omitempty"` } +func ResolveThinking(opts core.ChatOptions) *AnthropicThinking { return resolveThinking(opts) } + +func ResolveToolChoice(tc *core.ToolChoiceOption) *AnthropicToolChoice { return resolveToolChoice(tc) } + +func ResolveOutputConfig(opts core.ChatOptions) *AnthropicOutputConfig { + return resolveOutputConfig(opts) +} + +func AudioFormatToMediaType(format string) string { return audioFormatToMediaType(format) } + // thinkingForBudget returns an enabled thinking config when budget > 0, else nil. func thinkingForBudget(budget int) *anthropicThinking { if budget <= 0 { @@ -133,8 +157,8 @@ func thinkingDisabled() *anthropicThinking { return &anthropicThinking{Type: "disabled"} } -// resolveThinking builds the thinking config from ChatOptions. -func resolveThinking(opts ChatOptions) *anthropicThinking { +// resolveThinking builds the thinking config from core.ChatOptions. +func resolveThinking(opts core.ChatOptions) *anthropicThinking { switch opts.ThinkingMode { case "adaptive": return thinkingAdaptive() @@ -152,8 +176,8 @@ func resolveThinking(opts ChatOptions) *anthropicThinking { } } -// resolveToolChoice converts ChatOptions.ToolChoice to wire format. -func resolveToolChoice(tc *ToolChoiceOption) *anthropicToolChoice { +// resolveToolChoice converts core.ChatOptions.ToolChoice to wire format. +func resolveToolChoice(tc *core.ToolChoiceOption) *anthropicToolChoice { if tc == nil { return nil } @@ -164,16 +188,16 @@ func resolveToolChoice(tc *ToolChoiceOption) *anthropicToolChoice { } } -// resolveMetadata builds metadata from ChatOptions. -func resolveMetadata(opts ChatOptions) *anthropicMetadata { +// resolveMetadata builds metadata from core.ChatOptions. +func resolveMetadata(opts core.ChatOptions) *anthropicMetadata { if opts.MetadataUserID == "" { return nil } return &anthropicMetadata{UserID: opts.MetadataUserID} } -// resolveOutputConfig builds output config from ChatOptions. -func resolveOutputConfig(opts ChatOptions) *anthropicOutputConfig { +// resolveOutputConfig builds output config from core.ChatOptions. +func resolveOutputConfig(opts core.ChatOptions) *anthropicOutputConfig { if opts.OutputEffort == "" && opts.OutputSchema == "" { return nil } @@ -217,8 +241,8 @@ type anthropicResponse struct { } `json:"usage"` } -// parseAnthropicResponse converts a parsed Anthropic Messages API -// response into an EyrieResponse. Shared by Anthropic, Bedrock, and +// ParseAnthropicResponse converts a parsed Anthropic Messages API +// response into an core.EyrieResponse. Shared by Anthropic, Bedrock, and // Vertex clients (all three receive the same wire format). // // Content blocks are extracted per type: @@ -229,9 +253,9 @@ type anthropicResponse struct { // // requestID is required. orgID is the Anthropic-Organization-Id // response header (Anthropic-specific; Bedrock and Vertex pass ""). -func parseAnthropicResponse(ar anthropicResponse, requestID, orgID string) *EyrieResponse { +func ParseAnthropicResponse(ar anthropicResponse, requestID, orgID string) *core.EyrieResponse { var content, thinkingContent string - var toolCalls []ToolCall + var toolCalls []core.ToolCall for _, block := range ar.Content { switch block.Type { case "text": @@ -246,13 +270,13 @@ func parseAnthropicResponse(ar anthropicResponse, requestID, orgID string) *Eyri if err := json.Unmarshal(block.Input, &args); err != nil { slog.Warn("anthropic: failed to parse tool_use input", "error", err, "tool_name", block.Name) } - toolCalls = append(toolCalls, ToolCall{ID: block.ID, Name: block.Name, Arguments: args}) + toolCalls = append(toolCalls, core.ToolCall{ID: block.ID, Name: block.Name, Arguments: args}) } } - return &EyrieResponse{ + return &core.EyrieResponse{ Content: content, Thinking: thinkingContent, FinishReason: ar.StopReason, ToolCalls: toolCalls, RequestID: requestID, OrganizationID: orgID, - Usage: &EyrieUsage{ + Usage: &core.EyrieUsage{ PromptTokens: ar.Usage.InputTokens, CompletionTokens: ar.Usage.OutputTokens, TotalTokens: ar.Usage.InputTokens + ar.Usage.OutputTokens, @@ -263,7 +287,11 @@ func parseAnthropicResponse(ar anthropicResponse, requestID, orgID string) *Eyri } } -func buildAnthropicMessages(messages []EyrieMessage) ([]map[string]interface{}, string) { +func BuildAnthropicMessages(messages []core.EyrieMessage) ([]map[string]interface{}, string) { + return buildAnthropicMessages(messages) +} + +func buildAnthropicMessages(messages []core.EyrieMessage) ([]map[string]interface{}, string) { var system string msgs := make([]map[string]interface{}, 0, len(messages)) for _, m := range messages { @@ -314,7 +342,7 @@ func buildAnthropicMessages(messages []EyrieMessage) ([]map[string]interface{}, content = append(content, map[string]interface{}{"type": "text", "text": part.Text}) case "image_url": if part.ImageURL != nil { - mediaType, data, isBase64 := parseImageString(part.ImageURL.URL) + mediaType, data, isBase64 := core.ParseImageString(part.ImageURL.URL) if isBase64 { content = append(content, map[string]interface{}{ "type": "image", @@ -359,7 +387,7 @@ func buildAnthropicMessages(messages []EyrieMessage) ([]map[string]interface{}, content = append(content, map[string]interface{}{"type": "text", "text": m.Content}) } for _, img := range m.Images { - mediaType, data, isBase64 := parseImageString(img) + mediaType, data, isBase64 := core.ParseImageString(img) if isBase64 { content = append(content, map[string]interface{}{ "type": "image", @@ -425,7 +453,7 @@ func audioFormatToMediaType(format string) string { // every field — System, Temperature, TopP, TopK, StopSequences, // EnableCaching, tools, thinking, metadata, serviceTier, // outputConfig — was previously re-applied in both methods. -func (c *AnthropicClient) buildAnthropicRequest(ctx context.Context, messages []EyrieMessage, opts ChatOptions, stream bool) (*http.Request, []byte, error) { +func (c *AnthropicClient) buildAnthropicRequest(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions, stream bool) (*http.Request, []byte, error) { if opts.ResponseFormat != nil { switch opts.ResponseFormat.Type { case "json_schema": @@ -443,7 +471,7 @@ func (c *AnthropicClient) buildAnthropicRequest(ctx context.Context, messages [] return nil, nil, fmt.Errorf("eyrie: unsupported anthropic response format %q", opts.ResponseFormat.Type) } } - messages = SanitizeMessages(messages) + messages = core.SanitizeMessages(messages) if opts.Model == "" { return nil, nil, fmt.Errorf("eyrie: model is required for anthropic") } @@ -457,9 +485,9 @@ func (c *AnthropicClient) buildAnthropicRequest(ctx context.Context, messages [] if opts.EnableCaching { allMessages := messages if opts.System != "" { - allMessages = append([]EyrieMessage{{Role: "system", Content: opts.System}}, allMessages...) + allMessages = append([]core.EyrieMessage{{Role: "system", Content: opts.System}}, allMessages...) } - tools := convertToAnthropicTools(opts.Tools) + tools := ConvertToAnthropicTools(opts.Tools) cachedReq := buildAnthropicCachedRequest(allMessages, opts.Model, maxTokens, opts.Temperature, stream, tools, thinking, resolveToolChoice(opts.ToolChoice), opts.TopP, opts.TopK, opts.StopSequences) var err error @@ -486,7 +514,7 @@ func (c *AnthropicClient) buildAnthropicRequest(ctx context.Context, messages [] TopK: opts.TopK, StopSequences: opts.StopSequences, Stream: stream, - Tools: convertToAnthropicTools(opts.Tools), + Tools: ConvertToAnthropicTools(opts.Tools), ToolChoice: resolveToolChoice(opts.ToolChoice), Thinking: thinking, Metadata: resolveMetadata(opts), @@ -521,7 +549,7 @@ func (c *AnthropicClient) buildAnthropicRequest(ctx context.Context, messages [] // Anthropic structured output is sent through output_config.format. A // schema-less json_object request is rejected instead of being silently // ignored because Anthropic requires a JSON schema. -func (c *AnthropicClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { +func (c *AnthropicClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { req, body, err := c.buildAnthropicRequest(ctx, messages, opts, false) if err != nil { return nil, err @@ -542,8 +570,8 @@ func (c *AnthropicClient) Chat(ctx context.Context, messages []EyrieMessage, opt orgID := resp.Header.Get("Anthropic-Organization-Id") if resp.StatusCode != 200 { - detail, readErr := parseProviderError(resp.Body) - return nil, formatAPIError("anthropic", "chat", resp.StatusCode, requestID, detail, readErr) + detail, readErr := core.ParseProviderError(resp.Body) + return nil, core.FormatAPIError("anthropic", "chat", resp.StatusCode, requestID, detail, readErr) } var ar anthropicResponse @@ -551,9 +579,9 @@ func (c *AnthropicClient) Chat(ctx context.Context, messages []EyrieMessage, opt return nil, fmt.Errorf("eyrie: failed to decode anthropic response: %w", err) } - eyrieResp := parseAnthropicResponse(ar, requestID, orgID) + eyrieResp := ParseAnthropicResponse(ar, requestID, orgID) - if err := applyGuardrails(ctx, eyrieResp, c.guardrails); err != nil { + if err := core.ApplyGuardrails(ctx, eyrieResp, c.guardrails); err != nil { return nil, err } @@ -561,7 +589,7 @@ func (c *AnthropicClient) Chat(ctx context.Context, messages []EyrieMessage, opt } // StreamChat sends a streaming message to Anthropic. -func (c *AnthropicClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { +func (c *AnthropicClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { req, body, err := c.buildAnthropicRequest(ctx, messages, opts, true) if err != nil { return nil, err @@ -575,20 +603,20 @@ func (c *AnthropicClient) StreamChat(ctx context.Context, messages []EyrieMessag requestID := resp.Header.Get("Request-Id") if resp.StatusCode != 200 { - detail, readErr := parseProviderError(resp.Body) + detail, readErr := core.ParseProviderError(resp.Body) _ = resp.Body.Close() - return nil, formatAPIError("anthropic", "stream", resp.StatusCode, requestID, detail, readErr) + return nil, core.FormatAPIError("anthropic", "stream", resp.StatusCode, requestID, detail, readErr) } streamCtx, cancel := context.WithCancel(ctx) - sseEvents := parseSSEStream(streamCtx, resp.Body, c.logger) - events := processAnthropicStream(streamCtx, sseEvents, c.logger) + sseEvents := core.ParseSSEStream(streamCtx, resp.Body, c.logger) + events := core.ProcessAnthropicStream(streamCtx, sseEvents, c.logger) - return NewStreamResultWithRequestID(events, requestID, cancel), nil + return core.NewStreamResultWithRequestID(events, requestID, cancel), nil } func (c *AnthropicClient) doRequestWithMimoAuthRetry(ctx context.Context, req *http.Request, body []byte) (*http.Response, error) { - resp, err := doWithRetry(ctx, c.httpClient, req, c.retry, c.logger) + resp, err := core.DoWithRetry(ctx, c.httpClient, req, c.retry, c.logger) if err != nil { return nil, err } @@ -603,12 +631,12 @@ func (c *AnthropicClient) doRequestWithMimoAuthRetry(ctx context.Context, req *h req2.Header.Set("Content-Type", "application/json") req2.Header.Set("Authorization", "Bearer "+c.apiKey) req2.Header.Set("Anthropic-Version", c.version) - req2.Header.Set("User-Agent", userAgent()) + req2.Header.Set("User-Agent", core.UserAgent()) if req.Header.Get("Accept") != "" { req2.Header.Set("Accept", req.Header.Get("Accept")) } req2.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil } - return doWithRetry(ctx, c.httpClient, req2, c.retry, c.logger) + return core.DoWithRetry(ctx, c.httpClient, req2, c.retry, c.logger) } // Ping checks connectivity to the Anthropic API using a lightweight GET request. @@ -630,7 +658,7 @@ func (c *AnthropicClient) Ping(ctx context.Context) error { return nil } -func convertToAnthropicTools(tools []EyrieTool) []anthropicTool { +func ConvertToAnthropicTools(tools []core.EyrieTool) []anthropicTool { if len(tools) == 0 { return nil } @@ -649,8 +677,8 @@ type TokenCountResult struct { // CountTokens counts the number of tokens in a message without generating a response. // Uses the same request format as Chat but hits the /v1/messages/count_tokens endpoint. // The request body includes model, messages, system, and tools (but not max_tokens or stream). -func (c *AnthropicClient) CountTokens(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*TokenCountResult, error) { - messages = SanitizeMessages(messages) +func (c *AnthropicClient) CountTokens(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*TokenCountResult, error) { + messages = core.SanitizeMessages(messages) if opts.Model == "" { return nil, fmt.Errorf("eyrie: model is required for anthropic count_tokens") } @@ -673,7 +701,7 @@ func (c *AnthropicClient) CountTokens(ctx context.Context, messages []EyrieMessa reqBody["system"] = system } if len(opts.Tools) > 0 { - reqBody["tools"] = convertToAnthropicTools(opts.Tools) + reqBody["tools"] = ConvertToAnthropicTools(opts.Tools) } body, err := json.Marshal(reqBody) @@ -704,8 +732,8 @@ func (c *AnthropicClient) CountTokens(ctx context.Context, messages []EyrieMessa }() if resp.StatusCode != 200 { - detail, readErr := parseProviderError(resp.Body) - return nil, formatAPIError("anthropic", "count_tokens", resp.StatusCode, resp.Header.Get("Request-Id"), detail, readErr) + detail, readErr := core.ParseProviderError(resp.Body) + return nil, core.FormatAPIError("anthropic", "count_tokens", resp.StatusCode, resp.Header.Get("Request-Id"), detail, readErr) } var result TokenCountResult @@ -714,3 +742,13 @@ func (c *AnthropicClient) CountTokens(ctx context.Context, messages []EyrieMessa } return &result, nil } + +// BuildAnthropicRequest constructs a complete Anthropic Messages request. +func (c *AnthropicClient) BuildAnthropicRequest(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions, stream bool) (*http.Request, []byte, error) { + return c.buildAnthropicRequest(ctx, messages, opts, stream) +} + +// ThinkingForBudget builds the wire-level extended-thinking configuration. +func ThinkingForBudget(budget int) *anthropicThinking { + return thinkingForBudget(budget) +} diff --git a/client/adapters/anthropic_cache.go b/client/adapters/anthropic_cache.go new file mode 100644 index 0000000..1d0ca86 --- /dev/null +++ b/client/adapters/anthropic_cache.go @@ -0,0 +1,93 @@ +package adapters + +import "github.com/GrayCodeAI/eyrie/client/core" + +// buildAnthropicCachedRequest builds an Anthropic request body with cache_control. +// - System prompt gets cache_control (cached for all turns) +// - Second-to-last message gets cache_control (caches conversation prefix) +// - Last tool definition gets cache_control (caches tool schema) +func buildAnthropicCachedRequest(messages []core.EyrieMessage, model string, maxTokens int, temperature *float64, stream bool, tools []anthropicTool, + thinking *anthropicThinking, toolChoice *anthropicToolChoice, topP *float64, topK *int, stopSequences []string, +) map[string]interface{} { + msgs, system := buildAnthropicMessages(messages) + + // Apply cache breakpoint to second-to-last non-system message + if len(msgs) >= 2 { + idx := len(msgs) - 2 + applyCacheBreakpointToMessage(msgs[idx]) + } + + req := map[string]interface{}{ + "model": model, + "max_tokens": maxTokens, + "messages": msgs, + "stream": stream, + } + if system != "" { + req["system"] = []map[string]interface{}{ + { + "type": "text", + "text": system, + "cache_control": map[string]string{"type": "ephemeral"}, + }, + } + } + if len(tools) > 0 { + toolMaps := make([]map[string]interface{}, len(tools)) + for i, t := range tools { + toolMaps[i] = map[string]interface{}{ + "name": t.Name, + "description": t.Description, + "input_schema": t.InputSchema, + } + } + // Annotate last tool with cache_control + toolMaps[len(toolMaps)-1]["cache_control"] = map[string]string{"type": "ephemeral"} + req["tools"] = toolMaps + } + if temperature != nil { + req["temperature"] = *temperature + } + if thinking != nil { + req["thinking"] = thinking + } + if toolChoice != nil { + req["tool_choice"] = toolChoice + } + if topP != nil { + req["top_p"] = *topP + } + if topK != nil { + req["top_k"] = *topK + } + if len(stopSequences) > 0 { + req["stop_sequences"] = stopSequences + } + return req +} + +// applyCacheBreakpointToMessage adds cache_control to a message's content. +func applyCacheBreakpointToMessage(msg map[string]interface{}) { + content := msg["content"] + switch c := content.(type) { + case string: + msg["content"] = []map[string]interface{}{ + { + "type": "text", + "text": c, + "cache_control": map[string]string{"type": "ephemeral"}, + }, + } + case []map[string]interface{}: + if len(c) > 0 { + c[len(c)-1]["cache_control"] = map[string]string{"type": "ephemeral"} + } + } +} + +// BuildAnthropicCachedRequest creates an Anthropic request with cache breakpoints. +func BuildAnthropicCachedRequest(messages []core.EyrieMessage, model string, maxTokens int, temperature *float64, stream bool, tools []AnthropicTool, + thinking *AnthropicThinking, toolChoice *AnthropicToolChoice, topP *float64, topK *int, stopSequences []string, +) map[string]interface{} { + return buildAnthropicCachedRequest(messages, model, maxTokens, temperature, stream, tools, thinking, toolChoice, topP, topK, stopSequences) +} diff --git a/client/azure.go b/client/adapters/azure.go similarity index 67% rename from client/azure.go rename to client/adapters/azure.go index d404317..b3efb3a 100644 --- a/client/azure.go +++ b/client/adapters/azure.go @@ -1,4 +1,4 @@ -package client +package adapters import ( "bytes" @@ -9,6 +9,8 @@ import ( "log/slog" "net/http" "strings" + + "github.com/GrayCodeAI/eyrie/client/core" ) const ( @@ -21,12 +23,12 @@ type AzureClient struct { endpoint string apiVersion string httpClient *http.Client - retry RetryConfig + retry core.RetryConfig logger *slog.Logger - guardrails *Guardrails + guardrails *core.Guardrails } -var _ Provider = (*AzureClient)(nil) +var _ core.Provider = (*AzureClient)(nil) func NewAzureClient(apiKey, endpoint, apiVersion string) *AzureClient { if apiVersion == "" { @@ -36,8 +38,8 @@ func NewAzureClient(apiKey, endpoint, apiVersion string) *AzureClient { apiKey: apiKey, endpoint: strings.TrimRight(endpoint, "/"), apiVersion: apiVersion, - httpClient: NewPooledHTTPClient(defaultTimeout), - retry: DefaultRetryConfig(), + httpClient: core.NewPooledHTTPClient(core.DefaultTimeout), + retry: core.DefaultRetryConfig(), logger: slog.Default(), } } @@ -47,11 +49,11 @@ func (c *AzureClient) Name() string { return "azure" } func (c *AzureClient) setHeaders(req *http.Request) { req.Header.Set("Content-Type", "application/json") req.Header.Set("api-key", c.apiKey) - req.Header.Set("User-Agent", userAgent()) + req.Header.Set("User-Agent", core.UserAgent()) } -func (c *AzureClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { - messages = SanitizeMessages(messages) +func (c *AzureClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + messages = core.SanitizeMessages(messages) if opts.Model == "" { return nil, fmt.Errorf("eyrie: model is required for azure") } @@ -74,7 +76,7 @@ func (c *AzureClient) Chat(ctx context.Context, messages []EyrieMessage, opts Ch c.logger.Debug("azure chat", "model", opts.Model, "endpoint", c.endpoint) - resp, err := doWithRetry(ctx, c.httpClient, req, c.retry, c.logger) + resp, err := core.DoWithRetry(ctx, c.httpClient, req, c.retry, c.logger) if err != nil { return nil, fmt.Errorf("eyrie: azure request failed: %w", err) } @@ -86,8 +88,8 @@ func (c *AzureClient) Chat(ctx context.Context, messages []EyrieMessage, opts Ch requestID := resp.Header.Get("X-Request-Id") if resp.StatusCode != 200 { - detail, readErr := parseProviderError(resp.Body) - return nil, formatAPIError("azure", "chat", resp.StatusCode, requestID, detail, readErr) + detail, readErr := core.ParseProviderError(resp.Body) + return nil, core.FormatAPIError("azure", "chat", resp.StatusCode, requestID, detail, readErr) } var or openaiResponse @@ -95,7 +97,7 @@ func (c *AzureClient) Chat(ctx context.Context, messages []EyrieMessage, opts Ch return nil, fmt.Errorf("eyrie: azure decode failed: %w", err) } - result := &EyrieResponse{FinishReason: "unknown", RequestID: requestID} + result := &core.EyrieResponse{FinishReason: "unknown", RequestID: requestID} if len(or.Choices) > 0 { ch := or.Choices[0] result.Content = ch.Message.Content @@ -103,11 +105,11 @@ func (c *AzureClient) Chat(ctx context.Context, messages []EyrieMessage, opts Ch for _, tc := range ch.Message.ToolCalls { var args map[string]interface{} _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) - result.ToolCalls = append(result.ToolCalls, ToolCall{ID: tc.ID, Name: tc.Function.Name, Arguments: args}) + result.ToolCalls = append(result.ToolCalls, core.ToolCall{ID: tc.ID, Name: tc.Function.Name, Arguments: args}) } } if or.Usage != nil { - result.Usage = &EyrieUsage{ + result.Usage = &core.EyrieUsage{ PromptTokens: or.Usage.PromptTokens, CompletionTokens: or.Usage.CompletionTokens, TotalTokens: or.Usage.TotalTokens, @@ -117,15 +119,15 @@ func (c *AzureClient) Chat(ctx context.Context, messages []EyrieMessage, opts Ch } } - if err := applyGuardrails(ctx, result, c.guardrails); err != nil { + if err := core.ApplyGuardrails(ctx, result, c.guardrails); err != nil { return nil, err } return result, nil } -func (c *AzureClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { - messages = SanitizeMessages(messages) +func (c *AzureClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + messages = core.SanitizeMessages(messages) if opts.Model == "" { return nil, fmt.Errorf("eyrie: model is required for azure") } @@ -146,23 +148,23 @@ func (c *AzureClient) StreamChat(ctx context.Context, messages []EyrieMessage, o c.logger.Debug("azure stream", "model", opts.Model, "endpoint", c.endpoint) - resp, err := doWithRetry(ctx, c.httpClient, req, c.retry, c.logger) + resp, err := core.DoWithRetry(ctx, c.httpClient, req, c.retry, c.logger) if err != nil { return nil, fmt.Errorf("eyrie: azure stream request failed: %w", err) } requestID := resp.Header.Get("X-Request-Id") if resp.StatusCode != 200 { - detail, readErr := parseProviderError(resp.Body) + detail, readErr := core.ParseProviderError(resp.Body) _ = resp.Body.Close() - return nil, formatAPIError("azure", "stream", resp.StatusCode, requestID, detail, readErr) + return nil, core.FormatAPIError("azure", "stream", resp.StatusCode, requestID, detail, readErr) } streamCtx, cancel := context.WithCancel(ctx) - sseEvents := parseSSEStream(streamCtx, resp.Body, c.logger) - events := processOpenAIStream(streamCtx, sseEvents, c.logger) + sseEvents := core.ParseSSEStream(streamCtx, resp.Body, c.logger) + events := core.ProcessOpenAIStream(streamCtx, sseEvents, c.logger) - return NewStreamResultWithRequestID(events, requestID, cancel), nil + return core.NewStreamResultWithRequestID(events, requestID, cancel), nil } func (c *AzureClient) Ping(ctx context.Context) error { @@ -183,6 +185,18 @@ func (c *AzureClient) Ping(ctx context.Context) error { return nil } -func (c *AzureClient) buildRequest(messages []EyrieMessage, opts ChatOptions, stream bool) openaiRequest { +func (c *AzureClient) buildRequest(messages []core.EyrieMessage, opts core.ChatOptions, stream bool) openaiRequest { return buildRequestBase(messages, opts, stream, &AzureCompat) } + +// SetHTTPClient replaces the transport used for Azure requests. +func (c *AzureClient) SetHTTPClient(hc *http.Client) { c.httpClient = hc } + +// SetRetry configures provider retry behavior. +func (c *AzureClient) SetRetry(rc core.RetryConfig) { c.retry = rc } + +// APIVersion returns the API version. +func (c *AzureClient) APIVersion() string { return c.apiVersion } + +// Endpoint returns the base endpoint. +func (c *AzureClient) Endpoint() string { return c.endpoint } diff --git a/client/bedrock.go b/client/adapters/bedrock.go similarity index 79% rename from client/bedrock.go rename to client/adapters/bedrock.go index 77471b6..70ab7ac 100644 --- a/client/bedrock.go +++ b/client/adapters/bedrock.go @@ -1,4 +1,4 @@ -package client +package adapters import ( "bytes" @@ -17,6 +17,8 @@ import ( "sort" "strings" "time" + + "github.com/GrayCodeAI/eyrie/client/core" ) const ( @@ -30,12 +32,12 @@ type BedrockClient struct { sessionToken string region string httpClient *http.Client - retry RetryConfig + retry core.RetryConfig logger *slog.Logger - guardrails *Guardrails + guardrails *core.Guardrails } -var _ Provider = (*BedrockClient)(nil) +var _ core.Provider = (*BedrockClient)(nil) func NewBedrockClient(accessKeyID, secretAccessKey, sessionToken, region string) *BedrockClient { return &BedrockClient{ @@ -43,15 +45,15 @@ func NewBedrockClient(accessKeyID, secretAccessKey, sessionToken, region string) secretAccessKey: secretAccessKey, sessionToken: sessionToken, region: region, - httpClient: NewPooledHTTPClient(defaultTimeout), - retry: DefaultRetryConfig(), + httpClient: core.NewPooledHTTPClient(core.DefaultTimeout), + retry: core.DefaultRetryConfig(), logger: slog.Default().With("component", "bedrock"), } } func (c *BedrockClient) Name() string { return "anthropic-bedrock" } -func (c *BedrockClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { +func (c *BedrockClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { if opts.Model == "" { return nil, fmt.Errorf("eyrie: model is required for bedrock") } @@ -70,7 +72,7 @@ func (c *BedrockClient) Chat(ctx context.Context, messages []EyrieMessage, opts return nil, err } - resp, err := doWithRetry(ctx, c.httpClient, req, c.retry, c.logger) + resp, err := core.DoWithRetry(ctx, c.httpClient, req, c.retry, c.logger) if err != nil { return nil, fmt.Errorf("eyrie: bedrock request failed: %w", err) } @@ -80,24 +82,24 @@ func (c *BedrockClient) Chat(ctx context.Context, messages []EyrieMessage, opts } }() if resp.StatusCode != http.StatusOK { - detail, readErr := parseProviderError(resp.Body) - return nil, formatAPIError("bedrock", "chat", resp.StatusCode, resp.Header.Get("X-Amzn-Requestid"), detail, readErr) + detail, readErr := core.ParseProviderError(resp.Body) + return nil, core.FormatAPIError("bedrock", "chat", resp.StatusCode, resp.Header.Get("X-Amzn-Requestid"), detail, readErr) } var ar anthropicResponse if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil { return nil, fmt.Errorf("eyrie: bedrock decode failed: %w", err) } - eyrieResp := parseAnthropicResponse(ar, resp.Header.Get("X-Amzn-Requestid"), "") + eyrieResp := ParseAnthropicResponse(ar, resp.Header.Get("X-Amzn-Requestid"), "") - if err := applyGuardrails(ctx, eyrieResp, c.guardrails); err != nil { + if err := core.ApplyGuardrails(ctx, eyrieResp, c.guardrails); err != nil { return nil, err } return eyrieResp, nil } -func (c *BedrockClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { +func (c *BedrockClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { if opts.Model == "" { return nil, fmt.Errorf("eyrie: model is required for bedrock") } @@ -128,7 +130,7 @@ func (c *BedrockClient) StreamChat(ctx context.Context, messages []EyrieMessage, return nil, err } - resp, err := doWithRetry(ctx, c.httpClient, req, c.retry, c.logger) //nolint:bodyclose // closed in goroutine for streaming + resp, err := core.DoWithRetry(ctx, c.httpClient, req, c.retry, c.logger) //nolint:bodyclose // closed in goroutine for streaming if err != nil { return nil, fmt.Errorf("eyrie: bedrock stream request failed: %w", err) } @@ -138,12 +140,12 @@ func (c *BedrockClient) StreamChat(ctx context.Context, messages []EyrieMessage, slog.Warn("bedrock: close error response body", "error", err) } }() - detail, readErr := parseProviderError(resp.Body) - return nil, formatAPIError("bedrock stream", "stream", resp.StatusCode, resp.Header.Get("X-Amzn-Requestid"), detail, readErr) + detail, readErr := core.ParseProviderError(resp.Body) + return nil, core.FormatAPIError("bedrock stream", "stream", resp.StatusCode, resp.Header.Get("X-Amzn-Requestid"), detail, readErr) } streamCtx, cancel := context.WithCancel(ctx) - ch := make(chan EyrieStreamEvent, 64) + ch := make(chan core.EyrieStreamEvent, 64) go func() { defer close(ch) @@ -155,7 +157,7 @@ func (c *BedrockClient) StreamChat(ctx context.Context, messages []EyrieMessage, }() var contentBuf strings.Builder - var usage *EyrieUsage + var usage *core.EyrieUsage var finishReason string // Bedrock uses Amazon EventStream format. Parse it. @@ -165,7 +167,7 @@ func (c *BedrockClient) StreamChat(ctx context.Context, messages []EyrieMessage, if err != nil { if err != io.EOF { select { - case ch <- EyrieStreamEvent{Type: "error", Content: err.Error()}: + case ch <- core.EyrieStreamEvent{Type: "error", Content: err.Error()}: case <-streamCtx.Done(): } } @@ -183,7 +185,7 @@ func (c *BedrockClient) StreamChat(ctx context.Context, messages []EyrieMessage, if chunk.Delta != nil && chunk.Delta.Text != "" { contentBuf.WriteString(chunk.Delta.Text) select { - case ch <- EyrieStreamEvent{Type: "content", Content: chunk.Delta.Text}: + case ch <- core.EyrieStreamEvent{Type: "content", Content: chunk.Delta.Text}: case <-streamCtx.Done(): return } @@ -191,7 +193,7 @@ func (c *BedrockClient) StreamChat(ctx context.Context, messages []EyrieMessage, if chunk.Delta != nil && chunk.Delta.Type == "input_json_delta" && chunk.Delta.PartialJSON != "" { // Accumulate tool input JSON select { - case ch <- EyrieStreamEvent{Type: "tool_input_delta", Content: chunk.Delta.PartialJSON}: + case ch <- core.EyrieStreamEvent{Type: "tool_input_delta", Content: chunk.Delta.PartialJSON}: case <-streamCtx.Done(): return } @@ -200,9 +202,9 @@ func (c *BedrockClient) StreamChat(ctx context.Context, messages []EyrieMessage, if chunk.ContentBlock != nil && chunk.ContentBlock.Type == "tool_use" { var args map[string]interface{} _ = json.Unmarshal(chunk.ContentBlock.Input, &args) - tc := ToolCall{ID: chunk.ContentBlock.ID, Name: chunk.ContentBlock.Name, Arguments: args} + tc := core.ToolCall{ID: chunk.ContentBlock.ID, Name: chunk.ContentBlock.Name, Arguments: args} select { - case ch <- EyrieStreamEvent{Type: "tool_call", ToolCall: &tc}: + case ch <- core.EyrieStreamEvent{Type: "tool_call", ToolCall: &tc}: case <-streamCtx.Done(): return } @@ -219,7 +221,7 @@ func (c *BedrockClient) StreamChat(ctx context.Context, messages []EyrieMessage, } // Send final done event with usage (matching Anthropic/OpenAI pattern) - doneEvt := EyrieStreamEvent{Type: "done", StopReason: finishReason} + doneEvt := core.EyrieStreamEvent{Type: "done", StopReason: finishReason} if usage != nil { doneEvt.Usage = usage } @@ -229,7 +231,7 @@ func (c *BedrockClient) StreamChat(ctx context.Context, messages []EyrieMessage, } }() - return NewStreamResultWithRequestID(ch, resp.Header.Get("X-Amzn-Requestid"), cancel), nil + return core.NewStreamResultWithRequestID(ch, resp.Header.Get("X-Amzn-Requestid"), cancel), nil } func (c *BedrockClient) Ping(ctx context.Context) error { @@ -254,8 +256,8 @@ func (c *BedrockClient) Ping(ctx context.Context) error { return nil } -func (c *BedrockClient) buildBody(messages []EyrieMessage, opts ChatOptions) ([]byte, error) { - messages = SanitizeMessages(messages) +func (c *BedrockClient) buildBody(messages []core.EyrieMessage, opts core.ChatOptions) ([]byte, error) { + messages = core.SanitizeMessages(messages) msgs, system := buildAnthropicMessages(messages) if opts.System != "" { if system != "" { @@ -277,7 +279,7 @@ func (c *BedrockClient) buildBody(messages []EyrieMessage, opts ChatOptions) ([] TopP: opts.TopP, TopK: opts.TopK, StopSequences: opts.StopSequences, - Tools: convertToAnthropicTools(opts.Tools), + Tools: ConvertToAnthropicTools(opts.Tools), ToolChoice: resolveToolChoice(opts.ToolChoice), Thinking: resolveThinking(opts), Metadata: resolveMetadata(opts), @@ -324,7 +326,7 @@ type anthropicStreamChunk struct { StopReason string `json:"stop_reason,omitempty"` } `json:"delta,omitempty"` Message *struct { - Usage *EyrieUsage `json:"usage,omitempty"` + Usage *core.EyrieUsage `json:"usage,omitempty"` } `json:"message,omitempty"` } @@ -515,3 +517,43 @@ func hmacSHA256(key []byte, data string) []byte { _, _ = mac.Write([]byte(data)) return mac.Sum(nil) } + +// ModelURL returns the signed model URL for Bedrock. +func (c *BedrockClient) ModelURL(model string) string { + return c.modelURL(model) +} + +// BuildBody creates the Anthropic-compatible Bedrock request payload. +func (c *BedrockClient) BuildBody(messages []core.EyrieMessage, opts core.ChatOptions) ([]byte, error) { + return c.buildBody(messages, opts) +} + +// HTTPClient returns the underlying HTTP client. +func (c *BedrockClient) HTTPClient() *http.Client { return c.httpClient } + +// Retry returns the retry configuration. +func (c *BedrockClient) Retry() core.RetryConfig { return c.retry } + +// Region returns the AWS region. +func (c *BedrockClient) Region() string { return c.region } + +// AWSCanonicalURI returns the canonical URI used by SigV4. +func AWSCanonicalURI(uri string) string { return awsCanonicalURI(uri) } + +// AWSSigningKey derives a SigV4 signing key from explicit inputs. +func AWSSigningKey(secret, dateStamp, region, service string) []byte { + return awsSigningKey(secret, dateStamp, region, service) +} + +// Sign applies AWS SigV4 headers to a Bedrock request. +func (c *BedrockClient) Sign(req *http.Request, body []byte, now time.Time) error { + return c.sign(req, body, now) +} + +// Sha256Hex returns a lowercase SHA-256 digest. +func Sha256Hex(data []byte) string { return sha256Hex(data) } + +// CanonicalAWSHeaders canonicalizes a header set for SigV4. +func CanonicalAWSHeaders(headers http.Header) (string, string) { + return canonicalAWSHeaders(headers) +} diff --git a/client/adapters/compat.go b/client/adapters/compat.go new file mode 100644 index 0000000..1990cd2 --- /dev/null +++ b/client/adapters/compat.go @@ -0,0 +1,172 @@ +package adapters + +// OpenAICompatConfig holds provider-specific compatibility flags +// that control how API requests are constructed for each provider. +type OpenAICompatConfig struct { + SupportsStore bool `json:"supports_store,omitempty"` + SupportsDeveloperRole bool `json:"supports_developer_role,omitempty"` + SupportsReasoningEffort bool `json:"supports_reasoning_effort,omitempty"` + SupportsUsageInStreaming bool `json:"supports_usage_in_streaming,omitempty"` + SupportsStrictMode bool `json:"supports_strict_mode,omitempty"` + MaxTokensField string `json:"max_tokens_field,omitempty"` // "max_tokens" or "max_completion_tokens" + RequiresToolResultName bool `json:"requires_tool_result_name,omitempty"` + RequiresAssistantAfterToolResult bool `json:"requires_assistant_after_tool_result,omitempty"` + RequiresThinkingAsText bool `json:"requires_thinking_as_text,omitempty"` + ThinkingFormat string `json:"thinking_format,omitempty"` // "openai", "zai", "qwen", "openrouter" + // StripReasoningFromInput instructs buildRequestBase to omit the reasoning_content + // field from assistant messages. DeepSeek (and compatible providers) return HTTP 400 + // if reasoning_content appears in the input context of a multi-turn conversation. + StripReasoningFromInput bool `json:"strip_reasoning_from_input,omitempty"` + // SupportsCacheRole enables Kimi/Moonshot context-cache injection: when + // core.ChatOptions.KimiContextCacheID is non-empty, buildRequestBase prepends a + // {"role":"cache","content":} message per the MoonshotAI-Cookbook spec. + SupportsCacheRole bool `json:"supports_cache_role,omitempty"` +} + +// Per-provider compat configs. +var ( + OpenAICompat = OpenAICompatConfig{ + SupportsStore: true, SupportsDeveloperRole: true, + SupportsReasoningEffort: true, SupportsUsageInStreaming: true, + MaxTokensField: "max_completion_tokens", + } + GrokCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + } + OpenRouterCompat = OpenAICompatConfig{ + ThinkingFormat: "openrouter", MaxTokensField: "max_tokens", + SupportsUsageInStreaming: true, + } + GeminiCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, + } + ZAICompat = OpenAICompatConfig{ + ThinkingFormat: "zai", MaxTokensField: "max_tokens", + SupportsUsageInStreaming: true, + } + CanopyWaveCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + } + OllamaCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + } + OpenCodeGoCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + SupportsUsageInStreaming: true, + ThinkingFormat: "openrouter", + StripReasoningFromInput: true, + } + PoolsideCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + } + GroqCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + } + ClinePassCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + } + KimiCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + SupportsCacheRole: true, + } + XiaomiCompat = OpenAICompatConfig{ + MaxTokensField: "max_completion_tokens", + } + AzureCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + } + BedrockCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + } + VertexCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + } + // DeepSeekCompat: OpenAI-compatible with usage in streaming. + // The provider rejects reasoning_content in input messages with HTTP 400, so we strip it. + DeepSeekCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + SupportsUsageInStreaming: true, + StripReasoningFromInput: true, + } +) + +func init() { + // Attach compat configs to provider registry. + // Acquire dynamicMu for consistency with the runtime lock protocol, + // even though init() is single-threaded. + DynamicMu.Lock() + defer DynamicMu.Unlock() + + if p, ok := OpenAICompatibleProviders["grok"]; ok { + p.Compat = &GrokCompat + OpenAICompatibleProviders["grok"] = p + } + if p, ok := OpenAICompatibleProviders["openrouter"]; ok { + p.Compat = &OpenRouterCompat + OpenAICompatibleProviders["openrouter"] = p + } + if p, ok := OpenAICompatibleProviders["gemini"]; ok { + p.Compat = &GeminiCompat + OpenAICompatibleProviders["gemini"] = p + } + for _, id := range []string{"zai_payg", "zai_coding"} { + if p, ok := OpenAICompatibleProviders[id]; ok { + p.Compat = &ZAICompat + OpenAICompatibleProviders[id] = p + } + } + if p, ok := OpenAICompatibleProviders["canopywave"]; ok { + p.Compat = &CanopyWaveCompat + OpenAICompatibleProviders["canopywave"] = p + } + if p, ok := OpenAICompatibleProviders["poolside"]; ok { + p.Compat = &PoolsideCompat + OpenAICompatibleProviders["poolside"] = p + } + if p, ok := OpenAICompatibleProviders["groq"]; ok { + p.Compat = &GroqCompat + OpenAICompatibleProviders["groq"] = p + } + if p, ok := OpenAICompatibleProviders["clinepass"]; ok { + p.Compat = &ClinePassCompat + OpenAICompatibleProviders["clinepass"] = p + } + if p, ok := OpenAICompatibleProviders["ollama"]; ok { + p.Compat = &OllamaCompat + OpenAICompatibleProviders["ollama"] = p + } + if p, ok := OpenAICompatibleProviders["opencodego"]; ok { + p.Compat = &OpenCodeGoCompat + OpenAICompatibleProviders["opencodego"] = p + } + if p, ok := OpenAICompatibleProviders["kimi"]; ok { + p.Compat = &KimiCompat + OpenAICompatibleProviders["kimi"] = p + } + for _, id := range []string{"xiaomi_mimo", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan"} { + if p, ok := OpenAICompatibleProviders[id]; ok { + p.Compat = &XiaomiCompat + OpenAICompatibleProviders[id] = p + } + } + if p, ok := OpenAICompatibleProviders["deepseek"]; ok { + p.Compat = &DeepSeekCompat + OpenAICompatibleProviders["deepseek"] = p + } + if p, ok := CoreProviders["openai"]; ok { + p.Compat = &OpenAICompat + CoreProviders["openai"] = p + } + if p, ok := CoreProviders["azure"]; ok { + p.Compat = &AzureCompat + CoreProviders["azure"] = p + } + if p, ok := CoreProviders["bedrock"]; ok { + p.Compat = &BedrockCompat + CoreProviders["bedrock"] = p + } + if p, ok := CoreProviders["vertex"]; ok { + p.Compat = &VertexCompat + CoreProviders["vertex"] = p + } +} diff --git a/client/deepseek.go b/client/adapters/deepseek.go similarity index 70% rename from client/deepseek.go rename to client/adapters/deepseek.go index c21cc58..6e2691c 100644 --- a/client/deepseek.go +++ b/client/adapters/deepseek.go @@ -1,9 +1,11 @@ -package client +package adapters import ( "context" "log/slog" "strings" + + "github.com/GrayCodeAI/eyrie/client/core" ) // DeepSeekClient uses OpenAI-compatible DeepSeek endpoints first, @@ -16,10 +18,10 @@ type DeepSeekClient struct { // NewDeepSeekClient builds a DeepSeek provider client. // openAIBase is typically "https://api.deepseek.com/v1" // anthropicBase is typically "https://api.deepseek.com/anthropic" -func NewDeepSeekClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, opts ...ClientOption) *DeepSeekClient { +func NewDeepSeekClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *DeepSeekClient { openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") anthropicBase = strings.TrimRight(strings.TrimSpace(anthropicBase), "/") - dsOpts := append(append([]ClientOption{}, opts...), WithProviderName("deepseek")) + dsOpts := append(append([]core.ClientOption{}, opts...), core.WithProviderName("deepseek")) o := NewOpenAIClient(apiKey, openAIBase, compat, dsOpts...) var a *AnthropicClient if anthropicBase != "" { @@ -33,9 +35,9 @@ func NewDeepSeekClient(apiKey, openAIBase, anthropicBase string, compat *OpenAIC func (c *DeepSeekClient) Name() string { return "deepseek" } -func (c *DeepSeekClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { - return c.router.Chat(ctx, messages, opts, ChatProtocolCompletions, func(err error, _ *EyrieResponse) bool { - if err != nil && c.router.Anthropic != nil && isRetriableError(err) { +func (c *DeepSeekClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + return c.router.Chat(ctx, messages, opts, ChatProtocolCompletions, func(err error, _ *core.EyrieResponse) bool { + if err != nil && c.router.Anthropic != nil && core.IsRetriableError(err) { c.logger.Info("DeepSeek: OpenAI endpoint failed; retrying via Anthropic compatibility", "error", err) return true } @@ -43,11 +45,11 @@ func (c *DeepSeekClient) Chat(ctx context.Context, messages []EyrieMessage, opts }) } -func (c *DeepSeekClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { +func (c *DeepSeekClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { return c.router.StreamChat(ctx, messages, opts, ProtocolStreamConfig{ Primary: ChatProtocolCompletions, FallbackOnError: func(err error) bool { - if c.router.Anthropic != nil && isRetriableError(err) { + if c.router.Anthropic != nil && core.IsRetriableError(err) { c.logger.Info("DeepSeek: OpenAI stream failed; retrying via Anthropic compatibility", "error", err) return true } @@ -59,10 +61,10 @@ func (c *DeepSeekClient) StreamChat(ctx context.Context, messages []EyrieMessage func (c *DeepSeekClient) Ping(ctx context.Context) error { if err := c.router.OpenAI.Ping(ctx); err == nil { return nil - } else if c.router.Anthropic == nil || !isRetriableError(err) { + } else if c.router.Anthropic == nil || !core.IsRetriableError(err) { return err } return c.router.Anthropic.Ping(ctx) } -var _ Provider = (*DeepSeekClient)(nil) +var _ core.Provider = (*DeepSeekClient)(nil) diff --git a/client/adapters/dynamic.go b/client/adapters/dynamic.go new file mode 100644 index 0000000..d45357f --- /dev/null +++ b/client/adapters/dynamic.go @@ -0,0 +1,75 @@ +package adapters + +import ( + "fmt" + "net/url" + "os" + "strings" + "sync" + "sync/atomic" +) + +// DynamicMu protects the OpenAICompatibleProviders map from concurrent access. +var DynamicMu sync.RWMutex + +// registryFrozen prevents new provider registrations after first use. +var registryFrozen atomic.Bool + +// DynamicProviderEnvVar is the opt-in env var that allows eyrie to +// auto-register an OpenAI-compatible provider from OPENAI_API_BASE / +// OPENAI_BASE_URL when an unknown provider name is requested. +const DynamicProviderEnvVar = "EYRIE_ALLOW_DYNAMIC_PROVIDERS" + +// DynamicProviderEnabled reports whether callers may auto-register an +// OpenAI-compatible provider from OPENAI_API_BASE / OPENAI_BASE_URL when +// an unknown provider name is requested. +func DynamicProviderEnabled() bool { + v := strings.TrimSpace(strings.ToLower(os.Getenv(DynamicProviderEnvVar))) + return v == "1" || v == "true" || v == "yes" +} + +// FreezeRegistry prevents further provider registrations. +func FreezeRegistry() { + registryFrozen.Store(true) +} + +// RegisterDynamicProvider adds a user-defined OpenAI-compatible provider at runtime. +func RegisterDynamicProvider(name, baseURL, envKey string) error { + if registryFrozen.Load() { + return fmt.Errorf("eyrie: provider registry is frozen; register providers before first use") + } + if baseURL == "" { + return fmt.Errorf("eyrie: RegisterDynamicProvider: baseURL must not be empty") + } + u, err := url.Parse(baseURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("eyrie: RegisterDynamicProvider: invalid baseURL %q (must be http/https with host)", baseURL) + } + DynamicMu.Lock() + defer DynamicMu.Unlock() + + OpenAICompatibleProviders[name] = ProviderRegistryConfig{ + Name: name, + Type: ProviderTypeOpenAICompatible, + BaseURL: baseURL, + EnvKey: envKey, + SupportsStreaming: true, + SupportsTools: true, + SupportsReasoning: false, + Compat: &OpenAICompatConfig{ + MaxTokensField: "max_tokens", + }, + } + return nil +} + +// OpenAIBaseFallbackURL returns the OPENAI_API_BASE or OPENAI_BASE_URL env var. +func OpenAIBaseFallbackURL() string { + if u := os.Getenv("OPENAI_API_BASE"); u != "" { + return u + } + if u := os.Getenv("OPENAI_BASE_URL"); u != "" { + return u + } + return "" +} diff --git a/client/gemini.go b/client/adapters/gemini.go similarity index 85% rename from client/gemini.go rename to client/adapters/gemini.go index 9e7c3e8..23361d6 100644 --- a/client/gemini.go +++ b/client/adapters/gemini.go @@ -1,4 +1,4 @@ -package client +package adapters import ( "bytes" @@ -10,6 +10,8 @@ import ( "net/http" "os" "strings" + + "github.com/GrayCodeAI/eyrie/client/core" ) // geminiSharedParserEnvVar is the opt-out flag for the new @@ -19,35 +21,38 @@ import ( // release once the new path is validated in production. const geminiSharedParserEnvVar = "EYRIE_GEMINI_SHARED_PARSER" +// GeminiSharedParserEnvVar controls the shared SSE parser compatibility switch. +const GeminiSharedParserEnvVar = geminiSharedParserEnvVar + // maxGeminiRequestSize is the maximum request body size for Gemini API (32 MB). const maxGeminiRequestSize = 32 * 1024 * 1024 // geminiSharedParserEnabled reports whether the Gemini client should -// use the shared parseSSEStream + processGeminiStream path (the new +// use the shared core.ParseSSEStream + processGeminiStream path (the new // behavior). Default: enabled. func geminiSharedParserEnabled() bool { v := strings.TrimSpace(strings.ToLower(os.Getenv(geminiSharedParserEnvVar))) return v != "0" && v != "false" && v != "no" } -// GeminiClient implements Provider for the Google Gemini API. +// GeminiClient implements core.Provider for the Google Gemini API. type GeminiClient struct { apiKey string baseURL string httpClient *http.Client - retry RetryConfig + retry core.RetryConfig logger *slog.Logger - guardrails *Guardrails + guardrails *core.Guardrails } -var _ Provider = (*GeminiClient)(nil) +var _ core.Provider = (*GeminiClient)(nil) func NewGeminiClient(apiKey, baseURL string) *GeminiClient { c := &GeminiClient{ apiKey: apiKey, baseURL: baseURL, - httpClient: NewPooledHTTPClient(defaultTimeout), - retry: DefaultRetryConfig(), + httpClient: core.NewPooledHTTPClient(core.DefaultTimeout), + retry: core.DefaultRetryConfig(), logger: slog.Default().With("component", "gemini"), } if c.baseURL == "" { @@ -60,7 +65,7 @@ func (c *GeminiClient) Name() string { return "gemini" } func (c *GeminiClient) setHeaders(req *http.Request) { req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", userAgent()) + req.Header.Set("User-Agent", core.UserAgent()) if c.isVertex() { req.Header.Set("Authorization", "Bearer "+c.apiKey) } else { @@ -72,7 +77,7 @@ func (c *GeminiClient) isVertex() bool { return strings.Contains(c.baseURL, "aiplatform.googleapis.com") } -func (c *GeminiClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { +func (c *GeminiClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { if opts.Model == "" { return nil, fmt.Errorf("eyrie: model is required for gemini") } @@ -88,7 +93,7 @@ func (c *GeminiClient) Chat(ctx context.Context, messages []EyrieMessage, opts C c.setHeaders(req) req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil } - resp, err := doWithRetry(ctx, c.httpClient, req, c.retry, c.logger) + resp, err := core.DoWithRetry(ctx, c.httpClient, req, c.retry, c.logger) if err != nil { return nil, fmt.Errorf("eyrie: gemini request failed: %w", err) } @@ -101,8 +106,8 @@ func (c *GeminiClient) Chat(ctx context.Context, messages []EyrieMessage, opts C requestID := resp.Header.Get("X-Goog-Request-Id") if resp.StatusCode != http.StatusOK { - detail, readErr := parseProviderError(resp.Body) - return nil, formatAPIError("gemini", "chat", resp.StatusCode, requestID, detail, readErr) + detail, readErr := core.ParseProviderError(resp.Body) + return nil, core.FormatAPIError("gemini", "chat", resp.StatusCode, requestID, detail, readErr) } respBody, err := io.ReadAll(resp.Body) @@ -115,14 +120,14 @@ func (c *GeminiClient) Chat(ctx context.Context, messages []EyrieMessage, opts C return nil, err } - if err := applyGuardrails(ctx, eyrieResp, c.guardrails); err != nil { + if err := core.ApplyGuardrails(ctx, eyrieResp, c.guardrails); err != nil { return nil, err } return eyrieResp, nil } -func (c *GeminiClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { +func (c *GeminiClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { if opts.Model == "" { return nil, fmt.Errorf("eyrie: model is required for gemini") } @@ -138,7 +143,7 @@ func (c *GeminiClient) StreamChat(ctx context.Context, messages []EyrieMessage, c.setHeaders(req) req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil } - resp, err := doWithRetry(ctx, c.httpClient, req, c.retry, c.logger) + resp, err := core.DoWithRetry(ctx, c.httpClient, req, c.retry, c.logger) if err != nil { return nil, fmt.Errorf("eyrie: gemini stream request failed: %w", err) } @@ -146,21 +151,21 @@ func (c *GeminiClient) StreamChat(ctx context.Context, messages []EyrieMessage, requestID := resp.Header.Get("X-Goog-Request-Id") if resp.StatusCode != http.StatusOK { - detail, readErr := parseProviderError(resp.Body) + detail, readErr := core.ParseProviderError(resp.Body) _ = resp.Body.Close() - return nil, formatAPIError("gemini", "stream", resp.StatusCode, requestID, detail, readErr) + return nil, core.FormatAPIError("gemini", "stream", resp.StatusCode, requestID, detail, readErr) } streamCtx, cancel := context.WithCancel(ctx) if geminiSharedParserEnabled() { - sseEvents := parseSSEStream(streamCtx, resp.Body, c.logger) + sseEvents := core.ParseSSEStream(streamCtx, resp.Body, c.logger) events := processGeminiStream(streamCtx, sseEvents, c.logger) - return NewStreamResult(events, cancel), nil + return core.NewStreamResult(events, cancel), nil } // Fallback (opt-out via EYRIE_GEMINI_SHARED_PARSER=0): old bespoke parser. - events := make(chan EyrieStreamEvent, 64) + events := make(chan core.EyrieStreamEvent, 64) go c.streamLoop(streamCtx, resp.Body, events) - return NewStreamResult(events, cancel), nil + return core.NewStreamResult(events, cancel), nil } func (c *GeminiClient) Ping(ctx context.Context) error { @@ -271,8 +276,8 @@ type geminiSafetySetting struct { Threshold string `json:"threshold"` // e.g., "BLOCK_NONE", "BLOCK_LOW_AND_ABOVE", etc. } -func (c *GeminiClient) buildBody(messages []EyrieMessage, opts ChatOptions) ([]byte, error) { - messages = SanitizeMessages(messages) +func (c *GeminiClient) buildBody(messages []core.EyrieMessage, opts core.ChatOptions) ([]byte, error) { + messages = core.SanitizeMessages(messages) contents := make([]geminiContent, 0, len(messages)) var systemInstruction *geminiContent @@ -310,7 +315,7 @@ func (c *GeminiClient) buildBody(messages []EyrieMessage, opts ChatOptions) ([]b gc.Parts = append(gc.Parts, geminiPart{Text: part.Text}) case "image_url": if part.ImageURL != nil { - mimeType, data, isBase64 := parseImageString(part.ImageURL.URL) + mimeType, data, isBase64 := core.ParseImageString(part.ImageURL.URL) if !isBase64 { mimeType = "image/png" data = part.ImageURL.URL @@ -420,7 +425,7 @@ func (c *GeminiClient) buildBody(messages []EyrieMessage, opts ChatOptions) ([]b {Category: "HARM_CATEGORY_CIVIC_INTEGRITY", Threshold: "BLOCK_NONE"}, } - // Map additional ChatOptions to generationConfig + // Map additional core.ChatOptions to generationConfig if req.GenerationConfig == nil && (opts.PresencePenalty != nil || opts.FrequencyPenalty != nil || opts.Seed != nil || opts.N != nil || opts.LogProbs != nil || opts.TopLogProbs != nil) { req.GenerationConfig = &geminiGenerationConfig{} } @@ -482,7 +487,7 @@ type geminiPromptFeedback struct { BlockReasonMessage string `json:"blockReasonMessage,omitempty"` } -func (c *GeminiClient) parseResponse(data []byte, requestID string) (*EyrieResponse, error) { +func (c *GeminiClient) parseResponse(data []byte, requestID string) (*core.EyrieResponse, error) { var gr geminiResponse if err := json.Unmarshal(data, &gr); err != nil { return nil, fmt.Errorf("eyrie: gemini response parse failed: %w", err) @@ -500,7 +505,7 @@ func (c *GeminiClient) parseResponse(data []byte, requestID string) (*EyrieRespo } candidate := gr.Candidates[0] - resp := &EyrieResponse{ + resp := &core.EyrieResponse{ FinishReason: mapGeminiFinishReason(candidate.FinishReason), RequestID: requestID, } @@ -510,7 +515,7 @@ func (c *GeminiClient) parseResponse(data []byte, requestID string) (*EyrieRespo resp.Content += part.Text } if part.FunctionCall != nil { - tc := ToolCall{ + tc := core.ToolCall{ Name: part.FunctionCall.Name, Arguments: part.FunctionCall.Args, } @@ -522,7 +527,7 @@ func (c *GeminiClient) parseResponse(data []byte, requestID string) (*EyrieRespo } if gr.Usage != nil { - resp.Usage = &EyrieUsage{ + resp.Usage = &core.EyrieUsage{ PromptTokens: gr.Usage.PromptTokenCount, CompletionTokens: gr.Usage.CandidatesTokenCount, TotalTokens: gr.Usage.TotalTokenCount, @@ -550,7 +555,7 @@ func mapGeminiFinishReason(reason string) string { // --- Streaming --- // processGeminiStream converts Gemini SSE events (parsed by the shared -// parseSSEStream) into EyrieStreamEvents. Handles text, tool calls +// core.ParseSSEStream) into EyrieStreamEvents. Handles text, tool calls // (functionCall), and the final usage+done event. // // Behavior matches the original streamLoop's processStreamChunk: @@ -563,8 +568,8 @@ func mapGeminiFinishReason(reason string) string { // StopReason but no Usage. // - If the SSE channel closes without a finish reason, a bare "done" // is emitted (matches the original "if !doneSent" fallback). -func processGeminiStream(ctx context.Context, sseEvents <-chan SSEEvent, logger *slog.Logger) <-chan EyrieStreamEvent { - ch := make(chan EyrieStreamEvent, streamChannelBuffer) +func processGeminiStream(ctx context.Context, sseEvents <-chan core.SSEEvent, logger *slog.Logger) <-chan core.EyrieStreamEvent { + ch := make(chan core.EyrieStreamEvent, core.StreamChannelBuffer) go func() { defer close(ch) doneSent := false @@ -575,14 +580,14 @@ func processGeminiStream(ctx context.Context, sseEvents <-chan SSEEvent, logger case evt, ok := <-sseEvents: if !ok { if !doneSent { - emit(ctx, ch, EyrieStreamEvent{Type: "done"}) + core.Emit(ctx, ch, core.EyrieStreamEvent{Type: "done"}) } return } - // Propagate SSE-level errors (raised by parseSSEStream on + // Propagate SSE-level errors (raised by core.ParseSSEStream on // scanner failure or non-cancel context expiry). if evt.Event == "error" { - emit(ctx, ch, EyrieStreamEvent{Type: "error", Error: evt.Data}) + core.Emit(ctx, ch, core.EyrieStreamEvent{Type: "error", Error: evt.Data}) return } data := strings.TrimSpace(evt.Data) @@ -600,17 +605,17 @@ func processGeminiStream(ctx context.Context, sseEvents <-chan SSEEvent, logger candidate := chunk.Candidates[0] for _, part := range candidate.Content.Parts { if part.Text != "" { - emit(ctx, ch, EyrieStreamEvent{Type: "content", Content: part.Text}) + core.Emit(ctx, ch, core.EyrieStreamEvent{Type: "content", Content: part.Text}) } if part.FunctionCall != nil { - tc := &ToolCall{ + tc := &core.ToolCall{ Name: part.FunctionCall.Name, Arguments: part.FunctionCall.Args, } if part.FunctionCall.ID != "" { tc.ID = part.FunctionCall.ID } - emit(ctx, ch, EyrieStreamEvent{ + core.Emit(ctx, ch, core.EyrieStreamEvent{ Type: "tool_call", ToolCall: tc, }) @@ -621,9 +626,9 @@ func processGeminiStream(ctx context.Context, sseEvents <-chan SSEEvent, logger // original streamLoop emitted these together in a // single event when the chunk carried both. if chunk.Usage != nil || candidate.FinishReason != "" { - evt := EyrieStreamEvent{Type: "done"} + evt := core.EyrieStreamEvent{Type: "done"} if chunk.Usage != nil { - evt.Usage = &EyrieUsage{ + evt.Usage = &core.EyrieUsage{ PromptTokens: chunk.Usage.PromptTokenCount, CompletionTokens: chunk.Usage.CandidatesTokenCount, TotalTokens: chunk.Usage.TotalTokenCount, @@ -634,7 +639,7 @@ func processGeminiStream(ctx context.Context, sseEvents <-chan SSEEvent, logger if candidate.FinishReason != "" { evt.StopReason = mapGeminiFinishReason(candidate.FinishReason) } - emit(ctx, ch, evt) + core.Emit(ctx, ch, evt) return } } @@ -643,7 +648,7 @@ func processGeminiStream(ctx context.Context, sseEvents <-chan SSEEvent, logger return ch } -func (c *GeminiClient) streamLoop(ctx context.Context, body io.ReadCloser, events chan<- EyrieStreamEvent) { +func (c *GeminiClient) streamLoop(ctx context.Context, body io.ReadCloser, events chan<- core.EyrieStreamEvent) { defer close(events) defer func() { _ = body.Close() }() @@ -678,13 +683,13 @@ func (c *GeminiClient) streamLoop(ctx context.Context, body io.ReadCloser, event if !doneSent { select { - case events <- EyrieStreamEvent{Type: "done"}: + case events <- core.EyrieStreamEvent{Type: "done"}: case <-ctx.Done(): } } } -func (c *GeminiClient) processStreamChunk(ctx context.Context, data string, events chan<- EyrieStreamEvent) bool { +func (c *GeminiClient) processStreamChunk(ctx context.Context, data string, events chan<- core.EyrieStreamEvent) bool { var chunk geminiResponse if err := json.Unmarshal([]byte(data), &chunk); err != nil { return false @@ -696,13 +701,13 @@ func (c *GeminiClient) processStreamChunk(ctx context.Context, data string, even for _, part := range candidate.Content.Parts { if part.Text != "" { select { - case events <- EyrieStreamEvent{Type: "content", Content: part.Text}: + case events <- core.EyrieStreamEvent{Type: "content", Content: part.Text}: case <-ctx.Done(): return false } } if part.FunctionCall != nil { - tc := &ToolCall{ + tc := &core.ToolCall{ Name: part.FunctionCall.Name, Arguments: part.FunctionCall.Args, } @@ -710,7 +715,7 @@ func (c *GeminiClient) processStreamChunk(ctx context.Context, data string, even tc.ID = part.FunctionCall.ID } select { - case events <- EyrieStreamEvent{ + case events <- core.EyrieStreamEvent{ Type: "tool_call", ToolCall: tc, }: @@ -721,9 +726,9 @@ func (c *GeminiClient) processStreamChunk(ctx context.Context, data string, even } if chunk.Usage != nil { select { - case events <- EyrieStreamEvent{ + case events <- core.EyrieStreamEvent{ Type: "done", - Usage: &EyrieUsage{ + Usage: &core.EyrieUsage{ PromptTokens: chunk.Usage.PromptTokenCount, CompletionTokens: chunk.Usage.CandidatesTokenCount, TotalTokens: chunk.Usage.TotalTokenCount, @@ -737,3 +742,14 @@ func (c *GeminiClient) processStreamChunk(ctx context.Context, data string, even } return false } + +// HTTPClient returns the configured transport client. +func (c *GeminiClient) HTTPClient() *http.Client { return c.httpClient } + +// Retry returns the configured retry policy. +func (c *GeminiClient) Retry() core.RetryConfig { return c.retry } + +// ProcessGeminiStream normalizes decoded Gemini SSE events. +func ProcessGeminiStream(ctx context.Context, events <-chan core.SSEEvent, logger *slog.Logger) <-chan core.EyrieStreamEvent { + return processGeminiStream(ctx, events, logger) +} diff --git a/client/mimo.go b/client/adapters/mimo.go similarity index 75% rename from client/mimo.go rename to client/adapters/mimo.go index 567a909..93cda77 100644 --- a/client/mimo.go +++ b/client/adapters/mimo.go @@ -1,4 +1,4 @@ -package client +package adapters import ( "context" @@ -8,6 +8,8 @@ import ( "strconv" "strings" + "github.com/GrayCodeAI/eyrie/client/core" + "github.com/GrayCodeAI/eyrie/catalog/xiaomi" "github.com/GrayCodeAI/eyrie/types" ) @@ -20,10 +22,10 @@ type MiMoClient struct { } // NewMiMoClient builds a MiMo provider client (payg or token_plan gateway). -func NewMiMoClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, providerID string, opts ...ClientOption) *MiMoClient { +func NewMiMoClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, providerID string, opts ...core.ClientOption) *MiMoClient { openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") anthropicBase = strings.TrimRight(strings.TrimSpace(anthropicBase), "/") - mimoOpts := append(append([]ClientOption{}, opts...), WithMimoAuth(), WithProviderName(providerID)) + mimoOpts := append(append([]core.ClientOption{}, opts...), core.WithMimoAuth(), core.WithProviderName(providerID)) o := NewOpenAIClient(apiKey, openAIBase, compat, mimoOpts...) var a *AnthropicClient if anthropicBase != "" { @@ -36,7 +38,7 @@ func NewMiMoClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompa } } -// WithProviderName and WithMimoAuth live in client/core (options.go wraps them). +// core.WithProviderName and core.WithMimoAuth live in client/core (options.go wraps them). func (c *MiMoClient) Name() string { if c.router.OpenAI != nil { @@ -45,8 +47,8 @@ func (c *MiMoClient) Name() string { return c.providerID } -func (c *MiMoClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { - return c.router.Chat(ctx, messages, opts, ChatProtocolCompletions, func(err error, _ *EyrieResponse) bool { +func (c *MiMoClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + return c.router.Chat(ctx, messages, opts, ChatProtocolCompletions, func(err error, _ *core.EyrieResponse) bool { if err != nil && c.router.Anthropic != nil && mimoFallbackChatError(err) { c.logger.Info("MiMo: OpenAI endpoint failed; retrying via Anthropic compatibility", "provider", c.providerID, "error", err) return true @@ -55,7 +57,7 @@ func (c *MiMoClient) Chat(ctx context.Context, messages []EyrieMessage, opts Cha }) } -func (c *MiMoClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { +func (c *MiMoClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { return c.router.StreamChat(ctx, messages, opts, ProtocolStreamConfig{ Primary: ChatProtocolCompletions, FallbackOnError: func(err error) bool { @@ -102,8 +104,8 @@ func mimoRetryableChatError(err error) bool { return true } } - // Structured path: trust EyrieError's IsRetriable - var eyrieErr *EyrieError + // Structured path: trust core.EyrieError's IsRetriable + var eyrieErr *core.EyrieError if errors.As(err, &eyrieErr) { return eyrieErr.IsRetriable() } @@ -129,9 +131,18 @@ func parseHTTPStatusFromError(msg string) int { return 0 } -var _ Provider = (*MiMoClient)(nil) +var _ core.Provider = (*MiMoClient)(nil) // mimoAuthHeaders sets MiMo-preferred authentication on outbound requests. func mimoAuthHeaders(req *http.Request, apiKey string) { xiaomi.SetMimoRequestAuth(req, apiKey) } + +// MimoRetryableChatError reports whether an error is retryable for MiMo. +func MimoRetryableChatError(err error) bool { return mimoRetryableChatError(err) } + +// MimoFallbackChatError reports whether the error should trigger Anthropic fallback. +func MimoFallbackChatError(err error) bool { return mimoFallbackChatError(err) } + +// ProviderID reports the configured MiMo gateway identity. +func (c *MiMoClient) ProviderID() string { return c.providerID } diff --git a/client/adapters/mimo_test.go b/client/adapters/mimo_test.go new file mode 100644 index 0000000..d2d9346 --- /dev/null +++ b/client/adapters/mimo_test.go @@ -0,0 +1,74 @@ +package adapters + +import ( + "context" + "net/http" + "testing" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestMiMoClientChatFallsBackToAnthropicOnParamIncorrect(t *testing.T) { + t.Parallel() + anthropicCalls := 0 + openAITransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path != "/v1/chat/completions" { + t.Fatalf("openai path = %q", req.URL.Path) + } + return jsonResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"message": "Param Incorrect"}}), nil + }) + anthropicTransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + anthropicCalls++ + if req.URL.Path != "/anthropic/v1/messages" { + t.Fatalf("anthropic path = %q", req.URL.Path) + } + if req.Header.Get("api-key") != "tp-test-key" { + t.Fatalf("missing MiMo api-key auth header") + } + return jsonResponse(http.StatusOK, map[string]any{ + "id": "msg_1", + "type": "message", + "role": "assistant", + "content": []map[string]string{{"type": "text", "text": "ok"}}, + "stop_reason": "end_turn", + "usage": map[string]int{"input_tokens": 1, "output_tokens": 1}, + }), nil + }) + + client := NewMiMoClient("tp-test-key", "https://openai.example/v1", "https://anthropic.example/anthropic", &XiaomiCompat, "xiaomi_mimo_token_plan") + client.router.OpenAI.httpClient = &http.Client{Transport: openAITransport} + client.router.Anthropic.httpClient = &http.Client{Transport: anthropicTransport} + response, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ + Model: "mimo-v2.5-pro", + MaxTokens: 1024, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if response.Content != "ok" { + t.Fatalf("content = %q, want ok", response.Content) + } + if anthropicCalls != 1 { + t.Fatalf("anthropic calls = %d, want 1", anthropicCalls) + } +} + +func TestMiMoClientPreservesProtocolBaseURLs(t *testing.T) { + t.Parallel() + client := NewMiMoClient( + "key", + "https://openai.example/v1/", + "https://anthropic.example/anthropic/", + &XiaomiCompat, + "xiaomi_mimo_token_plan", + ) + if got := client.router.OpenAI.baseURL; got != "https://openai.example/v1" { + t.Fatalf("OpenAI base URL = %q", got) + } + if client.router.Anthropic == nil { + t.Fatal("Anthropic fallback client is nil") + } + if got := client.router.Anthropic.baseURL; got != "https://anthropic.example/anthropic" { + t.Fatalf("Anthropic base URL = %q", got) + } +} diff --git a/client/openai.go b/client/adapters/openai.go similarity index 83% rename from client/openai.go rename to client/adapters/openai.go index 036a36e..166f39c 100644 --- a/client/openai.go +++ b/client/adapters/openai.go @@ -1,4 +1,4 @@ -package client +package adapters import ( "bytes" @@ -8,6 +8,8 @@ import ( "io" "log/slog" "net/http" + + "github.com/GrayCodeAI/eyrie/client/core" ) const ( @@ -21,27 +23,27 @@ type OpenAIClient struct { providerName string compat *OpenAICompatConfig httpClient *http.Client - retry RetryConfig + retry core.RetryConfig logger *slog.Logger defaultModel string defaultMaxTokens int defaultTemperature *float64 - guardrails *Guardrails + guardrails *core.Guardrails useMimoAuth bool } -// Compile-time check that OpenAIClient implements Provider. -var _ Provider = (*OpenAIClient)(nil) +// Compile-time check that OpenAIClient implements core.Provider. +var _ core.Provider = (*OpenAIClient)(nil) // NewOpenAIClient creates a configured OpenAI/compatible client. -func NewOpenAIClient(apiKey, baseURL string, compat *OpenAICompatConfig, opts ...ClientOption) *OpenAIClient { +func NewOpenAIClient(apiKey, baseURL string, compat *OpenAICompatConfig, opts ...core.ClientOption) *OpenAIClient { c := &OpenAIClient{ apiKey: apiKey, baseURL: baseURL, providerName: "openai", compat: compat, - httpClient: NewPooledHTTPClient(defaultTimeout), - retry: DefaultRetryConfig(), + httpClient: core.NewPooledHTTPClient(core.DefaultTimeout), + retry: core.DefaultRetryConfig(), logger: slog.Default(), } if c.baseURL == "" { @@ -66,19 +68,19 @@ func (c *OpenAIClient) setHeaders(req *http.Request) { } else { req.Header.Set("Authorization", "Bearer "+c.apiKey) } - req.Header.Set("User-Agent", userAgent()) + req.Header.Set("User-Agent", core.UserAgent()) } func (c *OpenAIClient) setBearerHeaders(req *http.Request) { req.Header.Set("Content-Type", "application/json") req.Header.Del("api-key") req.Header.Set("Authorization", "Bearer "+c.apiKey) - req.Header.Set("User-Agent", userAgent()) + req.Header.Set("User-Agent", core.UserAgent()) } // doRequestWithMimoAuthRetry runs the HTTP request; on 401 with MiMo api-key auth, retries once with Bearer. func (c *OpenAIClient) doRequestWithMimoAuthRetry(ctx context.Context, req *http.Request, body []byte) (*http.Response, error) { - resp, err := doWithRetry(ctx, c.httpClient, req, c.retry, c.logger) + resp, err := core.DoWithRetry(ctx, c.httpClient, req, c.retry, c.logger) if err != nil { return nil, err } @@ -95,9 +97,14 @@ func (c *OpenAIClient) doRequestWithMimoAuthRetry(ctx context.Context, req *http req2.Header.Set("Accept", req.Header.Get("Accept")) } req2.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil } - return doWithRetry(ctx, c.httpClient, req2, c.retry, c.logger) + return core.DoWithRetry(ctx, c.httpClient, req2, c.retry, c.logger) } +type ( + OpenAIRequest = openaiRequest + OpenAIResponse = openaiResponse +) + type openaiRequest struct { Model string `json:"model"` Messages []map[string]interface{} `json:"messages"` @@ -110,7 +117,7 @@ type openaiRequest struct { Tools []map[string]interface{} `json:"tools,omitempty"` ToolChoice interface{} `json:"tool_choice,omitempty"` ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` - ResponseFormat map[string]interface{} `json:"response_format,omitempty"` + ResponseFormat interface{} `json:"response_format,omitempty"` ReasoningEffort string `json:"reasoning_effort,omitempty"` Thinking map[string]interface{} `json:"thinking,omitempty"` Stop interface{} `json:"stop,omitempty"` @@ -160,10 +167,10 @@ type openaiResponse struct { } `json:"usage"` } -// openAIToolChoice converts an Anthropic-style ToolChoiceOption to OpenAI wire format. +// openAIToolChoice converts an Anthropic-style core.ToolChoiceOption to OpenAI wire format. // Anthropic: {type: "auto"|"any"|"tool"|"none", name: "X"} // OpenAI: "auto" | "none" | "required" | {type: "function", function: {name: "X"}} -func openAIToolChoice(tc *ToolChoiceOption) interface{} { +func openAIToolChoice(tc *core.ToolChoiceOption) interface{} { if tc == nil { return nil } @@ -193,9 +200,9 @@ func openAIToolChoice(tc *ToolChoiceOption) interface{} { // buildRequestBase builds an OpenAI-compatible request body. // When compat is non-nil, MaxTokensField and SupportsUsageInStreaming overrides are applied. -// EyrieMessage.Thinking (reasoning_content from prior responses) is never forwarded into +// core.EyrieMessage.Thinking (reasoning_content from prior responses) is never forwarded into // the wire format — providers like DeepSeek return HTTP 400 if it appears in input messages. -func buildRequestBase(messages []EyrieMessage, opts ChatOptions, stream bool, compat *OpenAICompatConfig) openaiRequest { +func buildRequestBase(messages []core.EyrieMessage, opts core.ChatOptions, stream bool, compat *OpenAICompatConfig) openaiRequest { var msgs []map[string]interface{} for _, m := range messages { if len(m.ToolResults) > 0 { @@ -255,7 +262,7 @@ func buildRequestBase(messages []EyrieMessage, opts ChatOptions, stream bool, co for _, img := range m.Images { content = append(content, map[string]interface{}{ "type": "image_url", - "image_url": map[string]interface{}{"url": openAIImageURL(img)}, + "image_url": map[string]interface{}{"url": core.OpenAIImageURL(img)}, }) } msg["content"] = content @@ -333,7 +340,7 @@ func buildRequestBase(messages []EyrieMessage, opts ChatOptions, stream bool, co if compat != nil && compat.SupportsReasoningEffort && opts.ReasoningEffort != "" { req.ReasoningEffort = opts.ReasoningEffort } - // Map ChatOptions fields that apply to all OpenAI-compatible providers + // Map core.ChatOptions fields that apply to all OpenAI-compatible providers if opts.TopP != nil { req.TopP = opts.TopP } @@ -404,7 +411,7 @@ func buildRequestBase(messages []EyrieMessage, opts ChatOptions, stream bool, co return req } -func (c *OpenAIClient) buildRequest(messages []EyrieMessage, opts ChatOptions, stream bool) openaiRequest { +func (c *OpenAIClient) buildRequest(messages []core.EyrieMessage, opts core.ChatOptions, stream bool) openaiRequest { return buildRequestBase(messages, opts, stream, c.compat) } @@ -414,8 +421,8 @@ func (c *OpenAIClient) buildRequest(messages []EyrieMessage, opts ChatOptions, s // above; this helper is just the request-construction dedup. If // stream is true, the request gets the `Accept: text/event-stream` // header. -func (c *OpenAIClient) buildOpenAIRequest(ctx context.Context, messages []EyrieMessage, opts ChatOptions, stream bool) (*http.Request, []byte, error) { - messages = SanitizeMessages(messages) +func (c *OpenAIClient) buildOpenAIRequest(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions, stream bool) (*http.Request, []byte, error) { + messages = core.SanitizeMessages(messages) if opts.Model == "" { return nil, nil, fmt.Errorf("eyrie: model is required for %s", c.providerName) } @@ -441,7 +448,7 @@ func (c *OpenAIClient) buildOpenAIRequest(ctx context.Context, messages []EyrieM } // Chat sends a non-streaming request. -func (c *OpenAIClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { +func (c *OpenAIClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { req, body, err := c.buildOpenAIRequest(ctx, messages, opts, false) if err != nil { return nil, err @@ -462,8 +469,8 @@ func (c *OpenAIClient) Chat(ctx context.Context, messages []EyrieMessage, opts C requestID := resp.Header.Get("X-Request-Id") if resp.StatusCode != 200 { - detail, readErr := parseProviderError(resp.Body) - return nil, formatAPIError(c.providerName, "chat", resp.StatusCode, requestID, detail, readErr) + detail, readErr := core.ParseProviderError(resp.Body) + return nil, core.FormatAPIError(c.providerName, "chat", resp.StatusCode, requestID, detail, readErr) } var or openaiResponse @@ -471,7 +478,7 @@ func (c *OpenAIClient) Chat(ctx context.Context, messages []EyrieMessage, opts C return nil, fmt.Errorf("eyrie: failed to decode %s response: %w", c.providerName, err) } - result := &EyrieResponse{FinishReason: "unknown", RequestID: requestID} + result := &core.EyrieResponse{FinishReason: "unknown", RequestID: requestID} if len(or.Choices) > 0 { ch := or.Choices[0] result.Content = ch.Message.Content @@ -482,11 +489,11 @@ func (c *OpenAIClient) Chat(ctx context.Context, messages []EyrieMessage, opts C if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { c.logger.Warn("openai: failed to parse tool call arguments", "error", err, "tool_name", tc.Function.Name) } - result.ToolCalls = append(result.ToolCalls, ToolCall{ID: tc.ID, Name: tc.Function.Name, Arguments: args}) + result.ToolCalls = append(result.ToolCalls, core.ToolCall{ID: tc.ID, Name: tc.Function.Name, Arguments: args}) } } if or.Usage != nil { - result.Usage = &EyrieUsage{ + result.Usage = &core.EyrieUsage{ PromptTokens: or.Usage.PromptTokens, CompletionTokens: or.Usage.CompletionTokens, TotalTokens: or.Usage.TotalTokens, @@ -496,7 +503,7 @@ func (c *OpenAIClient) Chat(ctx context.Context, messages []EyrieMessage, opts C } } - if err := applyGuardrails(ctx, result, c.guardrails); err != nil { + if err := core.ApplyGuardrails(ctx, result, c.guardrails); err != nil { return nil, err } @@ -504,7 +511,7 @@ func (c *OpenAIClient) Chat(ctx context.Context, messages []EyrieMessage, opts C } // StreamChat sends a streaming request. -func (c *OpenAIClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { +func (c *OpenAIClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { req, body, err := c.buildOpenAIRequest(ctx, messages, opts, true) if err != nil { return nil, err @@ -518,16 +525,16 @@ func (c *OpenAIClient) StreamChat(ctx context.Context, messages []EyrieMessage, requestID := resp.Header.Get("X-Request-Id") if resp.StatusCode != 200 { - detail, readErr := parseProviderError(resp.Body) + detail, readErr := core.ParseProviderError(resp.Body) _ = resp.Body.Close() - return nil, formatAPIError(c.providerName, "stream", resp.StatusCode, requestID, detail, readErr) + return nil, core.FormatAPIError(c.providerName, "stream", resp.StatusCode, requestID, detail, readErr) } streamCtx, cancel := context.WithCancel(ctx) - sseEvents := parseSSEStream(streamCtx, resp.Body, c.logger) - events := processOpenAIStream(streamCtx, sseEvents, c.logger) + sseEvents := core.ParseSSEStream(streamCtx, resp.Body, c.logger) + events := core.ProcessOpenAIStream(streamCtx, sseEvents, c.logger) - return NewStreamResultWithRequestID(events, requestID, cancel), nil + return core.NewStreamResultWithRequestID(events, requestID, cancel), nil } // Ping checks connectivity. @@ -559,3 +566,18 @@ func (c *OpenAIClient) Ping(ctx context.Context) error { } return nil } + +// BuildRequestBase creates the provider-neutral OpenAI-compatible wire body. +func BuildRequestBase(messages []core.EyrieMessage, opts core.ChatOptions, stream bool, compat *OpenAICompatConfig) OpenAIRequest { + return buildRequestBase(messages, opts, stream, compat) +} + +// OpenAIToolChoice converts the public tool-choice contract to wire form. +func OpenAIToolChoice(tc *core.ToolChoiceOption) interface{} { + return openAIToolChoice(tc) +} + +// BuildOpenAIRequest constructs a complete OpenAI-compatible HTTP request. +func (c *OpenAIClient) BuildOpenAIRequest(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions, stream bool) (*http.Request, []byte, error) { + return c.buildOpenAIRequest(ctx, messages, opts, stream) +} diff --git a/client/adapters/openai_embedding.go b/client/adapters/openai_embedding.go new file mode 100644 index 0000000..616e947 --- /dev/null +++ b/client/adapters/openai_embedding.go @@ -0,0 +1,113 @@ +package adapters + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +// Compile-time check that OpenAIClient implements core.Embedder. +var _ core.Embedder = (*OpenAIClient)(nil) + +// openaiEmbeddingResponse is the wire format for OpenAI-compatible embedding APIs. +type openaiEmbeddingData struct { + Object string `json:"object"` + Index int `json:"index"` + Embedding []float64 `json:"embedding"` +} + +type OpenAIEmbeddingData = openaiEmbeddingData + +type openaiEmbeddingUsage struct { + PromptTokens int `json:"prompt_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type OpenAIEmbeddingUsage = openaiEmbeddingUsage + +type openaiEmbeddingResponse struct { + Object string `json:"object"` + Data []openaiEmbeddingData `json:"data"` + Model string `json:"model"` + Usage openaiEmbeddingUsage `json:"usage"` +} + +type OpenAIEmbeddingResponse = openaiEmbeddingResponse + +// CreateEmbedding sends an embedding request to the OpenAI-compatible API endpoint. +func (c *OpenAIClient) CreateEmbedding(ctx context.Context, req core.EmbeddingRequest) (*core.EmbeddingResponse, error) { + if req.Model == "" { + return nil, fmt.Errorf("eyrie: model is required for %s embeddings", c.providerName) + } + + bodyMap := map[string]interface{}{ + "model": req.Model, + "input": req.Input, + } + for k, v := range req.Params { + bodyMap[k] = v + } + + body, err := json.Marshal(bodyMap) + if err != nil { + return nil, fmt.Errorf("eyrie: failed to marshal embedding request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/embeddings", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("eyrie: failed to create embedding request: %w", err) + } + c.setHeaders(httpReq) + httpReq.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil } + + c.logger.Debug("openai embedding", "provider", c.providerName, "model", req.Model) + + resp, err := core.DoWithRetry(ctx, c.httpClient, httpReq, c.retry, c.logger) + if err != nil { + return nil, fmt.Errorf("eyrie: %s embedding request failed: %w", c.providerName, err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Warn("embedding: close response body", "error", err) + } + }() + + if resp.StatusCode != 200 { + requestID := resp.Header.Get("X-Request-Id") + detail, readErr := core.ParseProviderError(resp.Body) + return nil, core.FormatAPIError(c.providerName+" embedding", "embedding", resp.StatusCode, requestID, detail, readErr) + } + + var or openaiEmbeddingResponse + if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { + return nil, fmt.Errorf("eyrie: failed to decode %s embedding response: %w", c.providerName, err) + } + + result := &core.EmbeddingResponse{ + Model: or.Model, + } + if len(or.Data) > 0 { + result.Embeddings = make([][]float32, len(or.Data)) + for _, d := range or.Data { + vec := make([]float32, len(d.Embedding)) + for j, v := range d.Embedding { + vec[j] = float32(v) + } + result.Embeddings[d.Index] = vec + } + } + if or.Usage.PromptTokens > 0 || or.Usage.TotalTokens > 0 { + result.Usage = &core.EyrieUsage{ + PromptTokens: or.Usage.PromptTokens, + TotalTokens: or.Usage.TotalTokens, + } + } + + return result, nil +} diff --git a/client/opencodego.go b/client/adapters/opencodego.go similarity index 73% rename from client/opencodego.go rename to client/adapters/opencodego.go index 8d0ba95..33fdb9a 100644 --- a/client/opencodego.go +++ b/client/adapters/opencodego.go @@ -1,9 +1,11 @@ -package client +package adapters import ( "context" "strings" + "github.com/GrayCodeAI/eyrie/client/core" + "github.com/GrayCodeAI/eyrie/catalog/opencodego" ) @@ -15,12 +17,12 @@ type OpenCodeGoClient struct { } // NewOpenCodeGoClient builds an OpenCode Go provider client. -func NewOpenCodeGoClient(apiKey, baseURL string, opts ...ClientOption) *OpenCodeGoClient { +func NewOpenCodeGoClient(apiKey, baseURL string, opts ...core.ClientOption) *OpenCodeGoClient { openBase := strings.TrimRight(strings.TrimSpace(baseURL), "/") if openBase == "" { openBase = opencodego.DefaultBaseURL } - ocgOpts := append(append([]ClientOption{}, opts...), WithProviderName("opencodego")) + ocgOpts := append(append([]core.ClientOption{}, opts...), core.WithProviderName("opencodego")) return &OpenCodeGoClient{router: ProtocolRouter{ OpenAI: NewOpenAIClient(apiKey, openBase, &OpenCodeGoCompat, ocgOpts...), Anthropic: NewAnthropicClient(apiKey, AnthropicBaseFromOpenAIV1(openBase), ocgOpts...), @@ -29,7 +31,7 @@ func NewOpenCodeGoClient(apiKey, baseURL string, opts ...ClientOption) *OpenCode func (c *OpenCodeGoClient) Name() string { return "opencodego" } -func (c *OpenCodeGoClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { +func (c *OpenCodeGoClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { opts.Model = opencodego.NativeModelID(opts.Model) if opencodego.UsesMessagesAPI(opts.Model) { return c.router.Chat(ctx, messages, opts, ChatProtocolMessages, openCodeGoMessagesFallback) @@ -37,7 +39,7 @@ func (c *OpenCodeGoClient) Chat(ctx context.Context, messages []EyrieMessage, op return c.router.OpenAI.Chat(ctx, messages, opts) } -func (c *OpenCodeGoClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { +func (c *OpenCodeGoClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { opts.Model = opencodego.NativeModelID(opts.Model) if opencodego.UsesMessagesAPI(opts.Model) { return c.router.StreamChat(ctx, messages, opts, ProtocolStreamConfig{ @@ -55,11 +57,11 @@ func (c *OpenCodeGoClient) Ping(ctx context.Context) error { return c.router.Anthropic.Ping(ctx) } -func openCodeGoMessagesFallback(primaryErr error, primaryResp *EyrieResponse) bool { +func openCodeGoMessagesFallback(primaryErr error, primaryResp *core.EyrieResponse) bool { if primaryErr != nil { return oaCompatUnsupportedError(primaryErr) } - return !ResponseHasContent(primaryResp) + return !core.ResponseHasContent(primaryResp) } func oaCompatUnsupportedError(err error) bool { @@ -73,4 +75,7 @@ func oaCompatUnsupportedError(err error) bool { strings.Contains(msg, "not supported") } -var _ Provider = (*OpenCodeGoClient)(nil) +// OACompatUnsupportedError reports whether an error indicates OA-compat is unsupported. +func OACompatUnsupportedError(err error) bool { return oaCompatUnsupportedError(err) } + +var _ core.Provider = (*OpenCodeGoClient)(nil) diff --git a/client/adapters/opencodego_test.go b/client/adapters/opencodego_test.go new file mode 100644 index 0000000..3f25d65 --- /dev/null +++ b/client/adapters/opencodego_test.go @@ -0,0 +1,234 @@ +package adapters + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestOpenCodeGoClientRoutesMiniMaxToAnthropic(t *testing.T) { + t.Parallel() + var gotPath, gotAuth string + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotPath = req.URL.Path + gotAuth = req.Header.Get("X-Api-Key") + return jsonResponse(http.StatusOK, map[string]any{ + "id": "msg_1", "type": "message", "role": "assistant", + "content": []map[string]string{{"type": "text", "text": "Hello!"}}, + "stop_reason": "end_turn", + "usage": map[string]int{"input_tokens": 1, "output_tokens": 2}, + }), nil + }) + + client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") + client.router.Anthropic.httpClient = &http.Client{Transport: transport} + response, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ + Model: "minimax-m2.5", MaxTokens: 256, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if response.Content != "Hello!" { + t.Fatalf("content = %q, want Hello!", response.Content) + } + if !strings.HasSuffix(gotPath, "/v1/messages") { + t.Fatalf("path = %q, want suffix /v1/messages", gotPath) + } + if gotAuth != "ocg-test-key" { + t.Fatalf("X-Api-Key = %q, want ocg-test-key", gotAuth) + } +} + +func TestOpenCodeGoClientRoutesKimiToOpenAI(t *testing.T) { + t.Parallel() + var gotPath string + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotPath = req.URL.Path + return jsonResponse(http.StatusOK, map[string]any{ + "id": "chatcmpl-1", "object": "chat.completion", + "choices": []map[string]any{ + {"message": map[string]string{"role": "assistant", "content": "Hi there!"}, "finish_reason": "stop"}, + }, + "usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}, + }), nil + }) + + client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") + client.router.OpenAI.httpClient = &http.Client{Transport: transport} + response, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ + Model: "kimi-k2.5", MaxTokens: 256, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if response.Content != "Hi there!" { + t.Fatalf("content = %q, want Hi there!", response.Content) + } + if !strings.HasSuffix(gotPath, "/chat/completions") { + t.Fatalf("path = %q, want suffix /chat/completions", gotPath) + } +} + +func TestOpenCodeGoClientQwen401FallsBackToOpenAI(t *testing.T) { + t.Parallel() + var paths []string + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + paths = append(paths, req.URL.Path) + if strings.HasSuffix(req.URL.Path, "/messages") { + return jsonResponse(http.StatusUnauthorized, map[string]any{ + "error": map[string]string{"message": "Invalid API key"}, + }), nil + } + return jsonResponse(http.StatusOK, map[string]any{ + "id": "chatcmpl-1", "object": "chat.completion", + "choices": []map[string]any{ + {"message": map[string]string{"role": "assistant", "content": "OK"}, "finish_reason": "stop"}, + }, + }), nil + }) + + client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") + client.router.Anthropic.httpClient = &http.Client{Transport: transport} + client.router.OpenAI.httpClient = &http.Client{Transport: transport} + response, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ + Model: "qwen3.7-max", MaxTokens: 256, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if response.Content != "OK" { + t.Fatalf("content = %q, want OK; paths=%v", response.Content, paths) + } +} + +func TestOpenCodeGoClientMessagesEmptyFallsBackToOpenAI(t *testing.T) { + t.Parallel() + var paths []string + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + paths = append(paths, req.URL.Path) + if strings.HasSuffix(req.URL.Path, "/messages") { + return jsonResponse(http.StatusOK, map[string]any{ + "id": "msg_1", "type": "message", "role": "assistant", + "content": []map[string]string{{"type": "thinking", "thinking": "hmm"}}, + "stop_reason": "end_turn", + }), nil + } + return jsonResponse(http.StatusOK, map[string]any{ + "id": "chatcmpl-1", "object": "chat.completion", + "choices": []map[string]any{ + {"message": map[string]string{"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}, + }, + }), nil + }) + + client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") + client.router.Anthropic.httpClient = &http.Client{Transport: transport} + client.router.OpenAI.httpClient = &http.Client{Transport: transport} + response, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ + Model: "minimax-m3", MaxTokens: 256, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if response.Content != "Hello!" { + t.Fatalf("content = %q, want Hello!; paths=%v", response.Content, paths) + } + if len(paths) < 2 { + t.Fatalf("expected anthropic then openai, got %v", paths) + } +} + +func TestOpenCodeGoClientNormalizesModelID(t *testing.T) { + t.Parallel() + var gotModel string + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.HasSuffix(req.URL.Path, "/chat/completions") { + var body struct { + Model string `json:"model"` + } + if err := jsonDecodeRequest(req, &body); err != nil { + t.Fatalf("decode request: %v", err) + } + gotModel = body.Model + } + return jsonResponse(http.StatusOK, map[string]any{ + "choices": []map[string]any{ + {"message": map[string]string{"role": "assistant", "content": "Hi"}, "finish_reason": "stop"}, + }, + }), nil + }) + client := NewOpenCodeGoClient("key", "https://opencode.example/zen/go/v1") + client.router.OpenAI.httpClient = &http.Client{Transport: transport} + _, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ + Model: "opencode-go/kimi-k2.6", MaxTokens: 16, + }) + if err != nil { + t.Fatal(err) + } + if gotModel != "kimi-k2.6" { + t.Fatalf("model = %q, want kimi-k2.6", gotModel) + } +} + +func TestOpenCodeGoClientStreamMiniMaxReasoningOnlyFallsBackToChat(t *testing.T) { + t.Parallel() + var paths []string + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + paths = append(paths, req.URL.Path) + if strings.HasSuffix(req.URL.Path, "/messages") { + body := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":10}}}\n\n" + + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\"}}\n\n" + + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"text\":\"hmm\"}}\n\n" + + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n" + + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n" + + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + } + if strings.HasSuffix(req.URL.Path, "/chat/completions") && req.Header.Get("Accept") == "text/event-stream" { + t.Fatal("stream fallback should not run before non-streaming chat fallback") + } + return jsonResponse(http.StatusOK, map[string]any{ + "id": "chatcmpl-1", "object": "chat.completion", + "choices": []map[string]any{ + {"message": map[string]string{"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}, + }, + }), nil + }) + + client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") + client.router.Anthropic.httpClient = &http.Client{Transport: transport} + client.router.OpenAI.httpClient = &http.Client{Transport: transport} + result, err := client.StreamChat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hello how are you?"}}, core.ChatOptions{ + Model: "minimax-m3", MaxTokens: 256, + }) + if err != nil { + t.Fatalf("StreamChat: %v", err) + } + defer result.Close() + + var content string + for event := range result.Events { + switch event.Type { + case "thinking": + t.Fatal("reasoning-only primary stream must not leak thinking before chat fallback") + case "content": + content += event.Content + case "error": + t.Fatalf("unexpected stream error: %s", event.Error) + } + } + if content != "Hello!" { + t.Fatalf("content = %q, want Hello!; paths=%v", content, paths) + } + if len(paths) < 2 { + t.Fatalf("expected /messages then /chat/completions, got %v", paths) + } +} diff --git a/client/options_test.go b/client/adapters/options_test.go similarity index 95% rename from client/options_test.go rename to client/adapters/options_test.go index 20f70d1..7e03152 100644 --- a/client/options_test.go +++ b/client/adapters/options_test.go @@ -1,4 +1,4 @@ -package client +package adapters import ( "log/slog" @@ -6,9 +6,30 @@ import ( "testing" "time" + "github.com/GrayCodeAI/eyrie/client/core" "github.com/GrayCodeAI/eyrie/types" ) +type ( + ClientOption = core.ClientOption + RetryConfig = core.RetryConfig +) + +const defaultTimeout = core.DefaultTimeout + +var ( + WithAPIKey = core.WithAPIKey + WithBaseURL = core.WithBaseURL + WithHTTPClient = core.WithHTTPClient + WithLogger = core.WithLogger + WithMaxTokens = core.WithMaxTokens + WithModel = core.WithModel + WithProviderName = core.WithProviderName + WithRetry = core.WithRetry + WithTemperature = core.WithTemperature + WithTimeout = core.WithTimeout +) + // --- Individual option tests --- func TestWithAPIKeyAnthropic(t *testing.T) { diff --git a/client/protocol_router.go b/client/adapters/protocol_router.go similarity index 70% rename from client/protocol_router.go rename to client/adapters/protocol_router.go index 693802b..d17bc09 100644 --- a/client/protocol_router.go +++ b/client/adapters/protocol_router.go @@ -1,9 +1,11 @@ -package client +package adapters import ( "context" "fmt" "strings" + + "github.com/GrayCodeAI/eyrie/client/core" ) // ChatProtocol selects which existing eyrie client handles a gateway request. @@ -16,9 +18,9 @@ const ( ChatProtocolMessages ) -type streamOpener func(context.Context, []EyrieMessage, ChatOptions) (*StreamResult, error) +type streamOpener func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.StreamResult, error) -type chatOpener func(context.Context, []EyrieMessage, ChatOptions) (*EyrieResponse, error) +type chatOpener func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.EyrieResponse, error) // protocolStreamFallback retries a reasoning-only /v1/messages stream via the // alternate protocol. Non-streaming Chat is tried first because OpenCode Go @@ -31,7 +33,7 @@ type protocolStreamFallback struct { // ProtocolChatFallback decides whether to retry via the alternate protocol // after the primary attempt. resp is non-nil only when primary returned err=nil. -type ProtocolChatFallback func(primaryErr error, primaryResp *EyrieResponse) bool +type ProtocolChatFallback func(primaryErr error, primaryResp *core.EyrieResponse) bool // ProtocolRouter picks between OpenAIClient and AnthropicClient for gateways // that expose both APIs (OpenCode Go, MiMo). All HTTP work stays in openai.go @@ -50,14 +52,14 @@ type ProtocolStreamConfig struct { // Chat sends a request via the primary protocol, optionally falling back to the // alternate protocol when fallback returns true. -func (r ProtocolRouter) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions, primary ChatProtocol, fallback ProtocolChatFallback) (*EyrieResponse, error) { +func (r ProtocolRouter) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions, primary ChatProtocol, fallback ProtocolChatFallback) (*core.EyrieResponse, error) { primaryClient, fallbackClient := r.providers(primary) resp, err := primaryClient.Chat(ctx, messages, opts) if fallback == nil || !fallback(err, resp) { return resp, err } fallbackResp, fallbackErr := fallbackClient.Chat(ctx, messages, opts) - if fallbackErr == nil && ResponseHasContent(fallbackResp) { + if fallbackErr == nil && core.ResponseHasContent(fallbackResp) { return fallbackResp, nil } return resp, err @@ -67,7 +69,7 @@ func (r ProtocolRouter) Chat(ctx context.Context, messages []EyrieMessage, opts // the alternate protocol is tried immediately. When ReasoningOnlyFallback is set // and the primary is /v1/messages, an empty reasoning-only stream retries via // chat/completions. -func (r ProtocolRouter) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions, cfg ProtocolStreamConfig) (*StreamResult, error) { +func (r ProtocolRouter) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions, cfg ProtocolStreamConfig) (*core.StreamResult, error) { primaryClient, fallbackClient := r.providers(cfg.Primary) result, err := primaryClient.StreamChat(ctx, messages, opts) if err != nil { @@ -79,10 +81,10 @@ func (r ProtocolRouter) StreamChat(ctx context.Context, messages []EyrieMessage, if cfg.ReasoningOnlyFallback && cfg.Primary == ChatProtocolMessages { fallback := fallbackClient return newStreamWithReasoningFallback(ctx, messages, opts, result, protocolStreamFallback{ - chat: func(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { + chat: func(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { return fallback.Chat(ctx, messages, opts) }, - stream: func(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { + stream: func(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { return fallback.StreamChat(ctx, messages, opts) }, }), nil @@ -90,7 +92,7 @@ func (r ProtocolRouter) StreamChat(ctx context.Context, messages []EyrieMessage, return result, nil } -func (r ProtocolRouter) providers(primary ChatProtocol) (Provider, Provider) { +func (r ProtocolRouter) providers(primary ChatProtocol) (core.Provider, core.Provider) { if primary == ChatProtocolMessages { return r.Anthropic, r.OpenAI } @@ -106,39 +108,39 @@ func AnthropicBaseFromOpenAIV1(openAIBase string) string { return base } -func streamResultFromChat(resp *EyrieResponse) *StreamResult { - out := make(chan EyrieStreamEvent, streamChannelBuffer) +func streamResultFromChat(resp *core.EyrieResponse) *core.StreamResult { + out := make(chan core.EyrieStreamEvent, core.StreamChannelBuffer) go func() { defer close(out) if resp == nil { return } if strings.TrimSpace(resp.Thinking) != "" { - out <- EyrieStreamEvent{Type: "thinking", Thinking: resp.Thinking} + out <- core.EyrieStreamEvent{Type: "thinking", Thinking: resp.Thinking} } if strings.TrimSpace(resp.Content) != "" { - out <- EyrieStreamEvent{Type: "content", Content: resp.Content} + out <- core.EyrieStreamEvent{Type: "content", Content: resp.Content} } for i := range resp.ToolCalls { tc := resp.ToolCalls[i] - out <- EyrieStreamEvent{Type: "tool_call", ToolCall: &tc} + out <- core.EyrieStreamEvent{Type: "tool_call", ToolCall: &tc} } if resp.Usage != nil { - out <- EyrieStreamEvent{Type: "usage", Usage: resp.Usage} + out <- core.EyrieStreamEvent{Type: "usage", Usage: resp.Usage} } stop := resp.FinishReason if stop == "" { stop = "stop" } - out <- EyrieStreamEvent{Type: "done", StopReason: stop} + out <- core.EyrieStreamEvent{Type: "done", StopReason: stop} }() - return NewStreamResult(out, func() {}) + return core.NewStreamResult(out, func() {}) } -func (f protocolStreamFallback) open(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { +func (f protocolStreamFallback) open(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { if f.chat != nil { resp, err := f.chat(ctx, messages, opts) - if err == nil && ResponseHasContent(resp) { + if err == nil && core.ResponseHasContent(resp) { return streamResultFromChat(resp), nil } } @@ -148,8 +150,8 @@ func (f protocolStreamFallback) open(ctx context.Context, messages []EyrieMessag return nil, fmt.Errorf("eyrie: protocol stream fallback is not configured") } -func newStreamWithReasoningFallback(ctx context.Context, messages []EyrieMessage, opts ChatOptions, primary *StreamResult, fallback protocolStreamFallback) *StreamResult { - out := make(chan EyrieStreamEvent, streamChannelBuffer) +func newStreamWithReasoningFallback(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions, primary *core.StreamResult, fallback protocolStreamFallback) *core.StreamResult { + out := make(chan core.EyrieStreamEvent, core.StreamChannelBuffer) cancelCtx, cancel := context.WithCancel(ctx) go func() { defer close(out) @@ -160,7 +162,7 @@ func newStreamWithReasoningFallback(ctx context.Context, messages []EyrieMessage sawReasoning bool contentLen int toolCalls int - buffered []EyrieStreamEvent + buffered []core.EyrieStreamEvent streamErr bool ) flush := func() { @@ -196,14 +198,14 @@ func newStreamWithReasoningFallback(ctx context.Context, messages []EyrieMessage buffered = append(buffered, ev) } - health := DetectResponseHealth(ResponseSignals{ + health := core.DetectResponseHealth(core.ResponseSignals{ SawReasoning: sawReasoning, ContentLen: contentLen, ToolCalls: toolCalls, StreamEnded: true, StreamErr: streamErr, }) - if health != ResponseErrorOnlyReasoning { + if health != core.ResponseErrorOnlyReasoning { flush() return } @@ -212,7 +214,7 @@ func newStreamWithReasoningFallback(ctx context.Context, messages []EyrieMessage if err != nil { flush() select { - case out <- EyrieStreamEvent{Type: "error", Error: err.Error()}: + case out <- core.EyrieStreamEvent{Type: "error", Error: err.Error()}: case <-cancelCtx.Done(): } return @@ -226,5 +228,5 @@ func newStreamWithReasoningFallback(ctx context.Context, messages []EyrieMessage } } }() - return NewStreamResult(out, cancel) + return core.NewStreamResult(out, cancel) } diff --git a/client/adapters/protocol_router_test.go b/client/adapters/protocol_router_test.go new file mode 100644 index 0000000..57af603 --- /dev/null +++ b/client/adapters/protocol_router_test.go @@ -0,0 +1,152 @@ +package adapters + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestStreamResultFromChat(t *testing.T) { + t.Parallel() + result := streamResultFromChat(&core.EyrieResponse{ + Content: "Hi there!", + FinishReason: "stop", + }) + var content string + for event := range result.Events { + if event.Type == "content" { + content += event.Content + } + } + if content != "Hi there!" { + t.Fatalf("content = %q, want Hi there!", content) + } +} + +func TestNewStreamWithReasoningFallbackChatFirst(t *testing.T) { + t.Parallel() + primaryEvents := make(chan core.EyrieStreamEvent, 4) + primaryEvents <- core.EyrieStreamEvent{Type: "thinking", Thinking: "internal reasoning"} + primaryEvents <- core.EyrieStreamEvent{Type: "done", StopReason: "end_turn"} + close(primaryEvents) + primary := core.NewStreamResult(primaryEvents, func() {}) + + var chatCalled, streamCalled bool + fallback := protocolStreamFallback{ + chat: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.EyrieResponse, error) { + chatCalled = true + return &core.EyrieResponse{Content: "Hello from chat fallback!", FinishReason: "stop"}, nil + }, + stream: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.StreamResult, error) { + streamCalled = true + return nil, fmt.Errorf("stream fallback should not run") + }, + } + + result := newStreamWithReasoningFallback(context.Background(), nil, core.ChatOptions{}, primary, fallback) + var content string + for event := range result.Events { + switch event.Type { + case "thinking": + t.Fatal("primary reasoning must not leak before chat fallback succeeds") + case "content": + content += event.Content + } + } + if content != "Hello from chat fallback!" { + t.Fatalf("content = %q, want Hello from chat fallback!", content) + } + if !chatCalled { + t.Fatal("expected chat fallback to run") + } + if streamCalled { + t.Fatal("stream fallback must not run when chat fallback succeeds") + } +} + +func TestNewStreamWithReasoningFallbackStreamWhenChatEmpty(t *testing.T) { + t.Parallel() + primaryEvents := make(chan core.EyrieStreamEvent, 4) + primaryEvents <- core.EyrieStreamEvent{Type: "thinking", Thinking: "internal reasoning"} + primaryEvents <- core.EyrieStreamEvent{Type: "done", StopReason: "end_turn"} + close(primaryEvents) + primary := core.NewStreamResult(primaryEvents, func() {}) + + fallbackEvents := make(chan core.EyrieStreamEvent, 4) + fallbackEvents <- core.EyrieStreamEvent{Type: "content", Content: "stream answer"} + fallbackEvents <- core.EyrieStreamEvent{Type: "done", StopReason: "stop"} + close(fallbackEvents) + + var chatCalled, streamCalled bool + fallback := protocolStreamFallback{ + chat: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.EyrieResponse, error) { + chatCalled = true + return &core.EyrieResponse{Thinking: "still thinking"}, nil + }, + stream: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.StreamResult, error) { + streamCalled = true + return core.NewStreamResult(fallbackEvents, func() {}), nil + }, + } + + result := newStreamWithReasoningFallback(context.Background(), nil, core.ChatOptions{}, primary, fallback) + var content string + for event := range result.Events { + if event.Type == "content" { + content += event.Content + } + } + if content != "stream answer" { + t.Fatalf("content = %q, want stream answer", content) + } + if !chatCalled || !streamCalled { + t.Fatalf("chatCalled=%v streamCalled=%v, want both true", chatCalled, streamCalled) + } +} + +func TestProtocolRouterChatFallbackOnError(t *testing.T) { + t.Parallel() + openAI := NewOpenAIClient("key", "https://example/openai", nil) + anthropic := NewAnthropicClient("key", "https://example") + openAI.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusBadGateway, map[string]string{"error": "down"}), nil + })} + anthropic.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusOK, map[string]any{ + "id": "msg_1", "type": "message", "role": "assistant", + "content": []map[string]string{{"type": "text", "text": "fallback"}}, + "stop_reason": "end_turn", + }), nil + })} + + router := ProtocolRouter{OpenAI: openAI, Anthropic: anthropic} + response, err := router.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ + Model: "test", MaxTokens: 16, + }, ChatProtocolCompletions, func(err error, _ *core.EyrieResponse) bool { + return err != nil + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if response.Content != "fallback" { + t.Fatalf("content = %q, want fallback", response.Content) + } +} + +func TestProtocolRouterNoFallbackWhenNil(t *testing.T) { + t.Parallel() + openAI := NewOpenAIClient("key", "https://example/openai", nil) + openAI.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(http.StatusBadGateway, map[string]string{"error": "down"}), nil + })} + router := ProtocolRouter{OpenAI: openAI} + _, err := router.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ + Model: "test", MaxTokens: 16, + }, ChatProtocolCompletions, nil) + if err == nil { + t.Fatal("expected error without fallback") + } +} diff --git a/client/adapters/provider_registry.go b/client/adapters/provider_registry.go new file mode 100644 index 0000000..2061150 --- /dev/null +++ b/client/adapters/provider_registry.go @@ -0,0 +1,142 @@ +package adapters + +import ( + "context" + "time" + + "github.com/GrayCodeAI/eyrie/catalog/registry" + "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" +) + +// ProviderType classifies providers. +type ProviderType string + +const ( + // ProviderTypeAnthropic uses the Anthropic Messages API. + ProviderTypeAnthropic ProviderType = "anthropic" + // ProviderTypeOpenAI uses the OpenAI Chat Completions API. + ProviderTypeOpenAI ProviderType = "openai" + // ProviderTypeOpenAICompatible uses OpenAI-compatible APIs with custom base URLs. + ProviderTypeOpenAICompatible ProviderType = "openai-compatible" + // ProviderTypeAzure uses Azure OpenAI. + ProviderTypeAzure ProviderType = "azure" + // ProviderTypeBedrock uses AWS Bedrock. + ProviderTypeBedrock ProviderType = "bedrock" + // ProviderTypeVertex uses Google Vertex AI. + ProviderTypeVertex ProviderType = "vertex" +) + +// ProviderRegistryConfig holds provider registry info. +type ProviderRegistryConfig struct { + Name string `json:"name"` + Type ProviderType `json:"type"` + BaseURL string `json:"base_url,omitempty"` + EnvKey string `json:"env_key"` + SupportsStreaming bool `json:"supports_streaming"` + SupportsTools bool `json:"supports_tools"` + SupportsReasoning bool `json:"supports_reasoning"` + Compat *OpenAICompatConfig `json:"compat,omitempty"` +} + +// CoreProviders and OpenAICompatibleProviders are derived from the canonical +// catalog registry. Dynamic providers are added only to the compatible map. +var CoreProviders, OpenAICompatibleProviders = staticProviderMaps() + +func staticProviderMaps() (map[string]ProviderRegistryConfig, map[string]ProviderRegistryConfig) { + coreMap := make(map[string]ProviderRegistryConfig) + compatible := make(map[string]ProviderRegistryConfig) + for _, spec := range registry.All() { + providerType := ProviderTypeOpenAICompatible + if spec.TransportKind != "" { + providerType = ProviderType(spec.TransportKind) + } + baseURL := spec.RuntimeBaseURL + if baseURL == "" { + baseURL = spec.ProbeBaseURL + } + envKey := spec.RuntimeCredentialEnv + if envKey == "" { + envKey = spec.CredentialEnv + } + provider := ProviderRegistryConfig{ + Name: spec.ProviderID, Type: providerType, BaseURL: baseURL, EnvKey: envKey, + SupportsStreaming: true, SupportsTools: true, SupportsReasoning: !spec.IsLocal, + } + if providerType == ProviderTypeOpenAICompatible { + compatible[spec.ProviderID] = provider + } else { + coreMap[spec.ProviderID] = provider + } + } + return coreMap, compatible +} + +// DetectProvider detects the active provider from the credential store (not process env). +func DetectProvider() string { + ctx := context.Background() + checks := map[string]func() bool{ + "anthropic": func() bool { return credentials.HasSecret(ctx, "ANTHROPIC_API_KEY") }, + "deepseek": func() bool { return credentials.HasSecret(ctx, "DEEPSEEK_API_KEY") }, + "openrouter": func() bool { return credentials.HasSecret(ctx, "OPENROUTER_API_KEY") }, + "grok": func() bool { return credentials.HasSecret(ctx, "XAI_API_KEY") }, + "gemini": func() bool { return credentials.HasSecret(ctx, "GEMINI_API_KEY") }, + "zai_payg": func() bool { return credentials.HasSecret(ctx, "ZAI_API_KEY") }, + "zai_coding": func() bool { return credentials.HasSecret(ctx, "ZAI_CODING_API_KEY") }, + "canopywave": func() bool { return credentials.HasSecret(ctx, "CANOPYWAVE_API_KEY") }, + "poolside": func() bool { return credentials.HasSecret(ctx, "POOLSIDE_API_KEY") }, + "groq": func() bool { return credentials.HasSecret(ctx, "GROQ_API_KEY") }, + "openai": func() bool { return credentials.HasSecret(ctx, "OPENAI_API_KEY") }, + "opencodego": func() bool { return credentials.HasSecret(ctx, "OPENCODEGO_API_KEY") }, + "kimi": func() bool { return credentials.HasSecret(ctx, "MOONSHOT_API_KEY") }, + "xiaomi_mimo_payg": func() bool { + return credentials.HasSecret(ctx, config.EnvXiaomiPaygAPIKey) || credentials.HasSecret(ctx, "XIAOMI_MIMO_API_KEY") + }, + "xiaomi_mimo_token_plan": func() bool { + return credentials.HasSecret(ctx, config.EnvXiaomiTokenPlanAPIKey) + }, + "minimax_token_plan": func() bool { + return credentials.HasSecret(ctx, "MINIMAX_TOKEN_PLAN_API_KEY") + }, + "minimax_payg": func() bool { + return credentials.HasSecret(ctx, "MINIMAX_PAYG_API_KEY") + }, + "ollama": func() bool { return ResolveEnvSecret("OLLAMA_BASE_URL") != "" }, + "azure": func() bool { + return credentials.HasSecret(ctx, "AZURE_OPENAI_API_KEY") && ResolveEnvSecret("AZURE_OPENAI_ENDPOINT") != "" + }, + "bedrock": func() bool { + return credentials.HasSecret(ctx, "AWS_ACCESS_KEY_ID") && credentials.HasSecret(ctx, "AWS_SECRET_ACCESS_KEY") + }, + "vertex": func() bool { + return credentials.HasSecret(ctx, "VERTEX_PROJECT_ID") && credentials.HasSecret(ctx, "VERTEX_ACCESS_TOKEN") + }, + } + for _, p := range config.APIProviderDetectionOrder { + if fn, ok := checks[p]; ok && fn() { + return p + } + } + return "anthropic" +} + +// ResolveProviderModelEnvOverride resolves the model env override for a provider. +func ResolveProviderModelEnvOverride(provider string) string { + if provider == "" { + provider = DetectProvider() + } + for _, k := range config.ProviderModelEnvKeys[provider] { + if v := ResolveEnvSecret(k); v != "" { + return v + } + } + return "" +} + +// ResolveEnvSecret looks up an environment variable via the credential store +// with a bounded timeout. +func ResolveEnvSecret(envKey string) string { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return credentials.LookupSecret(ctx, envKey) +} diff --git a/client/adapters/test_helpers_test.go b/client/adapters/test_helpers_test.go new file mode 100644 index 0000000..dcef8ba --- /dev/null +++ b/client/adapters/test_helpers_test.go @@ -0,0 +1,37 @@ +package adapters + +import ( + "bytes" + "encoding/json" + "io" + "net/http" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func jsonResponse(status int, payload any) *http.Response { + body, err := json.Marshal(payload) + if err != nil { + panic(err) + } + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + } +} + +func jsonDecodeRequest(req *http.Request, value any) error { + if req.Body == nil { + return nil + } + body, err := io.ReadAll(req.Body) + if err != nil { + return err + } + return json.Unmarshal(body, value) +} diff --git a/client/vertex.go b/client/adapters/vertex.go similarity index 66% rename from client/vertex.go rename to client/adapters/vertex.go index a3fc75d..c7f812c 100644 --- a/client/vertex.go +++ b/client/adapters/vertex.go @@ -1,4 +1,4 @@ -package client +package adapters import ( "bytes" @@ -8,6 +8,8 @@ import ( "io" "log/slog" "net/http" + + "github.com/GrayCodeAI/eyrie/client/core" ) // maxVertexRequestSize is the maximum request body size for Vertex AI (30 MB). @@ -18,20 +20,20 @@ type VertexClient struct { region string token string httpClient *http.Client - retry RetryConfig + retry core.RetryConfig logger *slog.Logger - guardrails *Guardrails + guardrails *core.Guardrails } -var _ Provider = (*VertexClient)(nil) +var _ core.Provider = (*VertexClient)(nil) func NewVertexClient(projectID, region, token string) *VertexClient { return &VertexClient{ projectID: projectID, region: region, token: token, - httpClient: NewPooledHTTPClient(defaultTimeout), - retry: DefaultRetryConfig(), + httpClient: core.NewPooledHTTPClient(core.DefaultTimeout), + retry: core.DefaultRetryConfig(), logger: slog.Default().With("component", "vertex"), } } @@ -42,8 +44,8 @@ func (c *VertexClient) baseURL() string { return fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models", c.region, c.projectID, c.region) } -func (c *VertexClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { - messages = SanitizeMessages(messages) +func (c *VertexClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + messages = core.SanitizeMessages(messages) if opts.Model == "" { return nil, fmt.Errorf("eyrie: model is required for vertex") } @@ -65,7 +67,7 @@ func (c *VertexClient) Chat(ctx context.Context, messages []EyrieMessage, opts C c.setHeaders(req) req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil } - resp, err := doWithRetry(ctx, c.httpClient, req, c.retry, c.logger) + resp, err := core.DoWithRetry(ctx, c.httpClient, req, c.retry, c.logger) if err != nil { return nil, fmt.Errorf("eyrie: vertex request failed: %w", err) } @@ -78,8 +80,8 @@ func (c *VertexClient) Chat(ctx context.Context, messages []EyrieMessage, opts C requestID := resp.Header.Get("X-Goog-Request-Id") if resp.StatusCode != 200 { - detail, readErr := parseProviderError(resp.Body) - return nil, formatAPIError("vertex", "chat", resp.StatusCode, requestID, detail, readErr) + detail, readErr := core.ParseProviderError(resp.Body) + return nil, core.FormatAPIError("vertex", "chat", resp.StatusCode, requestID, detail, readErr) } var ar anthropicResponse @@ -87,17 +89,17 @@ func (c *VertexClient) Chat(ctx context.Context, messages []EyrieMessage, opts C return nil, fmt.Errorf("eyrie: vertex decode failed: %w", err) } - eyrieResp := parseAnthropicResponse(ar, requestID, "") + eyrieResp := ParseAnthropicResponse(ar, requestID, "") - if err := applyGuardrails(ctx, eyrieResp, c.guardrails); err != nil { + if err := core.ApplyGuardrails(ctx, eyrieResp, c.guardrails); err != nil { return nil, err } return eyrieResp, nil } -func (c *VertexClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { - messages = SanitizeMessages(messages) +func (c *VertexClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + messages = core.SanitizeMessages(messages) if opts.Model == "" { return nil, fmt.Errorf("eyrie: model is required for vertex") } @@ -120,24 +122,24 @@ func (c *VertexClient) StreamChat(ctx context.Context, messages []EyrieMessage, req.Header.Set("Accept", "text/event-stream") req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil } - resp, err := doWithRetry(ctx, c.httpClient, req, c.retry, c.logger) + resp, err := core.DoWithRetry(ctx, c.httpClient, req, c.retry, c.logger) if err != nil { return nil, fmt.Errorf("eyrie: vertex stream request failed: %w", err) } if resp.StatusCode != 200 { - detail, readErr := parseProviderError(resp.Body) + detail, readErr := core.ParseProviderError(resp.Body) _ = resp.Body.Close() - return nil, formatAPIError("vertex", "stream", resp.StatusCode, resp.Header.Get("X-Goog-Request-Id"), detail, readErr) + return nil, core.FormatAPIError("vertex", "stream", resp.StatusCode, resp.Header.Get("X-Goog-Request-Id"), detail, readErr) } requestID := resp.Header.Get("X-Goog-Request-Id") streamCtx, cancel := context.WithCancel(ctx) - sseEvents := parseSSEStream(streamCtx, resp.Body, c.logger) - events := processAnthropicStream(streamCtx, sseEvents, c.logger) + sseEvents := core.ParseSSEStream(streamCtx, resp.Body, c.logger) + events := core.ProcessAnthropicStream(streamCtx, sseEvents, c.logger) - return NewStreamResultWithRequestID(events, requestID, cancel), nil + return core.NewStreamResultWithRequestID(events, requestID, cancel), nil } func (c *VertexClient) Ping(ctx context.Context) error { @@ -161,10 +163,10 @@ func (c *VertexClient) Ping(ctx context.Context) error { func (c *VertexClient) setHeaders(req *http.Request) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("User-Agent", userAgent()) + req.Header.Set("User-Agent", core.UserAgent()) } -func (c *VertexClient) buildBody(messages []EyrieMessage, opts ChatOptions, stream bool) ([]byte, error) { +func (c *VertexClient) buildBody(messages []core.EyrieMessage, opts core.ChatOptions, stream bool) ([]byte, error) { msgs, system := buildAnthropicMessages(messages) if opts.System != "" { if system != "" { @@ -189,7 +191,7 @@ func (c *VertexClient) buildBody(messages []EyrieMessage, opts ChatOptions, stre TopK: opts.TopK, StopSequences: opts.StopSequences, Stream: stream, - Tools: convertToAnthropicTools(opts.Tools), + Tools: ConvertToAnthropicTools(opts.Tools), ToolChoice: resolveToolChoice(opts.ToolChoice), Thinking: thinking, Metadata: resolveMetadata(opts), @@ -210,3 +212,26 @@ func (c *VertexClient) buildBody(messages []EyrieMessage, opts ChatOptions, stre m["anthropic_version"] = "vertex-2023-10-16" return json.Marshal(m) } + +// SetHTTPClient replaces the transport used for Vertex requests. +func (c *VertexClient) SetHTTPClient(hc *http.Client) { c.httpClient = hc } + +// SetRetry configures provider retry behavior. +func (c *VertexClient) SetRetry(rc core.RetryConfig) { c.retry = rc } + +// HTTPClient returns the configured transport client. +func (c *VertexClient) HTTPClient() *http.Client { return c.httpClient } + +// BaseURL returns the base URL for Vertex API requests. +func (c *VertexClient) BaseURL() string { return c.baseURL() } + +// BuildBody constructs the request body for Vertex API calls. +func (c *VertexClient) BuildBody(messages []core.EyrieMessage, opts core.ChatOptions, stream bool) ([]byte, error) { + return c.buildBody(messages, opts, stream) +} + +// Region returns the configured Google Cloud region. +func (c *VertexClient) Region() string { return c.region } + +// ProjectID returns the configured Google Cloud project identifier. +func (c *VertexClient) ProjectID() string { return c.projectID } diff --git a/client/zai.go b/client/adapters/zai.go similarity index 83% rename from client/zai.go rename to client/adapters/zai.go index ed7cf22..9cf8dd4 100644 --- a/client/zai.go +++ b/client/adapters/zai.go @@ -1,4 +1,4 @@ -package client +package adapters import ( "context" @@ -7,6 +7,8 @@ import ( "net/http" "strings" + "github.com/GrayCodeAI/eyrie/client/core" + "github.com/GrayCodeAI/eyrie/types" ) @@ -25,11 +27,11 @@ type ZAIClient struct { // anthropicBase should be the resolved /api/anthropic (global or cn). // The same apiKey is used for both sides; the plan subscription attached to the key // controls quota/billing when using the coding path. -func NewZAIClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, providerID string, opts ...ClientOption) *ZAIClient { +func NewZAIClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, providerID string, opts ...core.ClientOption) *ZAIClient { openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") anthropicBase = strings.TrimRight(strings.TrimSpace(anthropicBase), "/") - zaiOpts := append([]ClientOption{WithProviderName(providerID)}, opts...) + zaiOpts := append([]core.ClientOption{core.WithProviderName(providerID)}, opts...) o := NewOpenAIClient(apiKey, openAIBase, compat, zaiOpts...) var a *AnthropicClient @@ -51,8 +53,8 @@ func (c *ZAIClient) Name() string { return c.providerID } -func (c *ZAIClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { - return c.router.Chat(ctx, messages, opts, ChatProtocolCompletions, func(err error, _ *EyrieResponse) bool { +func (c *ZAIClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { + return c.router.Chat(ctx, messages, opts, ChatProtocolCompletions, func(err error, _ *core.EyrieResponse) bool { if err != nil && c.router.Anthropic != nil && zaiFallbackChatError(err) { c.logger.Info("Z.AI: OpenAI endpoint failed; retrying via Anthropic compatibility", "provider", c.providerID, "error", err) @@ -62,7 +64,7 @@ func (c *ZAIClient) Chat(ctx context.Context, messages []EyrieMessage, opts Chat }) } -func (c *ZAIClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { +func (c *ZAIClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { return c.router.StreamChat(ctx, messages, opts, ProtocolStreamConfig{ Primary: ChatProtocolCompletions, FallbackOnError: func(err error) bool { @@ -110,8 +112,8 @@ func zaiRetryableChatError(err error) bool { if n := parseHTTPStatusFromError(msg); n > 0 { return n >= 500 || n == http.StatusUnauthorized || n == http.StatusForbidden } - // Structured path: trust EyrieError's IsRetriable - var eyrieErr *EyrieError + // Structured path: trust core.EyrieError's IsRetriable + var eyrieErr *core.EyrieError if errors.As(err, &eyrieErr) { return eyrieErr.IsRetriable() } @@ -119,4 +121,4 @@ func zaiRetryableChatError(err error) bool { return types.IsTransient(err) } -var _ Provider = (*ZAIClient)(nil) +var _ core.Provider = (*ZAIClient)(nil) diff --git a/client/aliases.go b/client/aliases.go index d345e22..d9972d7 100644 --- a/client/aliases.go +++ b/client/aliases.go @@ -5,6 +5,7 @@ import ( "net/http" "time" + "github.com/GrayCodeAI/eyrie/client/adapters" "github.com/GrayCodeAI/eyrie/client/core" "github.com/GrayCodeAI/eyrie/client/embeddings" ) @@ -222,24 +223,12 @@ func NewEmbeddingCachedProvider(inner Provider, embedder Embedder, cfg SemanticC return embeddings.NewEmbeddingCachedProvider(inner, embedder, cfg) } -// Package-local bridges for helpers that moved to client/core. Declared as -// unexported names so the many in-package call sites read unchanged while -// the single implementation lives in core. -type providerErrorDetail = core.ProviderErrorDetail - var ( - doWithRetry = core.DoWithRetry - parseProviderError = core.ParseProviderError - formatAPIError = core.FormatAPIError - copyResponse = core.CopyResponse - parseSSEStream = core.ParseSSEStream - processAnthropicStream = core.ProcessAnthropicStream - processOpenAIStream = core.ProcessOpenAIStream - emit = core.Emit - openAIImageURL = core.OpenAIImageURL - parseImageString = core.ParseImageString - userAgent = core.UserAgent - applyGuardrails = core.ApplyGuardrails + copyResponse = core.CopyResponse + emit = core.Emit + parseImageString = core.ParseImageString + applyGuardrails = core.ApplyGuardrails + isRetriableError = core.IsRetriableError ) // NewStreamResult creates a StreamResult with a cancel function for resource cleanup. @@ -256,3 +245,139 @@ func NewStreamResultWithRequestID(events <-chan EyrieStreamEvent, requestID stri func DefaultContinuationConfig() ContinuationConfig { return core.DefaultContinuationConfig() } + +// --------------------------------------------------------------------------- +// Adapter type aliases (moved to client/adapters) +// --------------------------------------------------------------------------- + +type ( + // AnthropicClient implements Provider for the Anthropic Messages API. + AnthropicClient = adapters.AnthropicClient + // OpenAIClient implements Provider for the OpenAI Chat Completions API. + OpenAIClient = adapters.OpenAIClient + // GeminiClient implements Provider for the Google Gemini API. + GeminiClient = adapters.GeminiClient + // AzureClient implements Provider for the Azure OpenAI API. + AzureClient = adapters.AzureClient + // BedrockClient implements Provider for the AWS Bedrock API. + BedrockClient = adapters.BedrockClient + // VertexClient implements Provider for the Google Vertex AI API. + VertexClient = adapters.VertexClient + // DeepSeekClient implements Provider for the DeepSeek API. + DeepSeekClient = adapters.DeepSeekClient + // ZAIClient implements Provider for the Z.AI API. + ZAIClient = adapters.ZAIClient + // MiMoClient implements Provider for the Xiaomi MiMo API. + MiMoClient = adapters.MiMoClient + // OpenCodeGoClient implements Provider for the OpenCode Go API. + OpenCodeGoClient = adapters.OpenCodeGoClient + // ProtocolRouter routes between OpenAI and Anthropic protocols. + ProtocolRouter = adapters.ProtocolRouter + // ProtocolStreamConfig controls streaming across two protocols. + ProtocolStreamConfig = adapters.ProtocolStreamConfig + // TokenCountResult holds token counting results. + TokenCountResult = adapters.TokenCountResult +) + +// Adapter protocol constants. +const ( + ChatProtocolCompletions = adapters.ChatProtocolCompletions + ChatProtocolMessages = adapters.ChatProtocolMessages +) + +// Adapter constructors. +func NewAnthropicClient(apiKey, baseURL string, opts ...ClientOption) *AnthropicClient { + return adapters.NewAnthropicClient(apiKey, baseURL, opts...) +} + +func NewOpenAIClient(apiKey, baseURL string, compat *OpenAICompatConfig, opts ...ClientOption) *OpenAIClient { + return adapters.NewOpenAIClient(apiKey, baseURL, compat, opts...) +} + +func NewGeminiClient(apiKey, baseURL string) *GeminiClient { + return adapters.NewGeminiClient(apiKey, baseURL) +} + +func NewAzureClient(apiKey, endpoint, apiVersion string) *AzureClient { + return adapters.NewAzureClient(apiKey, endpoint, apiVersion) +} + +func NewBedrockClient(accessKeyID, secretAccessKey, sessionToken, region string) *BedrockClient { + return adapters.NewBedrockClient(accessKeyID, secretAccessKey, sessionToken, region) +} + +func NewVertexClient(projectID, region, token string) *VertexClient { + return adapters.NewVertexClient(projectID, region, token) +} + +func NewDeepSeekClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, opts ...ClientOption) *DeepSeekClient { + return adapters.NewDeepSeekClient(apiKey, openAIBase, anthropicBase, compat, opts...) +} + +func NewZAIClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, providerID string, opts ...ClientOption) *ZAIClient { + return adapters.NewZAIClient(apiKey, openAIBase, anthropicBase, compat, providerID, opts...) +} + +func NewMiMoClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, providerID string, opts ...ClientOption) *MiMoClient { + return adapters.NewMiMoClient(apiKey, openAIBase, anthropicBase, compat, providerID, opts...) +} + +func NewOpenCodeGoClient(apiKey, baseURL string, opts ...ClientOption) *OpenCodeGoClient { + return adapters.NewOpenCodeGoClient(apiKey, baseURL, opts...) +} + +func AnthropicBaseFromOpenAIV1(openAIBase string) string { + return adapters.AnthropicBaseFromOpenAIV1(openAIBase) +} + +// Provider registry constants. +const ( + ProviderTypeAnthropic = adapters.ProviderTypeAnthropic + ProviderTypeOpenAI = adapters.ProviderTypeOpenAI + ProviderTypeOpenAICompatible = adapters.ProviderTypeOpenAICompatible + ProviderTypeAzure = adapters.ProviderTypeAzure + ProviderTypeBedrock = adapters.ProviderTypeBedrock + ProviderTypeVertex = adapters.ProviderTypeVertex +) + +// Package-local aliases keep existing in-package tests and helpers readable +// without expanding the public client facade. +type ( + anthropicRequest = adapters.AnthropicRequest + anthropicResponse = adapters.AnthropicResponse + anthropicTool = adapters.AnthropicTool + anthropicToolChoice = adapters.AnthropicToolChoice + anthropicThinking = adapters.AnthropicThinking + anthropicMetadata = adapters.AnthropicMetadata + anthropicOutputConfig = adapters.AnthropicOutputConfig + openaiEmbeddingData = adapters.OpenAIEmbeddingData + openaiEmbeddingResponse = adapters.OpenAIEmbeddingResponse + openaiEmbeddingUsage = adapters.OpenAIEmbeddingUsage +) + +var ( + audioFormatToMediaType = adapters.AudioFormatToMediaType + resolveThinking = adapters.ResolveThinking + resolveToolChoice = adapters.ResolveToolChoice + resolveOutputConfig = adapters.ResolveOutputConfig + buildAnthropicMessages = adapters.BuildAnthropicMessages + buildAnthropicCachedRequest = adapters.BuildAnthropicCachedRequest + parseAnthropicResponse = adapters.ParseAnthropicResponse + convertToAnthropicTools = adapters.ConvertToAnthropicTools + buildRequestBase = adapters.BuildRequestBase + openaiBaseFallbackURL = adapters.OpenAIBaseFallbackURL + dynamicProviderEnvVar = adapters.DynamicProviderEnvVar + geminiSharedParserEnvVar = adapters.GeminiSharedParserEnvVar + processGeminiStream = adapters.ProcessGeminiStream + mimoRetryableChatError = adapters.MimoRetryableChatError + mimoFallbackChatError = adapters.MimoFallbackChatError + oaCompatUnsupportedError = adapters.OACompatUnsupportedError + CoreProviders = adapters.CoreProviders + OpenAICompatibleProviders = adapters.OpenAICompatibleProviders + sha256Hex = adapters.Sha256Hex + awsSigningKey = adapters.AWSSigningKey + canonicalAWSHeaders = adapters.CanonicalAWSHeaders + awsCanonicalURI = adapters.AWSCanonicalURI + dynamicProviderEnabled = adapters.DynamicProviderEnabled + thinkingForBudget = adapters.ThinkingForBudget +) diff --git a/client/anthropic_chat_test.go b/client/anthropic_chat_test.go index 83f25ad..4e49b0f 100644 --- a/client/anthropic_chat_test.go +++ b/client/anthropic_chat_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" "time" + + "github.com/GrayCodeAI/eyrie/client/adapters" ) // AnthropicClient Chat and StreamChat tests. Split out of anthropic_test.go for clarity. @@ -35,7 +37,7 @@ func TestAnthropicChat_Success(t *testing.T) { } // Decode request body - var req anthropicRequest + var req adapters.AnthropicRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("failed to decode request: %v", err) } @@ -193,7 +195,7 @@ func TestAnthropicChat_MultipleToolCalls(t *testing.T) { func TestAnthropicChat_DefaultMaxTokens(t *testing.T) { t.Parallel() - var capturedBody anthropicRequest + var capturedBody adapters.AnthropicRequest server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil { t.Fatalf("failed to decode request: %v", err) @@ -236,7 +238,7 @@ func TestAnthropicChat_ModelRequired(t *testing.T) { func TestAnthropicChat_SystemMerge(t *testing.T) { t.Parallel() - var capturedBody anthropicRequest + var capturedBody adapters.AnthropicRequest server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil { t.Fatalf("failed to decode request: %v", err) @@ -270,7 +272,7 @@ func TestAnthropicChat_SystemMerge(t *testing.T) { func TestAnthropicChat_WithTools(t *testing.T) { t.Parallel() - var capturedBody anthropicRequest + var capturedBody adapters.AnthropicRequest server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil { t.Fatalf("failed to decode request: %v", err) diff --git a/client/anthropic_features_test.go b/client/anthropic_features_test.go index d8ef6e1..fb64c3e 100644 --- a/client/anthropic_features_test.go +++ b/client/anthropic_features_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" "time" + + "github.com/GrayCodeAI/eyrie/client/adapters" ) // Anthropic Ping, error handling, client config, and feature (thinking/tool-choice) tests. Split out of anthropic_test.go for clarity. @@ -239,16 +241,16 @@ func TestAnthropicClient_Name(t *testing.T) { func TestAnthropicClient_DefaultBaseURL(t *testing.T) { t.Parallel() client := NewAnthropicClient("key", "") - if client.baseURL != "https://api.anthropic.com" { - t.Errorf("expected default base URL, got %q", client.baseURL) + if client.BaseURL() != "https://api.anthropic.com" { + t.Errorf("expected default base URL, got %q", client.BaseURL()) } } func TestAnthropicClient_CustomBaseURL(t *testing.T) { t.Parallel() client := NewAnthropicClient("key", "https://custom.proxy.com") - if client.baseURL != "https://custom.proxy.com" { - t.Errorf("expected custom base URL, got %q", client.baseURL) + if client.BaseURL() != "https://custom.proxy.com" { + t.Errorf("expected custom base URL, got %q", client.BaseURL()) } } @@ -262,11 +264,11 @@ func TestAnthropicClient_WithOptions(t *testing.T) { WithHTTPClient(customHTTP), WithRetry(retryConfig), ) - if client.httpClient != customHTTP { + if client.HTTPClient() != customHTTP { t.Error("expected custom HTTP client to be set") } - if client.retry.MaxRetries != 5 { - t.Errorf("expected 5 max retries, got %d", client.retry.MaxRetries) + if client.Retry().MaxRetries != 5 { + t.Errorf("expected 5 max retries, got %d", client.Retry().MaxRetries) } } @@ -574,7 +576,7 @@ func TestResolveOutputConfig(t *testing.T) { func TestAnthropicResponseFormatJSONSchema(t *testing.T) { c := NewAnthropicClient("test", "http://example.test") - _, body, err := c.buildAnthropicRequest(context.Background(), []EyrieMessage{{Role: "user", Content: "hi"}}, ChatOptions{ + _, body, err := c.BuildAnthropicRequest(context.Background(), []EyrieMessage{{Role: "user", Content: "hi"}}, ChatOptions{ Model: "claude-test", ResponseFormat: &ResponseFormat{ Type: "json_schema", @@ -591,7 +593,7 @@ func TestAnthropicResponseFormatJSONSchema(t *testing.T) { func TestAnthropicResponseFormatRejectsSchemaLessJSON(t *testing.T) { c := NewAnthropicClient("test", "http://example.test") - _, _, err := c.buildAnthropicRequest(context.Background(), nil, ChatOptions{ + _, _, err := c.BuildAnthropicRequest(context.Background(), nil, ChatOptions{ ResponseFormat: &ResponseFormat{Type: "json_object"}, }, false) if err == nil || !strings.Contains(err.Error(), "use json_schema") { @@ -601,7 +603,7 @@ func TestAnthropicResponseFormatRejectsSchemaLessJSON(t *testing.T) { func TestAnthropicRequest_NewFields(t *testing.T) { t.Parallel() - req := anthropicRequest{ + req := adapters.AnthropicRequest{ Model: "claude-sonnet-4-6", MaxTokens: 4096, TopP: float64Ptr(0.9), diff --git a/client/anthropic_response_test.go b/client/anthropic_response_test.go index ba5742d..4d163f3 100644 --- a/client/anthropic_response_test.go +++ b/client/anthropic_response_test.go @@ -157,7 +157,7 @@ func TestParseAnthropicResponse_MultipleToolUse(t *testing.T) { } // TestParseAnthropicResponse_ToolUse_BadJSON: a tool_use block -// with malformed Input JSON still produces a ToolCall entry, but +// with malformed Input JSON still produces a core.ToolCall entry, but // with nil Arguments (the unmarshal error is swallowed — same // behavior as the previous inlined copy). func TestParseAnthropicResponse_ToolUse_BadJSON(t *testing.T) { diff --git a/client/cache.go b/client/cache.go index e2de24a..818ec58 100644 --- a/client/cache.go +++ b/client/cache.go @@ -63,89 +63,3 @@ type anthropicCachedMessage struct { Content interface{} `json:"content"` // string or []CachedContent CacheControl interface{} `json:"cache_control,omitempty"` } - -// buildAnthropicCachedRequest builds an Anthropic request body with cache_control. -// It reuses buildAnthropicMessages for proper tool_use/tool_result handling, -// then applies cache_control breakpoints following Anthropic's best practices: -// - System prompt gets cache_control (cached for all turns) -// - Second-to-last message gets cache_control (caches conversation prefix) -// - Last tool definition gets cache_control (caches tool schema) -func buildAnthropicCachedRequest(messages []EyrieMessage, model string, maxTokens int, temperature *float64, stream bool, tools []anthropicTool, - thinking *anthropicThinking, toolChoice *anthropicToolChoice, topP *float64, topK *int, stopSequences []string, -) map[string]interface{} { - msgs, system := buildAnthropicMessages(messages) - - // Apply cache breakpoint to second-to-last non-system message - if len(msgs) >= 2 { - idx := len(msgs) - 2 - applyCacheBreakpointToMessage(msgs[idx]) - } - - req := map[string]interface{}{ - "model": model, - "max_tokens": maxTokens, - "messages": msgs, - "stream": stream, - } - if system != "" { - req["system"] = []map[string]interface{}{ - { - "type": "text", - "text": system, - "cache_control": map[string]string{"type": "ephemeral"}, - }, - } - } - if len(tools) > 0 { - toolMaps := make([]map[string]interface{}, len(tools)) - for i, t := range tools { - toolMaps[i] = map[string]interface{}{ - "name": t.Name, - "description": t.Description, - "input_schema": t.InputSchema, - } - } - // Annotate last tool with cache_control - toolMaps[len(toolMaps)-1]["cache_control"] = map[string]string{"type": "ephemeral"} - req["tools"] = toolMaps - } - if temperature != nil { - req["temperature"] = *temperature - } - if thinking != nil { - req["thinking"] = thinking - } - if toolChoice != nil { - req["tool_choice"] = toolChoice - } - if topP != nil { - req["top_p"] = *topP - } - if topK != nil { - req["top_k"] = *topK - } - if len(stopSequences) > 0 { - req["stop_sequences"] = stopSequences - } - return req -} - -// applyCacheBreakpointToMessage adds cache_control to a message's content. -// Handles both string content and array content (tool_use/tool_result blocks). -func applyCacheBreakpointToMessage(msg map[string]interface{}) { - content := msg["content"] - switch c := content.(type) { - case string: - msg["content"] = []map[string]interface{}{ - { - "type": "text", - "text": c, - "cache_control": map[string]string{"type": "ephemeral"}, - }, - } - case []map[string]interface{}: - if len(c) > 0 { - c[len(c)-1]["cache_control"] = map[string]string{"type": "ephemeral"} - } - } -} diff --git a/client/client.go b/client/client.go index 8f31a51..b862153 100644 --- a/client/client.go +++ b/client/client.go @@ -6,9 +6,8 @@ import ( "context" "os" "strings" - "time" - "sync" + "time" "github.com/GrayCodeAI/eyrie/client/core" diff --git a/client/client_test.go b/client/client_test.go index 675199c..2b07e8e 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -93,8 +93,8 @@ func TestClientConfigBaseURL_OpenAICompatible(t *testing.T) { if !ok { t.Fatalf("provider type = %T, want *OpenAIClient", p) } - if oc.baseURL != "https://proxy.example/v1" { - t.Fatalf("baseURL = %q, want override", oc.baseURL) + if oc.BaseURL() != "https://proxy.example/v1" { + t.Fatalf("baseURL = %q, want override", oc.BaseURL()) } } @@ -108,8 +108,8 @@ func TestClientConfigBaseURL_Anthropic(t *testing.T) { if !ok { t.Fatalf("provider type = %T, want *AnthropicClient", p) } - if ac.baseURL != "https://anthropic-proxy.example" { - t.Fatalf("baseURL = %q, want override", ac.baseURL) + if ac.BaseURL() != "https://anthropic-proxy.example" { + t.Fatalf("baseURL = %q, want override", ac.BaseURL()) } } diff --git a/client/cloud_providers_bedrock_test.go b/client/cloud_providers_bedrock_test.go index 22d9eb0..534b57a 100644 --- a/client/cloud_providers_bedrock_test.go +++ b/client/cloud_providers_bedrock_test.go @@ -19,8 +19,8 @@ import ( func newTestBedrockClient(serverURL, accessKey, secretKey, sessionToken, region string) *BedrockClient { c := NewBedrockClient(accessKey, secretKey, sessionToken, region) - c.httpClient = &http.Client{} - c.retry = NewRetryConfig(0, 0, 0) + c.SetHTTPClient(&http.Client{}) + c.SetRetry(NewRetryConfig(0, 0, 0)) return c } @@ -35,7 +35,7 @@ func TestBedrockClient_Name(t *testing.T) { func TestBedrockClient_ModelURL(t *testing.T) { t.Parallel() c := NewBedrockClient("AKID", "secret", "", "us-west-2") - url := c.modelURL("anthropic.claude-3-5-sonnet-20241022-v2:0") + url := c.ModelURL("anthropic.claude-3-5-sonnet-20241022-v2:0") // url.PathEscape does not encode ":" in Go, so it stays as-is expected := "https://bedrock-runtime.us-west-2.amazonaws.com/model/anthropic.claude-3-5-sonnet-20241022-v2:0/invoke" if url != expected { @@ -72,10 +72,10 @@ func TestBedrockChat_Success(t *testing.T) { defer server.Close() c := NewBedrockClient("AKID", "secret-key", "session-token", "us-east-1") - c.httpClient = &http.Client{ + c.SetHTTPClient(&http.Client{ Transport: &bedrockRewriteTransport{target: server.URL}, - } - c.retry = NewRetryConfig(0, 0, 0) + }) + c.SetRetry(NewRetryConfig(0, 0, 0)) resp, err := c.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "Hello Bedrock"}, @@ -173,10 +173,10 @@ func TestBedrockChat_NoSessionToken(t *testing.T) { defer server.Close() c := NewBedrockClient("AKID", "secret", "", "us-east-1") // No session token - c.httpClient = &http.Client{ + c.SetHTTPClient(&http.Client{ Transport: &bedrockRewriteTransport{target: server.URL}, - } - c.retry = NewRetryConfig(0, 0, 0) + }) + c.SetRetry(NewRetryConfig(0, 0, 0)) _, err := c.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hi"}, @@ -216,10 +216,10 @@ func TestBedrockChat_ToolUseResponse(t *testing.T) { defer server.Close() c := NewBedrockClient("AKID", "secret", "session", "us-east-1") - c.httpClient = &http.Client{ + c.SetHTTPClient(&http.Client{ Transport: &bedrockRewriteTransport{target: server.URL}, - } - c.retry = NewRetryConfig(0, 0, 0) + }) + c.SetRetry(NewRetryConfig(0, 0, 0)) resp, err := c.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "What's the weather in Seattle?"}, @@ -258,7 +258,7 @@ func TestBedrockBuildBody_DefaultMaxTokens(t *testing.T) { t.Parallel() c := NewBedrockClient("AKID", "secret", "", "us-east-1") - body, err := c.buildBody([]EyrieMessage{ + body, err := c.BuildBody([]EyrieMessage{ {Role: "user", Content: "Hello"}, }, ChatOptions{Model: "claude-sonnet-4-6"}) if err != nil { @@ -280,7 +280,7 @@ func TestBedrockBuildBody_CustomMaxTokens(t *testing.T) { t.Parallel() c := NewBedrockClient("AKID", "secret", "", "us-east-1") - body, err := c.buildBody([]EyrieMessage{ + body, err := c.BuildBody([]EyrieMessage{ {Role: "user", Content: "Hello"}, }, ChatOptions{Model: "claude-sonnet-4-6", MaxTokens: 8192}) if err != nil { @@ -299,7 +299,7 @@ func TestBedrockBuildBody_WithSystemPrompt(t *testing.T) { t.Parallel() c := NewBedrockClient("AKID", "secret", "", "us-east-1") - body, err := c.buildBody([]EyrieMessage{ + body, err := c.BuildBody([]EyrieMessage{ {Role: "system", Content: "Be concise."}, {Role: "user", Content: "Hello"}, }, ChatOptions{Model: "claude-sonnet-4-6"}) @@ -323,7 +323,7 @@ func TestBedrockBuildBody_WithTools(t *testing.T) { t.Parallel() c := NewBedrockClient("AKID", "secret", "", "us-east-1") - body, err := c.buildBody([]EyrieMessage{ + body, err := c.BuildBody([]EyrieMessage{ {Role: "user", Content: "Hello"}, }, ChatOptions{ Model: "claude-sonnet-4-6", @@ -358,7 +358,7 @@ func TestBedrockBuildBody_ToolResultMessage(t *testing.T) { t.Parallel() c := NewBedrockClient("AKID", "secret", "", "us-east-1") - body, err := c.buildBody([]EyrieMessage{ + body, err := c.BuildBody([]EyrieMessage{ {Role: "user", Content: "What is the weather?"}, {Role: "assistant", ToolUse: []ToolCall{ {ID: "toolu_1", Name: "get_weather", Arguments: map[string]interface{}{"city": "NYC"}}, @@ -382,7 +382,7 @@ func TestBedrockBuildBody_SystemMerge(t *testing.T) { t.Parallel() c := NewBedrockClient("AKID", "secret", "", "us-east-1") - body, err := c.buildBody([]EyrieMessage{ + body, err := c.BuildBody([]EyrieMessage{ {Role: "system", Content: "From messages"}, {Role: "user", Content: "Hello"}, }, ChatOptions{Model: "claude-sonnet-4-6", System: "From opts"}) @@ -411,10 +411,10 @@ func TestBedrockChat_ErrorResponse(t *testing.T) { defer server.Close() c := NewBedrockClient("AKID", "secret", "", "us-east-1") - c.httpClient = &http.Client{ + c.SetHTTPClient(&http.Client{ Transport: &bedrockRewriteTransport{target: server.URL}, - } - c.retry = NewRetryConfig(0, 0, 0) + }) + c.SetRetry(NewRetryConfig(0, 0, 0)) _, err := c.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hi"}, @@ -430,8 +430,8 @@ func TestBedrockChat_ErrorResponse(t *testing.T) { func TestBedrockChat_MissingCredentials(t *testing.T) { t.Parallel() c := NewBedrockClient("", "", "", "us-east-1") - c.httpClient = &http.Client{} - c.retry = NewRetryConfig(0, 0, 0) + c.SetHTTPClient(&http.Client{}) + c.SetRetry(NewRetryConfig(0, 0, 0)) _, err := c.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hi"}, @@ -453,7 +453,7 @@ func TestBedrockSigV4_SignatureComponents(t *testing.T) { req.Header.Set("Content-Type", "application/json") body := []byte(`{"model":"test"}`) - err := c.sign(req, body, mustParseTime("20230901T000000Z")) + err := c.Sign(req, body, mustParseTime("20230901T000000Z")) if err != nil { t.Fatalf("sign error: %v", err) } @@ -496,11 +496,11 @@ func TestBedrockSigV4_DeterministicSignature(t *testing.T) { req1, _ := http.NewRequest("POST", "https://bedrock-runtime.us-east-1.amazonaws.com/model/test/invoke", nil) req1.Header.Set("Content-Type", "application/json") - c.sign(req1, body, now) + c.Sign(req1, body, now) req2, _ := http.NewRequest("POST", "https://bedrock-runtime.us-east-1.amazonaws.com/model/test/invoke", nil) req2.Header.Set("Content-Type", "application/json") - c.sign(req2, body, now) + c.Sign(req2, body, now) auth1 := req1.Header.Get("Authorization") auth2 := req2.Header.Get("Authorization") @@ -526,9 +526,9 @@ func TestBedrockPing_Success(t *testing.T) { defer server.Close() c := NewBedrockClient("AKID", "secret", "", "us-east-1") - c.httpClient = &http.Client{ + c.SetHTTPClient(&http.Client{ Transport: &bedrockRewriteTransport{target: server.URL}, - } + }) err := c.Ping(context.Background()) if err != nil { @@ -556,9 +556,9 @@ func TestBedrockPing_InvalidCredentials(t *testing.T) { defer server.Close() c := NewBedrockClient("AKID", "secret", "", "us-east-1") - c.httpClient = &http.Client{ + c.SetHTTPClient(&http.Client{ Transport: &bedrockRewriteTransport{target: server.URL}, - } + }) err := c.Ping(context.Background()) if err == nil { @@ -586,8 +586,8 @@ func TestBedrockStreamChat_ModelRequired(t *testing.T) { func TestBedrockStreamChat_MissingCredentials(t *testing.T) { t.Parallel() c := NewBedrockClient("", "", "", "us-east-1") - c.httpClient = &http.Client{} - c.retry = NewRetryConfig(0, 0, 0) + c.SetHTTPClient(&http.Client{}) + c.SetRetry(NewRetryConfig(0, 0, 0)) _, err := c.StreamChat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hi"}, @@ -613,7 +613,7 @@ func TestBedrockModelIDMapping(t *testing.T) { for _, tt := range tests { t.Run(tt.model, func(t *testing.T) { - url := c.modelURL(tt.model) + url := c.ModelURL(tt.model) // Verify base URL structure if !strings.HasPrefix(url, "https://bedrock-runtime.us-east-1.amazonaws.com/model/") { t.Errorf("expected bedrock-runtime URL prefix, got %q", url) @@ -645,10 +645,10 @@ func TestBedrockChat_RegionInURL(t *testing.T) { // Test with different regions - they should be reflected in the URL c := NewBedrockClient("AKID", "secret", "", "eu-west-1") - c.httpClient = &http.Client{ + c.SetHTTPClient(&http.Client{ Transport: &bedrockRewriteTransport{target: server.URL}, - } - c.retry = NewRetryConfig(0, 0, 0) + }) + c.SetRetry(NewRetryConfig(0, 0, 0)) _, err := c.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hi"}, diff --git a/client/cloud_providers_test.go b/client/cloud_providers_test.go index 29618dd..5b5ebbb 100644 --- a/client/cloud_providers_test.go +++ b/client/cloud_providers_test.go @@ -8,6 +8,8 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/GrayCodeAI/eyrie/client/adapters" ) // Vertex AI provider tests live in cloud_providers_vertex_test.go and @@ -19,8 +21,8 @@ import ( func newTestAzureClient(serverURL string) *AzureClient { c := NewAzureClient("test-api-key", serverURL, "2024-08-01-preview") - c.httpClient = &http.Client{} - c.retry = NewRetryConfig(0, 0, 0) + c.SetHTTPClient(&http.Client{}) + c.SetRetry(NewRetryConfig(0, 0, 0)) return c } @@ -35,24 +37,24 @@ func TestAzureClient_Name(t *testing.T) { func TestAzureClient_DefaultAPIVersion(t *testing.T) { t.Parallel() c := NewAzureClient("key", "https://example.openai.azure.com", "") - if c.apiVersion != "2024-10-21" { - t.Errorf("expected default api-version '2024-10-21', got %q", c.apiVersion) + if c.APIVersion() != "2024-10-21" { + t.Errorf("expected default api-version '2024-10-21', got %q", c.APIVersion()) } } func TestAzureClient_CustomAPIVersion(t *testing.T) { t.Parallel() c := NewAzureClient("key", "https://example.openai.azure.com", "2024-10-01-preview") - if c.apiVersion != "2024-10-01-preview" { - t.Errorf("expected custom api-version '2024-10-01-preview', got %q", c.apiVersion) + if c.APIVersion() != "2024-10-01-preview" { + t.Errorf("expected custom api-version '2024-10-01-preview', got %q", c.APIVersion()) } } func TestAzureClient_EndpointTrailingSlashStripped(t *testing.T) { t.Parallel() c := NewAzureClient("key", "https://example.openai.azure.com/", "") - if c.endpoint != "https://example.openai.azure.com" { - t.Errorf("expected trailing slash stripped, got %q", c.endpoint) + if c.Endpoint() != "https://example.openai.azure.com" { + t.Errorf("expected trailing slash stripped, got %q", c.Endpoint()) } } @@ -73,7 +75,7 @@ func TestAzureChat_Success(t *testing.T) { } w.Header().Set("X-Request-Id", "azure-req-001") - _ = json.NewEncoder(w).Encode(openaiResponse{ + _ = json.NewEncoder(w).Encode(adapters.OpenAIResponse{ ID: "chatcmpl-azure-001", Choices: []struct { Message struct { diff --git a/client/cloud_providers_vertex_test.go b/client/cloud_providers_vertex_test.go index 0c55e9d..a415127 100644 --- a/client/cloud_providers_vertex_test.go +++ b/client/cloud_providers_vertex_test.go @@ -17,8 +17,8 @@ import ( func newTestVertexClient(serverURL, projectID, region, token string) *VertexClient { c := NewVertexClient(projectID, region, token) - c.httpClient = &http.Client{} - c.retry = NewRetryConfig(0, 0, 0) + c.SetHTTPClient(&http.Client{}) + c.SetRetry(NewRetryConfig(0, 0, 0)) return c } @@ -34,8 +34,8 @@ func TestVertexClient_BaseURL(t *testing.T) { t.Parallel() c := NewVertexClient("my-project", "us-east1", "token") expected := "https://us-east1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-east1/publishers/anthropic/models" - if c.baseURL() != expected { - t.Errorf("expected baseURL %q, got %q", expected, c.baseURL()) + if c.BaseURL() != expected { + t.Errorf("expected baseURL %q, got %q", expected, c.BaseURL()) } } @@ -70,22 +70,22 @@ func TestVertexChat_Success(t *testing.T) { c := newTestVertexClient(server.URL, "my-project", "us-central1", "test-bearer-token") // Override the baseURL by constructing with server URL host - // We need to set the URL directly since baseURL() is computed from projectID/region + // We need to set the URL directly since BaseURL() is computed from projectID/region // For testing, we'll monkey-patch the httpClient to redirect to our test server - originalDo := c.httpClient - c.httpClient = &http.Client{ + originalDo := c.HTTPClient() + c.SetHTTPClient(&http.Client{ Transport: &redirectTransport{target: server.URL}, - } - defer func() { c.httpClient = originalDo }() + }) + defer func() { c.SetHTTPClient(originalDo) }() // We can't easily redirect because the Vertex client constructs the full URL. // Instead, use a test that verifies the body and headers by running against - // a server that acts as the Vertex endpoint. Since baseURL() is computed, - // we test the buildBody method and headers separately. + // a server that acts as the Vertex endpoint. Since BaseURL() is computed, + // we test the BuildBody method and headers separately. // For an integration-level test, we use a custom approach. // Let's test by verifying the buildBody output and header behavior separately. - body, err := c.buildBody([]EyrieMessage{ + body, err := c.BuildBody([]EyrieMessage{ {Role: "user", Content: "Hello Vertex"}, }, ChatOptions{Model: "claude-sonnet-4-6"}, false) if err != nil { @@ -113,16 +113,6 @@ func TestVertexChat_Success(t *testing.T) { t.Errorf("expected default max_tokens=4096, got %v", bodyMap["max_tokens"]) } - // Verify headers - req, _ := http.NewRequest("POST", "http://example.com", nil) - c.setHeaders(req) - if req.Header.Get("Authorization") != "Bearer test-bearer-token" { - t.Errorf("expected Bearer token, got %q", req.Header.Get("Authorization")) - } - if req.Header.Get("Content-Type") != "application/json" { - t.Errorf("expected Content-Type application/json, got %q", req.Header.Get("Content-Type")) - } - // Suppress unused warnings _ = capturedMethod _ = capturedPath @@ -147,7 +137,7 @@ func TestVertexBuildBody_WithSystemPrompt(t *testing.T) { t.Parallel() c := NewVertexClient("proj", "us-central1", "token") - body, err := c.buildBody([]EyrieMessage{ + body, err := c.BuildBody([]EyrieMessage{ {Role: "system", Content: "You are a helpful assistant."}, {Role: "user", Content: "Hello"}, }, ChatOptions{Model: "claude-sonnet-4-6"}, false) @@ -171,7 +161,7 @@ func TestVertexBuildBody_SystemMerge(t *testing.T) { t.Parallel() c := NewVertexClient("proj", "us-central1", "token") - body, err := c.buildBody([]EyrieMessage{ + body, err := c.BuildBody([]EyrieMessage{ {Role: "system", Content: "From messages"}, {Role: "user", Content: "Hello"}, }, ChatOptions{Model: "claude-sonnet-4-6", System: "From opts"}, false) @@ -195,7 +185,7 @@ func TestVertexBuildBody_CustomMaxTokens(t *testing.T) { t.Parallel() c := NewVertexClient("proj", "us-central1", "token") - body, err := c.buildBody([]EyrieMessage{ + body, err := c.BuildBody([]EyrieMessage{ {Role: "user", Content: "Hello"}, }, ChatOptions{Model: "claude-sonnet-4-6", MaxTokens: 8192}, false) if err != nil { @@ -215,7 +205,7 @@ func TestVertexBuildBody_WithTemperature(t *testing.T) { c := NewVertexClient("proj", "us-central1", "token") temp := 0.5 - body, err := c.buildBody([]EyrieMessage{ + body, err := c.BuildBody([]EyrieMessage{ {Role: "user", Content: "Hello"}, }, ChatOptions{Model: "claude-sonnet-4-6", Temperature: &temp}, false) if err != nil { @@ -234,7 +224,7 @@ func TestVertexBuildBody_WithTools(t *testing.T) { t.Parallel() c := NewVertexClient("proj", "us-central1", "token") - body, err := c.buildBody([]EyrieMessage{ + body, err := c.BuildBody([]EyrieMessage{ {Role: "user", Content: "Hello"}, }, ChatOptions{ Model: "claude-sonnet-4-6", @@ -269,7 +259,7 @@ func TestVertexBuildBody_StreamFlag(t *testing.T) { t.Parallel() c := NewVertexClient("proj", "us-central1", "token") - body, err := c.buildBody([]EyrieMessage{ + body, err := c.BuildBody([]EyrieMessage{ {Role: "user", Content: "Hello"}, }, ChatOptions{Model: "claude-sonnet-4-6"}, true) if err != nil { @@ -288,7 +278,7 @@ func TestVertexBuildBody_ToolResultMessage(t *testing.T) { t.Parallel() c := NewVertexClient("proj", "us-central1", "token") - body, err := c.buildBody([]EyrieMessage{ + body, err := c.BuildBody([]EyrieMessage{ {Role: "user", Content: "What is the weather?"}, {Role: "assistant", ToolUse: []ToolCall{ {ID: "toolu_1", Name: "get_weather", Arguments: map[string]interface{}{"city": "NYC"}}, @@ -312,7 +302,7 @@ func TestVertexBuildBody_VertexVersionField(t *testing.T) { t.Parallel() c := NewVertexClient("proj", "us-central1", "token") - body, err := c.buildBody([]EyrieMessage{ + body, err := c.BuildBody([]EyrieMessage{ {Role: "user", Content: "Hello"}, }, ChatOptions{Model: "claude-sonnet-4-6"}, false) if err != nil { @@ -366,15 +356,15 @@ func TestVertexChat_SuccessWithFullResponse(t *testing.T) { defer server.Close() c := NewVertexClient("test-project", "us-central1", "vert-token") - c.httpClient = server.Client() - c.retry = NewRetryConfig(0, 0, 0) + c.SetHTTPClient(server.Client()) + c.SetRetry(NewRetryConfig(0, 0, 0)) // We need to override the region/project so the URL hits our test server // The baseURL() method constructs the URL from region/projectID. // For testing, we create a custom transport that rewrites the URL. - c.httpClient = &http.Client{ + c.SetHTTPClient(&http.Client{ Transport: &vertexRewriteTransport{target: server.URL, originalHost: "us-central1-aiplatform.googleapis.com"}, - } + }) resp, err := c.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "Hello"}, @@ -422,10 +412,10 @@ func TestVertexChat_ToolUseResponse(t *testing.T) { defer server.Close() c := NewVertexClient("proj", "us-central1", "token") - c.httpClient = &http.Client{ + c.SetHTTPClient(&http.Client{ Transport: &vertexRewriteTransport{target: server.URL, originalHost: "us-central1-aiplatform.googleapis.com"}, - } - c.retry = NewRetryConfig(0, 0, 0) + }) + c.SetRetry(NewRetryConfig(0, 0, 0)) resp, err := c.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "Search for vertex pricing"}, @@ -463,10 +453,10 @@ func TestVertexChat_ErrorResponse(t *testing.T) { defer server.Close() c := NewVertexClient("proj", "us-central1", "token") - c.httpClient = &http.Client{ + c.SetHTTPClient(&http.Client{ Transport: &vertexRewriteTransport{target: server.URL, originalHost: "us-central1-aiplatform.googleapis.com"}, - } - c.retry = NewRetryConfig(0, 0, 0) + }) + c.SetRetry(NewRetryConfig(0, 0, 0)) _, err := c.Chat(context.Background(), []EyrieMessage{ {Role: "user", Content: "hi"}, @@ -510,10 +500,10 @@ func TestVertexStreamChat_Success(t *testing.T) { defer server.Close() c := NewVertexClient("proj", "us-central1", "token") - c.httpClient = &http.Client{ + c.SetHTTPClient(&http.Client{ Transport: &vertexRewriteTransport{target: server.URL, originalHost: "us-central1-aiplatform.googleapis.com"}, - } - c.retry = NewRetryConfig(0, 0, 0) + }) + c.SetRetry(NewRetryConfig(0, 0, 0)) sr, err := c.StreamChat(context.Background(), []EyrieMessage{ {Role: "user", Content: "Hello"}, @@ -572,9 +562,9 @@ func TestVertexPing_Success(t *testing.T) { defer server.Close() c := NewVertexClient("proj", "us-central1", "token") - c.httpClient = &http.Client{ + c.SetHTTPClient(&http.Client{ Transport: &vertexRewriteTransport{target: server.URL, originalHost: "us-central1-aiplatform.googleapis.com"}, - } + }) err := c.Ping(context.Background()) if err != nil { @@ -590,9 +580,9 @@ func TestVertexPing_InvalidCredentials(t *testing.T) { defer server.Close() c := NewVertexClient("proj", "us-central1", "token") - c.httpClient = &http.Client{ + c.SetHTTPClient(&http.Client{ Transport: &vertexRewriteTransport{target: server.URL, originalHost: "us-central1-aiplatform.googleapis.com"}, - } + }) err := c.Ping(context.Background()) if err == nil { diff --git a/client/compat.go b/client/compat.go index 37f0dab..d7adb90 100644 --- a/client/compat.go +++ b/client/compat.go @@ -1,172 +1,27 @@ package client -// OpenAICompatConfig holds provider-specific compatibility flags -// that control how API requests are constructed for each provider. -type OpenAICompatConfig struct { - SupportsStore bool `json:"supports_store,omitempty"` - SupportsDeveloperRole bool `json:"supports_developer_role,omitempty"` - SupportsReasoningEffort bool `json:"supports_reasoning_effort,omitempty"` - SupportsUsageInStreaming bool `json:"supports_usage_in_streaming,omitempty"` - SupportsStrictMode bool `json:"supports_strict_mode,omitempty"` - MaxTokensField string `json:"max_tokens_field,omitempty"` // "max_tokens" or "max_completion_tokens" - RequiresToolResultName bool `json:"requires_tool_result_name,omitempty"` - RequiresAssistantAfterToolResult bool `json:"requires_assistant_after_tool_result,omitempty"` - RequiresThinkingAsText bool `json:"requires_thinking_as_text,omitempty"` - ThinkingFormat string `json:"thinking_format,omitempty"` // "openai", "zai", "qwen", "openrouter" - // StripReasoningFromInput instructs buildRequestBase to omit the reasoning_content - // field from assistant messages. DeepSeek (and compatible providers) return HTTP 400 - // if reasoning_content appears in the input context of a multi-turn conversation. - StripReasoningFromInput bool `json:"strip_reasoning_from_input,omitempty"` - // SupportsCacheRole enables Kimi/Moonshot context-cache injection: when - // ChatOptions.KimiContextCacheID is non-empty, buildRequestBase prepends a - // {"role":"cache","content":} message per the MoonshotAI-Cookbook spec. - SupportsCacheRole bool `json:"supports_cache_role,omitempty"` -} +import "github.com/GrayCodeAI/eyrie/client/adapters" + +// OpenAICompatConfig holds provider-specific compatibility flags. +type OpenAICompatConfig = adapters.OpenAICompatConfig // Per-provider compat configs. var ( - OpenAICompat = OpenAICompatConfig{ - SupportsStore: true, SupportsDeveloperRole: true, - SupportsReasoningEffort: true, SupportsUsageInStreaming: true, - MaxTokensField: "max_completion_tokens", - } - GrokCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - } - OpenRouterCompat = OpenAICompatConfig{ - ThinkingFormat: "openrouter", MaxTokensField: "max_tokens", - SupportsUsageInStreaming: true, - } - GeminiCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, - } - ZAICompat = OpenAICompatConfig{ - ThinkingFormat: "zai", MaxTokensField: "max_tokens", - SupportsUsageInStreaming: true, - } - CanopyWaveCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - } - OllamaCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - } - OpenCodeGoCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - SupportsUsageInStreaming: true, - ThinkingFormat: "openrouter", - StripReasoningFromInput: true, - } - PoolsideCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - } - GroqCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - } - ClinePassCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - } - KimiCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - SupportsCacheRole: true, - } - XiaomiCompat = OpenAICompatConfig{ - MaxTokensField: "max_completion_tokens", - } - AzureCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - } - BedrockCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - } - VertexCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - } - // DeepSeekCompat: OpenAI-compatible with usage in streaming. - // The provider rejects reasoning_content in input messages with HTTP 400, so we strip it. - DeepSeekCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - SupportsUsageInStreaming: true, - StripReasoningFromInput: true, - } + OpenAICompat = adapters.OpenAICompat + GrokCompat = adapters.GrokCompat + OpenRouterCompat = adapters.OpenRouterCompat + GeminiCompat = adapters.GeminiCompat + ZAICompat = adapters.ZAICompat + CanopyWaveCompat = adapters.CanopyWaveCompat + OllamaCompat = adapters.OllamaCompat + OpenCodeGoCompat = adapters.OpenCodeGoCompat + PoolsideCompat = adapters.PoolsideCompat + GroqCompat = adapters.GroqCompat + ClinePassCompat = adapters.ClinePassCompat + KimiCompat = adapters.KimiCompat + XiaomiCompat = adapters.XiaomiCompat + AzureCompat = adapters.AzureCompat + BedrockCompat = adapters.BedrockCompat + VertexCompat = adapters.VertexCompat + DeepSeekCompat = adapters.DeepSeekCompat ) - -func init() { - // Attach compat configs to provider registry. - // Acquire dynamicMu for consistency with the runtime lock protocol, - // even though init() is single-threaded. - dynamicMu.Lock() - defer dynamicMu.Unlock() - - if p, ok := OpenAICompatibleProviders["grok"]; ok { - p.Compat = &GrokCompat - OpenAICompatibleProviders["grok"] = p - } - if p, ok := OpenAICompatibleProviders["openrouter"]; ok { - p.Compat = &OpenRouterCompat - OpenAICompatibleProviders["openrouter"] = p - } - if p, ok := OpenAICompatibleProviders["gemini"]; ok { - p.Compat = &GeminiCompat - OpenAICompatibleProviders["gemini"] = p - } - for _, id := range []string{"zai_payg", "zai_coding"} { - if p, ok := OpenAICompatibleProviders[id]; ok { - p.Compat = &ZAICompat - OpenAICompatibleProviders[id] = p - } - } - if p, ok := OpenAICompatibleProviders["canopywave"]; ok { - p.Compat = &CanopyWaveCompat - OpenAICompatibleProviders["canopywave"] = p - } - if p, ok := OpenAICompatibleProviders["poolside"]; ok { - p.Compat = &PoolsideCompat - OpenAICompatibleProviders["poolside"] = p - } - if p, ok := OpenAICompatibleProviders["groq"]; ok { - p.Compat = &GroqCompat - OpenAICompatibleProviders["groq"] = p - } - if p, ok := OpenAICompatibleProviders["clinepass"]; ok { - p.Compat = &ClinePassCompat - OpenAICompatibleProviders["clinepass"] = p - } - if p, ok := OpenAICompatibleProviders["ollama"]; ok { - p.Compat = &OllamaCompat - OpenAICompatibleProviders["ollama"] = p - } - if p, ok := OpenAICompatibleProviders["opencodego"]; ok { - p.Compat = &OpenCodeGoCompat - OpenAICompatibleProviders["opencodego"] = p - } - if p, ok := OpenAICompatibleProviders["kimi"]; ok { - p.Compat = &KimiCompat - OpenAICompatibleProviders["kimi"] = p - } - for _, id := range []string{"xiaomi_mimo", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan"} { - if p, ok := OpenAICompatibleProviders[id]; ok { - p.Compat = &XiaomiCompat - OpenAICompatibleProviders[id] = p - } - } - if p, ok := OpenAICompatibleProviders["deepseek"]; ok { - p.Compat = &DeepSeekCompat - OpenAICompatibleProviders["deepseek"] = p - } - if p, ok := CoreProviders["openai"]; ok { - p.Compat = &OpenAICompat - CoreProviders["openai"] = p - } - if p, ok := CoreProviders["azure"]; ok { - p.Compat = &AzureCompat - CoreProviders["azure"] = p - } - if p, ok := CoreProviders["bedrock"]; ok { - p.Compat = &BedrockCompat - CoreProviders["bedrock"] = p - } - if p, ok := CoreProviders["vertex"]; ok { - p.Compat = &VertexCompat - CoreProviders["vertex"] = p - } -} diff --git a/client/core/audio.go b/client/core/audio.go new file mode 100644 index 0000000..3610d41 --- /dev/null +++ b/client/core/audio.go @@ -0,0 +1,27 @@ +package core + +import "strings" + +// AudioFormatToMediaType converts a short audio format string to a full MIME type. +func AudioFormatToMediaType(format string) string { + switch strings.ToLower(format) { + case "wav": + return "audio/wav" + case "mp3": + return "audio/mpeg" + case "flac": + return "audio/flac" + case "ogg": + return "audio/ogg" + case "aac": + return "audio/aac" + case "webm": + return "audio/webm" + default: + // If it looks like a full MIME type already, pass through + if strings.Contains(format, "/") { + return format + } + return "audio/" + format + } +} diff --git a/client/core/errors.go b/client/core/errors.go index 4653fe6..18fd36d 100644 --- a/client/core/errors.go +++ b/client/core/errors.go @@ -1,6 +1,12 @@ package core -import "fmt" +import ( + "context" + "errors" + "fmt" + + "github.com/GrayCodeAI/eyrie/types" +) // EyrieError is a structured error that preserves provider context, // HTTP metadata, and request identification for debugging. @@ -50,3 +56,56 @@ func (e *EyrieError) IsAuthError() bool { func (e *EyrieError) IsRateLimited() bool { return e.StatusCode == 429 } + +// nonRetriableStatusCodes are HTTP status codes that indicate a client-side +// error — falling back won't help because the request itself is bad. +var nonRetriableStatusCodes = map[int]bool{ + 400: true, // bad request + 401: true, // unauthorized + 403: true, // forbidden + 404: true, // not found (wrong model name, etc.) + 422: true, // unprocessable entity +} + +// IsRetriableError determines whether a fallback to the next provider should +// be attempted. It delegates to types.IsTransient for known error patterns +// but diverges on unknown errors: where IsTransient is conservative (returns +// false for unrecognized errors), IsRetriableError is optimistic (returns true). +// +// Rationale: in a fallback chain, trying the next provider is cheap and may +// succeed even if the current provider failed with an unexpected error type. +// In contrast, retry middleware (which uses IsTransient) should be conservative +// to avoid wasting requests on errors that won't resolve with a retry. +// +// *EyrieError (returned by FormatAPIError and friends) is preferred over +// the string-based heuristic: it carries the structured status code so the +// classification is exact rather than regex-parsed. +func IsRetriableError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.DeadlineExceeded) { + return true + } + if errors.Is(err, context.Canceled) { + return false + } + // Structured path: trust *EyrieError's IsRetriable. + var eyrieErr *EyrieError + if errors.As(err, &eyrieErr) { + return eyrieErr.IsRetriable() + } + // Heuristic path for legacy errors that predate the EyrieError migration. + if types.IsTransient(err) { + return true + } + // If the error contains a known non-retriable HTTP status code, give up + // immediately — the request itself is bad, not the provider. + if code, ok := types.ExtractHTTPStatus(err); ok { + if nonRetriableStatusCodes[code] { + return false + } + } + // Unknown error types: treat as retriable so we at least try the next provider. + return true +} diff --git a/client/core/sanitize.go b/client/core/sanitize.go new file mode 100644 index 0000000..850a6f7 --- /dev/null +++ b/client/core/sanitize.go @@ -0,0 +1,48 @@ +package core + +// SanitizeMessages inspects messages for orphaned tool_use blocks +// (assistant messages with tool calls that lack matching tool_result blocks) +// and injects synthetic error results to prevent 400 errors from providers. +// This is critical for session resume and compaction scenarios. +func SanitizeMessages(messages []EyrieMessage) []EyrieMessage { + if len(messages) == 0 { + return messages + } + + // Collect all tool_result IDs + resultIDs := make(map[string]bool) + for _, msg := range messages { + if msg.Role == "user" { + for _, tr := range msg.ToolResults { + if tr.ToolUseID != "" { + resultIDs[tr.ToolUseID] = true + } + } + } + } + + // Find orphaned tool_use IDs and inject synthetic results + var result []EyrieMessage + for _, msg := range messages { + result = append(result, msg) + + if msg.Role == "assistant" && len(msg.ToolUse) > 0 { + for _, tc := range msg.ToolUse { + if tc.ID != "" && !resultIDs[tc.ID] { + // Inject synthetic error result + result = append(result, EyrieMessage{ + Role: "user", + ToolResults: []ToolResult{{ + ToolUseID: tc.ID, + Content: "Tool execution was interrupted", + IsError: true, + }}, + }) + resultIDs[tc.ID] = true + } + } + } + } + + return result +} diff --git a/client/dynamic.go b/client/dynamic.go index da8c72c..a841563 100644 --- a/client/dynamic.go +++ b/client/dynamic.go @@ -1,90 +1,11 @@ package client -import ( - "fmt" - "net/url" - "os" - "strings" - "sync" - "sync/atomic" -) - -// dynamicMu protects the OpenAICompatibleProviders map from concurrent access. -var dynamicMu sync.RWMutex - -// registryFrozen prevents new provider registrations after first use. -var registryFrozen atomic.Bool - -// dynamicProviderEnvVar is the opt-in env var that allows eyrie to -// auto-register an OpenAI-compatible provider from OPENAI_API_BASE / -// OPENAI_BASE_URL when an unknown provider name is requested. -// -// The default is "deny" — a poisoned OPENAI_API_BASE in a leaked .envrc -// or shared shell history would otherwise cause eyrie to silently route -// requests (with the user's OPENAI_API_KEY header) to an attacker- -// controlled server. Set this to "1", "true", or "yes" to restore the -// previous auto-register behavior. -const dynamicProviderEnvVar = "EYRIE_ALLOW_DYNAMIC_PROVIDERS" - -// dynamicProviderEnabled reports whether callers may auto-register an -// OpenAI-compatible provider from OPENAI_API_BASE / OPENAI_BASE_URL when -// an unknown provider name is requested. See dynamicProviderEnvVar. -func dynamicProviderEnabled() bool { - v := strings.TrimSpace(strings.ToLower(os.Getenv(dynamicProviderEnvVar))) - return v == "1" || v == "true" || v == "yes" -} +import "github.com/GrayCodeAI/eyrie/client/adapters" // FreezeRegistry prevents further provider registrations. -// Called automatically after first provider lookup. -func FreezeRegistry() { - registryFrozen.Store(true) -} +func FreezeRegistry() { adapters.FreezeRegistry() } // RegisterDynamicProvider adds a user-defined OpenAI-compatible provider at runtime. -// name is the provider key (e.g. "my-local-llm"), baseURL is the API base -// (e.g. "http://localhost:8080/v1"), and envKey is the environment variable -// that holds the API key (e.g. "MY_LLM_API_KEY"). If envKey is empty, the -// provider is treated like ollama (no key required). -// Returns error if registry is frozen (after first provider lookup). func RegisterDynamicProvider(name, baseURL, envKey string) error { - if registryFrozen.Load() { - return fmt.Errorf("eyrie: provider registry is frozen; register providers before first use") - } - if baseURL == "" { - return fmt.Errorf("eyrie: RegisterDynamicProvider: baseURL must not be empty") - } - u, err := url.Parse(baseURL) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - return fmt.Errorf("eyrie: RegisterDynamicProvider: invalid baseURL %q (must be http/https with host)", baseURL) - } - dynamicMu.Lock() - defer dynamicMu.Unlock() - - OpenAICompatibleProviders[name] = ProviderRegistryConfig{ - Name: name, - Type: ProviderTypeOpenAICompatible, - BaseURL: baseURL, - EnvKey: envKey, - SupportsStreaming: true, - SupportsTools: true, - SupportsReasoning: false, - Compat: &OpenAICompatConfig{ - MaxTokensField: "max_tokens", - }, - } - return nil -} - -// getOrCreateProviderWithFallback extends getOrCreateProvider with a fallback: -// if the provider is not found in any registry map and OPENAI_API_BASE or -// OPENAI_BASE_URL is set, it creates an OpenAI-compatible client using that URL. -// This is invoked automatically inside getOrCreateProvider. -func openaiBaseFallbackURL() string { - if u := os.Getenv("OPENAI_API_BASE"); u != "" { - return u - } - if u := os.Getenv("OPENAI_BASE_URL"); u != "" { - return u - } - return "" + return adapters.RegisterDynamicProvider(name, baseURL, envKey) } diff --git a/client/embedding_methods.go b/client/embedding_methods.go index 5244cce..8f90a9c 100644 --- a/client/embedding_methods.go +++ b/client/embedding_methods.go @@ -1,19 +1,14 @@ package client import ( - "bytes" "context" - "encoding/json" "fmt" - "io" - "log/slog" - "net/http" - "github.com/GrayCodeAI/eyrie/client/core" + "github.com/GrayCodeAI/eyrie/client/adapters" ) -// Compile-time check that OpenAIClient implements embeddings.Embedder. -var _ Embedder = (*OpenAIClient)(nil) +// Compile-time check that *adapters.OpenAIClient implements embeddings.Embedder. +var _ Embedder = (*adapters.OpenAIClient)(nil) // CreateEmbedding sends an embedding request to the specified (or default) provider. func (c *EyrieClient) CreateEmbedding(ctx context.Context, req EmbeddingRequest, provider string) (*EmbeddingResponse, error) { @@ -30,94 +25,3 @@ func (c *EyrieClient) CreateEmbedding(ctx context.Context, req EmbeddingRequest, } return embedder.CreateEmbedding(ctx, req) } - -// openaiEmbeddingResponse is the wire format for OpenAI-compatible embedding APIs. -type openaiEmbeddingResponse struct { - Object string `json:"object"` - Data []openaiEmbeddingData `json:"data"` - Model string `json:"model"` - Usage openaiEmbeddingUsage `json:"usage"` -} - -type openaiEmbeddingData struct { - Object string `json:"object"` - Index int `json:"index"` - Embedding []float64 `json:"embedding"` -} - -type openaiEmbeddingUsage struct { - PromptTokens int `json:"prompt_tokens"` - TotalTokens int `json:"total_tokens"` -} - -// CreateEmbedding sends an embedding request to the OpenAI-compatible API endpoint. -func (c *OpenAIClient) CreateEmbedding(ctx context.Context, req EmbeddingRequest) (*EmbeddingResponse, error) { - if req.Model == "" { - return nil, fmt.Errorf("eyrie: model is required for %s embeddings", c.providerName) - } - - bodyMap := map[string]interface{}{ - "model": req.Model, - "input": req.Input, - } - for k, v := range req.Params { - bodyMap[k] = v - } - - body, err := json.Marshal(bodyMap) - if err != nil { - return nil, fmt.Errorf("eyrie: failed to marshal embedding request: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/embeddings", bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("eyrie: failed to create embedding request: %w", err) - } - c.setHeaders(httpReq) - httpReq.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil } - - c.logger.Debug("openai embedding", "provider", c.providerName, "model", req.Model) - - resp, err := core.DoWithRetry(ctx, c.httpClient, httpReq, c.retry, c.logger) - if err != nil { - return nil, fmt.Errorf("eyrie: %s embedding request failed: %w", c.providerName, err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - slog.Warn("embedding: close response body", "error", err) - } - }() - - if resp.StatusCode != 200 { - requestID := resp.Header.Get("X-Request-Id") - detail, readErr := core.ParseProviderError(resp.Body) - return nil, core.FormatAPIError(c.providerName+" embedding", "embedding", resp.StatusCode, requestID, detail, readErr) - } - - var or openaiEmbeddingResponse - if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { - return nil, fmt.Errorf("eyrie: failed to decode %s embedding response: %w", c.providerName, err) - } - - result := &EmbeddingResponse{ - Model: or.Model, - } - if len(or.Data) > 0 { - result.Embeddings = make([][]float32, len(or.Data)) - for _, d := range or.Data { - vec := make([]float32, len(d.Embedding)) - for j, v := range d.Embedding { - vec[j] = float32(v) - } - result.Embeddings[d.Index] = vec - } - } - if or.Usage.PromptTokens > 0 || or.Usage.TotalTokens > 0 { - result.Usage = &core.EyrieUsage{ - PromptTokens: or.Usage.PromptTokens, - TotalTokens: or.Usage.TotalTokens, - } - } - - return result, nil -} diff --git a/client/embedding_methods_test.go b/client/embedding_methods_test.go index 3aeab85..1188402 100644 --- a/client/embedding_methods_test.go +++ b/client/embedding_methods_test.go @@ -188,4 +188,3 @@ func TestCreateEmbeddingFloat32Precision(t *testing.T) { t.Errorf("Embeddings[0][0] = %f, want %f (within float32 precision)", resp.Embeddings[0][0], expected) } } - diff --git a/client/fallback.go b/client/fallback.go index f6a609b..f65079c 100644 --- a/client/fallback.go +++ b/client/fallback.go @@ -2,15 +2,12 @@ package client import ( "context" - "errors" "fmt" "log/slog" "os" "strings" "sync" "sync/atomic" - - "github.com/GrayCodeAI/eyrie/types" ) // FallbackProvider wraps multiple Providers and automatically falls back to the @@ -204,55 +201,5 @@ func (fp *FallbackProvider) recordSuccess(name string) { } } -// nonRetriableStatusCodes are HTTP status codes that indicate a client-side -// error -- falling back won't help because the request itself is bad. -var nonRetriableStatusCodes = map[int]bool{ - 400: true, // bad request - 401: true, // unauthorized - 403: true, // forbidden - 404: true, // not found (wrong model name, etc.) - 422: true, // unprocessable entity -} - -// isRetriableError determines whether a fallback to the next provider should -// be attempted. It delegates to types.IsTransient for known error patterns -// but diverges on unknown errors: where IsTransient is conservative (returns -// false for unrecognized errors), isRetriableError is optimistic (returns true). -// -// Rationale: in a fallback chain, trying the next provider is cheap and may -// succeed even if the current provider failed with an unexpected error type. -// In contrast, retry middleware (which uses IsTransient) should be conservative -// to avoid wasting requests on errors that won't resolve with a retry. -// -// *EyrieError (returned by formatAPIError and friends) is preferred over -// the string-based heuristic: it carries the structured status code so the -// classification is exact rather than regex-parsed. -func isRetriableError(err error) bool { - if err == nil { - return false - } - if errors.Is(err, context.DeadlineExceeded) { - return true - } - if errors.Is(err, context.Canceled) { - return false - } - // Structured path: trust *EyrieError's IsRetriable. - var eyrieErr *EyrieError - if errors.As(err, &eyrieErr) { - return eyrieErr.IsRetriable() - } - // Heuristic path for legacy errors that predate the EyrieError migration. - if types.IsTransient(err) { - return true - } - // If the error contains a known non-retriable HTTP status code, give up - // immediately — the request itself is bad, not the provider. - if code, ok := types.ExtractHTTPStatus(err); ok { - if nonRetriableStatusCodes[code] { - return false - } - } - // Unknown error types: treat as retriable so we at least try the next provider. - return true -} +// isRetriableError is an unexported bridge to core.IsRetriableError, +// declared in aliases.go so fallback.go and weighted.go read unchanged. diff --git a/client/gemini_stream_test.go b/client/gemini_stream_test.go index 9c1963c..a593b51 100644 --- a/client/gemini_stream_test.go +++ b/client/gemini_stream_test.go @@ -525,10 +525,10 @@ func TestGemini_Stream_SharedParser_PreservesClientState(t *testing.T) { defer srv.Close() c := NewGeminiClient("test-key", srv.URL) - if c.httpClient == nil { + if c.HTTPClient() == nil { t.Fatal("NewGeminiClient did not initialize httpClient") } - if c.retry.MaxRetries == 0 { + if c.Retry().MaxRetries == 0 { t.Fatal("retry config not initialized") } @@ -540,7 +540,7 @@ func TestGemini_Stream_SharedParser_PreservesClientState(t *testing.T) { } defer sr.Close() _ = drainGeminiStream(t, sr, 5*time.Second) - if c.httpClient == nil { + if c.HTTPClient() == nil { t.Error("httpClient was clobbered by StreamChat") } } diff --git a/client/guardrails.go b/client/guardrails.go index 283d95b..7be19b5 100644 --- a/client/guardrails.go +++ b/client/guardrails.go @@ -5,7 +5,6 @@ import ( "log/slog" ) - // --------------------------------------------------------------------------- // GuardrailProvider: standalone Provider wrapper // --------------------------------------------------------------------------- diff --git a/client/guardrails_provider_test.go b/client/guardrails_provider_test.go index c1dd396..66a7a5e 100644 --- a/client/guardrails_provider_test.go +++ b/client/guardrails_provider_test.go @@ -282,11 +282,11 @@ func TestWithGuardrails_Anthropic(t *testing.T) { {Type: GuardrailPII, Name: "test", Pattern: `test`, Action: GuardrailWarn}, } c := NewAnthropicClient("key", "", WithGuardrails(rules...)) - if c.guardrails == nil { + if c.Guardrails() == nil { t.Fatal("expected guardrails to be set") } - if len(c.guardrails.Rules()) != 1 { - t.Fatalf("expected 1 rule, got %d", len(c.guardrails.Rules())) + if len(c.Guardrails().Rules()) != 1 { + t.Fatalf("expected 1 rule, got %d", len(c.Guardrails().Rules())) } } @@ -296,21 +296,21 @@ func TestWithGuardrails_OpenAI(t *testing.T) { {Type: GuardrailPII, Name: "test", Pattern: `test`, Action: GuardrailWarn}, } c := NewOpenAIClient("key", "", nil, WithGuardrails(rules...)) - if c.guardrails == nil { + if c.Guardrails() == nil { t.Fatal("expected guardrails to be set") } - if len(c.guardrails.Rules()) != 1 { - t.Fatalf("expected 1 rule, got %d", len(c.guardrails.Rules())) + if len(c.Guardrails().Rules()) != 1 { + t.Fatalf("expected 1 rule, got %d", len(c.Guardrails().Rules())) } } func TestWithGuardrailType_Anthropic(t *testing.T) { t.Parallel() c := NewAnthropicClient("key", "", WithGuardrailType(GuardrailPII, GuardrailSecretLeak)) - if c.guardrails == nil { + if c.Guardrails() == nil { t.Fatal("expected guardrails to be set") } - rules := c.guardrails.Rules() + rules := c.Guardrails().Rules() if len(rules) == 0 { t.Fatal("expected rules to be populated") } @@ -336,10 +336,10 @@ func TestWithGuardrailType_Anthropic(t *testing.T) { func TestWithGuardrailType_OpenAI(t *testing.T) { t.Parallel() c := NewOpenAIClient("key", "", nil, WithGuardrailType(GuardrailPromptInjection, GuardrailHarmfulContent)) - if c.guardrails == nil { + if c.Guardrails() == nil { t.Fatal("expected guardrails to be set") } - rules := c.guardrails.Rules() + rules := c.Guardrails().Rules() hasInjection := false hasHarmful := false for _, r := range rules { @@ -361,10 +361,10 @@ func TestWithGuardrailType_OpenAI(t *testing.T) { func TestWithGuardrails_AllTypes(t *testing.T) { t.Parallel() c := NewAnthropicClient("key", "", WithGuardrailType(GuardrailPII, GuardrailSecretLeak, GuardrailPromptInjection, GuardrailHarmfulContent)) - if c.guardrails == nil { + if c.Guardrails() == nil { t.Fatal("expected guardrails to be set") } - rules := c.guardrails.Rules() + rules := c.Guardrails().Rules() if len(rules) < 10 { t.Fatalf("expected at least 10 rules for all types, got %d", len(rules)) } @@ -373,11 +373,11 @@ func TestWithGuardrails_AllTypes(t *testing.T) { func TestWithGuardrails_NilByDefault(t *testing.T) { t.Parallel() c := NewAnthropicClient("key", "") - if c.guardrails != nil { + if c.Guardrails() != nil { t.Fatal("expected nil guardrails by default") } c2 := NewOpenAIClient("key", "", nil) - if c2.guardrails != nil { + if c2.Guardrails() != nil { t.Fatal("expected nil guardrails by default for OpenAI") } } @@ -385,11 +385,11 @@ func TestWithGuardrails_NilByDefault(t *testing.T) { func TestWithGuardrails_EmptyRulesDoesNotPanic(t *testing.T) { t.Parallel() c := NewAnthropicClient("key", "", WithGuardrails()) - if c.guardrails == nil { + if c.Guardrails() == nil { t.Fatal("expected guardrails to be set (empty but non-nil)") } - if len(c.guardrails.Rules()) != 0 { - t.Fatalf("expected 0 rules, got %d", len(c.guardrails.Rules())) + if len(c.Guardrails().Rules()) != 0 { + t.Fatalf("expected 0 rules, got %d", len(c.Guardrails().Rules())) } } diff --git a/client/mimo_test.go b/client/mimo_test.go index 5bcf77a..453f86e 100644 --- a/client/mimo_test.go +++ b/client/mimo_test.go @@ -1,11 +1,7 @@ package client import ( - "bytes" - "context" - "encoding/json" "errors" - "io" "net/http" "testing" @@ -44,66 +40,6 @@ func TestMimoFallbackChatError_ParamIncorrect(t *testing.T) { } } -func TestMiMoClient_ChatFallsBackToAnthropicOnParamIncorrect(t *testing.T) { - anthropicCalls := 0 - openAITransport := roundTripFunc(func(r *http.Request) (*http.Response, error) { - if r.URL.Path != "/v1/chat/completions" { - t.Fatalf("openai path = %q", r.URL.Path) - } - return jsonResponse(http.StatusBadRequest, map[string]interface{}{"error": map[string]string{"message": "Param Incorrect"}}), nil - }) - anthropicTransport := roundTripFunc(func(r *http.Request) (*http.Response, error) { - anthropicCalls++ - if r.URL.Path != "/anthropic/v1/messages" { - t.Fatalf("anthropic path = %q", r.URL.Path) - } - if r.Header.Get("api-key") != "tp-test-key" { - t.Fatalf("missing MiMo api-key auth header") - } - return jsonResponse(http.StatusOK, map[string]interface{}{ - "id": "msg_1", - "type": "message", - "role": "assistant", - "content": []map[string]string{{"type": "text", "text": "ok"}}, - "stop_reason": "end_turn", - "usage": map[string]int{"input_tokens": 1, "output_tokens": 1}, - }), nil - }) - - c := NewMiMoClient("tp-test-key", "https://openai.example/v1", "https://anthropic.example/anthropic", &XiaomiCompat, "xiaomi_mimo_token_plan") - c.router.OpenAI.httpClient = &http.Client{Transport: openAITransport} - c.router.Anthropic.httpClient = &http.Client{Transport: anthropicTransport} - resp, err := c.Chat(context.Background(), []EyrieMessage{{Role: "user", Content: "hi"}}, ChatOptions{ - Model: "mimo-v2.5-pro", - MaxTokens: 1024, - }) - if err != nil { - t.Fatalf("Chat: %v", err) - } - if resp.Content != "ok" { - t.Fatalf("content = %q, want ok", resp.Content) - } - if anthropicCalls != 1 { - t.Fatalf("anthropic calls = %d, want 1", anthropicCalls) - } -} - -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { - return f(r) -} - -func jsonResponse(status int, payload interface{}) *http.Response { - var body bytes.Buffer - _ = json.NewEncoder(&body).Encode(payload) - return &http.Response{ - StatusCode: status, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewReader(body.Bytes())), - } -} - func TestGetOrCreateProvider_XiaomiTokenPlanUsesMimoBase(t *testing.T) { t.Setenv("HAWK_CONFIG_DIR", t.TempDir()) if err := eyriecfg.SaveProviderConfig(&eyriecfg.ProviderConfig{ @@ -121,10 +57,7 @@ func TestGetOrCreateProvider_XiaomiTokenPlanUsesMimoBase(t *testing.T) { if !ok { t.Fatalf("provider type = %T, want *MiMoClient", p) } - if mimo.router.OpenAI.baseURL != xiaomi.TokenPlanSGPOpenAIBase { - t.Fatalf("openAI baseURL = %q, want %q", mimo.router.OpenAI.baseURL, xiaomi.TokenPlanSGPOpenAIBase) - } - if mimo.router.Anthropic == nil || mimo.router.Anthropic.baseURL != xiaomi.TokenPlanSGPAnthropicBase { - t.Fatalf("anthropic baseURL = %#v, want %q", mimo.router.Anthropic, xiaomi.TokenPlanSGPAnthropicBase) + if mimo.ProviderID() != "xiaomi_mimo_token_plan" { + t.Fatalf("providerID = %q, want xiaomi_mimo_token_plan", mimo.ProviderID()) } } diff --git a/client/multimodal_test.go b/client/multimodal_test.go index 7e61882..3293459 100644 --- a/client/multimodal_test.go +++ b/client/multimodal_test.go @@ -465,7 +465,7 @@ func TestAudioFormatToMediaType(t *testing.T) { t.Run(tt.input, func(t *testing.T) { got := audioFormatToMediaType(tt.input) if got != tt.expected { - t.Errorf("audioFormatToMediaType(%q) = %q, want %q", tt.input, got, tt.expected) + t.Errorf("AudioFormatToMediaType(%q) = %q, want %q", tt.input, got, tt.expected) } }) } diff --git a/client/openai_misc_test.go b/client/openai_misc_test.go index eaf0df8..3f131bc 100644 --- a/client/openai_misc_test.go +++ b/client/openai_misc_test.go @@ -389,8 +389,8 @@ func TestOpenAIClient_Name(t *testing.T) { func TestOpenAIClient_DefaultBaseURL(t *testing.T) { t.Parallel() c := NewOpenAIClient("key", "", nil) - if c.baseURL != "https://api.openai.com/v1" { - t.Errorf("expected default baseURL, got %s", c.baseURL) + if c.BaseURL() != "https://api.openai.com/v1" { + t.Errorf("expected default baseURL, got %s", c.BaseURL()) } } diff --git a/client/openai_test.go b/client/openai_test.go index 055a2dc..f404b0d 100644 --- a/client/openai_test.go +++ b/client/openai_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" "time" + + "github.com/GrayCodeAI/eyrie/client/adapters" ) // StreamChat tests live in openai_stream_test.go; Ping, compat, image, @@ -49,7 +51,7 @@ func TestOpenAIChat_Success(t *testing.T) { } // Verify request body - var reqBody openaiRequest + var reqBody adapters.OpenAIRequest if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { t.Fatalf("failed to decode request: %v", err) } @@ -61,7 +63,7 @@ func TestOpenAIChat_Success(t *testing.T) { } w.Header().Set("X-Request-Id", "req-123") - json.NewEncoder(w).Encode(openaiResponse{ + json.NewEncoder(w).Encode(adapters.OpenAIResponse{ ID: "chatcmpl-abc", Choices: []struct { Message struct { diff --git a/client/opencodego_test.go b/client/opencodego_test.go index faf1261..e0d4e24 100644 --- a/client/opencodego_test.go +++ b/client/opencodego_test.go @@ -1,12 +1,7 @@ package client import ( - "context" - "encoding/json" "fmt" - "io" - "net/http" - "strings" "testing" "github.com/GrayCodeAI/eyrie/catalog/opencodego" @@ -52,239 +47,7 @@ func TestOpenCodeGoOACompatUnsupportedError(t *testing.T) { } for _, tc := range tests { if got := oaCompatUnsupportedError(tc.err); got != tc.want { - t.Errorf("oaCompatUnsupportedError(%v) = %v, want %v", tc.err, got, tc.want) + t.Errorf("OACompatUnsupportedError(%v) = %v, want %v", tc.err, got, tc.want) } } } - -func TestOpenCodeGoClient_RoutesMiniMaxToAnthropic(t *testing.T) { - t.Parallel() - var gotPath, gotAuth string - transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { - gotPath = r.URL.Path - gotAuth = r.Header.Get("X-Api-Key") - return jsonResponse(http.StatusOK, map[string]interface{}{ - "id": "msg_1", "type": "message", "role": "assistant", - "content": []map[string]string{{"type": "text", "text": "Hello!"}}, - "stop_reason": "end_turn", - "usage": map[string]int{"input_tokens": 1, "output_tokens": 2}, - }), nil - }) - - c := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") - c.router.Anthropic.httpClient = &http.Client{Transport: transport} - resp, err := c.Chat(context.Background(), []EyrieMessage{{Role: "user", Content: "Hi"}}, ChatOptions{ - Model: "minimax-m2.5", MaxTokens: 256, - }) - if err != nil { - t.Fatalf("Chat: %v", err) - } - if resp.Content != "Hello!" { - t.Fatalf("content = %q, want Hello!", resp.Content) - } - if !strings.HasSuffix(gotPath, "/v1/messages") { - t.Fatalf("path = %q, want suffix /v1/messages", gotPath) - } - if gotAuth != "ocg-test-key" { - t.Fatalf("X-Api-Key = %q, want ocg-test-key", gotAuth) - } -} - -func TestOpenCodeGoClient_RoutesKimiToOpenAI(t *testing.T) { - t.Parallel() - var gotPath string - transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { - gotPath = r.URL.Path - return jsonResponse(http.StatusOK, map[string]interface{}{ - "id": "chatcmpl-1", "object": "chat.completion", - "choices": []map[string]interface{}{ - {"message": map[string]string{"role": "assistant", "content": "Hi there!"}, "finish_reason": "stop"}, - }, - "usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}, - }), nil - }) - - c := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") - c.router.OpenAI.httpClient = &http.Client{Transport: transport} - resp, err := c.Chat(context.Background(), []EyrieMessage{{Role: "user", Content: "Hi"}}, ChatOptions{ - Model: "kimi-k2.5", MaxTokens: 256, - }) - if err != nil { - t.Fatalf("Chat: %v", err) - } - if resp.Content != "Hi there!" { - t.Fatalf("content = %q, want Hi there!", resp.Content) - } - if !strings.HasSuffix(gotPath, "/chat/completions") { - t.Fatalf("path = %q, want suffix /chat/completions", gotPath) - } -} - -func TestOpenCodeGoClient_Qwen401FallsBackToOpenAI(t *testing.T) { - t.Parallel() - var paths []string - transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { - paths = append(paths, r.URL.Path) - if strings.HasSuffix(r.URL.Path, "/messages") { - return jsonResponse(http.StatusUnauthorized, map[string]interface{}{ - "error": map[string]string{"message": "Invalid API key"}, - }), nil - } - return jsonResponse(http.StatusOK, map[string]interface{}{ - "id": "chatcmpl-1", "object": "chat.completion", - "choices": []map[string]interface{}{ - {"message": map[string]string{"role": "assistant", "content": "OK"}, "finish_reason": "stop"}, - }, - }), nil - }) - - c := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") - c.router.Anthropic.httpClient = &http.Client{Transport: transport} - c.router.OpenAI.httpClient = &http.Client{Transport: transport} - resp, err := c.Chat(context.Background(), []EyrieMessage{{Role: "user", Content: "Hi"}}, ChatOptions{ - Model: "qwen3.7-max", MaxTokens: 256, - }) - if err != nil { - t.Fatalf("Chat: %v", err) - } - if resp.Content != "OK" { - t.Fatalf("content = %q, want OK; paths=%v", resp.Content, paths) - } -} - -func TestOpenCodeGoClient_MessagesEmptyFallsBackToOpenAI(t *testing.T) { - t.Parallel() - var paths []string - transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { - paths = append(paths, r.URL.Path) - if strings.HasSuffix(r.URL.Path, "/messages") { - return jsonResponse(http.StatusOK, map[string]interface{}{ - "id": "msg_1", "type": "message", "role": "assistant", - "content": []map[string]string{{"type": "thinking", "thinking": "hmm"}}, - "stop_reason": "end_turn", - }), nil - } - return jsonResponse(http.StatusOK, map[string]interface{}{ - "id": "chatcmpl-1", "object": "chat.completion", - "choices": []map[string]interface{}{ - {"message": map[string]string{"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}, - }, - }), nil - }) - - c := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") - c.router.Anthropic.httpClient = &http.Client{Transport: transport} - c.router.OpenAI.httpClient = &http.Client{Transport: transport} - resp, err := c.Chat(context.Background(), []EyrieMessage{{Role: "user", Content: "Hi"}}, ChatOptions{ - Model: "minimax-m3", MaxTokens: 256, - }) - if err != nil { - t.Fatalf("Chat: %v", err) - } - if resp.Content != "Hello!" { - t.Fatalf("content = %q, want Hello!; paths=%v", resp.Content, paths) - } - if len(paths) < 2 { - t.Fatalf("expected anthropic then openai, got %v", paths) - } -} - -func TestOpenCodeGoClient_NormalizesModelID(t *testing.T) { - t.Parallel() - var gotModel string - transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { - if strings.HasSuffix(r.URL.Path, "/chat/completions") { - var body struct { - Model string `json:"model"` - } - _ = jsonDecodeRequest(r, &body) - gotModel = body.Model - } - return jsonResponse(http.StatusOK, map[string]interface{}{ - "choices": []map[string]interface{}{ - {"message": map[string]string{"role": "assistant", "content": "Hi"}, "finish_reason": "stop"}, - }, - }), nil - }) - c := NewOpenCodeGoClient("key", "https://opencode.example/zen/go/v1") - c.router.OpenAI.httpClient = &http.Client{Transport: transport} - _, err := c.Chat(context.Background(), []EyrieMessage{{Role: "user", Content: "Hi"}}, ChatOptions{ - Model: "opencode-go/kimi-k2.6", MaxTokens: 16, - }) - if err != nil { - t.Fatal(err) - } - if gotModel != "kimi-k2.6" { - t.Fatalf("model = %q, want kimi-k2.6", gotModel) - } -} - -func TestOpenCodeGoClient_StreamMiniMaxReasoningOnlyFallsBackToChat(t *testing.T) { - t.Parallel() - var paths []string - transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { - paths = append(paths, r.URL.Path) - if strings.HasSuffix(r.URL.Path, "/messages") { - body := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":10}}}\n\n" + - "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\"}}\n\n" + - "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"text\":\"hmm\"}}\n\n" + - "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n" + - "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n" + - "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(body)), - }, nil - } - if strings.HasSuffix(r.URL.Path, "/chat/completions") && r.Header.Get("Accept") == "text/event-stream" { - t.Fatal("stream fallback should not run before non-streaming chat fallback") - } - return jsonResponse(http.StatusOK, map[string]interface{}{ - "id": "chatcmpl-1", "object": "chat.completion", - "choices": []map[string]interface{}{ - {"message": map[string]string{"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}, - }, - }), nil - }) - - c := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") - c.router.Anthropic.httpClient = &http.Client{Transport: transport} - c.router.OpenAI.httpClient = &http.Client{Transport: transport} - result, err := c.StreamChat(context.Background(), []EyrieMessage{{Role: "user", Content: "Hello how are you?"}}, ChatOptions{ - Model: "minimax-m3", MaxTokens: 256, - }) - if err != nil { - t.Fatalf("StreamChat: %v", err) - } - defer result.Close() - - var content string - for ev := range result.Events { - switch ev.Type { - case "thinking": - t.Fatal("reasoning-only primary stream must not leak thinking before chat fallback") - case "content": - content += ev.Content - case "error": - t.Fatalf("unexpected stream error: %s", ev.Error) - } - } - if content != "Hello!" { - t.Fatalf("content = %q, want Hello!; paths=%v", content, paths) - } - if len(paths) < 2 { - t.Fatalf("expected /messages then /chat/completions, got %v", paths) - } -} - -func jsonDecodeRequest(r *http.Request, v interface{}) error { - if r.Body == nil { - return nil - } - body, err := io.ReadAll(r.Body) - if err != nil { - return err - } - return json.Unmarshal(body, v) -} diff --git a/client/options.go b/client/options.go index df8f83c..45009c7 100644 --- a/client/options.go +++ b/client/options.go @@ -8,8 +8,6 @@ import ( "github.com/GrayCodeAI/eyrie/client/core" ) -const defaultTimeout = core.DefaultTimeout - // ClientOption and the adapter-level With* constructors live in client/core // (see core.Configurable); they are aliased/wrapped here so the public // client.* API is unchanged. Only WithCoalescing is defined locally — it diff --git a/client/options_facade_test.go b/client/options_facade_test.go new file mode 100644 index 0000000..ae57cdc --- /dev/null +++ b/client/options_facade_test.go @@ -0,0 +1,74 @@ +package client + +import ( + "log/slog" + "net/http" + "testing" + "time" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +type recordingConfigurable struct { + timeout time.Duration + httpClient *http.Client + retry core.RetryConfig + logger *slog.Logger + apiKey string + baseURL string + model string + maxTokens int + temperature float64 + guardrails *core.Guardrails + providerName string + mimoAuth bool +} + +func (c *recordingConfigurable) SetTimeout(value time.Duration) { c.timeout = value } +func (c *recordingConfigurable) SetHTTPClient(value *http.Client) { c.httpClient = value } +func (c *recordingConfigurable) SetRetry(value core.RetryConfig) { c.retry = value } +func (c *recordingConfigurable) SetLogger(value *slog.Logger) { c.logger = value } +func (c *recordingConfigurable) SetAPIKey(value string) { c.apiKey = value } +func (c *recordingConfigurable) SetBaseURL(value string) { c.baseURL = value } +func (c *recordingConfigurable) SetDefaultModel(value string) { c.model = value } +func (c *recordingConfigurable) SetDefaultMaxTokens(value int) { c.maxTokens = value } +func (c *recordingConfigurable) SetDefaultTemperature(value float64) { c.temperature = value } +func (c *recordingConfigurable) SetGuardrails(value *core.Guardrails) { c.guardrails = value } +func (c *recordingConfigurable) SetProviderName(value string) { c.providerName = value } +func (c *recordingConfigurable) SetMimoAuth() { c.mimoAuth = true } + +func TestOptionFacadeDelegatesWithoutExposingAdapterSecrets(t *testing.T) { + t.Parallel() + config := &recordingConfigurable{} + httpClient := &http.Client{Timeout: 11 * time.Second} + logger := slog.Default() + retry := NewRetryConfig(2, time.Millisecond, time.Second) + + options := []ClientOption{ + WithTimeout(7 * time.Second), + WithHTTPClient(httpClient), + WithRetry(retry), + WithLogger(logger), + WithAPIKey("secret"), + WithBaseURL("https://provider.example/v1"), + WithModel("model"), + WithMaxTokens(2048), + WithTemperature(0.4), + WithGuardrails(), + WithProviderName("provider"), + WithMimoAuth(), + } + for _, option := range options { + option.Apply(config) + } + + if config.timeout != 7*time.Second || config.httpClient != httpClient || config.retry.MaxRetries != 2 || config.logger != logger { + t.Fatal("transport options were not delegated") + } + if config.apiKey != "secret" || config.baseURL != "https://provider.example/v1" || config.providerName != "provider" { + t.Fatal("identity options were not delegated") + } + if config.model != "model" || config.maxTokens != 2048 || config.temperature != 0.4 || config.guardrails == nil || !config.mimoAuth { + t.Fatal("request options were not delegated") + } +} diff --git a/client/protocol_router_test.go b/client/protocol_router_test.go index 75a2703..2396984 100644 --- a/client/protocol_router_test.go +++ b/client/protocol_router_test.go @@ -1,110 +1,9 @@ package client import ( - "context" - "fmt" - "net/http" "testing" ) -func TestStreamResultFromChat(t *testing.T) { - t.Parallel() - result := streamResultFromChat(&EyrieResponse{ - Content: "Hi there!", - FinishReason: "stop", - }) - var content string - for ev := range result.Events { - if ev.Type == "content" { - content += ev.Content - } - } - if content != "Hi there!" { - t.Fatalf("content = %q, want Hi there!", content) - } -} - -func TestNewStreamWithReasoningFallback_ChatFirst(t *testing.T) { - t.Parallel() - primaryEvents := make(chan EyrieStreamEvent, 4) - primaryEvents <- EyrieStreamEvent{Type: "thinking", Thinking: "internal reasoning"} - primaryEvents <- EyrieStreamEvent{Type: "done", StopReason: "end_turn"} - close(primaryEvents) - primary := NewStreamResult(primaryEvents, func() {}) - - var chatCalled, streamCalled bool - fallback := protocolStreamFallback{ - chat: func(context.Context, []EyrieMessage, ChatOptions) (*EyrieResponse, error) { - chatCalled = true - return &EyrieResponse{Content: "Hello from chat fallback!", FinishReason: "stop"}, nil - }, - stream: func(context.Context, []EyrieMessage, ChatOptions) (*StreamResult, error) { - streamCalled = true - return nil, fmt.Errorf("stream fallback should not run") - }, - } - - result := newStreamWithReasoningFallback(context.Background(), nil, ChatOptions{}, primary, fallback) - var content string - for ev := range result.Events { - switch ev.Type { - case "thinking": - t.Fatal("primary reasoning must not leak before chat fallback succeeds") - case "content": - content += ev.Content - } - } - if content != "Hello from chat fallback!" { - t.Fatalf("content = %q, want Hello from chat fallback!", content) - } - if !chatCalled { - t.Fatal("expected chat fallback to run") - } - if streamCalled { - t.Fatal("stream fallback must not run when chat fallback succeeds") - } -} - -func TestNewStreamWithReasoningFallback_StreamWhenChatEmpty(t *testing.T) { - t.Parallel() - primaryEvents := make(chan EyrieStreamEvent, 4) - primaryEvents <- EyrieStreamEvent{Type: "thinking", Thinking: "internal reasoning"} - primaryEvents <- EyrieStreamEvent{Type: "done", StopReason: "end_turn"} - close(primaryEvents) - primary := NewStreamResult(primaryEvents, func() {}) - - fallbackEvents := make(chan EyrieStreamEvent, 4) - fallbackEvents <- EyrieStreamEvent{Type: "content", Content: "stream answer"} - fallbackEvents <- EyrieStreamEvent{Type: "done", StopReason: "stop"} - close(fallbackEvents) - - var chatCalled, streamCalled bool - fallback := protocolStreamFallback{ - chat: func(context.Context, []EyrieMessage, ChatOptions) (*EyrieResponse, error) { - chatCalled = true - return &EyrieResponse{Thinking: "still thinking"}, nil - }, - stream: func(context.Context, []EyrieMessage, ChatOptions) (*StreamResult, error) { - streamCalled = true - return NewStreamResult(fallbackEvents, func() {}), nil - }, - } - - result := newStreamWithReasoningFallback(context.Background(), nil, ChatOptions{}, primary, fallback) - var content string - for ev := range result.Events { - if ev.Type == "content" { - content += ev.Content - } - } - if content != "stream answer" { - t.Fatalf("content = %q, want stream answer", content) - } - if !chatCalled || !streamCalled { - t.Fatalf("chatCalled=%v streamCalled=%v, want both true", chatCalled, streamCalled) - } -} - func TestAnthropicBaseFromOpenAIV1(t *testing.T) { t.Parallel() if got := AnthropicBaseFromOpenAIV1("https://example.com/zen/go/v1"); got != "https://example.com/zen/go" { @@ -114,47 +13,3 @@ func TestAnthropicBaseFromOpenAIV1(t *testing.T) { t.Fatalf("got %q", got) } } - -func TestProtocolRouter_ChatFallbackOnError(t *testing.T) { - t.Parallel() - openAI := NewOpenAIClient("key", "https://example/openai", nil) - anthropic := NewAnthropicClient("key", "https://example") - openAI.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusBadGateway, map[string]string{"error": "down"}), nil - })} - anthropic.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusOK, map[string]interface{}{ - "id": "msg_1", "type": "message", "role": "assistant", - "content": []map[string]string{{"type": "text", "text": "fallback"}}, - "stop_reason": "end_turn", - }), nil - })} - - router := ProtocolRouter{OpenAI: openAI, Anthropic: anthropic} - resp, err := router.Chat(context.Background(), []EyrieMessage{{Role: "user", Content: "hi"}}, ChatOptions{ - Model: "test", MaxTokens: 16, - }, ChatProtocolCompletions, func(err error, _ *EyrieResponse) bool { - return err != nil - }) - if err != nil { - t.Fatalf("Chat: %v", err) - } - if resp.Content != "fallback" { - t.Fatalf("content = %q, want fallback", resp.Content) - } -} - -func TestProtocolRouter_NoFallbackWhenNil(t *testing.T) { - t.Parallel() - openAI := NewOpenAIClient("key", "https://example/openai", nil) - openAI.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusBadGateway, map[string]string{"error": "down"}), nil - })} - router := ProtocolRouter{OpenAI: openAI} - _, err := router.Chat(context.Background(), []EyrieMessage{{Role: "user", Content: "hi"}}, ChatOptions{ - Model: "test", MaxTokens: 16, - }, ChatProtocolCompletions, nil) - if err == nil { - t.Fatal("expected error without fallback") - } -} diff --git a/client/provider_registry.go b/client/provider_registry.go index cfaa135..b46434e 100644 --- a/client/provider_registry.go +++ b/client/provider_registry.go @@ -1,101 +1,41 @@ package client import ( - "context" "fmt" "log/slog" - "time" - "github.com/GrayCodeAI/eyrie/catalog/registry" + "github.com/GrayCodeAI/eyrie/client/adapters" "github.com/GrayCodeAI/eyrie/config" - "github.com/GrayCodeAI/eyrie/credentials" ) // ProviderType classifies providers. -type ProviderType string - -const ( - // ProviderTypeAnthropic uses the Anthropic Messages API. - ProviderTypeAnthropic ProviderType = "anthropic" - // ProviderTypeOpenAI uses the OpenAI Chat Completions API. - ProviderTypeOpenAI ProviderType = "openai" - // ProviderTypeOpenAICompatible uses OpenAI-compatible APIs with custom base URLs. - ProviderTypeOpenAICompatible ProviderType = "openai-compatible" - // ProviderTypeAzure uses Azure OpenAI. - ProviderTypeAzure ProviderType = "azure" - // ProviderTypeBedrock uses AWS Bedrock. - ProviderTypeBedrock ProviderType = "bedrock" - // ProviderTypeVertex uses Google Vertex AI. - ProviderTypeVertex ProviderType = "vertex" -) +type ProviderType = adapters.ProviderType // ProviderRegistryConfig holds provider registry info. -type ProviderRegistryConfig struct { - Name string `json:"name"` - Type ProviderType `json:"type"` - BaseURL string `json:"base_url,omitempty"` - EnvKey string `json:"env_key"` - SupportsStreaming bool `json:"supports_streaming"` - SupportsTools bool `json:"supports_tools"` - SupportsReasoning bool `json:"supports_reasoning"` - Compat *OpenAICompatConfig `json:"compat,omitempty"` -} - -// CoreProviders and OpenAICompatibleProviders are derived from the canonical -// catalog registry. Dynamic providers are added only to the compatible map. -var CoreProviders, OpenAICompatibleProviders = staticProviderMaps() - -func staticProviderMaps() (map[string]ProviderRegistryConfig, map[string]ProviderRegistryConfig) { - core := make(map[string]ProviderRegistryConfig) - compatible := make(map[string]ProviderRegistryConfig) - for _, spec := range registry.All() { - providerType := ProviderTypeOpenAICompatible - if spec.TransportKind != "" { - providerType = ProviderType(spec.TransportKind) - } - baseURL := spec.RuntimeBaseURL - if baseURL == "" { - baseURL = spec.ProbeBaseURL - } - envKey := spec.RuntimeCredentialEnv - if envKey == "" { - envKey = spec.CredentialEnv - } - provider := ProviderRegistryConfig{ - Name: spec.ProviderID, Type: providerType, BaseURL: baseURL, EnvKey: envKey, - SupportsStreaming: true, SupportsTools: true, SupportsReasoning: !spec.IsLocal, - } - if providerType == ProviderTypeOpenAICompatible { - compatible[spec.ProviderID] = provider - } else { - core[spec.ProviderID] = provider - } - } - return core, compatible -} +type ProviderRegistryConfig = adapters.ProviderRegistryConfig // GetProviders lists all available providers. func (c *EyrieClient) GetProviders() []string { var providers []string - for k := range CoreProviders { + for k := range adapters.CoreProviders { providers = append(providers, k) } - dynamicMu.RLock() - for k := range OpenAICompatibleProviders { + adapters.DynamicMu.RLock() + for k := range adapters.OpenAICompatibleProviders { providers = append(providers, k) } - dynamicMu.RUnlock() + adapters.DynamicMu.RUnlock() return providers } // GetProviderInfo returns config for a provider. func (c *EyrieClient) GetProviderInfo(provider string) *ProviderRegistryConfig { - if p, ok := CoreProviders[provider]; ok { + if p, ok := adapters.CoreProviders[provider]; ok { return &p } - dynamicMu.RLock() - p, ok := OpenAICompatibleProviders[provider] - dynamicMu.RUnlock() + adapters.DynamicMu.RLock() + p, ok := adapters.OpenAICompatibleProviders[provider] + adapters.DynamicMu.RUnlock() if ok { return &p } @@ -112,38 +52,32 @@ func (c *EyrieClient) getOrCreateProvider(providerName string) (Provider, error) needsRegistration := !hasKey && c.GetProviderInfo(providerName) == nil c.mu.RUnlock() - // Register fallback provider BEFORE acquiring c.mu to avoid lock ordering issues. - // Gated on dynamicProviderEnvVar to prevent silent exfiltration of OPENAI_API_KEY - // to an attacker-controlled OPENAI_API_BASE. - if needsRegistration && dynamicProviderEnabled() { - if fallbackURL := openaiBaseFallbackURL(); fallbackURL != "" { + if needsRegistration && adapters.DynamicProviderEnabled() { + if fallbackURL := adapters.OpenAIBaseFallbackURL(); fallbackURL != "" { slog.Warn( "auto-registering OpenAI-compatible provider from OPENAI_API_BASE", "provider", providerName, "base_url", fallbackURL, - "opt_in_env", dynamicProviderEnvVar, + "opt_in_env", adapters.DynamicProviderEnvVar, ) - _ = RegisterDynamicProvider(providerName, fallbackURL, "OPENAI_API_KEY") + _ = adapters.RegisterDynamicProvider(providerName, fallbackURL, "OPENAI_API_KEY") } } c.mu.Lock() defer c.mu.Unlock() - // Double-check after acquiring write lock if p, ok := c.providers[providerName]; ok { return p, nil } - // Re-read apiKeys under write lock to avoid TOCTOU: another goroutine - // may have added a key between our RUnlock and Lock. apiKey := c.apiKeys[providerName] if apiKey == "" { info := c.GetProviderInfo(providerName) if info == nil { return nil, fmt.Errorf("eyrie: unknown provider: %s", providerName) } - apiKey = resolveEnvSecret(info.EnvKey) + apiKey = adapters.ResolveEnvSecret(info.EnvKey) } info := c.GetProviderInfo(providerName) @@ -161,36 +95,36 @@ func (c *EyrieClient) getOrCreateProvider(providerName string) (Provider, error) var p Provider switch info.Type { - case ProviderTypeAnthropic: - p = NewAnthropicClient(apiKey, baseURL) - case ProviderTypeAzure: - endpoint := resolveEnvSecret("AZURE_OPENAI_ENDPOINT") + case adapters.ProviderTypeAnthropic: + p = adapters.NewAnthropicClient(apiKey, baseURL) + case adapters.ProviderTypeAzure: + endpoint := adapters.ResolveEnvSecret("AZURE_OPENAI_ENDPOINT") if endpoint == "" { endpoint = baseURL } - apiVersion := resolveEnvSecret("AZURE_OPENAI_API_VERSION") - p = NewAzureClient(apiKey, endpoint, apiVersion) - case ProviderTypeBedrock: - region := resolveEnvSecret("AWS_REGION") + apiVersion := adapters.ResolveEnvSecret("AZURE_OPENAI_API_VERSION") + p = adapters.NewAzureClient(apiKey, endpoint, apiVersion) + case adapters.ProviderTypeBedrock: + region := adapters.ResolveEnvSecret("AWS_REGION") if region == "" { - region = resolveEnvSecret("AWS_DEFAULT_REGION") + region = adapters.ResolveEnvSecret("AWS_DEFAULT_REGION") } if region == "" { region = "us-east-1" } - accessKey := resolveEnvSecret("AWS_ACCESS_KEY_ID") - sessionToken := resolveEnvSecret("AWS_SESSION_TOKEN") - p = NewBedrockClient(accessKey, apiKey, sessionToken, region) - case ProviderTypeVertex: - projectID := resolveEnvSecret("VERTEX_PROJECT_ID") + accessKey := adapters.ResolveEnvSecret("AWS_ACCESS_KEY_ID") + sessionToken := adapters.ResolveEnvSecret("AWS_SESSION_TOKEN") + p = adapters.NewBedrockClient(accessKey, apiKey, sessionToken, region) + case adapters.ProviderTypeVertex: + projectID := adapters.ResolveEnvSecret("VERTEX_PROJECT_ID") if projectID == "" { return nil, fmt.Errorf("eyrie: vertex requires VERTEX_PROJECT_ID") } - region := resolveEnvSecret("VERTEX_REGION") + region := adapters.ResolveEnvSecret("VERTEX_REGION") if region == "" { region = "us-central1" } - p = NewVertexClient(projectID, region, apiKey) + p = adapters.NewVertexClient(projectID, region, apiKey) default: if config.IsZAIProvider(providerName) { providerCfg := config.LoadProviderConfig("") @@ -199,7 +133,7 @@ func (c *EyrieClient) getOrCreateProvider(providerName string) (Provider, error) return nil, err } anthropicBase := config.ResolveZAIAnthropicBase(providerCfg) - p = NewZAIClient(apiKey, openAIBase, anthropicBase, info.Compat, providerName) + p = adapters.NewZAIClient(apiKey, openAIBase, anthropicBase, info.Compat, providerName) break } if config.IsXiaomiMimoProvider(providerName) { @@ -212,89 +146,24 @@ func (c *EyrieClient) getOrCreateProvider(providerName string) (Provider, error) if err != nil { return nil, err } - p = NewMiMoClient(apiKey, openAIBase, anthropicBase, info.Compat, providerName) + p = adapters.NewMiMoClient(apiKey, openAIBase, anthropicBase, info.Compat, providerName) break } if providerName == "opencodego" { - p = NewOpenCodeGoClient(apiKey, baseURL) + p = adapters.NewOpenCodeGoClient(apiKey, baseURL) break } - p = NewOpenAIClient(apiKey, baseURL, info.Compat) + p = adapters.NewOpenAIClient(apiKey, baseURL, info.Compat) } c.providers[providerName] = p return p, nil } -// DetectProvider detects the active provider from the credential store (not process env). -func DetectProvider() string { - ctx := context.Background() - checks := map[string]func() bool{ - "anthropic": func() bool { return credentials.HasSecret(ctx, "ANTHROPIC_API_KEY") }, - "deepseek": func() bool { return credentials.HasSecret(ctx, "DEEPSEEK_API_KEY") }, - "openrouter": func() bool { return credentials.HasSecret(ctx, "OPENROUTER_API_KEY") }, - "grok": func() bool { return credentials.HasSecret(ctx, "XAI_API_KEY") }, - "gemini": func() bool { return credentials.HasSecret(ctx, "GEMINI_API_KEY") }, - "zai_payg": func() bool { return credentials.HasSecret(ctx, "ZAI_API_KEY") }, - "zai_coding": func() bool { return credentials.HasSecret(ctx, "ZAI_CODING_API_KEY") }, - "canopywave": func() bool { return credentials.HasSecret(ctx, "CANOPYWAVE_API_KEY") }, - "poolside": func() bool { return credentials.HasSecret(ctx, "POOLSIDE_API_KEY") }, - "groq": func() bool { return credentials.HasSecret(ctx, "GROQ_API_KEY") }, - "openai": func() bool { return credentials.HasSecret(ctx, "OPENAI_API_KEY") }, - "opencodego": func() bool { return credentials.HasSecret(ctx, "OPENCODEGO_API_KEY") }, - "kimi": func() bool { return credentials.HasSecret(ctx, "MOONSHOT_API_KEY") }, - "xiaomi_mimo_payg": func() bool { - return credentials.HasSecret(ctx, config.EnvXiaomiPaygAPIKey) || credentials.HasSecret(ctx, "XIAOMI_MIMO_API_KEY") - }, - "xiaomi_mimo_token_plan": func() bool { - return credentials.HasSecret(ctx, config.EnvXiaomiTokenPlanAPIKey) - }, - "minimax_token_plan": func() bool { - return credentials.HasSecret(ctx, "MINIMAX_TOKEN_PLAN_API_KEY") - }, - "minimax_payg": func() bool { - return credentials.HasSecret(ctx, "MINIMAX_PAYG_API_KEY") - }, - "ollama": func() bool { return resolveEnvSecret("OLLAMA_BASE_URL") != "" }, - "azure": func() bool { - return credentials.HasSecret(ctx, "AZURE_OPENAI_API_KEY") && resolveEnvSecret("AZURE_OPENAI_ENDPOINT") != "" - }, - "bedrock": func() bool { - return credentials.HasSecret(ctx, "AWS_ACCESS_KEY_ID") && credentials.HasSecret(ctx, "AWS_SECRET_ACCESS_KEY") - }, - "vertex": func() bool { - return credentials.HasSecret(ctx, "VERTEX_PROJECT_ID") && credentials.HasSecret(ctx, "VERTEX_ACCESS_TOKEN") - }, - } - for _, p := range config.APIProviderDetectionOrder { - if fn, ok := checks[p]; ok && fn() { - return p - } - } - return "anthropic" -} +// DetectProvider detects the active provider from the credential store. +func DetectProvider() string { return adapters.DetectProvider() } // ResolveProviderModelEnvOverride resolves the model env override for a provider. func ResolveProviderModelEnvOverride(provider string) string { - if provider == "" { - provider = DetectProvider() - } - for _, k := range config.ProviderModelEnvKeys[provider] { - if v := resolveEnvSecret(k); v != "" { - return v - } - } - return "" -} - -func resolveEnvSecret(envKey string) string { - // Bound the lookup to prevent indefinite stalls when the OS keyring - // is unresponsive (e.g., locked keychain on macOS, D-Bus failure on - // Linux). The keyring itself has a 30s timeout, but resolveEnvSecret - // is called multiple times in sequence during provider construction - // (up to 6 calls for AWS Bedrock), so a per-call cap keeps the total - // stall bounded. - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return credentials.LookupSecret(ctx, envKey) + return adapters.ResolveProviderModelEnvOverride(provider) } diff --git a/client/provider_registry_test.go b/client/provider_registry_test.go index cb9ad3f..c5abb93 100644 --- a/client/provider_registry_test.go +++ b/client/provider_registry_test.go @@ -34,16 +34,13 @@ func TestGetOrCreateProvider_VertexUsesAnthropicVertexClient(t *testing.T) { if !ok { t.Fatalf("provider type = %T, want *VertexClient (regression: registry was creating a GeminiClient for ProviderTypeVertex)", p) } - if vc.projectID != "my-project" { - t.Errorf("projectID = %q, want %q", vc.projectID, "my-project") + if vc.ProjectID() != "my-project" { + t.Errorf("projectID = %q, want %q", vc.ProjectID(), "my-project") } - if vc.region != "us-east1" { - t.Errorf("region = %q, want %q", vc.region, "us-east1") + if vc.Region() != "us-east1" { + t.Errorf("region = %q, want %q", vc.Region(), "us-east1") } - if vc.token != "test-bearer-token" { - t.Errorf("token = %q, want %q", vc.token, "test-bearer-token") - } - if got := vc.baseURL(); got != "https://us-east1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-east1/publishers/anthropic/models" { + if got := vc.BaseURL(); got != "https://us-east1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-east1/publishers/anthropic/models" { t.Errorf("baseURL() = %q, want Anthropic-on-Vertex URL", got) } } @@ -67,8 +64,8 @@ func TestGetOrCreateProvider_VertexRegionDefaultsToUsCentral1(t *testing.T) { if !ok { t.Fatalf("provider type = %T, want *VertexClient", p) } - if vc.region != "us-central1" { - t.Errorf("region = %q, want default %q", vc.region, "us-central1") + if vc.Region() != "us-central1" { + t.Errorf("region = %q, want default %q", vc.Region(), "us-central1") } } @@ -133,8 +130,8 @@ func TestDynamicProvider_OptIn_Registers(t *testing.T) { if !ok { t.Fatalf("provider type = %T, want *OpenAIClient", p) } - if oc.baseURL != "http://localhost:9999/v1" { - t.Errorf("baseURL = %q, want %q", oc.baseURL, "http://localhost:9999/v1") + if oc.BaseURL() != "http://localhost:9999/v1" { + t.Errorf("baseURL = %q, want %q", oc.BaseURL(), "http://localhost:9999/v1") } } diff --git a/client/provider_request_test.go b/client/provider_request_test.go index c14ff56..84709ef 100644 --- a/client/provider_request_test.go +++ b/client/provider_request_test.go @@ -116,7 +116,7 @@ func TestAnthropic_ChatVsStream_SameBody(t *testing.T) { func TestAnthropic_BuildRequest_ModelRequired(t *testing.T) { t.Parallel() c := NewAnthropicClient("test-key", "http://localhost:0") - _, _, err := c.buildAnthropicRequest( + _, _, err := c.BuildAnthropicRequest( context.Background(), []EyrieMessage{{Role: "user", Content: "hi"}}, ChatOptions{}, // no Model @@ -138,7 +138,7 @@ func TestAnthropic_BuildRequest_StreamSetsAccept(t *testing.T) { messages := []EyrieMessage{{Role: "user", Content: "hi"}} opts := ChatOptions{Model: "claude-test"} - chatReq, _, err := c.buildAnthropicRequest(context.Background(), messages, opts, false) + chatReq, _, err := c.BuildAnthropicRequest(context.Background(), messages, opts, false) if err != nil { t.Fatalf("buildAnthropicRequest(chat): %v", err) } @@ -146,7 +146,7 @@ func TestAnthropic_BuildRequest_StreamSetsAccept(t *testing.T) { t.Errorf("Chat request has Accept=text/event-stream; should not") } - streamReq, _, err := c.buildAnthropicRequest(context.Background(), messages, opts, true) + streamReq, _, err := c.BuildAnthropicRequest(context.Background(), messages, opts, true) if err != nil { t.Fatalf("buildAnthropicRequest(stream): %v", err) } @@ -160,7 +160,7 @@ func TestAnthropic_BuildRequest_StreamSetsAccept(t *testing.T) { func TestAnthropic_BuildRequest_GetBody(t *testing.T) { t.Parallel() c := NewAnthropicClient("test-key", "http://localhost:0") - req, body, err := c.buildAnthropicRequest( + req, body, err := c.BuildAnthropicRequest( context.Background(), []EyrieMessage{{Role: "user", Content: "hi"}}, ChatOptions{Model: "claude-test"}, @@ -245,7 +245,7 @@ func TestAnthropic_BuildRequest_SizeLimit(t *testing.T) { // Build a single huge message that pushes the body over 32 MB. big := strings.Repeat("x", 33*1024*1024) // 33 MB messages := []EyrieMessage{{Role: "user", Content: big}} - _, _, err := c.buildAnthropicRequest(context.Background(), messages, + _, _, err := c.BuildAnthropicRequest(context.Background(), messages, ChatOptions{Model: "claude-test"}, false) if err == nil { t.Fatal("expected size-limit error, got nil") @@ -321,7 +321,7 @@ func TestOpenAI_BuildRequest_StreamSetsAccept(t *testing.T) { messages := []EyrieMessage{{Role: "user", Content: "hi"}} opts := ChatOptions{Model: "gpt-test"} - chatReq, _, err := c.buildOpenAIRequest(context.Background(), messages, opts, false) + chatReq, _, err := c.BuildOpenAIRequest(context.Background(), messages, opts, false) if err != nil { t.Fatalf("buildOpenAIRequest(chat): %v", err) } @@ -329,7 +329,7 @@ func TestOpenAI_BuildRequest_StreamSetsAccept(t *testing.T) { t.Errorf("Chat request has Accept=text/event-stream; should not") } - streamReq, _, err := c.buildOpenAIRequest(context.Background(), messages, opts, true) + streamReq, _, err := c.BuildOpenAIRequest(context.Background(), messages, opts, true) if err != nil { t.Fatalf("buildOpenAIRequest(stream): %v", err) } diff --git a/client/sanitize.go b/client/sanitize.go index 39bd141..4fe11a4 100644 --- a/client/sanitize.go +++ b/client/sanitize.go @@ -1,48 +1,9 @@ package client +import "github.com/GrayCodeAI/eyrie/client/core" + // SanitizeMessages inspects messages for orphaned tool_use blocks -// (assistant messages with tool calls that lack matching tool_result blocks) -// and injects synthetic error results to prevent 400 errors from providers. -// This is critical for session resume and compaction scenarios. +// and injects synthetic error results. Implementation lives in client/core. func SanitizeMessages(messages []EyrieMessage) []EyrieMessage { - if len(messages) == 0 { - return messages - } - - // Collect all tool_result IDs - resultIDs := make(map[string]bool) - for _, msg := range messages { - if msg.Role == "user" { - for _, tr := range msg.ToolResults { - if tr.ToolUseID != "" { - resultIDs[tr.ToolUseID] = true - } - } - } - } - - // Find orphaned tool_use IDs and inject synthetic results - var result []EyrieMessage - for _, msg := range messages { - result = append(result, msg) - - if msg.Role == "assistant" && len(msg.ToolUse) > 0 { - for _, tc := range msg.ToolUse { - if tc.ID != "" && !resultIDs[tc.ID] { - // Inject synthetic error result - result = append(result, EyrieMessage{ - Role: "user", - ToolResults: []ToolResult{{ - ToolUseID: tc.ID, - Content: "Tool execution was interrupted", - IsError: true, - }}, - }) - resultIDs[tc.ID] = true - } - } - } - } - - return result + return core.SanitizeMessages(messages) } diff --git a/config/provider_env.go b/config/provider_env.go index 0df4804..635b33b 100644 --- a/config/provider_env.go +++ b/config/provider_env.go @@ -85,6 +85,14 @@ type ProviderConfig struct { Routing *RoutingPolicy `json:"routing,omitempty"` } +// providerConfigJSON accepts the historical "version" spelling while keeping +// ProviderConfig's serialized form canonical ("_version"). Keeping the alias +// out of ProviderConfig also prevents new writes from reintroducing it. +type providerConfigJSON struct { + ProviderConfig + LegacyVersion string `json:"version,omitempty"` +} + type DeploymentConfig struct { APIKey string `json:"api_key,omitempty"` BaseURL string `json:"base_url,omitempty"` @@ -368,11 +376,15 @@ func LoadProviderConfig(path string) *ProviderConfig { if err != nil { return nil } - var cfg ProviderConfig - if json.Unmarshal(data, &cfg) != nil { + var wire providerConfigJSON + if json.Unmarshal(data, &wire) != nil { + return nil + } + cfg, err := normalizeProviderConfigVersion(wire) + if err != nil { return nil } - return &cfg + return cfg } // LoadProviderConfigWithError loads provider config from disk with detailed error reporting. @@ -389,10 +401,10 @@ func LoadProviderConfigWithError(path string) (*ProviderConfig, error) { } return nil, fmt.Errorf("eyrie: failed to read provider config at %s: %w", path, err) } - var cfg ProviderConfig + var wire providerConfigJSON decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() - if err := decoder.Decode(&cfg); err != nil { + if err := decoder.Decode(&wire); err != nil { return nil, fmt.Errorf("eyrie: corrupt provider config at %s: %w", path, err) } if err := decoder.Decode(&struct{}{}); err != io.EOF { @@ -401,10 +413,28 @@ func LoadProviderConfigWithError(path string) (*ProviderConfig, error) { } return nil, fmt.Errorf("eyrie: corrupt provider config at %s: %w", path, err) } + cfg, err := normalizeProviderConfigVersion(wire) + if err != nil { + return nil, fmt.Errorf("eyrie: corrupt provider config at %s: %w", path, err) + } if cfg.Version != "" && cfg.Version != "1" { return nil, fmt.Errorf("eyrie: unsupported provider config version %q at %s", cfg.Version, path) } - return &cfg, nil + return cfg, nil +} + +func normalizeProviderConfigVersion(wire providerConfigJSON) (*ProviderConfig, error) { + canonical := strings.TrimSpace(wire.Version) + legacy := strings.TrimSpace(wire.LegacyVersion) + if canonical != "" && legacy != "" && canonical != legacy { + return nil, fmt.Errorf("conflicting provider config versions %q and %q", canonical, legacy) + } + if canonical == "" { + wire.Version = legacy + } else { + wire.Version = canonical + } + return &wire.ProviderConfig, nil } // SaveProviderConfig atomically persists non-secret provider state. Credential diff --git a/config/provider_env_test.go b/config/provider_env_test.go index c70e97e..d07f9af 100644 --- a/config/provider_env_test.go +++ b/config/provider_env_test.go @@ -2,6 +2,7 @@ package config import ( + "bytes" "encoding/json" "os" "path/filepath" @@ -125,6 +126,45 @@ func TestLoadProviderConfigWithError_UnsupportedVersion(t *testing.T) { } } +func TestLoadProviderConfigWithErrorAcceptsLegacyVersionAlias(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "provider.json") + if err := os.WriteFile(path, []byte(`{"version":"1","active_provider":"openai"}`), 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := LoadProviderConfigWithError(path) + if err != nil { + t.Fatal(err) + } + if cfg == nil || cfg.Version != "1" || cfg.ActiveProvider != "openai" { + t.Fatalf("legacy version was not normalized: %#v", cfg) + } + + canonicalPath := filepath.Join(t.TempDir(), "provider.json") + if err := SaveProviderConfig(cfg, canonicalPath); err != nil { + t.Fatal(err) + } + canonical, err := os.ReadFile(canonicalPath) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(canonical, []byte(`"version"`)) || !bytes.Contains(canonical, []byte(`"_version"`)) { + t.Fatalf("provider config did not use canonical version field: %s", canonical) + } +} + +func TestLoadProviderConfigWithErrorRejectsConflictingVersionAliases(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "provider.json") + if err := os.WriteFile(path, []byte(`{"_version":"1","version":"2"}`), 0o600); err != nil { + t.Fatal(err) + } + if cfg, err := LoadProviderConfigWithError(path); err == nil || cfg != nil { + t.Fatalf("conflicting provider versions accepted: cfg=%#v err=%v", cfg, err) + } +} + func TestGetProviderConfigDir(t *testing.T) { // Test with env var set dir := t.TempDir() diff --git a/config/runtime_test.go b/config/runtime_test.go index 729b626..dafb92a 100644 --- a/config/runtime_test.go +++ b/config/runtime_test.go @@ -9,6 +9,24 @@ import ( "github.com/GrayCodeAI/eyrie/credentials" ) +func clearRuntimeProviderCredentials(t *testing.T) { + t.Helper() + + seen := make(map[string]struct{}) + for _, profile := range OpenAICompatibleRuntimeProfiles { + for _, key := range profile.DetectionEnv { + seen[key] = struct{}{} + } + for _, key := range profile.APIKeys { + seen[key.Env] = struct{}{} + } + } + seen["OLLAMA_BASE_URL"] = struct{}{} + for key := range seen { + t.Setenv(key, "") + } +} + func TestRuntimeProfileFields(t *testing.T) { t.Parallel() profiles := map[string]RuntimeProviderProfile{ @@ -119,6 +137,7 @@ func TestProviderModelEnvKeys_AllProvidersPresent(t *testing.T) { } func TestResolveOpenAICompatibleRuntime_WithEnv(t *testing.T) { + clearRuntimeProviderCredentials(t) store := &credentials.MapStore{} credentials.SetDefaultStore(store) t.Cleanup(func() { credentials.SetDefaultStore(nil) }) @@ -132,7 +151,7 @@ func TestResolveOpenAICompatibleRuntime_WithEnv(t *testing.T) { t.Errorf("expected resolved model 'gpt-4o', got %q", result.Request.ResolvedModel) } if result.APIKey != "sk-test-key-1234567890" { - t.Errorf("expected API key 'sk-test-key-1234567890', got %q", result.APIKey) + t.Error("resolved API key did not match the isolated test credential") } if result.APIKeySource != "openai" { t.Errorf("expected API key source 'openai', got %q", result.APIKeySource) @@ -140,6 +159,7 @@ func TestResolveOpenAICompatibleRuntime_WithEnv(t *testing.T) { } func TestResolveOpenAICompatibleRuntime_GrokProvider(t *testing.T) { + clearRuntimeProviderCredentials(t) store := &credentials.MapStore{} credentials.SetDefaultStore(store) t.Cleanup(func() { credentials.SetDefaultStore(nil) }) @@ -150,7 +170,7 @@ func TestResolveOpenAICompatibleRuntime_GrokProvider(t *testing.T) { t.Errorf("expected mode 'grok', got %q", result.Mode) } if result.APIKey != "xai-test-key-1234567890" { - t.Errorf("expected xAI API key, got %q", result.APIKey) + t.Error("resolved API key did not match the isolated xAI test credential") } if result.APIKeySource != "grok" { t.Errorf("expected source 'grok', got %q", result.APIKeySource) @@ -158,6 +178,7 @@ func TestResolveOpenAICompatibleRuntime_GrokProvider(t *testing.T) { } func TestResolveOpenAICompatibleRuntime_FallbackModel(t *testing.T) { + clearRuntimeProviderCredentials(t) clearKeys := []string{ "OPENROUTER_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY", "CANOPYWAVE_API_KEY", "DEEPSEEK_API_KEY", "ZAI_API_KEY", "OPENAI_API_KEY", @@ -177,13 +198,14 @@ func TestResolveOpenAICompatibleRuntime_FallbackModel(t *testing.T) { } func TestResolveOpenAICompatibleRuntime_NoKeys(t *testing.T) { + clearRuntimeProviderCredentials(t) store := &credentials.MapStore{} credentials.SetDefaultStore(store) t.Cleanup(func() { credentials.SetDefaultStore(nil) }) result := ResolveOpenAICompatibleRuntime("", "", "") if result.APIKey != "" { - t.Errorf("expected empty API key when no env set, got %q", result.APIKey) + t.Error("expected an empty API key when no provider credential is configured") } if result.APIKeySource != "none" { t.Errorf("expected source 'none', got %q", result.APIKeySource) diff --git a/engine/continuation.go b/engine/continuation.go index 450168d..2cb6fd4 100644 --- a/engine/continuation.go +++ b/engine/continuation.go @@ -74,7 +74,8 @@ func streamWithContinuation(ctx context.Context, provider client.Provider, messa }) { return } - msgs = append(msgs, + msgs = append( + msgs, client.EyrieMessage{Role: "assistant", Content: segment.String()}, client.EyrieMessage{Role: "user", Content: "Continue."}, ) diff --git a/engine/contract_e2e_test.go b/engine/contract_e2e_test.go index d35c169..d3ea7f7 100644 --- a/engine/contract_e2e_test.go +++ b/engine/contract_e2e_test.go @@ -121,6 +121,7 @@ func (p *continuationProvider) Ping(context.Context) error { return nil } func (p *continuationProvider) Chat(context.Context, []client.EyrieMessage, client.ChatOptions) (*client.EyrieResponse, error) { return nil, nil } + func (p *continuationProvider) StreamChat(_ context.Context, messages []client.EyrieMessage, _ client.ChatOptions) (*client.StreamResult, error) { p.calls++ p.requests = append(p.requests, append([]client.EyrieMessage(nil), messages...)) diff --git a/engine/host_control.go b/engine/host_control.go index d84c616..d312b7e 100644 --- a/engine/host_control.go +++ b/engine/host_control.go @@ -416,13 +416,14 @@ func (e *Engine) PreflightWithOptions(ctx context.Context, opts PreflightOptions checks = append(checks, PreflightCheck{Name: "credentials", Status: credentialStatus, Detail: credentialDetail}) modelDetail := activeModel - if activeModel == "" { + switch { + case activeModel == "": checks = append(checks, PreflightCheck{Name: "model", Status: CheckFail, Detail: "no model selected"}) - } else if custom { + case custom: checks = append(checks, PreflightCheck{Name: "model", Status: CheckOK, Detail: activeModel}) - } else if compiled != nil && providerModelAvailable(compiled, providerID, activeModel) { + case compiled != nil && providerModelAvailable(compiled, providerID, activeModel): checks = append(checks, PreflightCheck{Name: "model", Status: CheckOK, Detail: activeModel}) - } else { + default: checks = append(checks, PreflightCheck{Name: "model", Status: CheckFail, Detail: modelDetail + " is not available through " + providerID}) } localReady := true @@ -432,11 +433,12 @@ func (e *Engine) PreflightWithOptions(ctx context.Context, opts PreflightOptions } } liveVerified := false - if !opts.VerifyLive { + switch { + case !opts.VerifyLive: checks = append(checks, PreflightCheck{Name: "provider_live", Status: CheckWarn, Detail: "not checked (local preflight only)"}) - } else if !localReady { + case !localReady: checks = append(checks, PreflightCheck{Name: "provider_live", Status: CheckWarn, Detail: "skipped until local setup is complete"}) - } else { + default: var err error if custom { err = e.probeCustomGateway(ctx, customGateway, "") @@ -519,18 +521,20 @@ type PreflightReport struct { func FormatPreflight(report PreflightReport) string { var b strings.Builder - if report.Ready && report.LiveVerified { + switch { + case report.Ready && report.LiveVerified: b.WriteString("Preflight: ready to chat (live verified)\n") - } else if report.Ready { + case report.Ready: b.WriteString("Preflight: locally ready to chat\n") - } else { + default: b.WriteString("Preflight: setup incomplete\n") } for _, check := range report.Checks { icon := "+" - if check.Status == CheckWarn { + switch check.Status { + case CheckWarn: icon = "!" - } else if check.Status == CheckFail { + case CheckFail: icon = "x" } fmt.Fprintf(&b, " %s %s: %s\n", icon, check.Name, check.Detail) @@ -584,13 +588,6 @@ func (e *Engine) MigrateProviderSecretsContext(ctx context.Context) error { return nil } cfg := *cfgState - changed := config.ProviderConfigContainsSecrets(cfg) - if !changed { - if err := writeBytesAtomic(marker, []byte("ok\n")); err != nil { - return err - } - return nil - } writes, err := e.importLegacyProviderSecrets(ctx, cfg) if err != nil { return &Error{Code: ErrorInternal, Operation: "migrate_provider_secrets", Message: "eyrie engine: could not import legacy credentials", Cause: err} diff --git a/engine/provider_state.go b/engine/provider_state.go index 9f32b93..07b2107 100644 --- a/engine/provider_state.go +++ b/engine/provider_state.go @@ -15,7 +15,10 @@ func lockProviderStatePath(path string) func() { key = filepath.Clean(path) } value, _ := providerStateLocks.LoadOrStore(key, &sync.Mutex{}) - mu := value.(*sync.Mutex) + mu, ok := value.(*sync.Mutex) + if !ok { + panic("eyrie engine: provider-state lock map contains a non-mutex value") + } mu.Lock() return mu.Unlock } diff --git a/engine/provider_state_test.go b/engine/provider_state_test.go index 65efc67..cb2fb39 100644 --- a/engine/provider_state_test.go +++ b/engine/provider_state_test.go @@ -90,7 +90,9 @@ func TestProviderStateMutationsRefuseCorruptConfig(t *testing.T) { func TestProviderStateRejectsUnknownFieldsAndVersions(t *testing.T) { for _, raw := range [][]byte{ []byte(`{"_version":"future","active_provider":"openai"}`), + []byte(`{"version":"future","active_provider":"openai"}`), []byte(`{"_version":"1","future_field":true}`), + []byte(`{"version":"1","future_field":true}`), } { eng, err := New(Options{SecretStore: &credentials.MapStore{}, StateDir: t.TempDir()}) if err != nil { diff --git a/engine/state_security_test.go b/engine/state_security_test.go index f1f032c..d7ea921 100644 --- a/engine/state_security_test.go +++ b/engine/state_security_test.go @@ -1,6 +1,7 @@ package engine import ( + "bytes" "context" "encoding/json" "errors" @@ -48,6 +49,41 @@ func TestMigrateProviderSecretsImportsBeforeSanitizing(t *testing.T) { } } +func TestMigrateProviderStateCanonicalizesLegacyVersionAlias(t *testing.T) { + ctx := context.Background() + store := &credentials.MapStore{} + eng, err := New(Options{SecretStore: store, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + legacy := []byte(`{"version":"1","active_provider":"openai","openai_api_key":"sk-legacy-version-alias-1234567890"}`) + if err := os.WriteFile(eng.providerConfigPath, legacy, 0o600); err != nil { + t.Fatal(err) + } + + if err := eng.MigrateProviderSecretsContext(ctx); err != nil { + t.Fatal(err) + } + secret, err := store.Get(ctx, credentials.AccountForEnv("OPENAI_API_KEY")) + if err != nil || secret != "sk-legacy-version-alias-1234567890" { + t.Fatalf("legacy credential was not imported before rewrite: value=%q err=%v", secret, err) + } + persisted, err := os.ReadFile(eng.providerConfigPath) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(persisted, []byte(`"version"`)) { + t.Fatalf("migration persisted legacy version alias: %s", persisted) + } + if !bytes.Contains(persisted, []byte(`"_version"`)) || bytes.Contains(persisted, []byte("sk-legacy-version-alias")) { + t.Fatalf("migration did not atomically canonicalize and sanitize provider state: %s", persisted) + } + cfg, err := config.LoadProviderConfigWithError(eng.providerConfigPath) + if err != nil || cfg == nil || cfg.Version != "1" || cfg.ActiveProvider != "openai" || config.ProviderConfigContainsSecrets(*cfg) { + t.Fatalf("canonical provider state is invalid: cfg=%#v err=%v", cfg, err) + } +} + func TestMigrateProviderSecretsRollsBackOnStoreFailure(t *testing.T) { ctx := context.Background() store := &failSecondSetStore{inner: &credentials.MapStore{}} diff --git a/engine/stream.go b/engine/stream.go index c4a76a5..bd8c8a3 100644 --- a/engine/stream.go +++ b/engine/stream.go @@ -140,13 +140,9 @@ func normalizeEvent(event client.EyrieStreamEvent) (Event, error) { case "tool_input_delta": out.Type = EventToolCallDelta case "done": - if out.Usage != nil { - // Usage remains attached to done for backward-friendly single-event - // accounting; future providers may also emit EventUsage separately. - out.Type = EventDone - } else { - out.Type = EventDone - } + // Usage remains attached to done for backward-friendly single-event + // accounting; future providers may also emit EventUsage separately. + out.Type = EventDone case "ttft": out.Type = EventTTFT case "continuation": diff --git a/plans/client-package-decomposition.md b/plans/client-package-decomposition.md index 92e4982..e383b3f 100644 --- a/plans/client-package-decomposition.md +++ b/plans/client-package-decomposition.md @@ -1,6 +1,6 @@ # Feature Specification: `client` Package Decomposition -**Status:** In Progress — Phases 1–2 implemented 2026-07-12; layering guard live +**Status:** In Progress — Phases 1–3 implemented 2026-07-13; layering guard live **Author:** Claude (architecture review session) **Date:** 2026-07-12 **Repos affected:** eyrie (all changes), hawk (no code changes required; re-pin external/eyrie) @@ -155,24 +155,24 @@ Learned in Phases 1–2 (apply to later phases): `GuardrailProvider` middleware wrapper stays in the facade (`client/guardrails.go`). Full public API aliased. -### Phase 3b-iii: adapter file move — remaining -- [ ] Both structural blockers are now cleared. What's left is the physical - move of the 14 adapter files to `client/adapters` plus their in-package - tests (large: adapter tests read unexported fields and share helpers - like `newTestOpenAIClient` with facade tests — split the helpers first). -- [ ] Decide: `OpenAIClient.CreateEmbedding` implements `embeddings.Embedder`, - so either allow `adapters` → `embeddings` in the layering guard or move - the embedding DTOs into `core`. (Recommended: DTOs into `core`, - keeping the strict "siblings import core only" rule.) -- [ ] `buildAnthropicCachedRequest` moves with the Anthropic adapter. -- [ ] `provider_registry.go` and `protocol_router.go` move last; the registry - keyed by `AdapterID` stays the only construction path. +### Phase 3b-iii: adapter file move — DONE 2026-07-13 +- [x] Moved provider protocol implementations and construction helpers to + `client/adapters`; the package imports `client/core` only. +- [x] Preserved the existing `client` API through aliases and thin wrappers, + including constructors, adapter DTOs, compatibility options, provider + registry types, and test-facing helpers. +- [x] Kept embedding DTO ownership in `client/core` where adapters need it; + `client/embeddings` remains a sibling that imports only core. +- [x] Moved Anthropic cache request construction, protocol routing, dynamic + provider registration, and provider construction into the adapter layer. +- [x] Updated tests to exercise the adapter package directly where internals + are required while retaining facade compatibility coverage. ### Phase 4: middleware, cache, aux - [ ] One sub-move per PR, same alias recipe. ### Phase 5: enforcement + deprecation -- [ ] Add `scripts/check-client-layering.sh` (mirror of hawk's +- [x] Add `scripts/check-client-layering.sh` (mirror of hawk's `check-eyrie-client-imports.sh`) to CI: fail on any sibling→sibling import that bypasses `core`, and on any in-tree import of the facade. - [ ] Mark facade aliases `// Deprecated:` pointing at the subpackage; migrate