From 257f886bf8062ff3d33c23b271b48e55a64de7cb Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 31 Jul 2026 06:02:07 +0530 Subject: [PATCH] chore: merge origin/main into submodule (StepFun + local commits) (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: bump VERSION to 0.1.4 * fix(ci): gofumpt internal/grpc/server_grpc.go * feat: complete provider-uniformity boundary (#62) * 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 * WIP: client package decomposition and provider uniformity work * feat: add host-neutral engine facade * feat: complete engine state and continuation support * build: enforce host-neutral eyrie boundary * feat: complete engine generation contract * fix: list models by serving gateway * fix: preserve credential save reassurance * feat: expose engine selection lifecycle * fix: preserve model-only selection compatibility * feat: move native compaction behind engine * feat: expose provider control plane * feat: expose effective engine selection * fix: require configured local gateway * feat: complete host model metadata * feat: expose host-neutral model policy * fix: preserve deterministic model metadata * feat: expose host control diagnostics * feat: report complete catalog health * fix: expose credential env aliases * feat: complete host control facade * feat: close engine runtime boundary * feat: harden host engine boundary * refactor: enforce client adapter layering * chore: bump VERSION to 0.2.0 (#63) * fix(engine): preserve explicit provider overrides (#64) * chore: bump VERSION to 0.2.1 (#65) * fix(ci): repin trufflehog to a real release tag (#66) The shared workflow template pinned trufflesecurity/trufflehog to a commit SHA labeled "main 2026-05-18" — a floating branch reference disguised as a pin, not an actual release. Repins to v3.95.9, the current real trufflehog release. Companion fix to GrayCodeAI/hawk#96, which fixes the same issue at the template source. * chore(ci): pin third-party actions to commit SHAs, pin jscpd version, remove log truncation (#67) Removing '| head -50' on deadcode/jscpd output was hiding failures past line 50. Pinning actions/checkout, actions/setup-node, docker/* actions to SHA digests guards against tag-mutation supply-chain attacks. * chore(release): eyrie 0.2.2 * docs: add development workflow instructions to AGENTS.md * fix(eyrie): stop falling back to OPENAI base URL for other providers (#69) * docs: correct the provider table — 12 of 22 shipped providers were documented Also fixed a stale ID: the Z.AI row listed z-ai, which doesn't exist in the registry (the real IDs are zai_coding and zai_payg, two separate providers). Rewrote the full table from catalog/registry/providers.go directly — deepseek, the zai/minimax split, azure, bedrock, vertex, poolside, groq, and clinepass were all missing. * fix(eyrie): stop falling back to OPENAI base URL for other providers A stray OPENAI_BASE_URL / OPENAI_API_BASE in the host environment would send a non-OpenAI provider's API key to whatever host that var points at, because 17 providers and 16 runtime profiles ended their BaseURLEnv lookup chain on the OpenAI vars. Trim OPENAI_BASE_URL and OPENAI_API_BASE out of every non-OpenAI provider's BaseURLEnv slice in catalog/registry/providers.go and every non-OpenAI runtime profile in config/profiles.go. A provider's base URL must come only from its own env vars. Added a regression test asserting anthropic no longer resolves through the OpenAI vars. * test: fix preflight tests that were skipped due to environment - Added failingStore mock type for deterministic credential fail testing - TestPreflight_NotReady_WhenCredentialsFail now uses failing store - TestPreflight_MultipleFailures now uses failing store - Eliminates environment-dependent test skips * fix: add missing newline at end of file * fix(eyrie): fix Bedrock secret masking, SQLite perms (#71) * fix(eyrie): Bedrock secret masking, streaming context cancel, HTTP header injection fix, SQLite perms SECURITY FIXES: - Change secretAccessKey from string to []byte to prevent accidental logging - Add String() method to BedrockClient that masks secrets - Add context cancellation for Bedrock streaming EventStreamReader BUG FIXES: - Fix storage/sqlite.go chmod failure on :memory: databases SECURITY HARDENING: - Extend control character rejection in custom headers from \r\n to all chars < 0x20 - Add circuit breaker field to Router for future use * fix: gofmt formatting * fix: remove unused breakers field * Graphify integration: AGENTS.md + CLAUDE.md (#72) * Graphify integration: AGENTS.md + CLAUDE.md * fix: demote GitNexus heading to H2 (markdownlint MD025) * feat: host-neutral defaults, poolside adapter, categories migration (#77) * defaults: host-neutral ServiceName and config-dir (was hardcoded 'hawk') ServiceName defaults to 'eyrie' (host-neutral); embedders call SetServiceName before any credential access. Config-dir default renamed from 'hawk' to 'eyrie'. Engine API unchanged. * feat: improve provider catalog and credential handling * feat: migrate legacy provider.json from old 'hawk' config dir When the default config dir was renamed from 'hawk' to 'eyrie', an existing provider.json in the old dir was silently ignored — upgrading users lost their provider/model selection, deployments, and routing. On first engine start, if the eyrie-dir provider.json does not yet exist, copy it from /hawk/provider.json. One-time, idempotent, never overwrites newer state. * feat: migrate legacy categories.json + fix categories override dir loadOverrides now reads categories.json from the host-neutral eyrie dir (EYRIE_CONFIG_DIR → HAWK_CONFIG_DIR compat → /eyrie) to match GetProviderConfigDir, instead of hardcoding the old hawk dir. The one-time legacy_config migration now also copies categories.json from /hawk to /eyrie on upgrade, alongside provider.json. * docs: remove GitNexus references from AGENTS.md, delete CLAUDE.md GitNexus docs superseded by Graphify integration (PR #72). * ci: retrigger * Merge feat/llm-port-contract: type aliases to hawk-core-contracts (#78) * feat(client): alias client/core DTOs to hawk-core-contracts/llm Conversation DTOs (EyrieMessage, EyrieResponse, ChatOptions, ...) and StreamResult are now type aliases of the canonical port contract. client/core becomes a pure leaf alias layer; the names still re-export through client/aliases.go so the public client API is unchanged. StreamResult's Close() and constructors now resolve to the contract's single canonical NewStreamResult(events, requestID, cancel); all call sites updated. GLMThinkingEnabled added to the contract's ChatOptions so the Z.ai toggle hawk sets / OpenAI adapter reads lives in one place. * feat(engine): alias conversation DTOs to canonical contract The engine facade's conversation types (Message, ContentPart, Tool, Usage, GenerateResponse, Event, ...) now alias hawk-core-contracts/llm, the same definitions hawk and the client already alias. Field names conform to the contract: ToolCalls->ToolUse, flat ContentPart->nested, InputTokens/OutputTokens->PromptTokens/CompletionTokens, TTFTMillis->TTFTms, EventType enum->plain string constants. engine/convert.go collapses: toClientMessages/fromClientUsage are now identity (both sides speak the contract); fromClientResponse only sets the resolved route. Intent/ModelClass alias the contract enums. * fix(config): GetProviderConfigDir returns error instead of panicking GetProviderConfigDir previously panic()d when os.UserConfigDir() failed (e.g. containers/CI with unset HOME). Return (string, error) instead and propagate through GetProviderConfigPath. All 12 callers updated to fall back gracefully (empty path) or surface the error — never panic. * fix: refund rate limiter token on context cancellation - Add token refund in wait() when context is cancelled during min-interval - Change GetProviderConfigDir() to return (string, error) instead of panicking - Update callers to handle new error return - Add test coverage for error paths * feat: add nil transport guard, CatalogSnapshot alias, token refund - Guard against nil transport in resolveProvider - Alias CatalogSnapshot to llm.CatalogSnapshot - Refund rate limiter token on context cancellation --------- * docs: add submodule workflow note (#79) * fix(engine): re-export SetSecretStoreServiceName from credentials (#80) * fix(engine): re-export credential test fixtures from engine, setSecretStoreServiceName (#81) * fix: refund rate limiter token on context cancellation (#82) - Add token refund in wait() when context is cancelled during min-interval - Change GetProviderConfigDir() to return (string, error) instead of panicking - Update callers to handle new error return - Add test coverage for error paths * fix(eyrie): M1-M6 MEDIUM hardening + centralize constants (#83) * fix(eyrie): make legacy config migration correct and atomic The migration I added in b2cab57 (PR #77) had three real bugs: 1. Read source + dest directly from os.UserConfigDir(), ignoring EYRIE_CONFIG_DIR / HAWK_CONFIG_DIR. A host using a custom EYRIE_CONFIG_DIR ended up with the legacy file copied to a path eyrie never reads — the migration was effectively a no-op for that user, and silently wrote to an invisible location. 2. Swallowed MkdirAll / WriteFile errors with '_ ='. On a partial write or read-only filesystem the destination provider.json could be left corrupt / empty, and the next start looks like a fresh install. Now slog.Warn on each failure (best-effort, non-fatal). 3. TOCTOU between os.Stat and os.WriteFile. Two engines starting in parallel could both miss the file, both write, and race on contents. Now uses .tmp + os.Rename for an atomic swap, so a concurrent engine cannot observe a half-written file. Source + destination now both derived from config.GetProviderConfigDir(), so the migration honors the same env-var resolution as the rest of eyrie. Added tests covering EYRIE_CONFIG_DIR redirection and the atomic-write + idempotency invariants. * fix(eyrie): return error from GetProviderConfigDir; fix goroutine leak in adaptive rate limiter Two real bugs: 1. GetProviderConfigDir panicked with 'user config directory unavailable' on platforms where os.UserConfigDir fails (sandboxed runtimes, restricted $HOME). It now returns (string, error) so callers can recover; a MustGetProviderConfigDir shim is provided for startup paths that genuinely cannot proceed. Updated all 6 production call sites in runtime/, setup/, engine/ and the 2 test files. 2. AdaptiveRateLimitProvider.StreamChat wrapped the inner stream's events channel in a goroutine that blocked on 'wrappedCh <- evt' forever if the caller's ctx was cancelled. The goroutine never observed ctx.Done, never released the inner stream's body, and the caller waited indefinitely. Now selects on ctx.Done and calls result.Close() to release the inner stream, then drains remaining events so the inner parser can complete cleanly. * fix(eyrie): M1-M6 MEDIUM hardening - retry: return a clear error if a request body is set but GetBody is nil, instead of silently retrying a drained body. - conversation: emit EventError before EventDone when a continuation request fails, so truncated responses aren't indistinguishable from clean completions. - coalesce: replace time.Sleep with a cancellable timer that exits on ctx.Done, so the cleanup goroutine doesn't linger for the full TTL on shutdown. - config: delete the exported-but-unused ValidateBaseURL (only test caller removed). - adaptive_ratelimit: audit confirmed recordTokens + shared-window mutations already take a.mu — no code change; documented lock invariant. * chore(eyrie): centralize magic numbers in client/core/constants.go Retry defaults, transport timeouts and cooldown windows now reference named constants. DefaultRetryConfig uses them. Deferred: full split of the 215-line providerSpecs() literal and the batch.go inline retry (LOW effort, mechanical). * fix: vet DefaultPaths test signature, markdown table pipe style (#84) * fix: vet DefaultPaths test signature, markdown table pipe style * fix(ci): clone hawk-core-contracts dep, add -test flag to deadcode * fix(lint): sloppyReassign, S1016, S1011 in engine and config * fix(test): clear XDG_CONFIG_HOME so os.UserConfigDir() uses /Users/lakshmanpatel override * fix(ci): clone ecosystem deps for fuzz job too * ci: add replace-directive release guard script and Makefile target (#85) * fix(ci): drop local replace; pin hawk-core-contracts v0.1.7, use go.work for local dev (#87) * fix(engine): implement llm.Provider via contract aliases Re-export host-facing DTOs from hawk-core-contracts/llm, assert *Engine implements llm.Provider and *Stream implements EventStreamer, return EventStreamer from Stream with proper nil-interface handling, and document the llm dependency on the ecosystem boundary. * fix(engine): implement llm.Provider via contract aliases (#88) * fix(engine): implement llm.Provider via contract aliases Re-export host-facing DTOs from hawk-core-contracts/llm, assert *Engine implements llm.Provider and *Stream implements EventStreamer, return EventStreamer from Stream with proper nil-interface handling, and document the llm dependency on the ecosystem boundary. * fix(deps): pin hawk-core-contracts to llm host-port alignment CI resolves modules without go.work; pin the feature commit that provides EventStreamer, CheckStatus, and the aligned CatalogHealth/Preflight types. * fix(deps): pin hawk-core-contracts to main after #16 merge Use the published main tip so CI can resolve EventStreamer and aligned host types without feature-branch pseudo-version unshallow issues. * fix(deps): pin hawk-core-contracts to v0.1.8 Use the tagged host-port alignment release so CI avoids pseudo-version unshallow failures. * feat: ecosystem architecture enhancements (Graph, Loop, Context, Harness) (#89) * fix(engine): implement llm.Provider via contract aliases Re-export host-facing DTOs from hawk-core-contracts/llm, assert *Engine implements llm.Provider and *Stream implements EventStreamer, return EventStreamer from Stream with proper nil-interface handling, and document the llm dependency on the ecosystem boundary. * fix(deps): pin hawk-core-contracts to llm host-port alignment CI resolves modules without go.work; pin the feature commit that provides EventStreamer, CheckStatus, and the aligned CatalogHealth/Preflight types. * fix(deps): pin hawk-core-contracts to main after #16 merge Use the published main tip so CI can resolve EventStreamer and aligned host types without feature-branch pseudo-version unshallow issues. * fix(deps): pin hawk-core-contracts to v0.1.8 Use the tagged host-port alignment release so CI avoids pseudo-version unshallow failures. * fix(client): export cache type, propagate stream Close, error on empty fallback - Export AnthropicCachedMessage (was unexported anthropicCachedMessage), fixing golint/revive violation of exported func returning unexported type - Propagate inner stream Close/cancel in AdaptiveRateLimitProvider.StreamChat and CallbackProvider.StreamChat to prevent connection leaks on normal drain - Change NewFallbackProvider to return (*FallbackProvider, error) instead of returning nil on empty input, eliminating a nil-provider footgun * feat(engine): wire rate limiting and caching into the engine facade Add opt-in EnableRateLimiting and EnableCaching options to engine.Options. When enabled, the defaultTransport wraps the resolved provider with AdaptiveRateLimitProvider (outermost) and CachedProvider, closing the gap between advertised features and actual facade behavior. Both are off by default to preserve backward compatibility. * fix(engine,credentials): improve IntentFast heuristic, replace context.TODO - IntentFast now sorts by ContextWindow ascending (better latency proxy) with MaxOutput as secondary tiebreaker, instead of MaxOutput alone - Replace context.TODO() with context.Background() in credential lookup and health checks — TODO is for placeholder code, Background is correct when no caller context is available * feat(client): add per-provider timeout to fallback chain; document secret cache - Add PerProviderTimeout field to FallbackProvider that bounds each individual Chat attempt, preventing a hung provider from blocking the entire chain until the caller's context expires - Document the plaintext secret caching tradeoff in CombinedStore with threat model rationale and mitigation guidance * refactor(catalog): centralize model alias resolution into ResolveModel helper Extract the trim + nil-check + fallback-to-native pattern that was duplicated across router/preview.go, engine/host_control.go, and runtime/selection.go into a single catalog.ResolveModel function. Callers now delegate to this helper instead of reimplementing the same logic. * test(catalog): add ResolveModel edge-case tests Covers direct ID, alias, not-found, nil catalog (native ID + alias), empty string, and whitespace trimming. * feat(engine): add JSON tags to Error struct for machine-readable errors The Error struct now serializes to JSON with code, operation, provider, model, message, and retryable fields. The Cause field is excluded from JSON (json:"-") since it is an error interface that cannot be reliably serialized. * test(engine): add convert tests and conversation continuation tests - Add engine/convert_test.go: 15 tests for toClientMessages, toClientOptions, fromClientResponse, fromClientUsage, setMetadataIfPresent, cloneStringMap - Add conversation continuation test: verifies streamAndSave continues on max_tokens and emits a single done event - Add context-cancellation test: verifies the events channel closes promptly when the context is cancelled (no stream leak) * test: add coverage for gateway pure functions (SetupGatewayID, CatalogProviderID, CredentialEnvKeys, GatewayStatuses, normalizeRuntimeProviderID) * feat: add execution graph module - Add engine/graph.go with execution graph implementation - Add engine/graph_test.go with test coverage - Update README * feat: enhance operations graph - Add OperationsGraph implementation - Support node and edge management - Add serialization to portable GraphSpec --------- --- AGENTS.md | 5 +- catalog/live_enrich.go | 8 +- catalog/registry/providers.go | 44 ++--- catalog/v1_test.go | 26 --- client/adapters/anthropic.go | 16 -- client/adapters/compat.go | 93 +-------- client/adapters/deepseek.go | 52 ++++- client/adapters/mimo.go | 109 ++++++++--- client/adapters/mimo_test.go | 162 ++++------------ client/adapters/opencodego.go | 61 ++++-- client/adapters/opencodego_test.go | 151 ++++++++++----- client/adapters/poolside_test.go | 55 ------ client/adapters/protocol_router.go | 241 ++++++++++++++++++++++++ client/adapters/protocol_router_test.go | 153 +++++++++++++++ client/adapters/provider_registry.go | 27 ++- client/adapters/test_helpers_test.go | 8 - client/adapters/zai.go | 99 ++++++++-- client/aliases.go | 24 ++- client/compat.go | 38 ++-- client/mimo_test.go | 10 + client/opencodego_test.go | 19 ++ client/protocol_router_test.go | 15 ++ client/provider_policy.go | 22 +-- client/provider_policy_test.go | 27 +-- client/provider_registry.go | 13 +- config/provider_secrets.go | 7 +- config/provider_secrets_test.go | 12 -- conversation/engine_test.go | 8 +- engine/control_plane.go | 7 - engine/convert.go | 7 - engine/convert_test.go | 6 +- engine/engine.go | 8 +- engine/engine_test.go | 30 +-- engine/host_facade_contract_test.go | 7 - go.mod | 19 +- go.sum | 60 +++--- operationsgraph/operations_graph.go | 18 +- setup/deployment.go | 70 +++---- 38 files changed, 1030 insertions(+), 707 deletions(-) create mode 100644 client/adapters/protocol_router.go create mode 100644 client/adapters/protocol_router_test.go create mode 100644 client/protocol_router_test.go diff --git a/AGENTS.md b/AGENTS.md index c5f164b..f28ce4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,10 +8,7 @@ When starting any new work (feature, fix, refactor, chore), always create a feat ## Design Principles -- **Model-agnostic** — single interface for 23 registered LLM providers -- **Plan-accurate provider metadata** — Concentrate AI is pay-as-you-go, uses - `concentrate-payg` as its deployment identifier, and uses its native Responses - API (`/v1/responses`) without legacy Chat Completions or Messages routing +- **Model-agnostic** — single interface for 75+ LLM providers - **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 diff --git a/catalog/live_enrich.go b/catalog/live_enrich.go index 2bf0c27..519855c 100644 --- a/catalog/live_enrich.go +++ b/catalog/live_enrich.go @@ -56,8 +56,8 @@ func FetchLiveProviderCatalog(env map[string]string) (Catalog, []LiveProviderEnr ID: deploymentID, Name: providerID, ProviderID: providerID, - APIProtocolID: spec.ProtocolID, - AdapterConstructor: spec.AdapterID, + APIProtocolID: "openai-chat-completions", + AdapterConstructor: "openai", NativeModelIDSource: NativeModelIDDiscovered, ModelMappingsRequired: false, } @@ -83,7 +83,7 @@ func FetchLiveProviderCatalog(env map[string]string) (Catalog, []LiveProviderEnr } else if hasInputPricing(entry.RawJSON) { canonicalID = providerID + "/" + entryID } - } else { + } else if hasInputPricing(entry.RawJSON) { canonicalID = providerID + "/" + entryID } @@ -123,7 +123,7 @@ func FetchLiveModelEntriesForProvider(env map[string]string, providerID string) return nil, fmt.Errorf("catalog: provider %q has no live model list API", providerID) } env = registry.ScopedProviderEnv(spec, env) - if !spec.PublicModelCatalog && !registry.CredentialPresent(spec, env) { + if !registry.CredentialPresent(spec, env) { return nil, fmt.Errorf("catalog: set %s for %s", spec.CredentialEnv, providerID) } entries, err := live.Fetch(spec.LiveFetcherKey, env) diff --git a/catalog/registry/providers.go b/catalog/registry/providers.go index 50a5e49..4603703 100644 --- a/catalog/registry/providers.go +++ b/catalog/registry/providers.go @@ -17,7 +17,7 @@ func providerSpecs() []ProviderSpec { return []ProviderSpec{ // ── Direct API providers ────────────────────────────────────────── { - ProviderID: "anthropic", DisplayName: "Anthropic", DeploymentID: "anthropic-direct", SortOrder: 3, ChatPreference: 2, + ProviderID: "anthropic", DisplayName: "Anthropic", DeploymentID: "anthropic-direct", SortOrder: 1, ChatPreference: 2, TransportKind: "anthropic", RequiresKey: true, CredentialEnv: "ANTHROPIC_API_KEY", CredentialAliases: []string{"CLAUDE_API_KEY"}, @@ -28,7 +28,7 @@ func providerSpecs() []ProviderSpec { DirectFallbacks: []string{"openai"}, }, { - ProviderID: "openai", DisplayName: "OpenAI", DeploymentID: "openai-direct", SortOrder: 15, ChatPreference: 1, + ProviderID: "openai", DisplayName: "OpenAI", DeploymentID: "openai-direct", SortOrder: 2, ChatPreference: 1, TransportKind: "openai", RequiresKey: true, CredentialEnv: "OPENAI_API_KEY", BaseURLEnv: []string{"OPENAI_BASE_URL", "OPENAI_API_BASE"}, @@ -38,7 +38,7 @@ func providerSpecs() []ProviderSpec { DirectFallbacks: []string{"anthropic"}, }, { - ProviderID: "gemini", DisplayName: "Gemini API", DeploymentID: "gemini-direct", SortOrder: 9, ChatPreference: 5, + ProviderID: "gemini", DisplayName: "Gemini API", DeploymentID: "gemini-direct", SortOrder: 3, ChatPreference: 5, RuntimeBaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", RequiresKey: true, CredentialEnv: "GEMINI_API_KEY", CredentialAliases: []string{"GOOGLE_API_KEY"}, @@ -48,7 +48,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "gemini-generate-content", AdapterID: "gemini", RuntimeProfileKey: "gemini", }, { - ProviderID: "deepseek", DisplayName: "DeepSeek", DeploymentID: "deepseek-direct", SortOrder: 8, ChatPreference: 11, + ProviderID: "deepseek", DisplayName: "DeepSeek", DeploymentID: "deepseek-direct", SortOrder: 4, ChatPreference: 11, RequiresKey: true, CredentialEnv: "DEEPSEEK_API_KEY", BaseURLEnv: []string{"DEEPSEEK_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.deepseek.com/v1", @@ -56,7 +56,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "deepseek", RuntimeProfileKey: "deepseek", }, { - ProviderID: "grok", DisplayName: "xAI", DeploymentID: "grok-direct", SortOrder: 21, ChatPreference: 4, + ProviderID: "grok", DisplayName: "xAI", DeploymentID: "grok-direct", SortOrder: 5, ChatPreference: 4, RequiresKey: true, CredentialEnv: "XAI_API_KEY", BaseURLEnv: []string{"XAI_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.x.ai/v1", @@ -64,7 +64,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "grok", RuntimeProfileKey: "grok", }, { - ProviderID: "kimi", DisplayName: "Kimi", DeploymentID: "kimi-direct", SortOrder: 11, ChatPreference: 14, + ProviderID: "kimi", DisplayName: "Kimi", DeploymentID: "kimi-direct", SortOrder: 6, ChatPreference: 14, RequiresKey: true, CredentialEnv: "MOONSHOT_API_KEY", BaseURLEnv: []string{"MOONSHOT_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.moonshot.ai/v1", @@ -72,7 +72,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "kimi", RuntimeProfileKey: "kimi", }, { - ProviderID: "zai_coding", DisplayName: "Z.AI — Coding Plan", DeploymentID: "zai_coding-direct", SortOrder: 24, ChatPreference: 8, + ProviderID: "zai_coding", DisplayName: "Z.AI — Coding Plan", DeploymentID: "zai_coding-direct", SortOrder: 7, ChatPreference: 8, RequiresKey: true, CredentialEnv: "ZAI_CODING_API_KEY", BaseURLEnv: []string{"ZAI_CODING_BASE_URL", "ZAI_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.z.ai/api/coding/paas/v4", @@ -81,7 +81,7 @@ func providerSpecs() []ProviderSpec { PrepareCredentialEnv: true, }, { - ProviderID: "zai_payg", DisplayName: "Z.AI — Pay-as-you-go", DeploymentID: "zai_payg-direct", SortOrder: 25, ChatPreference: 9, + ProviderID: "zai_payg", DisplayName: "Z.AI — Pay-as-you-go", DeploymentID: "zai_payg-direct", SortOrder: 8, ChatPreference: 9, RequiresKey: true, CredentialEnv: "ZAI_API_KEY", BaseURLEnv: []string{"ZAI_BASE_URL", "ZAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.z.ai/api/paas/v4", @@ -90,7 +90,7 @@ func providerSpecs() []ProviderSpec { PrepareCredentialEnv: true, }, { - ProviderID: "xiaomi_mimo_token_plan", DisplayName: "Xiaomi MiMo — Token Plan", DeploymentID: "xiaomi_mimo_token_plan-direct", SortOrder: 23, ChatPreference: 16, + ProviderID: "xiaomi_mimo_token_plan", DisplayName: "Xiaomi MiMo — Token Plan", DeploymentID: "xiaomi_mimo_token_plan-direct", SortOrder: 9, ChatPreference: 16, RequiresKey: true, CredentialEnv: "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", BaseURLEnv: []string{"XIAOMI_MIMO_TOKEN_PLAN_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "", @@ -99,7 +99,7 @@ func providerSpecs() []ProviderSpec { PrepareCredentialEnv: true, }, { - ProviderID: "xiaomi_mimo_payg", DisplayName: "Xiaomi MiMo — Pay-as-you-go", DeploymentID: "xiaomi_mimo_payg-direct", SortOrder: 22, ChatPreference: 15, + ProviderID: "xiaomi_mimo_payg", DisplayName: "Xiaomi MiMo — Pay-as-you-go", DeploymentID: "xiaomi_mimo_payg-direct", SortOrder: 10, ChatPreference: 15, RequiresKey: true, CredentialEnv: "XIAOMI_MIMO_PAYG_API_KEY", CredentialAliases: []string{"XIAOMI_MIMO_API_KEY"}, BaseURLEnv: []string{"XIAOMI_MIMO_PAYG_BASE_URL", "XIAOMI_BASE_URL"}, @@ -108,7 +108,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "xiaomi_mimo", RuntimeProfileKey: "xiaomi_mimo_payg", }, { - ProviderID: "minimax_token_plan", DisplayName: "MiniMax — Token Plan", DeploymentID: "minimax_token_plan-direct", SortOrder: 14, ChatPreference: 17, + ProviderID: "minimax_token_plan", DisplayName: "MiniMax — Token Plan", DeploymentID: "minimax_token_plan-direct", SortOrder: 11, ChatPreference: 17, RequiresKey: true, CredentialEnv: "MINIMAX_TOKEN_PLAN_API_KEY", BaseURLEnv: []string{"MINIMAX_TOKEN_PLAN_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.minimax.io/v1", @@ -116,7 +116,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "minimax_token_plan", }, { - ProviderID: "minimax_payg", DisplayName: "MiniMax — Pay-as-you-go", DeploymentID: "minimax_payg-direct", SortOrder: 13, ChatPreference: 18, + ProviderID: "minimax_payg", DisplayName: "MiniMax — Pay-as-you-go", DeploymentID: "minimax_payg-direct", SortOrder: 12, ChatPreference: 18, RequiresKey: true, CredentialEnv: "MINIMAX_PAYG_API_KEY", BaseURLEnv: []string{"MINIMAX_PAYG_BASE_URL", "MINIMAX_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.minimax.io/v1", @@ -126,7 +126,7 @@ func providerSpecs() []ProviderSpec { // ── Cloud platform providers ────────────────────────────────────── { - ProviderID: "azure", DisplayName: "Azure OpenAI", DeploymentID: "openai-azure", SortOrder: 4, ChatPreference: 12, + ProviderID: "azure", DisplayName: "Azure OpenAI", DeploymentID: "openai-azure", SortOrder: 13, ChatPreference: 12, TransportKind: "azure", RequiresKey: true, CredentialEnv: "AZURE_OPENAI_API_KEY", BaseURLEnv: []string{"AZURE_OPENAI_ENDPOINT"}, @@ -135,7 +135,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "openai-azure", RuntimeProfileKey: "azure", }, { - ProviderID: "bedrock", DisplayName: "Amazon Bedrock", DeploymentID: "anthropic-bedrock", SortOrder: 2, ChatPreference: 7, + ProviderID: "bedrock", DisplayName: "Amazon Bedrock", DeploymentID: "anthropic-bedrock", SortOrder: 14, ChatPreference: 7, TransportKind: "bedrock", RequiresKey: true, CredentialEnv: "AWS_SECRET_ACCESS_KEY", CredentialEnvFallbacks: []string{"AWS_ACCESS_KEY_ID", "AWS_SESSION_TOKEN"}, @@ -145,7 +145,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "anthropic-messages", AdapterID: "anthropic-bedrock", RuntimeProfileKey: "bedrock", }, { - ProviderID: "vertex", DisplayName: "Vertex AI", DeploymentID: "gemini-vertex", SortOrder: 20, ChatPreference: 6, + ProviderID: "vertex", DisplayName: "Vertex AI", DeploymentID: "gemini-vertex", SortOrder: 15, ChatPreference: 6, TransportKind: "vertex", RequiresKey: true, CredentialEnv: "VERTEX_ACCESS_TOKEN", CredentialEnvFallbacks: []string{"GOOGLE_OAUTH_ACCESS_TOKEN"}, @@ -157,7 +157,7 @@ func providerSpecs() []ProviderSpec { // ── Aggregators ─────────────────────────────────────────────────── { - ProviderID: "openrouter", DisplayName: "OpenRouter", DeploymentID: "openrouter", SortOrder: 17, ChatPreference: 3, + ProviderID: "openrouter", DisplayName: "OpenRouter", DeploymentID: "openrouter", SortOrder: 16, ChatPreference: 3, RequiresKey: true, CredentialEnv: "OPENROUTER_API_KEY", BaseURLEnv: []string{"OPENROUTER_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://openrouter.ai/api/v1", @@ -200,7 +200,7 @@ func providerSpecs() []ProviderSpec { // ── Niche ───────────────────────────────────────────────────────── { - ProviderID: "canopywave", DisplayName: "CanopyWave", DeploymentID: "canopywave", SortOrder: 5, ChatPreference: 10, + ProviderID: "canopywave", DisplayName: "CanopyWave", DeploymentID: "canopywave", SortOrder: 17, ChatPreference: 10, RequiresKey: true, CredentialEnv: "CANOPYWAVE_API_KEY", BaseURLEnv: []string{"CANOPYWAVE_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://inference.canopywave.io/v1", @@ -208,7 +208,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "canopywave", RuntimeProfileKey: "canopywave", }, { - ProviderID: "poolside", DisplayName: "Poolside", DeploymentID: "poolside", SortOrder: 19, ChatPreference: 20, + ProviderID: "poolside", DisplayName: "Poolside", DeploymentID: "poolside", SortOrder: 18, ChatPreference: 20, RequiresKey: true, CredentialEnv: "POOLSIDE_API_KEY", BaseURLEnv: []string{"POOLSIDE_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://inference.poolside.ai/v1", @@ -216,7 +216,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "poolside", RuntimeProfileKey: "poolside", }, { - ProviderID: "groq", DisplayName: "Groq", DeploymentID: "groq-direct", SortOrder: 10, ChatPreference: 21, + ProviderID: "groq", DisplayName: "Groq", DeploymentID: "groq-direct", SortOrder: 19, ChatPreference: 21, RequiresKey: true, CredentialEnv: "GROQ_API_KEY", BaseURLEnv: []string{"GROQ_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.groq.com/openai/v1", @@ -224,7 +224,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "groq", RuntimeProfileKey: "groq", }, { - ProviderID: "clinepass", DisplayName: "ClinePass", DeploymentID: "clinepass", SortOrder: 6, ChatPreference: 22, + ProviderID: "clinepass", DisplayName: "ClinePass", DeploymentID: "clinepass", SortOrder: 20, ChatPreference: 22, RuntimeBaseURL: "https://api.cline.bot/api/v1", RequiresKey: true, CredentialEnv: "CLINE_API_KEY", BaseURLEnv: []string{"CLINE_API_BASE"}, @@ -233,7 +233,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "clinepass", RuntimeProfileKey: "clinepass", }, { - ProviderID: "opencodego", DisplayName: "OpenCode Go", DeploymentID: "opencodego", SortOrder: 16, ChatPreference: 13, + ProviderID: "opencodego", DisplayName: "OpenCode Go", DeploymentID: "opencodego", SortOrder: 21, ChatPreference: 13, RequiresKey: true, CredentialEnv: "OPENCODEGO_API_KEY", BaseURLEnv: []string{"OPENCODEGO_BASE_URL"}, ProbeKind: ProbeOpenAIModels, @@ -244,7 +244,7 @@ func providerSpecs() []ProviderSpec { // ── Local ───────────────────────────────────────────────────────── { - ProviderID: "ollama", DisplayName: "Ollama", DeploymentID: "ollama-local", SortOrder: 18, 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"}, diff --git a/catalog/v1_test.go b/catalog/v1_test.go index 349f6e3..0a88490 100644 --- a/catalog/v1_test.go +++ b/catalog/v1_test.go @@ -238,32 +238,6 @@ func TestCapabilitySetFromEntry_ToolsAliasSupportsFunctionCalling(t *testing.T) } } -func TestCapabilitySetFromEntry_KeepsContextAndMaxOutput(t *testing.T) { - t.Parallel() - set := CapabilitySetFromEntry(live.Entry{ - ContextWindow: 1_048_576, - MaxOutput: 131_072, - Features: []string{"tools", "thinking:enabled", "image_input"}, - ThinkingEnabled: true, - ImageInput: true, - }) - if set.MaxInputTokens != 1_048_576 { - t.Fatalf("MaxInputTokens = %d", set.MaxInputTokens) - } - if set.MaxOutputTokens != 131_072 { - t.Fatalf("MaxOutputTokens = %d", set.MaxOutputTokens) - } - if set.FunctionCalling != CapabilitySupported { - t.Fatalf("FunctionCalling = %q", set.FunctionCalling) - } - if set.ExplicitThinkingBudget != CapabilitySupported { - t.Fatalf("ExplicitThinkingBudget = %q", set.ExplicitThinkingBudget) - } - if set.ImageInput != CapabilitySupported { - t.Fatalf("ImageInput = %q", set.ImageInput) - } -} - func TestCapabilitySetFromLegacy_EmptyFeatures(t *testing.T) { t.Parallel() entry := ModelCatalogEntry{ID: "test-model"} diff --git a/client/adapters/anthropic.go b/client/adapters/anthropic.go index e6c0f40..13f714b 100644 --- a/client/adapters/anthropic.go +++ b/client/adapters/anthropic.go @@ -159,12 +159,6 @@ func thinkingDisabled() *anthropicThinking { } // resolveThinking builds the thinking config from core.ChatOptions. -// Explicit ThinkingMode / ThinkingBudgetTokens win. Otherwise ThinkingEnabled -// maps to Anthropic's documented toggle: -// - false → {type:"disabled"} -// - true → {type:"adaptive"} (recommended for current Claude models) -// -// See https://platform.claude.com/docs/en/build-with-claude/extended-thinking func resolveThinking(opts core.ChatOptions) *anthropicThinking { switch opts.ThinkingMode { case "adaptive": @@ -178,16 +172,6 @@ func resolveThinking(opts core.ChatOptions) *anthropicThinking { } return thinking default: - thinkingEnabled := opts.ThinkingEnabled - if thinkingEnabled == nil { - thinkingEnabled = opts.GLMThinkingEnabled - } - if thinkingEnabled != nil { - if !*thinkingEnabled { - return thinkingDisabled() - } - return thinkingAdaptive() - } // Legacy behavior: if budget > 0, enable with budget return thinkingForBudget(opts.ThinkingBudgetTokens) } diff --git a/client/adapters/compat.go b/client/adapters/compat.go index 5a06052..1990cd2 100644 --- a/client/adapters/compat.go +++ b/client/adapters/compat.go @@ -12,7 +12,7 @@ type OpenAICompatConfig struct { 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"` // "zai","longcat","kimi","deepseek","xiaomi","minimax","agnes","qwen","openrouter" + 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. @@ -21,17 +21,6 @@ type OpenAICompatConfig struct { // 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"` - // OmitMaxTokens instructs buildRequestBase to leave max_tokens unset instead - // of sending the default 4096. Providers that pre-authorize the maximum token - // cost (e.g. Agnes AI) can return insufficient_user_quota when that hold - // exceeds the account balance; omitting max_tokens lets the provider apply its - // own default, which avoids the oversized hold. - OmitMaxTokens bool `json:"omit_max_tokens,omitempty"` - // DefaultDisableThinking: when ThinkingEnabled is nil, emit an explicit - // thinking={"type":"disabled"} for formats that use the thinking object. - // LongCat enables thinking by default when the field is omitted; that often - // burns the entire max_tokens budget on reasoning_content with no reply. - DefaultDisableThinking bool `json:"default_disable_thinking,omitempty"` } // Per-provider compat configs. @@ -45,36 +34,9 @@ var ( MaxTokensField: "max_tokens", } OpenRouterCompat = OpenAICompatConfig{ - // OpenRouter: reasoning={enabled|effort} per official reasoning-tokens guide. - ThinkingFormat: "openrouter", - MaxTokensField: "max_tokens", + ThinkingFormat: "openrouter", MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, } - // AgnesCompat: Agnes AI is OpenAI-compatible only (chat completions). It does - // not honor OpenAI-specific features like store/developer-role/reasoning, - // so those are left disabled. It uses the standard max_tokens field. - // Agnes pre-authorizes the maximum token cost of every request; sending the - // default 4096 max_tokens makes that hold exceed the account balance and - // triggers an insufficient_user_quota (403). OmitMaxTokens leaves max_tokens - // unset so Agnes applies its own default, which keeps the hold small — this - // matches the behavior of a bare curl to the Agnes API. - // Thinking uses chat_template_kwargs.enable_thinking per Agnes OpenAI docs. - AgnesCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - SupportsUsageInStreaming: true, - OmitMaxTokens: true, - ThinkingFormat: "agnes", - } - // LongCatCompat: OpenAI-compatible only (https://api.longcat.chat/openai). - // Official docs: max_tokens, thinking={"type":"enabled"|"disabled"}, tools. - // DefaultDisableThinking is required: omitting thinking leaves LongCat's - // server-side default (enabled), which commonly yields reasoning-only replies. - LongCatCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - SupportsUsageInStreaming: true, - ThinkingFormat: "longcat", - DefaultDisableThinking: true, - } GeminiCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, } @@ -91,9 +53,8 @@ var ( OpenCodeGoCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, - // OpenCode Go routes many models through OpenRouter-style reasoning. - ThinkingFormat: "openrouter", - StripReasoningFromInput: true, + ThinkingFormat: "openrouter", + StripReasoningFromInput: true, } PoolsideCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", @@ -107,17 +68,9 @@ var ( KimiCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsCacheRole: true, - // Kimi K2.5/K2.6: thinking={"type":"enabled"|"disabled"} (defaults enabled). - // https://platform.kimi.ai/docs/guide/use-kimi-k2-thinking-model - ThinkingFormat: "kimi", - DefaultDisableThinking: true, } XiaomiCompat = OpenAICompatConfig{ MaxTokensField: "max_completion_tokens", - // Xiaomi MiMo: thinking={"type":"enabled"|"disabled"} (on by default for pro). - // https://mimo.mi.com/docs/en-US/quick-start/usage-guide/text-generation/deep-thinking - ThinkingFormat: "xiaomi", - DefaultDisableThinking: true, } AzureCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", @@ -129,29 +82,11 @@ var ( 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 - // for non-tool turns. Thinking mode: thinking={"type":...} (defaults enabled per - // https://api-docs.deepseek.com/guides/thinking_mode). + // The provider rejects reasoning_content in input messages with HTTP 400, so we strip it. DeepSeekCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, StripReasoningFromInput: true, - ThinkingFormat: "deepseek", - DefaultDisableThinking: true, - } - // ConcentrateCompat: OpenAI-compatible with usage in streaming. - ConcentrateCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - SupportsUsageInStreaming: true, - } - // MiniMaxCompat: OpenAI chat completions at api.minimax.io/v1. - // Official OpenAI path: thinking.type is "disabled" | "adaptive" (default on when omitted). - // https://platform.minimax.io/docs/api-reference/text-openai-api - MiniMaxCompat = OpenAICompatConfig{ - MaxTokensField: "max_tokens", - SupportsUsageInStreaming: true, - ThinkingFormat: "minimax", - DefaultDisableThinking: true, } ) @@ -162,14 +97,6 @@ func init() { DynamicMu.Lock() defer DynamicMu.Unlock() - if p, ok := OpenAICompatibleProviders["agnes"]; ok { - p.Compat = &AgnesCompat - OpenAICompatibleProviders["agnes"] = p - } - if p, ok := OpenAICompatibleProviders["longcat"]; ok { - p.Compat = &LongCatCompat - OpenAICompatibleProviders["longcat"] = p - } if p, ok := OpenAICompatibleProviders["grok"]; ok { p.Compat = &GrokCompat OpenAICompatibleProviders["grok"] = p @@ -226,16 +153,6 @@ func init() { p.Compat = &DeepSeekCompat OpenAICompatibleProviders["deepseek"] = p } - if p, ok := OpenAICompatibleProviders["concentrate"]; ok { - p.Compat = &ConcentrateCompat - OpenAICompatibleProviders["concentrate"] = p - } - for _, id := range []string{"minimax_payg", "minimax_token_plan"} { - if p, ok := OpenAICompatibleProviders[id]; ok { - p.Compat = &MiniMaxCompat - OpenAICompatibleProviders[id] = p - } - } if p, ok := CoreProviders["openai"]; ok { p.Compat = &OpenAICompat CoreProviders["openai"] = p diff --git a/client/adapters/deepseek.go b/client/adapters/deepseek.go index e320960..6e2691c 100644 --- a/client/adapters/deepseek.go +++ b/client/adapters/deepseek.go @@ -2,37 +2,69 @@ package adapters import ( "context" + "log/slog" "strings" "github.com/GrayCodeAI/eyrie/client/core" ) -// DeepSeekClient uses the official OpenAI-compatible DeepSeek surface only -// (https://api.deepseek.com). +// DeepSeekClient uses OpenAI-compatible DeepSeek endpoints first, +// with optional Anthropic-compat fallback if the OpenAI endpoint is down. type DeepSeekClient struct { - openai *OpenAIClient + router ProtocolRouter + logger *slog.Logger } -// NewDeepSeekClient builds an OpenAI-compatible DeepSeek client. -// openAIBase is typically "https://api.deepseek.com/v1". -func NewDeepSeekClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *DeepSeekClient { +// 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 ...core.ClientOption) *DeepSeekClient { openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + anthropicBase = strings.TrimRight(strings.TrimSpace(anthropicBase), "/") dsOpts := append(append([]core.ClientOption{}, opts...), core.WithProviderName("deepseek")) - return &DeepSeekClient{openai: NewOpenAIClient(apiKey, openAIBase, compat, dsOpts...)} + o := NewOpenAIClient(apiKey, openAIBase, compat, dsOpts...) + var a *AnthropicClient + if anthropicBase != "" { + a = NewAnthropicClient(apiKey, anthropicBase, dsOpts...) + } + return &DeepSeekClient{ + router: ProtocolRouter{OpenAI: o, Anthropic: a}, + logger: slog.Default(), + } } func (c *DeepSeekClient) Name() string { return "deepseek" } func (c *DeepSeekClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { - return c.openai.Chat(ctx, messages, opts) + 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 + } + return false + }) } func (c *DeepSeekClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { - return c.openai.StreamChat(ctx, messages, opts) + return c.router.StreamChat(ctx, messages, opts, ProtocolStreamConfig{ + Primary: ChatProtocolCompletions, + FallbackOnError: func(err error) bool { + if c.router.Anthropic != nil && core.IsRetriableError(err) { + c.logger.Info("DeepSeek: OpenAI stream failed; retrying via Anthropic compatibility", "error", err) + return true + } + return false + }, + }) } func (c *DeepSeekClient) Ping(ctx context.Context) error { - return c.openai.Ping(ctx) + if err := c.router.OpenAI.Ping(ctx); err == nil { + return nil + } else if c.router.Anthropic == nil || !core.IsRetriableError(err) { + return err + } + return c.router.Anthropic.Ping(ctx) } var _ core.Provider = (*DeepSeekClient)(nil) diff --git a/client/adapters/mimo.go b/client/adapters/mimo.go index 67e8ba0..93cda77 100644 --- a/client/adapters/mimo.go +++ b/client/adapters/mimo.go @@ -2,70 +2,115 @@ package adapters import ( "context" + "errors" + "log/slog" "net/http" "strconv" "strings" - "github.com/GrayCodeAI/eyrie/catalog/xiaomi" "github.com/GrayCodeAI/eyrie/client/core" + + "github.com/GrayCodeAI/eyrie/catalog/xiaomi" + "github.com/GrayCodeAI/eyrie/types" ) -// MiMoClient uses the official OpenAI-compatible Xiaomi MiMo surface only. +// MiMoClient uses OpenAI-compatible MiMo endpoints first, with optional Anthropic-compat fallback. type MiMoClient struct { - openai *OpenAIClient + router ProtocolRouter providerID string + logger *slog.Logger } -// NewMiMoClient builds an OpenAI-compatible MiMo client (payg or token_plan). -func NewMiMoClient(apiKey, openAIBase string, compat *OpenAICompatConfig, providerID string, opts ...core.ClientOption) *MiMoClient { +// NewMiMoClient builds a MiMo provider client (payg or token_plan gateway). +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([]core.ClientOption{}, opts...), core.WithMimoAuth(), core.WithProviderName(providerID)) + o := NewOpenAIClient(apiKey, openAIBase, compat, mimoOpts...) + var a *AnthropicClient + if anthropicBase != "" { + a = NewAnthropicClient(apiKey, anthropicBase, mimoOpts...) + } return &MiMoClient{ - openai: NewOpenAIClient(apiKey, openAIBase, compat, mimoOpts...), + router: ProtocolRouter{OpenAI: o, Anthropic: a}, providerID: providerID, + logger: slog.Default(), } } +// core.WithProviderName and core.WithMimoAuth live in client/core (options.go wraps them). + func (c *MiMoClient) Name() string { - if c.openai != nil { - return c.openai.Name() + if c.router.OpenAI != nil { + return c.router.OpenAI.Name() } return c.providerID } func (c *MiMoClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { - return c.openai.Chat(ctx, messages, opts) + 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 + } + return false + }) } func (c *MiMoClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { - return c.openai.StreamChat(ctx, messages, opts) + return c.router.StreamChat(ctx, messages, opts, ProtocolStreamConfig{ + Primary: ChatProtocolCompletions, + FallbackOnError: func(err error) bool { + if c.router.Anthropic != nil && mimoFallbackChatError(err) { + c.logger.Info("MiMo: OpenAI stream failed; retrying via Anthropic compatibility", "provider", c.providerID, "error", err) + return true + } + return false + }, + }) } func (c *MiMoClient) Ping(ctx context.Context) error { - return c.openai.Ping(ctx) + if err := c.router.OpenAI.Ping(ctx); err == nil { + return nil + } else if c.router.Anthropic == nil || !mimoRetryableChatError(err) { + return err + } + return c.router.Anthropic.Ping(ctx) } -var _ core.Provider = (*MiMoClient)(nil) - -// mimoAuthHeaders sets MiMo-preferred authentication on outbound requests. -func mimoAuthHeaders(req *http.Request, apiKey string) { - xiaomi.SetMimoRequestAuth(req, apiKey) +func mimoFallbackChatError(err error) bool { + if mimoRetryableChatError(err) { + return true + } + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "param incorrect") || + strings.Contains(msg, "invalid format") || + strings.Contains(msg, "reasoning_content") || + (strings.Contains(msg, "http 400") && strings.Contains(msg, "xiaomi")) } -// ProviderID reports the configured MiMo gateway identity. -func (c *MiMoClient) ProviderID() string { return c.providerID } - -// MimoRetryableChatError reports whether an error is retryable for MiMo HTTP status rules. -// Kept for callers that classify MiMo errors outside the transport. -func MimoRetryableChatError(err error) bool { +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 n := parseHTTPStatusFromError(msg); n > 0 { - return xiaomi.IsRetryableHTTPStatus(n) + if xiaomi.IsRetryableHTTPStatus(n) { + return true + } + } + // Structured path: trust core.EyrieError's IsRetriable + var eyrieErr *core.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 { @@ -85,3 +130,19 @@ func parseHTTPStatusFromError(msg string) int { } return 0 } + +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 index 9149908..d2d9346 100644 --- a/client/adapters/mimo_test.go +++ b/client/adapters/mimo_test.go @@ -2,48 +2,42 @@ package adapters import ( "context" - "fmt" "net/http" - "strings" "testing" "github.com/GrayCodeAI/eyrie/client/core" - "github.com/GrayCodeAI/eyrie/types" ) -func TestMiMoClient_OpenAIOnly(t *testing.T) { +func TestMiMoClientChatFallsBackToAnthropicOnParamIncorrect(t *testing.T) { t.Parallel() - client := NewMiMoClient("tp-test-key", "https://openai.example/v1/", &XiaomiCompat, "xiaomi_mimo_token_plan") - if client == nil || client.openai == nil { - t.Fatal("expected OpenAI client") - } - if got := client.openai.baseURL; got != "https://openai.example/v1" { - t.Fatalf("OpenAI base URL = %q", got) - } - if client.ProviderID() != "xiaomi_mimo_token_plan" { - t.Fatalf("ProviderID = %q", client.ProviderID()) - } -} - -func TestMiMoClient_ChatUsesOpenAIAndMimoAuth(t *testing.T) { - t.Parallel() - var gotPath, gotAPIKey, gotAuth string - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - gotPath = req.URL.Path - gotAPIKey = req.Header.Get("api-key") - gotAuth = req.Header.Get("Authorization") + 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": "chat", - "choices": []map[string]any{ - {"message": map[string]string{"role": "assistant", "content": "ok"}, "finish_reason": "stop"}, - }, + "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", &XiaomiCompat, "xiaomi_mimo_token_plan") - client.openai.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.openai.httpClient = &http.Client{Transport: transport} - + 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, @@ -54,101 +48,27 @@ func TestMiMoClient_ChatUsesOpenAIAndMimoAuth(t *testing.T) { if response.Content != "ok" { t.Fatalf("content = %q, want ok", response.Content) } - if !strings.HasSuffix(gotPath, "/chat/completions") { - t.Fatalf("path = %q, want /chat/completions", gotPath) - } - if gotAPIKey != "tp-test-key" && !strings.Contains(gotAuth, "tp-test-key") { - t.Fatalf("missing MiMo auth headers: api-key=%q Authorization=%q", gotAPIKey, gotAuth) - } -} - -func TestMiMoClient_Name(t *testing.T) { - t.Parallel() - client := NewMiMoClient("key", "https://oai.example/v1", &XiaomiCompat, "providerA") - if client.Name() != "providerA" { - t.Errorf("Name() = %q, want providerA", client.Name()) - } -} - -func TestMiMoClient_Name_NoOpenAI(t *testing.T) { - t.Parallel() - c := &MiMoClient{providerID: "bare-id"} - if c.Name() != "bare-id" { - t.Errorf("Name() = %q, want bare-id", c.Name()) - } -} - -func TestMiMoClient_Ping_SuccessViaOpenAI(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusOK, map[string]any{"data": []map[string]any{}}), nil - }) - client := NewMiMoClient("key", "https://oai.example/v1", &XiaomiCompat, "p") - client.openai.httpClient = &http.Client{Transport: transport} - if err := client.Ping(context.Background()); err != nil { - t.Fatalf("Ping: %v", err) - } -} - -func TestMiMoClient_Chat_SurfacesOpenAIError(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"message": "Param Incorrect"}}), nil - }) - client := NewMiMoClient("key", "https://oai.example/v1", &XiaomiCompat, "p") - client.openai.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.openai.httpClient = &http.Client{Transport: transport} - - _, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "mimo"}) - if err == nil { - t.Fatal("expected OpenAI error without Anthropic fallback") - } -} - -func TestMiMoClient_StreamChat_SurfacesOpenAIError(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"message": "Param Incorrect"}}), nil - }) - client := NewMiMoClient("key", "https://oai.example/v1", &XiaomiCompat, "p") - client.openai.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.openai.httpClient = &http.Client{Transport: transport} - - _, err := client.StreamChat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "mimo"}) - if err == nil { - t.Fatal("expected OpenAI error without Anthropic fallback") + if anthropicCalls != 1 { + t.Fatalf("anthropic calls = %d, want 1", anthropicCalls) } } -func TestMimoRetryableChatError_HTTPStatus(t *testing.T) { +func TestMiMoClientPreservesProtocolBaseURLs(t *testing.T) { t.Parallel() - if !MimoRetryableChatError(fmt.Errorf("HTTP 401")) { - t.Error("expected 401 to be retryable") - } - if MimoRetryableChatError(fmt.Errorf("HTTP 400")) { - t.Error("expected 400 not retryable") - } - if MimoRetryableChatError(fmt.Errorf("connection refused")) { - t.Error("expected non-HTTP errors not retryable via status helper") + 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) } -} - -func TestParseHTTPStatusFromError(t *testing.T) { - t.Parallel() - tests := []struct { - msg string - want int - }{ - {"HTTP 404 Not Found", 404}, - {"status 500 internal", 500}, - {"error (403) forbidden", 403}, - {"no status here", 0}, - {"", 0}, + if client.router.Anthropic == nil { + t.Fatal("Anthropic fallback client is nil") } - for _, tt := range tests { - got := parseHTTPStatusFromError(tt.msg) - if got != tt.want { - t.Errorf("parseHTTPStatusFromError(%q) = %d, want %d", tt.msg, got, tt.want) - } + if got := client.router.Anthropic.baseURL; got != "https://anthropic.example/anthropic" { + t.Fatalf("Anthropic base URL = %q", got) } } diff --git a/client/adapters/opencodego.go b/client/adapters/opencodego.go index 54f3ad1..33fdb9a 100644 --- a/client/adapters/opencodego.go +++ b/client/adapters/opencodego.go @@ -4,19 +4,16 @@ import ( "context" "strings" - "github.com/GrayCodeAI/eyrie/catalog/opencodego" "github.com/GrayCodeAI/eyrie/client/core" + + "github.com/GrayCodeAI/eyrie/catalog/opencodego" ) -// OpenCodeGoClient routes each model to exactly one protocol: -// - OpenAI /v1/chat/completions when the model has an OpenAI-compatible endpoint -// - Anthropic /v1/messages when the model is Anthropic-only on OpenCode Go -// -// Never falls back across protocols for the same request (official Go docs list -// one endpoint per model). +// OpenCodeGoClient routes OpenCode Go models through OpenAIClient and +// AnthropicClient per opencode.ai/docs/go, with cross-protocol fallback when +// the primary path returns no answer text (e.g. reasoning-only MiniMax streams). type OpenCodeGoClient struct { - openai *OpenAIClient - anthropic *AnthropicClient + router ProtocolRouter } // NewOpenCodeGoClient builds an OpenCode Go provider client. @@ -26,10 +23,10 @@ func NewOpenCodeGoClient(apiKey, baseURL string, opts ...core.ClientOption) *Ope openBase = opencodego.DefaultBaseURL } ocgOpts := append(append([]core.ClientOption{}, opts...), core.WithProviderName("opencodego")) - return &OpenCodeGoClient{ - openai: NewOpenAIClient(apiKey, openBase, &OpenCodeGoCompat, ocgOpts...), - anthropic: NewAnthropicClient(apiKey, AnthropicBaseFromOpenAIV1(openBase), ocgOpts...), - } + return &OpenCodeGoClient{router: ProtocolRouter{ + OpenAI: NewOpenAIClient(apiKey, openBase, &OpenCodeGoCompat, ocgOpts...), + Anthropic: NewAnthropicClient(apiKey, AnthropicBaseFromOpenAIV1(openBase), ocgOpts...), + }} } func (c *OpenCodeGoClient) Name() string { return "opencodego" } @@ -37,22 +34,48 @@ func (c *OpenCodeGoClient) Name() string { return "opencodego" } 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.anthropic.Chat(ctx, messages, opts) + return c.router.Chat(ctx, messages, opts, ChatProtocolMessages, openCodeGoMessagesFallback) } - return c.openai.Chat(ctx, messages, opts) + return c.router.OpenAI.Chat(ctx, messages, opts) } 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.anthropic.StreamChat(ctx, messages, opts) + return c.router.StreamChat(ctx, messages, opts, ProtocolStreamConfig{ + Primary: ChatProtocolMessages, + ReasoningOnlyFallback: true, + }) } - return c.openai.StreamChat(ctx, messages, opts) + return c.router.OpenAI.StreamChat(ctx, messages, opts) } func (c *OpenCodeGoClient) Ping(ctx context.Context) error { - // Health-check the OpenAI gateway surface; Anthropic-only models share the same host. - return c.openai.Ping(ctx) + if err := c.router.OpenAI.Ping(ctx); err == nil { + return nil + } + return c.router.Anthropic.Ping(ctx) +} + +func openCodeGoMessagesFallback(primaryErr error, primaryResp *core.EyrieResponse) bool { + if primaryErr != nil { + return oaCompatUnsupportedError(primaryErr) + } + return !core.ResponseHasContent(primaryResp) +} + +func oaCompatUnsupportedError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "status=401") || + strings.Contains(msg, "http 401") || + strings.Contains(msg, "oa-compat") || + strings.Contains(msg, "not supported") } +// 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 index 940464f..3f25d65 100644 --- a/client/adapters/opencodego_test.go +++ b/client/adapters/opencodego_test.go @@ -2,12 +2,12 @@ package adapters import ( "context" + "io" "net/http" "strings" "testing" "github.com/GrayCodeAI/eyrie/client/core" - "github.com/GrayCodeAI/eyrie/types" ) func TestOpenCodeGoClientRoutesMiniMaxToAnthropic(t *testing.T) { @@ -25,7 +25,7 @@ func TestOpenCodeGoClientRoutesMiniMaxToAnthropic(t *testing.T) { }) client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") - client.anthropic.httpClient = &http.Client{Transport: transport} + 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, }) @@ -58,7 +58,7 @@ func TestOpenCodeGoClientRoutesKimiToOpenAI(t *testing.T) { }) client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") - client.openai.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: "kimi-k2.5", MaxTokens: 256, }) @@ -73,7 +73,7 @@ func TestOpenCodeGoClientRoutesKimiToOpenAI(t *testing.T) { } } -func TestOpenCodeGoClientQwenUsesAnthropicOnly(t *testing.T) { +func TestOpenCodeGoClientQwen401FallsBackToOpenAI(t *testing.T) { t.Parallel() var paths []string transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { @@ -83,22 +83,62 @@ func TestOpenCodeGoClientQwenUsesAnthropicOnly(t *testing.T) { "error": map[string]string{"message": "Invalid API key"}, }), nil } - t.Fatalf("unexpected OpenAI path %q — no cross-protocol fallback", req.URL.Path) - return nil, 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.anthropic.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.anthropic.httpClient = &http.Client{Transport: transport} - client.openai.httpClient = &http.Client{Transport: transport} - _, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ + 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.Fatal("expected Anthropic error without OpenAI fallback") + 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) != 1 || !strings.HasSuffix(paths[0], "/messages") { - t.Fatalf("paths = %v, want single /messages call", paths) + if len(paths) < 2 { + t.Fatalf("expected anthropic then openai, got %v", paths) } } @@ -122,7 +162,7 @@ func TestOpenCodeGoClientNormalizesModelID(t *testing.T) { }), nil }) client := NewOpenCodeGoClient("key", "https://opencode.example/zen/go/v1") - client.openai.httpClient = &http.Client{Transport: transport} + 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, }) @@ -134,46 +174,61 @@ func TestOpenCodeGoClientNormalizesModelID(t *testing.T) { } } -func TestOpenCodeGoClient_Name(t *testing.T) { - t.Parallel() - client := NewOpenCodeGoClient("key", "https://opencode.example/zen/go/v1") - if client.Name() != "opencodego" { - t.Errorf("Name() = %q, want opencodego", client.Name()) - } -} - -func TestOpenCodeGoClient_Ping_Success(t *testing.T) { +func TestOpenCodeGoClientStreamMiniMaxReasoningOnlyFallsBackToChat(t *testing.T) { t.Parallel() + var paths []string transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusOK, map[string]any{}), nil + 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("key", "https://opencode.example/zen/go/v1") - client.openai.httpClient = &http.Client{Transport: transport} - if err := client.Ping(context.Background()); err != nil { - t.Fatalf("Ping: %v", err) - } -} -func TestOpenCodeGoClient_Ping_OpenAIOnly(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusUnauthorized, map[string]any{}), 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, }) - client := NewOpenCodeGoClient("key", "https://openai.example/v1") - client.openai.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.openai.httpClient = &http.Client{Transport: transport} - if err := client.Ping(context.Background()); err == nil { - t.Fatal("expected ping error without Anthropic fallback") + if err != nil { + t.Fatalf("StreamChat: %v", err) } -} + defer result.Close() -func TestNewOpenCodeGoClient_DefaultBaseURL(t *testing.T) { - t.Parallel() - client := NewOpenCodeGoClient("key", "") - if client == nil { - t.Fatal("expected non-nil client") + 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 client.Name() != "opencodego" { - t.Errorf("Name = %q, want opencodego", client.Name()) + if len(paths) < 2 { + t.Fatalf("expected /messages then /chat/completions, got %v", paths) } } diff --git a/client/adapters/poolside_test.go b/client/adapters/poolside_test.go index d6d6aa4..cee2c10 100644 --- a/client/adapters/poolside_test.go +++ b/client/adapters/poolside_test.go @@ -70,58 +70,3 @@ func TestPoolsideClientReasoningOnlyStreamFallsBackToChat(t *testing.T) { t.Fatalf("requests = %d, want stream plus chat fallback", requests) } } - -func TestPoolsideClientReasoningOnlyStreamFallbackBumpsMaxTokens(t *testing.T) { - t.Parallel() - var requests int - var fallbackMaxTokens int - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - requests++ - var body struct { - MaxTokens int `json:"max_tokens"` - } - if err := jsonDecodeRequest(req, &body); err != nil { - t.Fatalf("decode request: %v", err) - } - if requests == 2 { - fallbackMaxTokens = body.MaxTokens - } - if requests == 1 { - return jsonResponse(http.StatusOK, map[string]any{ - "choices": []map[string]any{{ - "message": map[string]string{"role": "assistant", "reasoning_content": "thinking"}, - "finish_reason": "stop", - }}, - }), nil - } - return jsonResponse(http.StatusOK, map[string]any{ - "choices": []map[string]any{{ - "message": map[string]string{"role": "assistant", "content": "Hi"}, - "finish_reason": "stop", - }}, - }), nil - }) - - client := NewPoolsideClient("poolside-test-key", "https://poolside.example/v1") - client.openAI.httpClient = &http.Client{Transport: transport} - result, err := client.StreamChat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ - Model: "poolside/laguna-m.1", MaxTokens: 128, - }) - if err != nil { - t.Fatalf("StreamChat: %v", err) - } - defer result.Close() - - var content string - for event := range result.Events { - if event.Type == "content" { - content += event.Content - } - } - if content != "Hi" { - t.Fatalf("content = %q, want Hi", content) - } - if fallbackMaxTokens != 512 { - t.Fatalf("fallback max_tokens = %d, want 512 (bumped from 128)", fallbackMaxTokens) - } -} diff --git a/client/adapters/protocol_router.go b/client/adapters/protocol_router.go new file mode 100644 index 0000000..86cda44 --- /dev/null +++ b/client/adapters/protocol_router.go @@ -0,0 +1,241 @@ +package adapters + +import ( + "context" + "fmt" + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" + "github.com/GrayCodeAI/hawk-core-contracts/llm" +) + +// ChatProtocol selects which existing eyrie client handles a gateway request. +type ChatProtocol int + +const ( + // ChatProtocolCompletions routes via OpenAIClient (POST /v1/chat/completions). + ChatProtocolCompletions ChatProtocol = iota + // ChatProtocolMessages routes via AnthropicClient (POST /v1/messages). + ChatProtocolMessages +) + +type streamOpener func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.StreamResult, 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 +// MiniMax often returns answer text on chat/completions while the stream only +// exposes reasoning_content. +type protocolStreamFallback struct { + stream streamOpener + chat chatOpener +} + +// 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 *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 +// and anthropic.go; this type only orchestrates routing and fallback. +type ProtocolRouter struct { + OpenAI *OpenAIClient + Anthropic *AnthropicClient +} + +// ProtocolStreamConfig controls streaming across two protocols. +type ProtocolStreamConfig struct { + Primary ChatProtocol + FallbackOnError func(error) bool + ReasoningOnlyFallback bool // retry alternate protocol when primary stream is reasoning-only +} + +// 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 []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 && core.ResponseHasContent(fallbackResp) { + return fallbackResp, nil + } + return resp, err +} + +// StreamChat streams via the primary protocol. When FallbackOnError matches, +// 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 []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 { + if cfg.FallbackOnError != nil && cfg.FallbackOnError(err) { + return fallbackClient.StreamChat(ctx, messages, opts) + } + return result, err + } + if cfg.ReasoningOnlyFallback && cfg.Primary == ChatProtocolMessages { + fallback := fallbackClient + return newStreamWithReasoningFallback(ctx, messages, opts, result, protocolStreamFallback{ + 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 []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { + return fallback.StreamChat(ctx, messages, opts) + }, + }), nil + } + return result, nil +} + +func (r ProtocolRouter) providers(primary ChatProtocol) (core.Provider, core.Provider) { + if primary == ChatProtocolMessages { + return r.Anthropic, r.OpenAI + } + return r.OpenAI, r.Anthropic +} + +// AnthropicBaseFromOpenAIV1 strips a trailing /v1 from an OpenAI-compatible base URL. +func AnthropicBaseFromOpenAIV1(openAIBase string) string { + base := strings.TrimRight(strings.TrimSpace(openAIBase), "/") + if strings.HasSuffix(base, "/v1") { + return strings.TrimSuffix(base, "/v1") + } + return base +} + +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 <- core.EyrieStreamEvent{Type: "thinking", Thinking: resp.Thinking} + } + if strings.TrimSpace(resp.Content) != "" { + out <- core.EyrieStreamEvent{Type: "content", Content: resp.Content} + } + for i := range resp.ToolCalls { + tc := resp.ToolCalls[i] + out <- core.EyrieStreamEvent{Type: "tool_call", ToolCall: &tc} + } + if resp.Usage != nil { + out <- core.EyrieStreamEvent{Type: "usage", Usage: resp.Usage} + } + stop := resp.FinishReason + if stop == "" { + stop = "stop" + } + out <- core.EyrieStreamEvent{Type: "done", StopReason: stop} + }() + return llm.NewStreamResult(out, "", func() {}) +} + +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 && core.ResponseHasContent(resp) { + return streamResultFromChat(resp), nil + } + } + if f.stream != nil { + return f.stream(ctx, messages, opts) + } + return nil, fmt.Errorf("eyrie: protocol stream fallback is not configured") +} + +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) + defer primary.Close() + defer cancel() + + var ( + sawReasoning bool + content strings.Builder + toolCalls int + buffered []core.EyrieStreamEvent + streamErr bool + ) + flush := func() { + for _, ev := range buffered { + select { + case out <- ev: + case <-cancelCtx.Done(): + return + } + } + buffered = nil + } + for ev := range primary.Events { + switch ev.Type { + case "thinking": + sawReasoning = true + case "content": + content.WriteString(ev.Content) + case "tool_call": + toolCalls++ + case "error": + if !isReasoningOnlyStreamDiagnostic(ev.Error) { + streamErr = true + } + } + if strings.TrimSpace(content.String()) != "" || toolCalls > 0 { + flush() + select { + case out <- ev: + case <-cancelCtx.Done(): + return + } + continue + } + buffered = append(buffered, ev) + } + + health := core.DetectResponseHealth(core.ResponseSignals{ + SawReasoning: sawReasoning, + ContentLen: len(strings.TrimSpace(content.String())), + ToolCalls: toolCalls, + StreamEnded: true, + StreamErr: streamErr, + }) + if health != core.ResponseErrorOnlyReasoning { + flush() + return + } + + fallbackResult, err := fallback.open(cancelCtx, messages, opts) + if err != nil { + flush() + select { + case out <- core.EyrieStreamEvent{Type: "error", Error: err.Error()}: + case <-cancelCtx.Done(): + } + return + } + defer fallbackResult.Close() + for ev := range fallbackResult.Events { + select { + case out <- ev: + case <-cancelCtx.Done(): + return + } + } + }() + return llm.NewStreamResult(out, "", cancel) +} + +func isReasoningOnlyStreamDiagnostic(message string) bool { + message = strings.ToLower(strings.TrimSpace(message)) + return strings.Contains(message, "error_only_reasoning") || + strings.Contains(message, "reasoning tokens but no answer") +} diff --git a/client/adapters/protocol_router_test.go b/client/adapters/protocol_router_test.go new file mode 100644 index 0000000..bad6055 --- /dev/null +++ b/client/adapters/protocol_router_test.go @@ -0,0 +1,153 @@ +package adapters + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/GrayCodeAI/eyrie/client/core" + "github.com/GrayCodeAI/hawk-core-contracts/llm" +) + +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 := llm.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 := llm.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 llm.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 index b72e151..2061150 100644 --- a/client/adapters/provider_registry.go +++ b/client/adapters/provider_registry.go @@ -76,20 +76,19 @@ func staticProviderMaps() (map[string]ProviderRegistryConfig, map[string]Provide 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") }, - "concentrate": func() bool { return credentials.HasSecret(ctx, "CONCENTRATE_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") }, + "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") }, diff --git a/client/adapters/test_helpers_test.go b/client/adapters/test_helpers_test.go index 1805eae..dcef8ba 100644 --- a/client/adapters/test_helpers_test.go +++ b/client/adapters/test_helpers_test.go @@ -4,15 +4,9 @@ import ( "bytes" "encoding/json" "io" - "log/slog" "net/http" - "testing" ) -func testLogger(t *testing.T) *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) -} - type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { @@ -41,5 +35,3 @@ func jsonDecodeRequest(req *http.Request, value any) error { } return json.Unmarshal(body, value) } - -func float64Ptr(v float64) *float64 { return &v } diff --git a/client/adapters/zai.go b/client/adapters/zai.go index afc29cb..9cf8dd4 100644 --- a/client/adapters/zai.go +++ b/client/adapters/zai.go @@ -2,46 +2,123 @@ package adapters import ( "context" + "errors" + "log/slog" + "net/http" "strings" "github.com/GrayCodeAI/eyrie/client/core" + + "github.com/GrayCodeAI/eyrie/types" ) -// ZAIClient uses the official OpenAI-compatible Z.AI surface only -// (paas/v4 or coding/paas/v4). +// ZAIClient uses the OpenAI-compatible endpoint (paas/v4 or coding/paas/v4) first, +// with Anthropic-compatible fallback (/api/anthropic) on retriable errors. +// This provides proper separation for General vs Coding Plan while giving +// both protocol surfaces (exactly as Xiaomi MiMo does for its plans). type ZAIClient struct { - openai *OpenAIClient + router ProtocolRouter providerID string + logger *slog.Logger } -// NewZAIClient builds an OpenAI-compatible Z.AI client for a plan/region gateway. +// NewZAIClient builds a Z.AI dual-protocol client for a given plan/region gateway. // openAIBase should be the resolved general or coding paas base. -func NewZAIClient(apiKey, openAIBase string, compat *OpenAICompatConfig, providerID string, opts ...core.ClientOption) *ZAIClient { +// 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 ...core.ClientOption) *ZAIClient { openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") + anthropicBase = strings.TrimRight(strings.TrimSpace(anthropicBase), "/") + zaiOpts := append([]core.ClientOption{core.WithProviderName(providerID)}, opts...) + o := NewOpenAIClient(apiKey, openAIBase, compat, zaiOpts...) + + var a *AnthropicClient + if anthropicBase != "" { + a = NewAnthropicClient(apiKey, anthropicBase, zaiOpts...) + } + return &ZAIClient{ - openai: NewOpenAIClient(apiKey, openAIBase, compat, zaiOpts...), + router: ProtocolRouter{OpenAI: o, Anthropic: a}, providerID: providerID, + logger: slog.Default(), } } func (c *ZAIClient) Name() string { - if c.openai != nil { - return c.openai.Name() + if c.router.OpenAI != nil { + return c.router.OpenAI.Name() } return c.providerID } func (c *ZAIClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { - return c.openai.Chat(ctx, messages, opts) + 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) + return true + } + return false + }) } func (c *ZAIClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { - return c.openai.StreamChat(ctx, messages, opts) + return c.router.StreamChat(ctx, messages, opts, ProtocolStreamConfig{ + Primary: ChatProtocolCompletions, + FallbackOnError: func(err error) bool { + if c.router.Anthropic != nil && zaiFallbackChatError(err) { + c.logger.Info("Z.AI: OpenAI stream failed; retrying via Anthropic compatibility", + "provider", c.providerID, "error", err) + return true + } + return false + }, + }) } func (c *ZAIClient) Ping(ctx context.Context) error { - return c.openai.Ping(ctx) + if err := c.router.OpenAI.Ping(ctx); err == nil { + return nil + } else if c.router.Anthropic == nil || !zaiRetryableChatError(err) { + return err + } + return c.router.Anthropic.Ping(ctx) +} + +func zaiFallbackChatError(err error) bool { + if zaiRetryableChatError(err) { + return true + } + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + // Common Z.AI / GLM specific transient or format issues seen in the wild + // (similar to reasoning_content or param problems on other compat layers). + return strings.Contains(msg, "param incorrect") || + strings.Contains(msg, "invalid format") || + strings.Contains(msg, "reasoning_content") || + (strings.Contains(msg, "http 400") && strings.Contains(msg, "zai")) +} + +func zaiRetryableChatError(err error) bool { + if err == nil { + return false + } + // Z.AI-specific: check HTTP status codes + msg := err.Error() + if n := parseHTTPStatusFromError(msg); n > 0 { + return n >= 500 || n == http.StatusUnauthorized || n == http.StatusForbidden + } + // Structured path: trust core.EyrieError's IsRetriable + var eyrieErr *core.EyrieError + if errors.As(err, &eyrieErr) { + return eyrieErr.IsRetriable() + } + // Conservative: only retry on explicitly transient errors + return types.IsTransient(err) } var _ core.Provider = (*ZAIClient)(nil) diff --git a/client/aliases.go b/client/aliases.go index 518af17..bdf6879 100644 --- a/client/aliases.go +++ b/client/aliases.go @@ -278,10 +278,20 @@ type ( OpenCodeGoClient = adapters.OpenCodeGoClient // PoolsideClient implements Poolside reasoning-only stream recovery. PoolsideClient = adapters.PoolsideClient + // 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...) @@ -311,16 +321,16 @@ func NewVertexClient(projectID, region, token string) *VertexClient { return adapters.NewVertexClient(projectID, region, token) } -func NewDeepSeekClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...ClientOption) *DeepSeekClient { - return adapters.NewDeepSeekClient(apiKey, openAIBase, compat, opts...) +func NewDeepSeekClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, opts ...ClientOption) *DeepSeekClient { + return adapters.NewDeepSeekClient(apiKey, openAIBase, anthropicBase, compat, opts...) } -func NewZAIClient(apiKey, openAIBase string, compat *OpenAICompatConfig, providerID string, opts ...ClientOption) *ZAIClient { - return adapters.NewZAIClient(apiKey, openAIBase, compat, providerID, 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 string, compat *OpenAICompatConfig, providerID string, opts ...ClientOption) *MiMoClient { - return adapters.NewMiMoClient(apiKey, openAIBase, 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 { @@ -371,6 +381,8 @@ var ( geminiSharedParserEnvVar = adapters.GeminiSharedParserEnvVar processGeminiStream = adapters.ProcessGeminiStream mimoRetryableChatError = adapters.MimoRetryableChatError + mimoFallbackChatError = adapters.MimoFallbackChatError + oaCompatUnsupportedError = adapters.OACompatUnsupportedError CoreProviders = adapters.CoreProviders OpenAICompatibleProviders = adapters.OpenAICompatibleProviders sha256Hex = adapters.Sha256Hex diff --git a/client/compat.go b/client/compat.go index 1d56230..d7adb90 100644 --- a/client/compat.go +++ b/client/compat.go @@ -7,25 +7,21 @@ type OpenAICompatConfig = adapters.OpenAICompatConfig // Per-provider compat configs. var ( - OpenAICompat = adapters.OpenAICompat - AgnesCompat = adapters.AgnesCompat - LongCatCompat = adapters.LongCatCompat - 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 - ConcentrateCompat = adapters.ConcentrateCompat - MiniMaxCompat = adapters.MiniMaxCompat + 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 ) diff --git a/client/mimo_test.go b/client/mimo_test.go index 6e30d7e..453f86e 100644 --- a/client/mimo_test.go +++ b/client/mimo_test.go @@ -30,6 +30,16 @@ func TestMimoRetryableChatError_UsesXiaomiHelper(t *testing.T) { } } +func TestMimoFallbackChatError_ParamIncorrect(t *testing.T) { + err := errors.New("eyrie: xiaomi_mimo_token_plan API error (request_id=): : Param Incorrect") + if !mimoFallbackChatError(err) { + t.Fatal("expected Param Incorrect to fallback to Anthropic compatibility") + } + if mimoFallbackChatError(errors.New("eyrie: openai API error: invalid model")) { + t.Fatal("non-MiMo unrelated errors should not trigger fallback") + } +} + func TestGetOrCreateProvider_XiaomiTokenPlanUsesMimoBase(t *testing.T) { t.Setenv("HAWK_CONFIG_DIR", t.TempDir()) if err := eyriecfg.SaveProviderConfig(&eyriecfg.ProviderConfig{ diff --git a/client/opencodego_test.go b/client/opencodego_test.go index 92e9b5d..e0d4e24 100644 --- a/client/opencodego_test.go +++ b/client/opencodego_test.go @@ -1,6 +1,7 @@ package client import ( + "fmt" "testing" "github.com/GrayCodeAI/eyrie/catalog/opencodego" @@ -32,3 +33,21 @@ func TestOpenCodeGoAnthropicBase(t *testing.T) { t.Fatalf("base = %q, want https://opencode.ai/zen/go", got) } } + +func TestOpenCodeGoOACompatUnsupportedError(t *testing.T) { + t.Parallel() + tests := []struct { + err error + want bool + }{ + {nil, false}, + {fmt.Errorf("status=401 unauthorized"), true}, + {fmt.Errorf("oa-compat not supported"), true}, + {fmt.Errorf("HTTP 400 bad request"), false}, + } + for _, tc := range tests { + if got := oaCompatUnsupportedError(tc.err); got != tc.want { + t.Errorf("OACompatUnsupportedError(%v) = %v, want %v", tc.err, got, tc.want) + } + } +} diff --git a/client/protocol_router_test.go b/client/protocol_router_test.go new file mode 100644 index 0000000..2396984 --- /dev/null +++ b/client/protocol_router_test.go @@ -0,0 +1,15 @@ +package client + +import ( + "testing" +) + +func TestAnthropicBaseFromOpenAIV1(t *testing.T) { + t.Parallel() + if got := AnthropicBaseFromOpenAIV1("https://example.com/zen/go/v1"); got != "https://example.com/zen/go" { + t.Fatalf("got %q", got) + } + if got := AnthropicBaseFromOpenAIV1("https://example.com/zen/go"); got != "https://example.com/zen/go" { + t.Fatalf("got %q", got) + } +} diff --git a/client/provider_policy.go b/client/provider_policy.go index ed07d09..f619835 100644 --- a/client/provider_policy.go +++ b/client/provider_policy.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/GrayCodeAI/eyrie/client/core" + "github.com/GrayCodeAI/eyrie/config" ) // ApplyProviderChatDefaults applies provider policy that host applications @@ -13,25 +14,8 @@ func ApplyProviderChatDefaults(provider string, opts ChatOptions) ChatOptions { if strings.EqualFold(strings.TrimSpace(provider), "anthropic") { opts.EnableCaching = true } - opts = NormalizeThinkingOptions(opts) - switch strings.ToLower(strings.TrimSpace(provider)) { - case "zai_payg", "zai_coding", "agnes", "openrouter", "opencodego", "anthropic": - // Keep ThinkingEnabled — wire encoding comes from each provider's ThinkingFormat - // (or Anthropic resolveThinking). - case "longcat", "kimi", "deepseek", "xiaomi_mimo", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan", - "minimax_payg", "minimax_token_plan": - // These providers enable thinking when the field is omitted. Default off so - // simple chat does not burn max_tokens on reasoning_content alone. - if EffectiveThinkingEnabled(opts) == nil { - disabled := false - opts.ThinkingEnabled = &disabled - opts.GLMThinkingEnabled = &disabled - } - default: - if !ProviderSupportsThinkingToggle(provider) { - opts.ThinkingEnabled = nil - opts.GLMThinkingEnabled = nil - } + if !config.IsZAIProvider(provider) { + opts.GLMThinkingEnabled = nil } return opts } diff --git a/client/provider_policy_test.go b/client/provider_policy_test.go index c5f9f72..f3df322 100644 --- a/client/provider_policy_test.go +++ b/client/provider_policy_test.go @@ -15,32 +15,11 @@ func TestApplyProviderChatDefaults(t *testing.T) { t.Fatal("non-Anthropic provider enabled caching") } enabled := true - if opts := ApplyProviderChatDefaults("zai_payg", ChatOptions{ThinkingEnabled: &enabled}); opts.ThinkingEnabled == nil { + if opts := ApplyProviderChatDefaults("zai_payg", ChatOptions{GLMThinkingEnabled: &enabled}); opts.GLMThinkingEnabled == nil { t.Fatal("Z.AI thinking preference was removed") } - if opts := ApplyProviderChatDefaults("longcat", ChatOptions{ThinkingEnabled: &enabled}); opts.ThinkingEnabled == nil || !*opts.ThinkingEnabled { - t.Fatal("LongCat explicit thinking preference was removed") - } - if opts := ApplyProviderChatDefaults("longcat", ChatOptions{}); opts.ThinkingEnabled == nil || *opts.ThinkingEnabled { - t.Fatal("LongCat should default ThinkingEnabled=false when unset") - } - if opts := ApplyProviderChatDefaults("kimi", ChatOptions{}); opts.ThinkingEnabled == nil || *opts.ThinkingEnabled { - t.Fatal("Kimi should default ThinkingEnabled=false when unset") - } - if opts := ApplyProviderChatDefaults("deepseek", ChatOptions{}); opts.ThinkingEnabled == nil || *opts.ThinkingEnabled { - t.Fatal("DeepSeek should default ThinkingEnabled=false when unset") - } - if opts := ApplyProviderChatDefaults("agnes", ChatOptions{GLMThinkingEnabled: &enabled}); opts.ThinkingEnabled == nil { - t.Fatal("Agnes should normalize deprecated GLMThinkingEnabled alias") - } - if opts := ApplyProviderChatDefaults("openrouter", ChatOptions{ThinkingEnabled: &enabled}); opts.ThinkingEnabled == nil { - t.Fatal("OpenRouter should keep ThinkingEnabled") - } - if opts := ApplyProviderChatDefaults("anthropic", ChatOptions{ThinkingEnabled: &enabled}); opts.ThinkingEnabled == nil { - t.Fatal("Anthropic should keep ThinkingEnabled") - } - if opts := ApplyProviderChatDefaults("openai", ChatOptions{ThinkingEnabled: &enabled}); opts.ThinkingEnabled != nil || opts.GLMThinkingEnabled != nil { - t.Fatal("non-thinking provider thinking preference was retained") + if opts := ApplyProviderChatDefaults("openai", ChatOptions{GLMThinkingEnabled: &enabled}); opts.GLMThinkingEnabled != nil { + t.Fatal("non-Z.AI thinking preference was retained") } } diff --git a/client/provider_registry.go b/client/provider_registry.go index fa21c45..32d3ea3 100644 --- a/client/provider_registry.go +++ b/client/provider_registry.go @@ -132,7 +132,8 @@ func (c *EyrieClient) getOrCreateProvider(providerName string) (Provider, error) if err != nil { return nil, err } - p = adapters.NewZAIClient(apiKey, openAIBase, info.Compat, providerName) + anthropicBase := config.ResolveZAIAnthropicBase(providerCfg) + p = adapters.NewZAIClient(apiKey, openAIBase, anthropicBase, info.Compat, providerName) break } if config.IsXiaomiMimoProvider(providerName) { @@ -141,17 +142,17 @@ func (c *EyrieClient) getOrCreateProvider(providerName string) (Provider, error) if err != nil { return nil, err } - p = adapters.NewMiMoClient(apiKey, openAIBase, info.Compat, providerName) + anthropicBase, err := config.ResolveXiaomiAnthropicBase(providerName, providerCfg) + if err != nil { + return nil, err + } + p = adapters.NewMiMoClient(apiKey, openAIBase, anthropicBase, info.Compat, providerName) break } if providerName == "opencodego" { p = adapters.NewOpenCodeGoClient(apiKey, baseURL) break } - if providerName == "concentrate" { - p = adapters.NewConcentrateResponsesClient(apiKey, baseURL) - break - } if providerName == "poolside" { p = adapters.NewPoolsideClient(apiKey, baseURL) break diff --git a/config/provider_secrets.go b/config/provider_secrets.go index 3139d14..157d43a 100644 --- a/config/provider_secrets.go +++ b/config/provider_secrets.go @@ -44,7 +44,6 @@ func SanitizeProviderConfigForDisk(cfg ProviderConfig) ProviderConfig { cfg.PoolsideAPIKey = "" cfg.GroqAPIKey = "" cfg.ClinePassAPIKey = "" - cfg.AgnesAPIKey = "" if cfg.Deployments != nil { deployments := make(map[string]DeploymentConfig, len(cfg.Deployments)) for id, deployment := range cfg.Deployments { @@ -92,7 +91,6 @@ func LegacyProviderSecretsStrict(cfg ProviderConfig) (map[string]string, error) put("POOLSIDE_API_KEY", cfg.PoolsideAPIKey) put("GROQ_API_KEY", cfg.GroqAPIKey) put("CLINE_API_KEY", cfg.ClinePassAPIKey) - put("AGNES_API_KEY", cfg.AgnesAPIKey) deploymentIDs := make([]string, 0, len(cfg.Deployments)) for id := range cfg.Deployments { @@ -134,14 +132,11 @@ func legacyDeploymentCredentialEnv(deploymentID string) string { "grok-direct": "XAI_API_KEY", "gemini-direct": "GEMINI_API_KEY", "openrouter": "OPENROUTER_API_KEY", - "concentrate-payg": "CONCENTRATE_API_KEY", "canopywave": "CANOPYWAVE_API_KEY", "deepseek-direct": "DEEPSEEK_API_KEY", "poolside": "POOLSIDE_API_KEY", "groq-direct": "GROQ_API_KEY", "clinepass": "CLINE_API_KEY", - "agnes-direct": "AGNES_API_KEY", - "longcat-direct": "LONGCAT_API_KEY", "zai_payg-direct": "ZAI_API_KEY", "zai_coding-direct": "ZAI_CODING_API_KEY", "opencodego": "OPENCODEGO_API_KEY", @@ -161,7 +156,7 @@ func providerConfigSecrets(cfg ProviderConfig) []string { cfg.OpenRouterAPIKey, cfg.GeminiAPIKey, cfg.OpenCodeGoAPIKey, cfg.MoonshotAPIKey, cfg.XiaomiMimoPaygAPIKey, cfg.XiaomiMimoTokenPlanAPIKey, cfg.MiniMaxTokenPlanAPIKey, cfg.MiniMaxPaygAPIKey, - cfg.PoolsideAPIKey, cfg.GroqAPIKey, cfg.ClinePassAPIKey, cfg.AgnesAPIKey, + cfg.PoolsideAPIKey, cfg.GroqAPIKey, cfg.ClinePassAPIKey, } } diff --git a/config/provider_secrets_test.go b/config/provider_secrets_test.go index d79b063..c6e4ca9 100644 --- a/config/provider_secrets_test.go +++ b/config/provider_secrets_test.go @@ -52,18 +52,6 @@ func TestLegacyProviderSecretsStrictRejectsUnmappedDeploymentFields(t *testing.T } } -func TestLegacyProviderSecretsStrictMapsLongCatDirect(t *testing.T) { - secrets, err := LegacyProviderSecretsStrict(ProviderConfig{Deployments: map[string]DeploymentConfig{ - "longcat-direct": {APIKey: "lc-direct-secret-1234567890"}, - }}) - if err != nil { - t.Fatal(err) - } - if secrets["LONGCAT_API_KEY"] != "lc-direct-secret-1234567890" { - t.Fatalf("LONGCAT_API_KEY = %q", secrets["LONGCAT_API_KEY"]) - } -} - func TestLegacyProviderSecretsStrictMapsBedrockCompatibilityFields(t *testing.T) { secrets, err := LegacyProviderSecretsStrict(ProviderConfig{Deployments: map[string]DeploymentConfig{ "anthropic-bedrock": {APIKey: "AKIALEGACY123456789", Token: "legacy-secret-1234567890"}, diff --git a/conversation/engine_test.go b/conversation/engine_test.go index dcb34c1..adc7277 100644 --- a/conversation/engine_test.go +++ b/conversation/engine_test.go @@ -168,7 +168,7 @@ func (m *maxTokensMockProvider) StreamChat(_ context.Context, msgs []client.Eyri sr := &client.StreamResult{Events: ch} // Wrap Close so we can count invocations. return &client.StreamResult{ - Events: sr.Events, + Events: sr.Events, RequestID: sr.RequestID, }, nil } @@ -258,8 +258,10 @@ func TestConversationEngine_ContextCancelClosesStream(t *testing.T) { } // blockingMockProvider returns a StreamResult whose Events channel blocks -// until the context is cancelled. -type blockingMockProvider struct{} +// until the context is cancelled. It signals via a channel when Close is called. +type blockingMockProvider struct { + closed chan struct{} +} func (b *blockingMockProvider) Name() string { return "blocking-mock" diff --git a/engine/control_plane.go b/engine/control_plane.go index cbb2ee6..62870dd 100644 --- a/engine/control_plane.go +++ b/engine/control_plane.go @@ -65,13 +65,6 @@ func (e *Engine) CredentialProviders(context.Context) []CredentialProvider { return out } -// RegisteredProviderCount reports the number of first-class providers exposed -// by Eyrie's canonical registry. Hosts can use this for informational UI copy -// without importing lower-level catalog packages. -func RegisteredProviderCount() int { - return len(registry.All()) -} - // 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 { diff --git a/engine/convert.go b/engine/convert.go index 8037854..d6a1518 100644 --- a/engine/convert.go +++ b/engine/convert.go @@ -25,14 +25,7 @@ func toClientOptions(req GenerateRequest, route Route, stream bool) client.ChatO opts.ThinkingBudgetTokens = advanced.ThinkingBudgetTokens opts.ThinkingMode = advanced.ThinkingMode opts.ThinkingDisplay = advanced.ThinkingDisplay - opts.ThinkingEnabled = advanced.ThinkingEnabled opts.GLMThinkingEnabled = advanced.GLMThinkingEnabled - if opts.ThinkingEnabled == nil && opts.GLMThinkingEnabled != nil { - opts.ThinkingEnabled = opts.GLMThinkingEnabled - } - if opts.GLMThinkingEnabled == nil && opts.ThinkingEnabled != nil { - opts.GLMThinkingEnabled = opts.ThinkingEnabled - } opts.VirtualKeyID = advanced.VirtualKeyID opts.KimiContextCacheID = advanced.KimiContextCacheID opts.KimiCacheResetTTL = advanced.KimiCacheResetTTL diff --git a/engine/convert_test.go b/engine/convert_test.go index e3463ab..68b649d 100644 --- a/engine/convert_test.go +++ b/engine/convert_test.go @@ -222,9 +222,9 @@ func TestToClientOptions_NoOutputSchemaLeavesResponseFormatNil(t *testing.T) { func TestToClientOptions_ClonesSlicesAndMaps(t *testing.T) { // Verify that mutating the request after conversion does not affect the options. req := llm.GenerateRequest{ - Tools: []llm.EyrieTool{{Name: "a"}, {Name: "b"}}, - Options: llm.GenerationOptions{StopSequences: []string{"x", "y"}}, - OutputSchema: "orig", + Tools: []llm.EyrieTool{{Name: "a"}, {Name: "b"}}, + Options: llm.GenerationOptions{StopSequences: []string{"x", "y"}}, + OutputSchema: "orig", } route := Route{Provider: "test", Model: "test/model"} opts := toClientOptions(req, route, false) diff --git a/engine/engine.go b/engine/engine.go index 87c2765..19346ba 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -552,13 +552,7 @@ func offeringSupports(compiled *catalog.CompiledCatalog, modelID string, req Req for _, offering := range offerings { caps := offering.Capabilities if req.Tools && caps.FunctionCalling != catalog.CapabilitySupported { - // Treat unknown capability as supported. Providers that don't - // report function-calling capability (e.g. free tiers, smaller - // gateways) may still support tools. Better to try and fail than - // block models from being used. Only skip on explicit unsupported. - if caps.FunctionCalling == catalog.CapabilityUnsupported { - continue - } + continue } if req.Vision && caps.ImageInput != catalog.CapabilitySupported { continue diff --git a/engine/engine_test.go b/engine/engine_test.go index 8429db1..525ce75 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -212,42 +212,20 @@ func TestSelectCompatibleModelUsesCapabilitiesAndIntent(t *testing.T) { }, } - // vendor/text has unknown capabilities — treated as supporting tools - // and selected because it's the cheapest option model, provider := selectCompatibleModel(compiled, SelectionRequest{ Requirements: Requirements{Tools: true, MinimumContext: 100_000}, Preference: Preference{Intent: llm.IntentEconomical}, }) - if model != "vendor/text" || provider != "vendor" { - t.Fatalf("selected %q via %q, want vendor/text via vendor", model, provider) + 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: llm.IntentReasoning}, }) - // vendor/text selected: unknown capabilities treated as supported, largest context - if model != "vendor/text" { - t.Fatalf("selected %q, want vendor/text", model) - } -} - -func TestSelectCompatibleModel_ConcentrateLegacyCacheAssumesTools(t *testing.T) { - compiled := &catalog.CompiledCatalog{ - ModelsByID: map[string]catalog.Model{ - "concentrate/deepseek-v4-pro": {ID: "concentrate/deepseek-v4-pro", ProviderID: "concentrate", ContextWindow: 1_040_000}, - }, - OfferingsByCanonicalModel: map[string][]catalog.ModelOffering{ - "concentrate/deepseek-v4-pro": {{CanonicalModelID: "concentrate/deepseek-v4-pro", DeploymentID: "concentrate-payg"}}, - }, - } - - model, provider := selectCompatibleModel(compiled, SelectionRequest{ - Requirements: Requirements{Tools: true}, - Preference: Preference{PreferredProvider: "concentrate"}, - }) - if model != "concentrate/deepseek-v4-pro" || provider != "concentrate" { - t.Fatalf("selected %q via %q, want Concentrate model with tools", model, provider) + if model != "vendor/rich" { + t.Fatalf("selected %q, want vendor/rich", model) } } diff --git a/engine/host_facade_contract_test.go b/engine/host_facade_contract_test.go index a15a711..11034d2 100644 --- a/engine/host_facade_contract_test.go +++ b/engine/host_facade_contract_test.go @@ -7,16 +7,9 @@ import ( "testing" "github.com/GrayCodeAI/eyrie/catalog" - "github.com/GrayCodeAI/eyrie/catalog/registry" "github.com/GrayCodeAI/eyrie/credentials" ) -func TestRegisteredProviderCountMatchesCanonicalRegistry(t *testing.T) { - if got, want := RegisteredProviderCount(), len(registry.All()); got != want { - t.Fatalf("registered provider count = %d, want %d", got, want) - } -} - func TestGatewayDefinitionsArePureMetadataWithSeparateRanks(t *testing.T) { store := &countingStore{inner: &credentials.MapStore{}} eng, err := New(Options{SecretStore: store, StateDir: t.TempDir(), CustomGateways: []CustomGateway{}}) diff --git a/go.mod b/go.mod index f69aa18..aa9abf2 100644 --- a/go.mod +++ b/go.mod @@ -3,38 +3,35 @@ module github.com/GrayCodeAI/eyrie go 1.26.5 require ( - github.com/GrayCodeAI/hawk-core-contracts v0.1.9 + github.com/GrayCodeAI/hawk-core-contracts v0.1.8 github.com/google/uuid v1.6.0 github.com/tiktoken-go/tokenizer v0.8.0 github.com/zalando/go-keyring v0.2.8 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 - google.golang.org/grpc v1.82.0 + google.golang.org/grpc v1.81.1 modernc.org/sqlite v1.51.0 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dlclark/regexp2/v2 v2.2.1 // indirect + github.com/dlclark/regexp2/v2 v2.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect - github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/stretchr/objx v0.5.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.44.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.72.5 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index ea04922..78e324e 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,13 @@ -github.com/GrayCodeAI/hawk-core-contracts v0.1.9 h1:uXX/gtNM+3kxSEzu+rZkHykzcEaAbASn1lmPyOGMXvc= -github.com/GrayCodeAI/hawk-core-contracts v0.1.9/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= +github.com/GrayCodeAI/hawk-core-contracts v0.1.8 h1:SkDsGZJXL+3DYG0Fi3NXvNe/NlhP/KZn+Feofnx35Zc= +github.com/GrayCodeAI/hawk-core-contracts v0.1.8/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= -github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2/v2 v2.1.0 h1:jHXRmHRZGbuQzDZjMlCAXOvQb75iv3HyLDzXGj5H1AY= +github.com/dlclark/regexp2/v2 v2.1.0/go.mod h1:Bz5TMy5d8fPK0ximH0Yi9KvsRHNnvXqUx9XG6a4wB+I= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -21,8 +21,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -31,8 +31,8 @@ github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= @@ -49,30 +49,30 @@ go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= -google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/operationsgraph/operations_graph.go b/operationsgraph/operations_graph.go index 1baf537..3da70c1 100644 --- a/operationsgraph/operations_graph.go +++ b/operationsgraph/operations_graph.go @@ -33,11 +33,11 @@ type OperationEdge struct { // OperationsGraph represents a graph of operations for eyrie. type OperationsGraph struct { mu sync.RWMutex - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` Nodes map[string]*OperationNode `json:"nodes"` - Edges []OperationEdge `json:"edges"` - Attrs map[string]interface{} `json:"attrs,omitempty"` + Edges []OperationEdge `json:"edges"` + Attrs map[string]interface{} `json:"attrs,omitempty"` } // NewOperationsGraph creates a new operations graph. @@ -126,7 +126,7 @@ func (g *OperationsGraph) ToGraphSpec() *graphcontracts.GraphSpec { nodes = append(nodes, graphcontracts.NodeSpec{ ID: id, - Type: graphcontracts.NodeTypeFunction, + Type: graphcontracts.NodeTypeOperations, Name: node.Name, Config: config, }) @@ -142,9 +142,9 @@ func (g *OperationsGraph) ToGraphSpec() *graphcontracts.GraphSpec { } return &graphcontracts.GraphSpec{ - ID: g.ID, - Name: g.Name, - Nodes: nodes, - Edges: edges, + ID: g.ID, + Name: g.Name, + Nodes: nodes, + Edges: edges, } } diff --git a/setup/deployment.go b/setup/deployment.go index 13fc214..87bd813 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -11,7 +11,6 @@ import ( "github.com/GrayCodeAI/eyrie/catalog/xiaomi" "github.com/GrayCodeAI/eyrie/catalog/zai" "github.com/GrayCodeAI/eyrie/client" - "github.com/GrayCodeAI/eyrie/client/adapters" "github.com/GrayCodeAI/eyrie/config" "github.com/GrayCodeAI/eyrie/credentials" "github.com/GrayCodeAI/eyrie/router" @@ -274,12 +273,6 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c return nil, false } return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultOpenRouterOpenAIBaseURL), &client.OpenRouterCompat), true - case "concentrate-payg": - apiKey := FirstNonEmpty(deployment.APIKey, lookup("CONCENTRATE_API_KEY")) - if apiKey == "" { - return nil, false - } - return adapters.NewConcentrateResponsesClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultConcentrateOpenAIBaseURL)), true case "canopywave": apiKey := FirstNonEmpty(deployment.APIKey, lookup("CANOPYWAVE_API_KEY")) if apiKey == "" { @@ -292,7 +285,8 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c return nil, false } openBase := FirstNonEmpty(deployment.BaseURL, "https://api.deepseek.com/v1") - return client.NewDeepSeekClient(apiKey, openBase, &client.DeepSeekCompat), true + anthropicBase := "https://api.deepseek.com/anthropic" + return client.NewDeepSeekClient(apiKey, openBase, anthropicBase, &client.DeepSeekCompat), true case "poolside": apiKey := FirstNonEmpty(deployment.APIKey, lookup("POOLSIDE_API_KEY")) if apiKey == "" { @@ -339,25 +333,13 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c if apiKey == "" { return nil, false } - return newMiniMaxOpenAIClient(apiKey, deployment.BaseURL), true - case "agnes-direct": - apiKey := FirstNonEmpty(deployment.APIKey, lookup("AGNES_API_KEY")) - if apiKey == "" { - return nil, false - } - return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, "https://apihub.agnes-ai.com/v1"), &client.AgnesCompat), true - case "longcat-direct": - apiKey := FirstNonEmpty(deployment.APIKey, lookup("LONGCAT_API_KEY")) - if apiKey == "" { - return nil, false - } - return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, "https://api.longcat.chat/openai/v1"), &client.LongCatCompat), true + return newMiniMaxDualProtocolClient(apiKey, deployment.BaseURL), true case "minimax_payg-direct": apiKey := FirstNonEmpty(deployment.APIKey, lookup("MINIMAX_PAYG_API_KEY")) if apiKey == "" { return nil, false } - return newMiniMaxOpenAIClient(apiKey, deployment.BaseURL), true + return newMiniMaxDualProtocolClient(apiKey, deployment.BaseURL), true default: return nil, false } @@ -378,11 +360,13 @@ func newMiMoDeploymentClient(deployment config.DeploymentConfig, providerID, env openBase = override } } - return client.NewMiMoClient(apiKey, openBase, &client.XiaomiCompat, providerID), true + anthropicBase, _ := config.ResolveXiaomiAnthropicBase(providerID, cfg) + return client.NewMiMoClient(apiKey, openBase, anthropicBase, &client.XiaomiCompat, providerID), true } -// newZAIDeploymentClient constructs an OpenAI-compatible Z.AI client for the -// general or Coding Plan gateway. +// 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, cfg *config.ProviderConfig) (client.Provider, bool) { apiKey := FirstNonEmpty(deployment.APIKey, lookup(envKey)) if apiKey == "" { @@ -400,7 +384,9 @@ func newZAIDeploymentClient(deployment config.DeploymentConfig, providerID, envK } } - return client.NewZAIClient(apiKey, openBase, &client.ZAICompat, providerID), true + anthropicBase := resolveZAIAnthropicBaseForDeployment(plan, cfg) + + return client.NewZAIClient(apiKey, openBase, anthropicBase, &client.ZAICompat, providerID), true } func resolveZAIOpenAIBaseForDeployment(plan zai.Plan, providerID string, cfg *config.ProviderConfig, override string) (string, error) { @@ -416,6 +402,19 @@ func resolveZAIOpenAIBaseForDeployment(plan zai.Plan, providerID string, cfg *co return zai.ResolveOpenAIBase(plan, region, override) } +func resolveZAIAnthropicBaseForDeployment(plan zai.Plan, cfg *config.ProviderConfig) string { + // region from general or coding, prefer coding if set + regionStr := "" + if cfg != nil { + regionStr = cfg.ZAICodingRegion + if regionStr == "" { + regionStr = cfg.ZAIRegion + } + } + region, _ := zai.NormalizeRegion(regionStr) + return zai.ResolveAnthropicBase(region) +} + // DefaultDeploymentForProvider maps a logical provider name to a deployment ID. func DefaultDeploymentForProvider(provider string) string { switch provider { @@ -435,8 +434,6 @@ func DefaultDeploymentForProvider(provider string) string { return "anthropic-bedrock" case config.ProviderOpenRouter: return "openrouter" - case config.ProviderConcentrate: - return "concentrate-payg" case config.ProviderCanopyWave: return "canopywave" case config.ProviderPoolside: @@ -484,8 +481,6 @@ func LegacyDeploymentConfig(cfg *config.ProviderConfig, provider string) config. return config.DeploymentConfig{APIKey: cfg.GeminiAPIKey, BaseURL: cfg.GeminiBaseURL} case config.ProviderOpenRouter: return config.DeploymentConfig{APIKey: cfg.OpenRouterAPIKey, BaseURL: cfg.OpenRouterBaseURL} - case config.ProviderConcentrate: - return config.DeploymentConfig{APIKey: cfg.ConcentrateAPIKey, BaseURL: cfg.ConcentrateBaseURL} case config.ProviderCanopyWave: return config.DeploymentConfig{APIKey: cfg.CanopyWaveAPIKey, BaseURL: cfg.CanopyWaveBaseURL} case config.ProviderPoolside: @@ -568,10 +563,19 @@ func FirstNonEmpty(values ...string) string { return "" } -// newMiniMaxOpenAIClient uses the official OpenAI-compatible MiniMax surface only. -func newMiniMaxOpenAIClient(apiKey, baseURL string) client.Provider { +// newMiniMaxDualProtocolClient creates a FallbackProvider that tries OpenAI-compatible +// endpoint first, then falls back to Anthropic-compatible endpoint. Both use the same API key. +func newMiniMaxDualProtocolClient(apiKey, baseURL string) client.Provider { openaiBase := FirstNonEmpty(baseURL, config.DefaultMiniMaxOpenAIBaseURL) - return client.NewOpenAIClient(apiKey, openaiBase, &client.MiniMaxCompat) + anthropicBase := config.DefaultMiniMaxAnthropicBaseURL + openaiClient := client.NewOpenAIClient(apiKey, openaiBase, &client.OpenAICompat) + anthropicClient := client.NewAnthropicClient(apiKey, anthropicBase) + fp, err := client.NewFallbackProvider(openaiClient, anthropicClient) + if err != nil { + // Cannot happen with two providers; return the primary as fallback. + return openaiClient + } + return fp } // CloneStringMap returns a shallow copy of m.