diff --git a/cmd/mecatui/ui/help.go b/cmd/mecatui/ui/help.go index 040110c2e..c1342493b 100644 --- a/cmd/mecatui/ui/help.go +++ b/cmd/mecatui/ui/help.go @@ -520,6 +520,9 @@ func (m Model) zeroStateMemoryNote() string { // provider id comes from the status row, so a future non-ToolHive // intent-driven provider reads naturally without a code change here. func (m Model) zeroStateGatewayNote() string { + if isToolhiveProviderID(m.resolvedSessionModel.ProviderID) { + return "" + } row, ok := availableNotDefaultStatus(m.modelCatalog.statuses) if !ok { return "" diff --git a/cmd/mecatui/ui/models_catalog.go b/cmd/mecatui/ui/models_catalog.go index 47697b6da..e72091a0a 100644 --- a/cmd/mecatui/ui/models_catalog.go +++ b/cmd/mecatui/ui/models_catalog.go @@ -77,7 +77,7 @@ func (m Model) applyModelsCatalog(msg client.ModelsMsg) (tea.Model, tea.Cmd, boo surface.catalog = m.modelCatalog surface.provenance = m.modelProvenanceLine() } - if !m.gatewayNoticeShown { + if !m.gatewayNoticeShown && !isToolhiveProviderID(m.resolvedSessionModel.ProviderID) { if row, ok := availableNotDefaultStatus(msg.Statuses); ok { pid := sanitizeTerminal(row.ProviderID) m.gatewayNotice = pid + " gateway available (" + strconv.Itoa(int(row.ModelCount)) + @@ -193,7 +193,7 @@ func statusAutoSelected(statuses []client.ProviderStatus, providerID string) boo func configProvenanceProviderSet(statuses []client.ProviderStatus) map[string]bool { var out map[string]bool for _, s := range statuses { - if s.ProviderID == "toolhive" { + if isToolhiveProviderID(s.ProviderID) { if out == nil { out = make(map[string]bool, 1) } @@ -203,6 +203,10 @@ func configProvenanceProviderSet(statuses []client.ProviderStatus) map[string]bo return out } +func isToolhiveProviderID(providerID string) bool { + return providerID == "toolhive" || providerID == "toolhive-anthropic" +} + // availableNotDefaultStatus returns the first reachable intent-driven provider // that is available but not the active default. func availableNotDefaultStatus(statuses []client.ProviderStatus) (client.ProviderStatus, bool) { diff --git a/cmd/mecatui/ui/models_surface.go b/cmd/mecatui/ui/models_surface.go index 2185b3859..b9dbd9ffb 100644 --- a/cmd/mecatui/ui/models_surface.go +++ b/cmd/mecatui/ui/models_surface.go @@ -239,13 +239,13 @@ var customProviderStatusCopy = map[string]string{ "unauthorized": "model service rejected access — check provider access configuration", "empty": "no selectable models", } -var toolhiveStatusCopy = map[string]string{"unreachable": "proxy not reachable", "unauthorized": "gateway rejected the credential", "empty": "credential lists no models"} +var toolhiveStatusCopy = map[string]string{"unreachable": "gateway not reachable", "unauthorized": "gateway rejected the credential", "empty": "credential lists no models"} var openAICodexStatusCopy = map[string]string{"unreachable": "ChatGPT Codex service not reachable", "unauthorized": "manual token rejected", "empty": "account lists no selectable models"} func providerStatusLine(s client.ProviderStatus) string { copyByState := customProviderStatusCopy switch s.ProviderID { - case "toolhive": + case "toolhive", "toolhive-anthropic": copyByState = toolhiveStatusCopy case "openai-codex": copyByState = openAICodexStatusCopy diff --git a/cmd/mecatui/ui/models_test.go b/cmd/mecatui/ui/models_test.go index 11b6f24bf..50c4609e4 100644 --- a/cmd/mecatui/ui/models_test.go +++ b/cmd/mecatui/ui/models_test.go @@ -1425,7 +1425,7 @@ func TestModelsEmptyCopy_PromotesAnyNonOkStatus(t *testing.T) { {ProviderID: "toolhive", State: "unreachable", Hint: "start it with `thv llm proxy start`"}, } got := modelsEmptyCopy(client.Capabilities{ModelSelection: true}, unreachable) - want := "toolhive: proxy not reachable — start it with `thv llm proxy start`" + want := "toolhive: gateway not reachable — start it with `thv llm proxy start`" if got != want { t.Errorf("unreachable empty copy = %q, want %q", got, want) } @@ -1534,7 +1534,7 @@ func TestRenderProviderStatusLines_UnreachableAndUnauthorized(t *testing.T) { lines := renderProviderStatusLines([]client.ProviderStatus{ {ProviderID: "toolhive", State: "unreachable", Hint: "start it with `thv llm proxy start`"}, }, false) - if len(lines) != 1 || lines[0] != "toolhive: proxy not reachable — start it with `thv llm proxy start`" { + if len(lines) != 1 || lines[0] != "toolhive: gateway not reachable — start it with `thv llm proxy start`" { t.Fatalf("unreachable line = %v", lines) } lines = renderProviderStatusLines([]client.ProviderStatus{ @@ -1556,7 +1556,7 @@ func TestModelsPickerStatuses_ThreadedFromMsg(t *testing.T) { t.Fatalf("models.statuses = %+v, want the threaded status", m.modelCatalog.statuses) } rendered := stripANSI([]byte(m.View().Content)) - if !strings.Contains(string(rendered), "toolhive: proxy not reachable") { + if !strings.Contains(string(rendered), "toolhive: gateway not reachable") { t.Fatalf("rendered picker missing the status line:\n%s", rendered) } } @@ -1587,7 +1587,7 @@ func TestModelsPickerCustomProviderStatusRendersAlongsideFloor(t *testing.T) { t.Errorf("rendered picker missing %q:\n%s", want, rendered) } } - for _, unwanted := range []string{"proxy not reachable", "gateway rejected the credential", "credential lists no models", "https://", "listing response body", "gateway-secret"} { + for _, unwanted := range []string{"gateway not reachable", "gateway rejected the credential", "credential lists no models", "https://", "listing response body", "gateway-secret"} { if strings.Contains(rendered, unwanted) { t.Errorf("rendered custom status leaked or used ToolHive copy %q:\n%s", unwanted, rendered) } @@ -1663,6 +1663,16 @@ func TestHeaderToolhiveSegment(t *testing.T) { t.Fatal("a toolhive session must show the gateway segment at a wide width") } + m.resolvedSessionModel = client.ResolvedModel{ProviderID: "toolhive-anthropic", ModelID: "claude-sonnet-4-6"} + m.modelCatalog.statuses = []client.ProviderStatus{ + {ProviderID: "toolhive", State: "ok", AvailableNotDefault: true}, + {ProviderID: "toolhive-anthropic", State: "ok"}, + } + header := stripANSIstr(m.renderHeader()) + if !strings.Contains(header, "via ToolHive gateway") || strings.Contains(header, "gateway available") { + t.Fatalf("native ToolHive header must show one active-family segment, got:\n%s", header) + } + // At a narrow width the segment sheds along with the other low-priority // segments; the header must not panic/overflow. m = applyAll(m, tea.WindowSizeMsg{Width: 40, Height: 30}) @@ -2082,6 +2092,26 @@ func TestGatewayNoticeNotFiredWhenNoAvailableNotDefault(t *testing.T) { } } +func TestGatewayNoticeNotFiredForActiveToolhiveFamily(t *testing.T) { + fm := gatewayModels() + statuses := []client.ProviderStatus{{ + ProviderID: "toolhive-anthropic", + State: "ok", + ModelCount: 1, + AvailableNotDefault: true, + }} + for _, providerID := range []string{"toolhive", "toolhive-anthropic"} { + m := newModelsModel(t, fm, &fakeStore{}, modelsCaps(), client.ModelSelection{}) + m.resolvedSessionModel = client.ResolvedModel{ProviderID: providerID, ModelID: "claude-sonnet-4-6"} + mm, _, _ := m.updateModelsMsg(client.ModelsMsg{Models: fm.models, Statuses: statuses}) + m = mm.(Model) + if m.gatewayNotice != "" || m.gatewayNoticeShown { + t.Errorf("active provider %q armed same-family gateway notice %q (shown=%t)", + providerID, m.gatewayNotice, m.gatewayNoticeShown) + } + } +} + // TestGatewayNoticeClearedOnKeypress: any keypress at idle clears the notice text // (the latch stays true so it never re-fires). func TestGatewayNoticeClearedOnKeypress(t *testing.T) { @@ -2171,6 +2201,7 @@ func TestModelRowOrgTagForConfigIntentProvider(t *testing.T) { func TestConfigIntentProviderSetExcludesOpenAICodexStatus(t *testing.T) { got := configProvenanceProviderSet([]client.ProviderStatus{ {ProviderID: "toolhive", State: "ok"}, + {ProviderID: "toolhive-anthropic", State: "ok"}, {ProviderID: "openai-codex", State: "ok"}, }) if !got["toolhive"] { @@ -2179,6 +2210,21 @@ func TestConfigIntentProviderSetExcludesOpenAICodexStatus(t *testing.T) { if got["openai-codex"] { t.Fatal("Codex entitlement status was misclassified as config intent") } + if !got["toolhive-anthropic"] { + t.Fatal("native ToolHive status lost its config-intent classification") + } +} + +func TestToolhiveNativeAnthropic_Scenario3_StatusAndPresentation(t *testing.T) { + got := providerStatusLine(client.ProviderStatus{ + ProviderID: "toolhive-anthropic", + State: "unreachable", + Hint: "check gateway connectivity or use `--toolhive-llm-mode proxy`", + }) + want := "toolhive-anthropic: gateway not reachable — check gateway connectivity or use `--toolhive-llm-mode proxy`" + if got != want { + t.Fatalf("providerStatusLine = %q, want %q", got, want) + } } // TestModelRowOrgTagNilConfigIntentProviderIDs: a nil configProvenanceProviderIDs map (no gateway) diff --git a/cmd/mecatui/ui/testdata/models_toolhive_unreachable.golden b/cmd/mecatui/ui/testdata/models_toolhive_unreachable.golden index f39ab2fdf..fcdbf4dae 100644 --- a/cmd/mecatui/ui/testdata/models_toolhive_unreachable.golden +++ b/cmd/mecatui/ui/testdata/models_toolhive_unreachable.golden @@ -15,7 +15,7 @@ ┃ openai · text-embed ┃ ┃ openrouter · Claude img reason 1M ┃ ┃ ┃ -┃ toolhive: proxy not reachable — start it with `thv llm proxy start` ┃ +┃ toolhive: gateway not reachable — start it with `thv llm proxy start` ┃ ┃ ┃ ┃ type to filter · ↑/↓/pgup move · enter use · ctrl+g set global default · esc clear filter / close ┃ ┃ ● current ★ global default ┃ diff --git a/cmd/mecatui/ui/view.go b/cmd/mecatui/ui/view.go index e704341fc..10919e2d9 100644 --- a/cmd/mecatui/ui/view.go +++ b/cmd/mecatui/ui/view.go @@ -340,7 +340,7 @@ func (m Model) headerIdentityParts(sid, withNext string) []string { // toolhive — disclosure-only (no acknowledgment required), riding the same // segment slice so the EXISTING width-shedding/fitHeader math applies // unchanged (it sheds like any other low-priority segment under pressure). - if m.resolvedSessionModel.ProviderID == "toolhive" { + if isToolhiveProviderID(m.resolvedSessionModel.ProviderID) { parts = append(parts, m.deps.Theme.Style("muted").Render("via ToolHive gateway")) } else if row, ok := availableNotDefaultStatus(m.modelCatalog.statuses); ok { // Sibling (N1): when an intent-driven provider is detected-and-reachable diff --git a/cmd/mecatui/ui/zerostate_test.go b/cmd/mecatui/ui/zerostate_test.go index 8e1360862..7e1f93b6e 100644 --- a/cmd/mecatui/ui/zerostate_test.go +++ b/cmd/mecatui/ui/zerostate_test.go @@ -145,6 +145,22 @@ func TestZeroStateGatewayNote(t *testing.T) { t.Errorf("splash should NOT render the gateway line when the gateway is the default, got:\n%s", plain) } + // Suppressed when either protocol-specific ToolHive provider is already + // active, even if its sibling is reported as available-but-not-default. + m.modelCatalog.statuses = []client.ProviderStatus{{ + ProviderID: "toolhive-anthropic", + State: "ok", + ModelCount: 1, + AvailableNotDefault: true, + }} + for _, providerID := range []string{"toolhive", "toolhive-anthropic"} { + m.resolvedSessionModel = client.ResolvedModel{ProviderID: providerID, ModelID: "claude-sonnet-4-6"} + plain = stripANSIstr(m.renderZeroState()) + if strings.Contains(plain, "gateway detected") { + t.Errorf("splash should NOT render a same-family gateway line for %q, got:\n%s", providerID, plain) + } + } + // Suppressed with no statuses (byte-identical pre-feature path). m.modelCatalog.statuses = nil plain = stripANSIstr(m.renderZeroState()) diff --git a/docs/acceptance/README.md b/docs/acceptance/README.md index 5312b96bc..28e0df61a 100644 --- a/docs/acceptance/README.md +++ b/docs/acceptance/README.md @@ -176,6 +176,10 @@ PR after verification. There is no cleanup or status-only PR. injection; all callers admitted to one mecated share configured endpoint availability and gateway identity, while ToolHive remains explicit optional proxy compatibility with no cross-store secret migration. Status: proposed. +- [ToolHive native Anthropic gateway support](toolhive-native-anthropic.md) — expose the + gateway's native Anthropic catalog and Messages endpoint as `toolhive-anthropic`, while + preserving the existing Responses-backed `toolhive` provider and shared gateway identity. + Status: implementation in progress under explicit workflow waiver; not approved or landed. - [Surface approval migration](surface-approval-migration.md) — final Phase-2 migration of the mecatui approval UI onto the dynamic surface contract, including ephemeral render-frame hit dispatch. Status: landed. - [Spine convergence](spine-convergence.md) — bring the to-acceptance-plan / plan-orchestrate / test-writer spine + ac-trace into diff --git a/docs/acceptance/toolhive-native-anthropic.md b/docs/acceptance/toolhive-native-anthropic.md new file mode 100644 index 000000000..01d38e1e4 --- /dev/null +++ b/docs/acceptance/toolhive-native-anthropic.md @@ -0,0 +1,91 @@ +# ToolHive native Anthropic gateway support — acceptance plan + +**Contract:** human-reviewed/v2 +**Work classification:** Architectural — this adds a wire-stable provider identity and extends the direct-mode OIDC authentication boundary across a second protocol adapter. +**Decision record:** [ADR 0326](../adr/0326-toolhive-protocol-specific-providers.md) +**Phase:** protocol-specific ToolHive AI gateway providers +**Status:** in-progress, 2026-09-10. The directing user explicitly waived the acceptance-plan spine for this named implementation; no plan approval or merge is claimed. +**Delivery:** Split. The directing user explicitly waived the separate Plan / Interface checkpoint for this local implementation; final human code review and merge remain required. +**Expected tasks:** implementation and aggregate verification in progress under the explicit workflow waiver +**Issue:** None assigned. +**Plan PR:** Absent until explicitly authorized and opened. +**Approved baseline:** absent; implementation was explicitly authorized without one. + +Expose the ToolHive AI gateway's native Anthropic catalog and Messages endpoint as a distinct +Mecatl provider without changing the existing OpenAI Responses-backed `toolhive` provider. Both +entries derive from the same ToolHive intent and routing mode, while catalog health and +last-known-good state remain independent. + +## Human decisions + +None — the requested contract fixes the provider ID, protocol separation, URL roots, authentication behavior, compatibility requirements, tests, documentation, and downstream exclusion. + +## Interface contract + +- **gRPC / protobuf:** None — existing open-string `provider_id` and current model/status messages carry `toolhive-anthropic`; no message, field, method, or field number changes. +- **Exported Go APIs / interfaces:** None — reuse `provider/anthropic` options and `anthropic.NewLister`; all new family classification, URL derivation, shared-client construction, probing, and status logic remains root-internal composition. +- **Tool schemas:** None — provider routing changes do not add or alter model-facing tools. +- **CLI / config:** Add no flag or key. One resolved ToolHive LLM intent in existing `auto|proxy|direct` mode registers both `toolhive` and `toolhive-anthropic`. Reserve `toolhive-anthropic` against custom-provider collisions. Existing `--default-provider` and operator `models.default_provider` may explicitly select it; `toolhive` remains the implicit first intent-driven provider. +- **Events / persistence:** None — no event or snapshot shape changes and no migration. Existing provider-ID strings may persist `toolhive-anthropic`; register-on-intent ensures it remains resolvable when either endpoint is temporarily unavailable. +- **Security / authority:** Proxy requests stay loopback-bound and redirect-refusing. The existing OpenAI proxy path stays byte-identical; the native Anthropic proxy transport removes its SDK `x-api-key`/conflicting auth and sends only `Authorization: Bearer thv-proxy` to loopback, where ToolHive replaces it, so no placeholder reaches the upstream gateway. Direct listing and inference for both protocols share one non-interactive ToolHive token source and bearer base transport/client policy; protocol listers may shallow-clone the client to add response caps/timeouts but retain that same transport/source. Every direct outbound attempt removes `Authorization` and `x-api-key`, invokes the token source once, and sets only the authoritative bearer before transport. Fetch both ToolHive endpoints concurrently under the existing operation-wide bounds (Build 1.5 seconds, background refresh 10 seconds, on-demand stale refresh 2 seconds). Preserve HTTPS enforcement with the literal-loopback exception, bounded model bodies, sanitized token errors, secret-free URL/status/diagnostics, no credential logging/persistence, and both SDKs' ambient-credential suppression. +- **Compatibility / migration:** `toolhive` keeps its explicit OpenAI base URL byte-for-byte, `/v1/models`, `/v1/responses`, default precedence, zero-selector model, last-known-good behavior, and regression coverage. `toolhive-anthropic` is additive and uses `/anthropic/v1/models` plus `/anthropic/v1/messages`. Its proxy base replaces only a terminal `v1` path segment with `anthropic`, otherwise appending `anthropic`; its direct base appends `anthropic` to `gateway_url`. Path prefixes are preserved and trailing slashes normalized; userinfo/query/fragment are not copied into the native base or diagnostics. Model/status ordering is deterministic. Mecatui classifies both as the same ToolHive gateway family and suppresses duplicate same-family availability notices while retaining both picker/status rows. + +## In scope — 3 scenarios, in implementation order + +### Scenario 1 — One gateway intent exposes two truthful protocol catalogs + +The registry follows the protocol-specific identity decision in [ADR 0326](../adr/0326-toolhive-protocol-specific-providers.md) while retaining the register-on-intent behavior from [ADR 0064](../adr/0064-toolhive-llm-gateway-provider.md). + +**Acceptance:** +- AC1.1: Proxy and direct intent each register sorted provider IDs `toolhive` and `toolhive-anthropic`; absent/disabled intent registers neither, and the new ID cannot be redefined as an operator custom provider. + - verify: `TestToolhiveNativeAnthropic_Scenario1_Registration` +- AC1.2: The existing OpenAI base stays verbatim and discovery retains its current `/models` behavior (`/v1/models` for detected/default gateway bases). Native discovery requests `/anthropic/v1/models`. Native base derivation covers origin, `/v1`, `/prefix/v1`, trailing-slash, and explicit non-`/v1` bases; it preserves path prefixes, never copies userinfo/query/fragment, and never moves path segments into query data. + - verify: `TestToolhiveNativeAnthropic_Scenario1_DiscoveryPaths` +- AC1.3: Native rows retain context/output limits, image capability, and adaptive/manual thinking metadata from `anthropic.NewLister`; matching embedded Anthropic metadata remains a fallback without becoming unverified gateway inventory. Tests carry output/thinking through `liveMetaStore.outputLimitFor`/`thinkingFor` into emitted Anthropic `max_tokens` and thinking mode, not only picker-visible fields. + - verify: `TestToolhiveNativeAnthropic_Scenario1_Metadata` + +### Scenario 2 — Native selections use Messages with one authoritative credential + +Direct authentication extends [ADR 0102](../adr/0102-toolhive-direct-mode.md) without widening the SDK or engine boundaries described in the [provider architecture](../architecture/providers.md). + +**Acceptance:** +- AC2.1: Selecting a `toolhive-anthropic` model sends Anthropic Messages JSON to `/anthropic/v1/messages` and never sends that selection to `/v1/responses`; selecting `toolhive` retains Responses JSON and `/v1/responses`. + - verify: `TestADR_0325_ProtocolSpecificWireRouting` +- AC2.2: Direct-mode listing and inference for both entries construct one token source and share its bearer base transport/client policy; each outbound attempt invokes the source once, removes placeholder or conflicting `Authorization` and `x-api-key`, and sends only the authoritative bearer. Protocol lister client clones retain the shared bearer transport. A token error forwards no request and remains credential-classified and sanitized. + - verify: `TestToolhiveNativeAnthropic_Scenario2_DirectAuthentication` +- AC2.3: Native proxy listing/inference converts the Anthropic SDK placeholder `x-api-key` into a loopback-only placeholder bearer; a mock proxy/upstream boundary proves no `x-api-key` or placeholder reaches upstream. Proxy and direct inference refuse redirects, direct mode rejects non-HTTPS non-loopback gateway URLs through the existing gate, reminted providers retain the correct transport, and no credential-bearing URL component or header enters inventory, status, or diagnostics. + - verify: `TestToolhiveNativeAnthropic_Scenario2_TransportSecurity` + +### Scenario 3 — Inventory failures are independent and defaults do not move + +The existing provider-keyed outcome store in the [provider architecture](../architecture/providers.md) remains the source of live status and last-known-good catalogs. + +**Acceptance:** +- AC3.1: Success, honest empty, unauthorized, unreachable, refresh, and last-known-good transitions are independent for `toolhive` and `toolhive-anthropic`. Both family fetches start concurrently under each existing overall deadline (Build 1.5 seconds, background 10 seconds, on-demand stale 2 seconds); failure or delay of either endpoint does not starve, erase, suppress, or relabel the healthy protocol's outcome or inventory, and results publish in deterministic provider order. + - verify: `TestToolhiveNativeAnthropic_Scenario3_IndependentOutcomes` +- AC3.2: With both entries present, `toolhive` remains the implicit default and retains its existing first-listed zero-selector model. An honest empty catalog for the resolved default remains a fatal actionable Build error for `toolhive` and applies consistently when the operator explicitly defaults to `toolhive-anthropic`; transport/auth failure of the resolved default and every empty/failure of a non-default sibling remain non-fatal. An explicit native selection works without automatic cross-protocol failover. + - verify: `TestToolhiveNativeAnthropic_Scenario3_DefaultCompatibility` +- AC3.3: Model inventory and provider status remain sorted and secret-free and report separate counts/states. Hints are routing-aware: proxy-unreachable says to start the proxy, direct-unreachable says to check gateway connectivity or use proxy mode, unauthorized says to re-authenticate, and empty retains setup/admin guidance. Mecatui gives both IDs ToolHive gateway provenance/disclosure, retains both picker/status rows, suppresses duplicate footer/welcome availability notices when either same-family provider is active, and does not change unrelated-provider rendering. + - verify: `TestToolhiveNativeAnthropic_Scenario3_StatusAndPresentation` +- AC3.4: Existing ToolHive proxy/direct discovery, Responses inference, rehydration, default healing, and no-intent behavior retain their regression coverage; living architecture, implementation notes, usage, and TUI documentation explain the two IDs and shared gateway identity. + - verify: inspection — run the existing ToolHive suites plus `task docs`; documentation is the operator-facing proof. + +## Out of scope + +| Item | Defer-to | Decision | +|---|---|---| +| Catalog merging or automatic protocol failover | separate architectural proposal | Provider identity selects the wire adapter; keep catalogs and failures independent. | +| New ToolHive flags, credentials, or login flows | not planned | Reuse the existing intent, routing mode, and OIDC token source. | +| Changes to native `anthropic` provider defaults | not planned | Reuse its adapter/lister without changing key-driven Anthropic behavior. | + +## Definition of done + +1. Focused offline tests use mock transports/servers and require no live gateway credential. +2. `task lint`, `task test`, `task api:check`, `task docs`, and `task ac-trace-strict` pass; `go run ./cmd/mecademo` remains green. +3. Model inventory/status output is deterministic and contains no credential or placeholder material. +4. The implementation PR links the Plan / Interface PR and approved commit and reports conformance to every interface clause. +5. `/panel-review` reports no ship blockers or unwaived failures. + +## Deferred decisions and known risks + +- None — any implementation need for a new public surface, credential source, routing mode, protocol failover, or inventory merge is contract drift and returns to human review. diff --git a/docs/adr/0326-toolhive-protocol-specific-providers.md b/docs/adr/0326-toolhive-protocol-specific-providers.md new file mode 100644 index 000000000..07f7bba67 --- /dev/null +++ b/docs/adr/0326-toolhive-protocol-specific-providers.md @@ -0,0 +1,94 @@ +# ADR 0326 — Protocol-specific ToolHive gateway providers + +- Status: Proposed +- Date: 2026-09-10 +- Scope: ToolHive LLM registry composition, native Anthropic routing, shared direct-mode authentication, live model inventory, provider status, and mecatui provider classification +- Supersedes: ADR 0064's D8 exclusion of an Anthropic-protocol gateway path; ADR 0102's token-source/authenticated-transport construction ownership only +- Superseded by: none + +## Context + +The ToolHive AI gateway exposes two catalogs and inference protocols for the same configured +gateway identity. `GET /v1/models` and `POST /v1/responses` are OpenAI-compatible, while +`GET /anthropic/v1/models` and `POST /anthropic/v1/messages` are native Anthropic. Mecatl +currently registers only the wire-stable `toolhive` provider and routes every selected model +through the OpenAI Responses adapter. Native-only Anthropic models are therefore neither +advertised nor usable. + +Mecatl already has a native Anthropic Messages adapter and rich Anthropic lister. Combining both +catalogs under `toolhive` would be incorrect: the provider ID selects the adapter, so a native +model merged into that catalog would still execute through `/v1/responses`. The protocols need +separate provider identities even though they share detection, routing mode, and credentials. + +## Decision + +Register two intent-driven providers from one ToolHive configuration: + +- `toolhive` retains its current OpenAI Responses behavior and precedence. +- `toolhive-anthropic` uses `provider/anthropic` and `anthropic.NewLister` exclusively. + +The provider ID is stable and protocol-specific. A model listed by `toolhive-anthropic` always +uses Anthropic Messages wire format; it is never merged into `toolhive` or sent to +`/v1/responses`. Reserve the new ID against operator-defined provider collisions. + +Keep the existing `toolhive` OpenAI base URL byte-for-byte. Derive only the new Anthropic base with +`net/url`: in proxy mode, replace a terminal path segment exactly equal to `v1` with `anthropic`, +or append `anthropic` when no terminal `v1` exists; in direct mode, append `anthropic` to the +configured `gateway_url`. Preserve any path prefix and normalize trailing slashes. Userinfo, +query, and fragment components are not copied into the derived Anthropic base or diagnostics; no +API base uses them as credentials. The Anthropic SDK then appends `/v1/models` or `/v1/messages`. +Do not construct paths with raw string concatenation. + +In direct mode, construct the ToolHive OIDC token source and authenticated HTTP client once and +share them across both protocol entries and both listing/inference paths. On every request, clone +the request, delete `Authorization` and `x-api-key`, obtain a fresh bearer from the token source, +and set the authoritative `Authorization: Bearer `. Refuse redirects. Never log or persist +the token, and retain the existing HTTPS requirement plus loopback exception. Both SDK adapters +continue suppressing ambient credential discovery. + +In proxy mode, both entries target the loopback ToolHive proxy. The existing OpenAI entry and its +placeholder bearer remain byte-identical. For native Anthropic, an auth-normalizing transport +deletes the SDK's placeholder `x-api-key` and any `Authorization`, then sets the non-secret +placeholder as `Authorization: Bearer thv-proxy` only for the loopback hop. The ToolHive proxy +strips that header before injecting its OIDC bearer, so neither an `x-api-key` nor the placeholder +reaches the upstream gateway. Redirect refusal still covers listing and inference. + +Keep live inventory, last-known-good data, and status keyed by provider ID. Fetch both ToolHive +protocol entries independently and concurrently under each existing family-wide bound: 1.5 seconds +at Build, 10 seconds for background refresh, and 2 seconds for on-demand stale refresh. Publish +results deterministically; one slow endpoint neither doubles a bound nor prevents the other outcome +from being recorded. Map `toolhive-anthropic` to the Anthropic metadata namespace for +context/output limits and capability fallbacks without advertising models absent from the gateway's +native catalog. Keep `toolhive` first among intent-driven providers, so adding the sibling never +changes the zero-selector provider or model. An honest empty catalog is fatal only when that +protocol entry is the resolved default; a transport/auth failure of the default remains non-fatal, +and any non-default sibling failure is non-fatal. + +Keep status rows, counts, and last-known-good snapshots separate. Remediation is routing-aware: +proxy-unreachable instructs the operator to start the ToolHive proxy; direct-unreachable instructs +them to check configured gateway connectivity or use proxy mode; unauthorized instructs them to +re-authenticate; empty retains the platform-admin/setup guidance. + +Treat both IDs as ToolHive gateway providers in mecatui disclosure and catalog provenance. Keep +both rows visible in the picker and status inventory, but suppress “another gateway available” +footer/welcome notices when either member of the same ToolHive family is already active. No +protobuf shape, CLI flag, configuration key, event, or snapshot migration is added; existing open +provider-ID fields carry the new value. + +## Consequences + +Native-only Anthropic gateway models become selectable and execute through their correct protocol, +while existing ToolHive OpenAI discovery, inference, defaults, and last-known-good behavior remain +unchanged. One login and token-refresh flow serves both protocol surfaces in direct mode. + +The registry now owns a two-entry ToolHive family and must keep protocol URL derivation, concurrent +fetching, routing-aware status hints, and UI classification aligned. The shared deadlines and +deterministic publication need explicit tests. Clients that filter provider +IDs rather than consuming the advertised inventory need a follow-up to display the new provider. + +## See also + +- [ToolHive native Anthropic acceptance plan](../acceptance/toolhive-native-anthropic.md) +- [ADR 0064 — ToolHive LLM gateway provider](./0064-toolhive-llm-gateway-provider.md) +- [ADR 0102 — ToolHive direct mode](./0102-toolhive-direct-mode.md) +- [Provider architecture](../architecture/providers.md) diff --git a/docs/architecture.md b/docs/architecture.md index 15d4f72de..869fe99d5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -267,6 +267,13 @@ in `provider/openai`, the native Anthropic Messages API in `provider/anthropic` ([multi-provider](architecture/providers.md)) — so the core is provider-agnostic and unit-testable against fakes (`mockllm`, `memfs`, `memstore`). +The ToolHive gateway composes those same two wire adapters as separate registry +identities backed by one detected gateway configuration: `toolhive` remains the +OpenAI Responses/default surface, while `toolhive-anthropic` exposes native +Anthropic discovery and Messages inference. Their inventories and health are +independent; their direct-mode OIDC source is shared. See the +[provider chapter](architecture/providers.md#multi-provider--registry-per-session-routing--model-inventory). + OpenAI has two deliberately separate registry identities. `openai` uses a public API key and the supported public Responses API. Experimental `openai-codex` uses a manually supplied ChatGPT Codex access-token snapshot against OpenAI's diff --git a/docs/architecture/providers.md b/docs/architecture/providers.md index 22f75d506..ad73112e0 100644 --- a/docs/architecture/providers.md +++ b/docs/architecture/providers.md @@ -257,14 +257,17 @@ prompt-cache prefix untouched. The routed downstream echoes back as (`"provider.route"`), absent on a cache hit — never fabricated. See [`docs/adr/0210-openrouter-downstream-provider-steering.md`](../adr/0210-openrouter-downstream-provider-steering.md). -**Intent-driven availability (issue #262, ADR 0064).** Every provider above is -**key-driven** — available iff a credential resolves. The ToolHive LLM gateway proxy -entry (`providerToolhive`, id `"toolhive"`) is **intent-driven** instead: it is -registered when `resolveToolhiveIntent` detects ToolHive's own config file (or an -explicit `--toolhive-llm-base-url`) — no credential required, and NEVER gated by -reachability (register-on-intent; a session persisting `provider_id: "toolhive"` must -survive a restart with the proxy down, never rejected as "unknown or unavailable -provider"). Each `providerEntry` carries an `intentDriven` bit that +**Intent-driven availability (issue #262, ADR 0064; ADR 0326).** Every provider above +is **key-driven** — available iff a credential resolves. One ToolHive gateway identity +instead registers two **intent-driven**, protocol-specific entries: `toolhive` uses +OpenAI Responses (`GET /v1/models`, `POST /v1/responses`) and +`toolhive-anthropic` uses native Anthropic Messages (`GET /anthropic/v1/models`, +`POST /anthropic/v1/messages`). Both are registered when `resolveToolhiveIntent` +detects ToolHive's config (or an explicit `--toolhive-llm-base-url`) — no credential +required, and NEVER gated by reachability. Their inventories, status, counts, and +last-known-good snapshots are independent; model IDs are never merged across wire +adapters. `toolhive` remains the preferred intent-driven default. Each +`providerEntry` carries an `intentDriven` bit that `preferredDefaultProvider` reads to place intent-driven providers at an explicit LOWEST-preference tier (any key-driven provider always wins the default). `ListModels` `provider_status` projects operator-actionable live-listing outcomes @@ -272,23 +275,23 @@ for intent-driven gateways, Codex entitlements, and operator-defined custom providers (identified by their configured custom default model); it exposes only safe provider ID/state/hint metadata, never an endpoint, credential, or raw listing error/body. `intentDriven` alone controls the TUI's `org` tier and gateway -availability notices. A BOUNDED (≤1.5s) Build-time probe runs immediately after -registration and drives ONLY the startup diagnostic, the initial live-model snapshot, -and default-model eligibility for a SOLE intent-driven provider — never registration -itself. See `docs/adr/0064-toolhive-llm-gateway-provider.md` for the full design +availability notices. The two bounded Build-time probes start concurrently under one +≤1.5s deadline and drive ONLY startup diagnostics, initial live-model snapshots, and +default-model eligibility — never registration itself. See +`docs/adr/0064-toolhive-llm-gateway-provider.md` and +`docs/adr/0326-toolhive-protocol-specific-providers.md` for the designs (including the accepted sole+probe-down boot deviation) and `internal/adapter/openaicompat` / `internal/adapter/toolhivellm` for the two-layer leaf split (protocol-generic lister + the one ToolHive-aware config reader). -**DIRECT mode (issue #265, ADR 0102).** The gateway entry can also talk DIRECTLY to the +**DIRECT mode (issue #265, ADR 0102).** Both gateway entries can talk DIRECTLY to the real `gateway_url` with no local proxy hop: mecatl imports ToolHive as a Go library (one file, `internal/adapter/toolhivellm/tokensource.go`, the package's sole toolhive-importing file alongside the stdlib-only `detect*.go`) and builds an in-process OIDC token source — the SAME `llm.NewTokenSource` `thv llm token` uses — so the bearer is minted and -refreshed in-process. The token rides a custom `http.RoundTripper` inside the -`*http.Client` passed to `openai.WithHTTPClient` (`bearerRoundTripper` in -`internal/app/registry.go`), which strips the SDK's placeholder `Authorization` header -and sets `Bearer ` per request — mirroring the ToolHive proxy's own `Rewrite`. +refreshed in-process. One token source and bearer-authenticated `*http.Client` serve +both protocol adapters. `bearerRoundTripper` strips conflicting `Authorization` and +`X-Api-Key` headers and sets `Bearer ` on every outbound attempt. The `WithHTTPClient` option rides every per-session/heal re-mint (the `newOpenAICompatEntry` closure appends it to every `construct()` call), so the token injection cannot drift off a re-minted adapter. A new `--toolhive-llm-mode @@ -298,7 +301,10 @@ auto|proxy|direct` flag (default `auto`) drives the routing in (`http://localhost`/`http://127.0.0.1` carve-out; a non-HTTPS gateway would send the bearer over cleartext), else falls back to the loopback proxy with a WARN; `proxy` forces the loopback path; `direct` forces the gateway path and Build-fails when OIDC -is absent. The direct base URL is derived (`gateway_url + "/v1"`), never hand-set. The +is absent. Direct bases are derived (`gateway_url + "/v1"` for Responses and +`gateway_url + "/anthropic"` for the Anthropic SDK), while proxy mode uses the +equivalent loopback paths. Legitimate path prefixes survive; userinfo, query, and +fragment data do not. The token never enters a log, an error string, or an env var (OS keyring; only its reference is persisted; errors are sanitised via `llm.SanitizeTokenError`). `mecatui llm login` runs the interactive OIDC flow in-process; a headless `mecated` cache-miss diff --git a/docs/design/IMPLEMENTATION-NOTES.md b/docs/design/IMPLEMENTATION-NOTES.md index 5cd7a37b8..c0f214bfb 100644 --- a/docs/design/IMPLEMENTATION-NOTES.md +++ b/docs/design/IMPLEMENTATION-NOTES.md @@ -4644,7 +4644,17 @@ isolated-child runner oracles prove provider credentials do not enter command-ru environments. Residual boundary: a same-UID Shell process can read a known plaintext `auth.yaml` path; mode `0600` is not privilege separation. -### `openaicompat` + `toolhivellm` — ToolHive LLM gateway provider (issue #262, ADR 0064) +### `openaicompat` + `toolhivellm` — ToolHive protocol providers (issue #262, ADR 0064, ADR 0326) + +One detected ToolHive gateway identity registers TWO protocol-specific entries. +`toolhive` is unchanged: `openaicompat` discovers `/v1/models` and +`provider/openai` infers through `/v1/responses`. `toolhive-anthropic` reuses +`anthropic.NewLister` and `provider/anthropic`, discovering +`/anthropic/v1/models` and inferring through `/anthropic/v1/messages`. Catalogs are +never merged or failed over: status, counts, and process-local last-known-good data +remain keyed by provider ID. `toolhive` stays the implicit default among the two. +Anthropic catalog metadata may enrich a matching gateway-listed ID, but never +creates gateway inventory by itself. Two-layer leaf split, mirroring the `providercatalog`/`openrouter` shape but for a config-detected (not credential-detected) provider. `internal/adapter/openaicompat` @@ -4680,15 +4690,18 @@ Composition (`internal/app/registry.go`): `resolveToolhiveIntent` decides REGISTRATION from intent alone (an explicit `--toolhive-llm-base-url`, pre-validated loopback-only by `validateToolhiveBaseURL` — literal `127.0.0.0/8`/`[::1]`/ `localhost` via `net.ParseIP`, NEVER a DNS lookup, TOCTOU-safe — or a config-file -detect) — the network probe that follows NEVER gates whether the "toolhive" entry -exists, only its diagnostics/default-model eligibility (D1's whole point: a +detect) — the network probes that follow NEVER gate whether either ToolHive entry +exists, only diagnostics/default-model eligibility (D1's whole point: a persisted `provider_id:"toolhive"` session must rehydrate even when the proxy is down, never the `ErrInvalidArgument` "unknown or unavailable provider" class of -error). `newGatewayEntry` delegates to `newOpenAICompatEntry` (the renamed +error). `newToolhiveEntries` preserves `newGatewayEntry` for the Responses surface +and constructs the native surface through `newAnthropicEntryFor`. The former +delegates to `newOpenAICompatEntry` (the renamed `newOpenAIEntry` — shared by openai/openrouter/toolhive, so all three cannot drift on resilience wrapping) with `toolhivellm.PlaceholderToken` (`"thv-proxy"`) as the -credential. `providerEntry.intentDriven`/`intentGatewayURL`/`intentExplicit` are the -three new fields: `intentDriven` tiers `preferredDefaultProvider` STRICTLY below +credential. Both carry `providerEntry.intentDriven`/`intentGatewayURL`/ +`intentExplicit` plus the resolved ToolHive routing mode: `intentDriven` tiers +`preferredDefaultProvider` STRICTLY below every key-driven provider (any resolved API key always wins the default, alphabetics be damned — pinned by an anthropic-keyed-beats-toolhive test, since "toolhive" sorts after "anthropic" and a naive sorted-pick would pass by accident) @@ -4697,8 +4710,9 @@ gateway notices. `providerStatusProto` is broader only for the operator-actionab Codex entitlement boundary; ordinary openrouter/anthropic blips still never grow the client-facing `provider_status` wire list. -`probeToolhive` is the BOUNDED (1.5s) Build-time probe, run once per Build -immediately after registration: ok(N) → INFO + (if sole+unset) fills +`probeToolhive` starts both protocol probes concurrently under ONE BOUNDED (1.5s) +Build-time deadline immediately after registration. Each outcome updates only its +own provider-keyed status/LKG. For the resolved default, ok(N) → INFO + (if unset) fills `reg.defaultModel` from the first-listed id, stamps `defaultModelAutoSelected`, and RE-RUNS the T7 caps fixup via the shared `remintEntry` helper; ok(0 models) on a SOLE/DEFAULT toolhive → `errToolhiveNoModels`, Build FAILS (R2.3 — there's genuinely @@ -4824,12 +4838,12 @@ a redirect response (CWE-918). had become the de-facto remediation map for ALL providers via the shared classifier, so an ordinary openrouter outage could record the ToolHive-specific "start it with `thv llm proxy start`" hint (latent-wrong-vendor). -`statusHintFor(pid, state)` (`internal/app/registry.go`) selects the ToolHive table -for `providerToolhive`, the manual-token/account table for `providerOpenAICodex`, -and `""` for ordinary providers; `resolveProviderModels`'s failure branch and -`liveOutcomeStore.recordSuccess`'s empty-state hint both route through it. -`probeToolhive` already keyed toolhive directly, so it needed no change. TRIP-WIRE: -a new surfaced provider needs its own vendor table, never copied wording. +`statusHintFor(entry, state)` (`internal/app/registry.go`) selects the proxy/direct +ToolHive table for both protocol-specific provider IDs from the entry's structural +`toolhiveMode`, the manual-token/account table for `providerOpenAICodex`, and `""` +for ordinary providers. `resolveProviderModels`, `liveOutcomeStore.recordSuccess`, +and `probeToolhive` all route through that one helper. TRIP-WIRE: a new surfaced +provider needs its own vendor table, never copied wording. mecatui: `client.ProviderStatus` mirrors the proto message (now with an `AutoSelected bool`); `ModelsMsg.Statuses` threads it through `ListModelsCmd`; @@ -4839,9 +4853,11 @@ remediation line from a PRIOR success must never render beneath an unrelated error); `renderModelsPanel`'s status-line loop is additionally gated on `st.err == nil` as render-time defense in depth. `renderProviderStatusLines` renders ONE muted line per non-`ok` status under the list/empty state -(`": "`, e.g. `"toolhive: proxy not reachable — start +(`": "`, e.g. `"toolhive: gateway not reachable — start it with `thv llm proxy start`"`), extracted into the shared `providerStatusLine` -helper. `modelsEmptyCopy` (review finding 6) now calls `promotedStatus` FIRST — +helper. The ToolHive clause is deliberately routing-neutral; the TUI never parses +the mode-specific free-text hint to infer proxy versus direct routing. +`modelsEmptyCopy` (review finding 6) now calls `promotedStatus` FIRST — the first entry whose state is neither `""` nor `"ok"` — and promotes ANY such status (not just `"empty"`) to the top-level empty-state cause line via `providerStatusLine`, ahead of the disabled note and the generic "No selectable @@ -4925,8 +4941,8 @@ discipline). It feeds the UNCHANGED `preferredDefaultProvider` ladder as an explicit operator override — it does NOT lower the precedence of key-driven providers. See ADR 0064 D9. -**DIRECT mode — in-process OIDC token injection (issue #265, ADR 0102).** The gateway -entry can also talk DIRECTLY to the real `gateway_url` with no local proxy hop. The +**DIRECT mode — in-process OIDC token injection (issue #265, ADR 0102).** Both gateway +entries can talk DIRECTLY to the real `gateway_url` with no local proxy hop. The ToolHive Go import that ADR 0064 D8 said would never exist now lives in ONE file — `internal/adapter/toolhivellm/tokensource.go` (the package's sole toolhive-importing file alongside the stdlib-only `detect*.go`; the detector's @@ -4947,21 +4963,28 @@ interactive variant (`mecatui llm login`). Errors are sanitised via `llm.SanitizeTokenError` (strips any bearer material an IdP echoes back) before they cross any boundary. -The token rides a custom `http.RoundTripper` inside the `*http.Client` passed to -`openai.WithHTTPClient` — NOT a `port.LLMProvider` decorator. `bearerRoundTripper` -(`internal/app/registry.go`) strips the openai-go SDK's placeholder `Authorization` -header (the SDK's `SetAPIKey` stamps `Bearer ` before `*http.Client.Transport` -fires) and sets `Bearer `, mirroring the ToolHive proxy's `Rewrite` +The ONE token source is shared by the two entries through a custom +`http.RoundTripper` inside the `*http.Client` passed to both SDKs — NOT a +`port.LLMProvider` decorator. `bearerRoundTripper` (`internal/app/registry.go`) +strips conflicting `Authorization` AND `X-Api-Key` headers before setting +`Bearer ` on every outbound attempt, mirroring the ToolHive proxy's `Rewrite` (`pkg/llm/proxy/proxy.go` : `Del` then `Set`). The HTTP client composes TWO policies: `bearerRoundTripper` over the SDK default transport, AND -`openaicompat.RefuseRedirects` (CWE-918). `newDirectGatewayEntry` passes both via -`openai.WithHTTPClient`, and `newOpenAICompatEntry` closes `extra` into `construct()` -so the option rides the default build AND every per-session/heal `remintEntry` re-mint -(zero drift, the same property the proxy entry relies on for redirect refusal). The +`openaicompat.RefuseRedirects` (CWE-918). `newDirectGatewayClient` constructs that +shared policy once; the OpenAI and Anthropic entry constructors pass it through their +SDK options, including every per-session/heal re-mint (zero drift, the same property +the proxy entry relies on for redirect refusal). The token NEVER enters a log, an error string, or an env var (OS keyring; only its reference is persisted); the `RoundTripper` must never log the Authorization header — and by construction it does not. +Proxy-mode native Anthropic uses the same normalizing transport with a static, +non-secret `thv-proxy` token: it removes any Anthropic SDK `X-Api-Key` and conflicting +authorization, then sends only `Authorization: Bearer thv-proxy` to the validated +loopback hop. ToolHive replaces that header upstream. Both listing and inference +clients refuse redirects; the lister's shallow client clone retains the underlying +authenticated transport while adding its response-size cap. + A new `--toolhive-llm-mode auto|proxy|direct` flag (default `auto`, registered by `cliconfig.RegisterToolhiveLLMFlags` on all four mains) drives the routing in `resolveToolhiveIntent` (`internal/app/registry.go`): `auto` selects direct when @@ -4970,8 +4993,10 @@ A new `--toolhive-llm-mode auto|proxy|direct` flag (default `auto`, registered b gateway would send the bearer over cleartext, CWE-319), else falls back to proxy with a WARN (byte-identical to pre-#265 when OIDC is absent); `proxy` forces the loopback path (the escape hatch for a misconfigured OIDC block or a self-signed cert); `direct` -forces the gateway path. The direct base URL is DERIVED via `directBaseURL` -(`gateway_url + "/v1"`, mirroring what the ToolHive proxy forwards), never hand-set — +forces the gateway path. The direct bases are DERIVED via `directBaseURL` and +`toolhiveAnthropicBaseURL` (`gateway_url + "/v1"` and `gateway_url + "/anthropic"`), +never hand-set; the same helper preserves a legitimate path prefix and strips +userinfo/query/fragment material — there is no `--toolhive-llm-direct-base-url` (it would duplicate the security-sensitive `--toolhive-llm-base-url` surface for zero gain). An explicit `--toolhive-llm-base-url` ALWAYS forces proxy (it is a loopback address; direct derives from the config's diff --git a/docs/design/PRODUCTION-READINESS.md b/docs/design/PRODUCTION-READINESS.md index f309ba45e..f9936e3ed 100644 --- a/docs/design/PRODUCTION-READINESS.md +++ b/docs/design/PRODUCTION-READINESS.md @@ -14,7 +14,7 @@ record; current behaviour is in the linked [architecture](../architecture.md) do | Subsystem | Status | Design record | Architecture | |---|---|---|---| -| Multi-provider / multi-model | ✅ P0+P1+live listing & metadata · ✅ same-provider history carryover (issue #20) · ✅ experimental manual `openai-codex` subscription token (ADR 0215) · ✅ internal opaque credential-store substrate consumed by MCP profiles with local encrypted-file Store and explicit read-only environment Reader (issues #519/#542) · ⛔ disk cache (P2) · ⛔ key acquisition/per-client routing/remote stores or Kubernetes Secret `resourceVersion` CAS backend (P3) | [MULTI-PROVIDER.md](../adr/0016-multi-provider.md) · [0215](../adr/0215-openai-subscription-manual-token.md) · [0218](../adr/0218-credential-store.md) · [0221](../adr/0221-read-only-credential-source.md) | [providers](../architecture/providers.md) · [credential store](../architecture.md#internal-credential-store) | +| Multi-provider / multi-model | ✅ P0+P1+live listing & metadata · ✅ same-provider history carryover (issue #20) · ✅ experimental manual `openai-codex` subscription token (ADR 0215) · ✅ ToolHive OpenAI Responses and native Anthropic protocol providers over one gateway identity (ADR 0326) · ✅ internal opaque credential-store substrate consumed by MCP profiles with local encrypted-file Store and explicit read-only environment Reader (issues #519/#542) · ⛔ disk cache (P2) · ⛔ key acquisition/per-client routing/remote stores or Kubernetes Secret `resourceVersion` CAS backend (P3) | [MULTI-PROVIDER.md](../adr/0016-multi-provider.md) · [0215](../adr/0215-openai-subscription-manual-token.md) · [0218](../adr/0218-credential-store.md) · [0221](../adr/0221-read-only-credential-source.md) · [0326](../adr/0326-toolhive-protocol-specific-providers.md) | [providers](../architecture/providers.md) · [credential store](../architecture.md#internal-credential-store) | | Remote mecatui OIDC login, refresh, recovery, and logout | ✅ fixed-callback PKCE enrollment, actual-record-only root-scoped keyring migration, one refresh/enroll/logout target transaction with ambiguous-commit compensation, target-bound encrypted credentials, token-demand-gated proactive refresh, exact structured-rejection cleanup, ownership-checked same-target recovery, CAS-safe local-first logout, one-budget best-effort RFC 7009 revocation, and target-aware TLS defaults with saved-auth verified-TLS enforcement shipped; ✅ offline rotation/rejection/crash-residual/logout/TLS-policy coverage · ⚠️ no cross-store journal: a crash may require login or leave an unenumerable credential-only orphan · ⚠️ legacy zero-padded-port credential identities require one login · ⛔ live Kind qualification remains environment-dependent | [0277](../adr/0277-remote-mecatui-oidc.md) · [0287](../adr/0287-target-aware-mecatui-tls.md) · [0218](../adr/0218-credential-store.md) | [remote login](../tui.md#oidc-connected-server) | | Provider-side conversation prompt caching | ✅ shipped: anthropic 4-slot breakpoint budget + uniform TTL, openai/openrouter dialect-gated `prompt_cache_key`/`prompt_cache_retention`/`cache_control`, openaichat dormant-but-tested · ⛔ operator-supplied cache key · ⛔ `settings.yaml` TTL key · ⛔ Anthropic 1h TTL via OpenRouter | [0100](../adr/0100-provider-prompt-caching.md) | [providers](../architecture/providers.md) | | OpenAI Responses adapter | ✅ shipped (research brief frozen) | [OPENAI-RESPONSES-API.md](../adr/0017-openai-responses-api.md) | [providers](../architecture/providers.md) | diff --git a/docs/tui.md b/docs/tui.md index 8e8356906..258de8796 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -931,6 +931,12 @@ actionable `auth.yaml`/restart or connectivity remedy. A prior successful list m remain visible during a later refresh failure, but that does not hide an inference failure. Codex rows never receive ToolHive's `org` intent label. +One configured ToolHive gateway may contribute two picker namespaces: +`toolhive` (OpenAI Responses) and `toolhive-anthropic` (native Anthropic Messages). +Both receive the `org` provenance tag, but the header/footer treat them as one +gateway family: when either is active, its sibling does not produce a duplicate +"gateway available" notice. Catalog failures and counts remain independent. + `enter` on the cursor row **switches immediately** — the conversation is ALWAYS kept. Because the provider is FIXED per session, switching live means a real handoff: a fresh session on the picked model is **seeded with the current session's conversation** through diff --git a/internal/adapter/permconfig/providers.go b/internal/adapter/permconfig/providers.go index 8ae3edcdb..a573342a4 100644 --- a/internal/adapter/permconfig/providers.go +++ b/internal/adapter/permconfig/providers.go @@ -17,7 +17,7 @@ const providerHTTPS = "https" var providerIDPattern = regexp.MustCompile(`^[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$`) var reservedProviderIDs = map[string]struct{}{ - "anthropic": {}, "mock": {}, "openai": {}, "openai-codex": {}, "openrouter": {}, "opencode": {}, "toolhive": {}, + "anthropic": {}, "mock": {}, "openai": {}, "openai-codex": {}, "openrouter": {}, "opencode": {}, "toolhive": {}, "toolhive-anthropic": {}, } var builtinOverrideIDs = map[string]struct{}{ diff --git a/internal/adapter/permconfig/providers_test.go b/internal/adapter/permconfig/providers_test.go index ea2dee48f..ee6c60346 100644 --- a/internal/adapter/permconfig/providers_test.go +++ b/internal/adapter/permconfig/providers_test.go @@ -41,6 +41,7 @@ func TestOperatorDefinedLLMProviders_Scenario1_InvalidDefinitionsFailClosed(t *t "providers:\n custom:\n base_url: https://x.example\n default_model: m\n api_flavor: invalid", "providers:\n custom:\n base_url: https://x.example\n api_flavor: openai-responses", "providers:\n mock:\n base_url: https://x.example\n default_model: m\n api_flavor: openai-responses", + "providers:\n toolhive-anthropic:\n base_url: https://x.example\n default_model: m\n api_flavor: anthropic-messages", "provider_overrides:\n toolhive:\n base_url: https://x.example", } for _, input := range cases { @@ -97,7 +98,7 @@ func TestADR_0238_BuiltinOverrideAllowlist(t *testing.T) { t.Fatalf("%s override rejected: %v", id, err) } } - for _, id := range []string{"openai-codex", "toolhive"} { + for _, id := range []string{"openai-codex", "toolhive", "toolhive-anthropic"} { if _, err := parseYAML([]byte("provider_overrides:\n " + id + ":\n base_url: https://proxy.example")); err == nil { t.Fatalf("%s override accepted", id) } diff --git a/internal/adapter/toolhivellm/detect.go b/internal/adapter/toolhivellm/detect.go index 0788973c6..41714aeac 100644 --- a/internal/adapter/toolhivellm/detect.go +++ b/internal/adapter/toolhivellm/detect.go @@ -2,10 +2,10 @@ // it detects, by reading ToolHive's OWN on-disk config file, whether a ToolHive // LLM gateway proxy is set up for this user — and, if so, what LOOPBACK port it // listens on — and (issue #265, tokensource.go) builds an in-process OIDC token -// source so the `toolhive` provider can talk DIRECTLY to the real gateway_url -// with no local proxy hop. The actual live-listing HTTP call is made by the -// protocol-generic internal/adapter/openaicompat.Lister, which this package -// merely feeds a hardcoded loopback base URL. +// source so both ToolHive protocol providers can talk DIRECTLY to the real +// gateway_url with no local proxy hop. Live listing remains owned by the wire +// adapters: internal/adapter/openaicompat for `toolhive` and provider/anthropic +// for `toolhive-anthropic`; this package supplies only detected routing intent. // // # What this reads // diff --git a/internal/app/build.go b/internal/app/build.go index 5a44744e3..26d2a7c1b 100644 --- a/internal/app/build.go +++ b/internal/app/build.go @@ -1118,6 +1118,12 @@ type Config struct { // composition detail, not an operator knob. liveModelHTTPClient *http.Client + // toolhiveTokenSourceFactory is the composition-only test seam for direct + // ToolHive OIDC. Production uses toolhivellm.DirectTokenSource; tests inject + // a deterministic source so both protocol entries can be exercised offline + // and can prove that one shared login/refresh flow serves the family. + toolhiveTokenSourceFactory toolhiveTokenSourceFactory + // openAICodexNow/openAICodexTransport are composition-only test seams for the // manual-token request policy. Production uses time.Now and the default // transport. Tests inject a fixed clock and capturing transport so every diff --git a/internal/app/livemeta.go b/internal/app/livemeta.go index 1890b88fa..f7d70d950 100644 --- a/internal/app/livemeta.go +++ b/internal/app/livemeta.go @@ -275,7 +275,7 @@ func (s *liveMetaStore) outputLimitFor(providerID, modelID string) int { if m, ok := s.lookup(providerID, modelID); ok && m.OutputLimit > 0 { return clampLive(m.OutputLimit, maxLiveOutputLimit) } - if providerID == providerAnthropic { + if providerID == providerAnthropic || providerID == providerToolhiveAnthropic { return anthropicOutputLimit(modelID) } return 0 diff --git a/internal/app/modellister.go b/internal/app/modellister.go index cc58ff227..ea76e890c 100644 --- a/internal/app/modellister.go +++ b/internal/app/modellister.go @@ -503,8 +503,14 @@ func embeddedModels(providerID string) []modelEntry { // id, but embeddedModels intentionally does not call this helper: OpenAI's API // inventory must never become Codex subscription inventory. func metadataCatalogProviderID(providerID string) string { - if providerID == providerOpenAICodex { + switch providerID { + case providerOpenAICodex: return providerOpenAI + case providerToolhiveAnthropic: + // Metadata only: embeddedModels deliberately does not call this helper, + // so Anthropic's public catalog can enrich a gateway-listed ID without + // fabricating that ID into the gateway's actual inventory. + return providerAnthropic } return providerID } @@ -575,7 +581,15 @@ func liveModelSnapshot(ctx context.Context, d port.Diagnostics, reg *providerReg return nil } byProvider := make(map[string][]modelEntry) - for _, pid := range reg.Available() { // available (keyed) providers ONLY + available := reg.Available() + toolhiveProviders := make([]string, 0, 2) + for _, pid := range available { + if isToolhiveProvider(pid) { + toolhiveProviders = append(toolhiveProviders, pid) + } + } + toolhiveResolved := false + for _, pid := range available { // available (keyed) providers ONLY if pid == providerMock { continue // the mock never advertises selectable models } @@ -585,11 +599,67 @@ func liveModelSnapshot(ctx context.Context, d port.Diagnostics, reg *providerReg byProvider[pid] = models continue } + if isToolhiveProvider(pid) { + if !toolhiveResolved { + for familyPID, models := range resolveToolhiveModels(ctx, d, reg, toolhiveProviders, 0) { + byProvider[familyPID] = models + } + toolhiveResolved = true + } + continue + } byProvider[pid] = resolveProviderModels(ctx, d, reg, pid) } return byProvider } +// resolveToolhiveModels fetches the two protocol surfaces of one ToolHive +// gateway concurrently. Concurrency is deliberately scoped to this family; +// unrelated providers retain the established sequential listing behaviour. +// A positive perProviderTimeout gives each family member its own bound, as +// required by the on-demand stale refresh path. The initial snapshot instead +// passes zero and relies on its existing operation-wide context. +func resolveToolhiveModels( + ctx context.Context, + d port.Diagnostics, + reg *providerRegistry, + providerIDs []string, + perProviderTimeout time.Duration, +) map[string][]modelEntry { + type result struct { + pid string + models []modelEntry + } + + resolve := func(pid string) []modelEntry { + fetchCtx := ctx + cancel := func() {} + if perProviderTimeout > 0 { + fetchCtx, cancel = context.WithTimeout(ctx, perProviderTimeout) + } + defer cancel() + return resolveProviderModels(fetchCtx, d, reg, pid) + } + + resolved := make(map[string][]modelEntry, len(providerIDs)) + if len(providerIDs) == 1 { + resolved[providerIDs[0]] = resolve(providerIDs[0]) + return resolved + } + + results := make(chan result, len(providerIDs)) + for _, pid := range providerIDs { + go func(pid string) { + results <- result{pid: pid, models: resolve(pid)} + }(pid) + } + for range providerIDs { + result := <-results + resolved[result.pid] = result.models + } + return resolved +} + // resolveProviderModels returns the per-provider model list applying the merge // + fail-safe rules, now OUTCOME-AWARE (issue #262, D3): live REPLACES // embedded on success (even an honest empty — see below); on a lister error, @@ -614,7 +684,7 @@ func resolveProviderModels(ctx context.Context, d port.Diagnostics, reg *provide live, err := entry.lister.ListModels(ctx) if err != nil { state := classifyLiveListError(err) - hint := statusHintFor(pid, state) + hint := statusHintFor(entry, state) reg.outcomes.recordFailure(pid, state, hint) d.Log(ctx, port.LevelWarn, "live model fetch failed", "provider", pid, "err", err, "state", state) if len(embedded) > 0 { @@ -630,7 +700,7 @@ func resolveProviderModels(ctx context.Context, d port.Diagnostics, reg *provide // Success: record ALWAYS, even an empty list — an honest empty IS a // successful list (R3.1) and must be available as a future last-known-good // fallback for a provider with no embedded floor. - reg.outcomes.recordSuccess(pid, live) + reg.outcomes.recordSuccess(entry, live) if len(live) == 0 && len(embedded) > 0 { // Legacy behaviour for a KEYED provider with a non-empty embedded floor: a // transient empty response must not blank an otherwise-rich picker. @@ -687,16 +757,17 @@ func newLiveOutcomeStore() *liveOutcomeStore { // live response is momentarily empty but has a non-empty embedded floor // still records "ok" (the caller returns the embedded floor to the picker, // but the provider itself is reachable and authorized). -func (s *liveOutcomeStore) recordSuccess(pid string, live []modelEntry) { +func (s *liveOutcomeStore) recordSuccess(entry providerEntry, live []modelEntry) { if s == nil { return } + pid := entry.id s.mu.Lock() defer s.mu.Unlock() s.lastGood[pid] = live state, hint := statusOK, "" if len(live) == 0 && len(embeddedModels(pid)) == 0 { - state, hint = statusEmpty, statusHintFor(pid, statusEmpty) + state, hint = statusEmpty, statusHintFor(entry, statusEmpty) } s.status[pid] = providerStatus{State: state, Hint: hint} } @@ -779,6 +850,12 @@ func providerStatusProto(reg *providerRegistry) []*mecatlv1.ProviderStatus { // provider is reachable (state == "ok") AND is NOT the active default. // reg.Default() is lock-free and immutable post-Build (see Default()). availableNotDefault := entry.intentDriven && status.State == statusOK && pid != reg.Default() + if isToolhiveProvider(pid) && isToolhiveProvider(reg.Default()) { + // The two rows are protocol surfaces of one gateway identity. Do not + // advertise the inactive sibling as a second gateway when either one is + // already the active default. + availableNotDefault = false + } // model_count is the live listing length (a slice len); a provider // never lists >2B models, so this reuses server.ClampInt32 (the same // overflow-safe int32 narrowing already used 15+ times in that @@ -869,7 +946,23 @@ func refreshStaleModels(ctx context.Context, d port.Diagnostics, reg *providerRe } fresh := make(map[string][]modelEntry, len(stale)) + toolhiveProviders := make([]string, 0, 2) + for _, pid := range stale { + if isToolhiveProvider(pid) { + toolhiveProviders = append(toolhiveProviders, pid) + } + } + toolhiveResolved := false for _, pid := range stale { + if isToolhiveProvider(pid) { + if !toolhiveResolved { + for familyPID, models := range resolveToolhiveModels(ctx, d, reg, toolhiveProviders, 2*time.Second) { + fresh[familyPID] = models + } + toolhiveResolved = true + } + continue + } fetchCtx, cancel := context.WithTimeout(ctx, 2*time.Second) fresh[pid] = resolveProviderModels(fetchCtx, d, reg, pid) cancel() diff --git a/internal/app/modellister_test.go b/internal/app/modellister_test.go index 270f84292..6f05525f6 100644 --- a/internal/app/modellister_test.go +++ b/internal/app/modellister_test.go @@ -875,7 +875,7 @@ func TestRefreshStaleModels_SkipsHealthyAndNonIntentDriven(t *testing.T) { meta: newLiveMetaStore(), outcomes: newLiveOutcomeStore(), } - reg.outcomes.recordSuccess(providerToolhive, []modelEntry{{ID: "already-ok"}}) // healthy, not stale + reg.outcomes.recordSuccess(reg.entries[providerToolhive], []modelEntry{{ID: "already-ok"}}) // healthy, not stale st := &refreshStaleModelsState{} refreshStaleModels(context.Background(), port.NopDiagnostics{}, reg, newFakeSwapper(), st) @@ -897,7 +897,7 @@ func TestResolveProviderModels_OpenRouterRegressionPin(t *testing.T) { lister := &fakeLister{err: errors.New("boom")} reg := regWithLister(lister) reg.outcomes = newLiveOutcomeStore() - reg.outcomes.recordSuccess(providerOpenRouter, []modelEntry{{ID: "should-never-win"}}) + reg.outcomes.recordSuccess(reg.entries[providerOpenRouter], []modelEntry{{ID: "should-never-win"}}) got := resolveProviderModels(context.Background(), port.NopDiagnostics{}, reg, providerOpenRouter) curated := orEmbeddedIDs(t) diff --git a/internal/app/network_attempt_provider_coverage_test.go b/internal/app/network_attempt_provider_coverage_test.go index ba6855098..28b229e0c 100644 --- a/internal/app/network_attempt_provider_coverage_test.go +++ b/internal/app/network_attempt_provider_coverage_test.go @@ -45,6 +45,10 @@ func TestProductionProviderEntriesShareNetworkAttemptObservation(t *testing.T) { return newAnthropicEntryFor(cfg, providerAnthropic, "test", server.URL, newLiveMetaStore(), false, anthropicprovider.WithRequestOption(anthropicoption.WithHTTPClient(server.Client()))) }}, + {name: providerToolhiveAnthropic, model: "claude-sonnet-4-6", entry: func() providerEntry { + return newAnthropicEntryFor(cfg, providerToolhiveAnthropic, "test", server.URL, newLiveMetaStore(), false, + anthropicprovider.WithRequestOption(anthropicoption.WithHTTPClient(server.Client()))) + }}, } for _, test := range tests { diff --git a/internal/app/openai_codex_registry_test.go b/internal/app/openai_codex_registry_test.go index afce103ed..423697995 100644 --- a/internal/app/openai_codex_registry_test.go +++ b/internal/app/openai_codex_registry_test.go @@ -343,7 +343,7 @@ func TestOpenAICodexAbsentPreservesExistingProviders(t *testing.T) { if err != nil { t.Fatalf("buildProviderRegistry: %v", err) } - if got, want := reg.Available(), []string{providerOpenAI, providerOpenRouter, providerToolhive}; !reflect.DeepEqual(got, want) { + if got, want := reg.Available(), []string{providerOpenAI, providerOpenRouter, providerToolhive, providerToolhiveAnthropic}; !reflect.DeepEqual(got, want) { t.Fatalf("Available() = %v, want %v", got, want) } if reg.Default() != providerOpenAI { diff --git a/internal/app/pending_default_test.go b/internal/app/pending_default_test.go index 31f03e9ac..b9e98ac7e 100644 --- a/internal/app/pending_default_test.go +++ b/internal/app/pending_default_test.go @@ -233,8 +233,8 @@ func TestToolhiveAvailableNotDefault_E2E(t *testing.T) { t.Fatalf("ListModels: %v", err) } status := resp.GetProviderStatus() - if len(status) != 1 { - t.Fatalf("provider_status = %d rows, want 1 (toolhive only — v1 intent-driven-scoped): %+v", len(status), status) + if len(status) != 2 { + t.Fatalf("provider_status = %d rows, want 2 (both ToolHive protocols): %+v", len(status), status) } row := status[0] if row.GetProviderId() != providerToolhive { diff --git a/internal/app/registry.go b/internal/app/registry.go index 19467337d..cc28b81c1 100644 --- a/internal/app/registry.go +++ b/internal/app/registry.go @@ -52,6 +52,11 @@ const ( // proxy entry: unlike the three above, it is registered by CONFIG-DETECTED // INTENT (or an explicit base-url override), never a credential. providerToolhive = "toolhive" + // providerToolhiveAnthropic is the native Anthropic Messages surface exposed + // by the same configured ToolHive gateway identity. It is deliberately a + // distinct provider: models listed here must execute through /anthropic/v1/messages, + // never the OpenAI Responses adapter used by providerToolhive. + providerToolhiveAnthropic = "toolhive-anthropic" // providerMock is the synthetic offline provider id used only when UseMock is // set. It never reaches the wire as a selectable provider; it exists so the // registry has exactly one entry in offline/smoke-test runs. @@ -206,6 +211,10 @@ type providerEntry struct { // unreachable diagnostic from INFO to WARN on this path (the operator // asked for this endpoint directly). intentExplicit bool + // toolhiveMode records the family entry's resolved route so status hints can + // distinguish a local proxy failure from a direct gateway/OIDC failure. + // It is meaningful only when intentDriven is true for a ToolHive provider. + toolhiveMode toolhiveRoutingMode // defaultEffort is the OPERATOR-DEFAULT reasoning-effort token (ADR 0055, // already normalised + per-provider clamped by operatorDefaultEffortFor) this // entry was built with. Stamped by buildProviderRegistry's fixup loop @@ -627,21 +636,18 @@ func buildProviderRegistryContext(ctx context.Context, cfg Config, detect envDet addCustomProviderEntries(entries, cfg, meta) - // toolhive (issue #262, D1): registered by CONFIG-DETECTED INTENT alone — + // ToolHive (issue #262, D1): both protocol-specific entries are registered + // by CONFIG-DETECTED INTENT alone — // resolveToolhiveIntent NEVER runs a network probe, so registration never - // blocks on (or is gated by) reachability (R1.1). With toolhive registered, + // blocks on (or is gated by) reachability (R1.1). With the family registered, // len(entries)>0 even with ZERO provider keys, so errNoProvider no longer // fires for a ToolHive-only operator — intended (zero-API-key onboarding). // Issue #265: the intent now carries a routing mode — proxy (loopback, // today's behaviour) or direct (gateway_url + in-process OIDC token). if intent, ok := resolveToolhiveIntent(cfg); ok { - switch intent.mode { - case toolhiveModeDirect: - entries[providerToolhive] = newDirectGatewayEntry(cfg, providerToolhive, intent, cfg.toolhiveConfigPath) - default: // toolhiveModeProxy (the zero value + the explicit-override path) - lister := gatewayLister{inner: openaicompat.NewLister(intent.baseURL, toolhivellm.PlaceholderToken, cfg.liveModelHTTPClient)} - entries[providerToolhive] = newGatewayEntry(cfg, providerToolhive, intent.baseURL, intent.gatewayURL, intent.explicit, lister) - } + openAIEntry, anthropicEntry := newToolhiveEntries(cfg, intent, cfg.toolhiveConfigPath, meta) + entries[providerToolhive] = openAIEntry + entries[providerToolhiveAnthropic] = anthropicEntry } if len(entries) == 0 { @@ -690,8 +696,8 @@ func buildProviderRegistryContext(ctx context.Context, cfg Config, detect envDet } reg.remintEntry(id, model) } - // Build-time probe (issue #262, R1.2): only runs when a toolhive entry - // exists (a single map lookup otherwise). It NEVER gates registration + // Build-time probe (issue #262, R1.2): only runs when the ToolHive family + // exists. It NEVER gates registration // (already done above) — it drives the startup diagnostic, the initial // provider_status + last-known-good seed, and default-model eligibility. if err := probeToolhive(reg, cfg); err != nil { @@ -870,7 +876,7 @@ func bootstrapOpenAICodexDefault(parent context.Context, reg *providerRegistry, } return fmt.Errorf("openai-codex: default model discovery unreachable: %w", err) } - reg.outcomes.recordSuccess(providerOpenAICodex, models) + reg.outcomes.recordSuccess(entry, models) if len(models) == 0 { return errors.New("openai-codex: account returned no picker-visible models; replace the manual token or choose an explicit model") } @@ -1264,26 +1270,24 @@ const ( toolhiveModeDirect toolhiveRoutingMode = 1 // gateway_url + in-process OIDC token ) -// toolhiveIntent is the resolved toolhive registration intent: whether to -// register, and — when registering — the routing mode + the URLs -// newGatewayEntry/newDirectGatewayEntry consume. baseURL is the request URL -// (loopback for proxy, gateway_url+"/v1" for direct); gatewayURL is the +// toolhiveIntent is the resolved ToolHive-family registration intent and its +// routing mode. baseURL is the OpenAI-compatible request URL (loopback for +// proxy, gateway_url+"/v1" for direct); the native Anthropic URL is derived +// from it without losing a gateway path prefix. gatewayURL is the // upstream the proxy forwards to (DIAGNOSTIC ONLY for proxy, the SAME as // baseURL's origin for direct); explicit marks an --toolhive-llm-base-url // override (proxy-only — direct is config-driven). type toolhiveIntent struct { - mode toolhiveRoutingMode - baseURL string - gatewayURL string - explicit bool - oidcConfigured bool // F3: loaded once after DetectConfig, used by newDirectGatewayEntry + mode toolhiveRoutingMode + baseURL string + gatewayURL string + explicit bool } -// resolveToolhiveIntent decides whether a "toolhive" registry entry should be -// registered (issue #262, D1) and, if so, what routing mode + URLs to serve it -// on (issue #265). It NEVER runs a network probe — registration is intent-only, -// and the intent it returns feeds newGatewayEntry/newDirectGatewayEntry, which -// the LATER Build-time probe (probeToolhive) reads off the constructed entry. +// resolveToolhiveIntent decides whether the ToolHive provider family should be +// registered (issue #262, D1) and, if so, what routing mode + URLs serve both +// protocol entries (issue #265). It NEVER runs a network probe — registration +// is intent-only; the LATER Build-time probe reads the constructed entries. // // Precedence: an EXPLICIT cfg.ToolhiveLLMBaseURL (already loopback-validated // by validateToolhiveBaseURL at Build) wins outright and SKIPS the config-file @@ -1343,6 +1347,7 @@ func resolveToolhiveIntent(cfg Config) (toolhiveIntent, bool) { // F3: load the OIDC config ONCE after DetectConfig validates the file, so // newDirectGatewayEntry consumes the validated result rather than re-reading. oidcOK := toolhivellm.OIDCConfigured(path) + diagnosticGatewayURL := sanitizeGatewayURL(detected.GatewayURL) // Mode discriminator (issue #265). auto upgrades to direct when the OIDC // trio is configured; proxy is the byte-identical fallback. direct is the @@ -1351,7 +1356,7 @@ func resolveToolhiveIntent(cfg Config) (toolhiveIntent, bool) { // and would be surprised by a loopback that has no token to inject). switch cfg.ToolhiveLLMMode { case "proxy": - return toolhiveIntent{mode: toolhiveModeProxy, baseURL: detected.BaseURL(), gatewayURL: detected.GatewayURL}, true + return toolhiveIntent{mode: toolhiveModeProxy, baseURL: detected.BaseURL(), gatewayURL: diagnosticGatewayURL}, true case "direct": if !oidcOK { // Not a fail-soft miss: validateToolhiveLLMMode (Build) already @@ -1365,21 +1370,21 @@ func resolveToolhiveIntent(cfg Config) (toolhiveIntent, bool) { if !gatewayURLIsHTTPS(detected.GatewayURL) { cfg.diag().Log(context.Background(), port.LevelWarn, "toolhive direct mode: gateway_url is not HTTPS, falling back to proxy mode", - "gateway_url", detected.GatewayURL) - return toolhiveIntent{mode: toolhiveModeProxy, baseURL: detected.BaseURL(), gatewayURL: detected.GatewayURL}, true + "gateway_url", diagnosticGatewayURL) + return toolhiveIntent{mode: toolhiveModeProxy, baseURL: detected.BaseURL(), gatewayURL: diagnosticGatewayURL}, true } - return toolhiveIntent{mode: toolhiveModeDirect, baseURL: directBaseURL(detected.GatewayURL), gatewayURL: detected.GatewayURL, oidcConfigured: true}, true + return toolhiveIntent{mode: toolhiveModeDirect, baseURL: directBaseURL(detected.GatewayURL), gatewayURL: diagnosticGatewayURL}, true default: // "auto" (and any unknown, treated as the default) if oidcOK { if !gatewayURLIsHTTPS(detected.GatewayURL) { cfg.diag().Log(context.Background(), port.LevelWarn, "toolhive direct mode: gateway_url is not HTTPS, falling back to proxy mode", - "gateway_url", detected.GatewayURL) - return toolhiveIntent{mode: toolhiveModeProxy, baseURL: detected.BaseURL(), gatewayURL: detected.GatewayURL}, true + "gateway_url", diagnosticGatewayURL) + return toolhiveIntent{mode: toolhiveModeProxy, baseURL: detected.BaseURL(), gatewayURL: diagnosticGatewayURL}, true } - return toolhiveIntent{mode: toolhiveModeDirect, baseURL: directBaseURL(detected.GatewayURL), gatewayURL: detected.GatewayURL, oidcConfigured: true}, true + return toolhiveIntent{mode: toolhiveModeDirect, baseURL: directBaseURL(detected.GatewayURL), gatewayURL: diagnosticGatewayURL}, true } - return toolhiveIntent{mode: toolhiveModeProxy, baseURL: detected.BaseURL(), gatewayURL: detected.GatewayURL}, true + return toolhiveIntent{mode: toolhiveModeProxy, baseURL: detected.BaseURL(), gatewayURL: diagnosticGatewayURL}, true } } @@ -1430,16 +1435,62 @@ func gatewayURLIsHTTPS(raw string) bool { // gateway_url yields "" (the same contract as the empty input); in practice // gatewayURLIsHTTPS has already rejected it and forced proxy mode. func directBaseURL(gatewayURL string) string { - if gatewayURL == "" { + return deriveGatewayBaseURL(gatewayURL, "v1", false) +} + +// toolhiveAnthropicBaseURL derives the base expected by anthropic-sdk-go. A +// normal ToolHive OpenAI base ends in /v1; native Anthropic is its sibling +// /anthropic, because the SDK appends /v1/models or /v1/messages itself. An +// explicit proxy override that does not end in /v1 instead gains a trailing +// /anthropic. In both cases any legitimate path prefix is retained. +func toolhiveAnthropicBaseURL(openAIBaseURL string) string { + return deriveGatewayBaseURL(openAIBaseURL, "anthropic", true) +} + +// deriveGatewayBaseURL joins a protocol segment onto a gateway URL without +// carrying credential-shaped userinfo, a query, or a fragment into request or +// diagnostic URLs. When replaceV1 is true, only a terminal path segment named +// exactly "v1" is replaced; an interior segment is preserved. +func deriveGatewayBaseURL(raw, segment string, replaceV1 bool) string { + sanitized := sanitizeGatewayURL(raw) + if sanitized == "" { return "" } - joined, err := url.JoinPath(gatewayURL, "v1") + u, _ := url.Parse(sanitized) + u.RawPath = "" + if replaceV1 { + clean := strings.TrimRight(u.Path, "/") + if clean == "/v1" { + u.Path = "" + } else if strings.HasSuffix(clean, "/v1") { + u.Path = strings.TrimSuffix(clean, "/v1") + } else { + u.Path = clean + } + } + joined, err := url.JoinPath(u.String(), segment) if err != nil { return "" } return joined } +func sanitizeGatewayURL(raw string) string { + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil || u.Scheme == "" || u.Host == "" { + return "" + } + u.User = nil + u.RawQuery = "" + u.ForceQuery = false + u.Fragment = "" + u.RawFragment = "" + return u.String() +} + // ToolhiveAvailable reports whether a ToolHive LLM gateway would be registered // for this Config — an explicit --toolhive-llm-base-url, or a detected // locally-running proxy. It runs the SAME resolveToolhiveIntent detection Build @@ -1471,6 +1522,52 @@ func newGatewayEntry(cfg Config, id, baseURL, gatewayURL string, explicit bool, entry.intentDriven = true entry.intentGatewayURL = gatewayURL entry.intentExplicit = explicit + entry.toolhiveMode = toolhiveModeProxy + return entry +} + +// toolhiveTokenSourceFactory constructs the one OIDC token source shared by +// both protocol-specific ToolHive entries in direct mode. +type toolhiveTokenSourceFactory func(string, port.Diagnostics) (toolhivellm.TokenSourceFunc, error) + +// newToolhiveEntries constructs the two protocol-specific providers backed by +// one detected ToolHive gateway identity. The OpenAI entry remains the legacy +// provider/default; the Anthropic entry always uses the native Messages adapter. +func newToolhiveEntries(cfg Config, intent toolhiveIntent, configPath string, meta *liveMetaStore) (providerEntry, providerEntry) { + if intent.mode == toolhiveModeDirect { + client := newDirectGatewayClient(cfg, intent, configPath) + return newDirectGatewayEntry(cfg, providerToolhive, intent, client), + newToolhiveAnthropicEntry(cfg, intent, meta, client) + } + + openAILister := gatewayLister{inner: openaicompat.NewLister( + intent.baseURL, toolhivellm.PlaceholderToken, cfg.liveModelHTTPClient)} + openAIEntry := newGatewayEntry(cfg, providerToolhive, intent.baseURL, + intent.gatewayURL, intent.explicit, openAILister) + + // The native SDK must never forward an x-api-key placeholder through the + // proxy. This transport strips both authentication schemes and adds only the + // proxy's documented loopback bearer on the cloned request. + client := newToolhiveBearerClient(cfg.liveModelHTTPClient, + func(context.Context) (string, error) { return toolhivellm.PlaceholderToken, nil }) + return openAIEntry, newToolhiveAnthropicEntry(cfg, intent, meta, client) +} + +// newToolhiveAnthropicEntry wires native Anthropic discovery and inference to +// the same ToolHive route. No API key is supplied to the SDK: the authoritative +// bearer transport owns authentication and strips any conflicting header. +func newToolhiveAnthropicEntry(cfg Config, intent toolhiveIntent, meta *liveMetaStore, client *http.Client) providerEntry { + baseURL := toolhiveAnthropicBaseURL(intent.baseURL) + entry := newAnthropicEntryFor(cfg, providerToolhiveAnthropic, "", baseURL, meta, false, + anthropic.WithRequestOption( + anthropicoption.WithHTTPClient(client), + anthropicoption.WithMaxRetries(0), + )) + entry.lister = anthropicLister{inner: anthropic.NewLister("", baseURL, client)} + entry.intentDriven = true + entry.intentGatewayURL = intent.gatewayURL + entry.intentExplicit = intent.explicit + entry.toolhiveMode = intent.mode return entry } @@ -1494,19 +1591,22 @@ func newGatewayEntry(cfg Config, id, baseURL, gatewayURL string, explicit bool, // to every mint, not just the initial build (zero drift, the same property the // proxy entry relies on). // -// The token source is built ONCE here (toolhivellm.DirectTokenSource) and -// captured by the RoundTripper; a per-request Token(ctx) call handles refresh +// newDirectGatewayClient builds one token source and one bearer client shared +// by both protocol entries. A per-request Token(ctx) call handles refresh // internally, so the RoundTripper is stateless across requests. A construction // failure (config unreadable, secrets provider unavailable) does NOT fail Build: // it logs ERROR once and installs a token source that returns the cause on every // request, so the operator learns the reason at the first request instead of a // startup crash (the proxy-mode §1 deviation — a down gateway must never brick // Build — applies here too). The live lister is -// wired too (direct mode serves /v1/models the same way), authenticated by the -// SAME bearer RoundTripper so the probe and inference paths share one -// credential. -func newDirectGatewayEntry(cfg Config, id string, intent toolhiveIntent, configPath string) providerEntry { - tokenSource, err := toolhivellm.DirectTokenSource(configPath, cfg.diag()) +// paths for both protocols are authenticated by the SAME bearer RoundTripper, +// so discovery and inference share one credential flow. +func newDirectGatewayClient(cfg Config, intent toolhiveIntent, configPath string) *http.Client { + factory := cfg.toolhiveTokenSourceFactory + if factory == nil { + factory = toolhivellm.DirectTokenSource + } + tokenSource, err := factory(configPath, cfg.diag()) if err != nil { // Deliberately NOT a Build failure. The caller // (buildProviderRegistry) cannot see this error anyway @@ -1518,14 +1618,13 @@ func newDirectGatewayEntry(cfg Config, id string, intent toolhiveIntent, configP // and every other provider in the registry stays usable. cfg.diag().Log(context.Background(), port.LevelError, "toolhive direct-mode token source unavailable — requests will fail until the gateway is configured", - "provider", id, "base_url", intent.baseURL, "error", err.Error()) + "provider", providerToolhive, "base_url", intent.baseURL, "error", err.Error()) tokenSource = func(context.Context) (string, error) { return "", err } } - rt := &bearerRoundTripper{base: http.DefaultTransport, token: tokenSource} - client := &http.Client{ - Transport: rt, - CheckRedirect: openaicompat.RefuseRedirects, - } + return newToolhiveBearerClient(cfg.liveModelHTTPClient, tokenSource) +} + +func newDirectGatewayEntry(cfg Config, id string, intent toolhiveIntent, client *http.Client) providerEntry { entry := newOpenAICompatEntry(cfg, id, toolhivellm.PlaceholderToken, intent.baseURL, openai.WithHTTPClient(client), openai.WithMaxRetries(0)) @@ -1538,9 +1637,28 @@ func newDirectGatewayEntry(cfg Config, id string, intent toolhiveIntent, configP entry.intentDriven = true entry.intentGatewayURL = intent.gatewayURL entry.intentExplicit = intent.explicit + entry.toolhiveMode = toolhiveModeDirect return entry } +// newToolhiveBearerClient shallow-clones the supplied client so lister tests +// retain their injected transport and timeout while ToolHive always owns the +// redirect and authentication policies. Production supplies nil and receives a +// standard client over http.DefaultTransport. +func newToolhiveBearerClient(baseClient *http.Client, token toolhivellm.TokenSourceFunc) *http.Client { + client := &http.Client{} + if baseClient != nil { + *client = *baseClient + } + base := client.Transport + if base == nil { + base = http.DefaultTransport + } + client.Transport = &bearerRoundTripper{base: base, token: token} + client.CheckRedirect = openaicompat.RefuseRedirects + return client +} + // bearerRoundTripper injects a fresh OIDC access token onto every request as // `Authorization: Bearer `, stripping any Authorization header the SDK // stamped before the transport fires (the openai-go SDK sets a placeholder @@ -1566,6 +1684,11 @@ type bearerRoundTripper struct { // forwarding the request (no partial credentials on the wire); the // llmresilience wrapper surfaces it as a failed attempt. It must never log. func (b *bearerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // Clone and remove both SDK authentication schemes before consulting the + // authoritative source. If token resolution fails, no request is forwarded. + clone := req.Clone(req.Context()) + clone.Header.Del("Authorization") + clone.Header.Del("X-Api-Key") tok, err := b.token(req.Context()) if err != nil { // The error is already sanitised (no bearer material). Do NOT include @@ -1581,10 +1704,6 @@ func (b *bearerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error // unhealthy" verdict for as long as the credential stays broken. return nil, fmt.Errorf("%w: %w", llmresilience.ErrCredentials, err) } - // Clone the request per RoundTripper contract (the caller may reuse it); - // mutate ONLY the Authorization header on the clone. - clone := req.Clone(req.Context()) - clone.Header.Del("Authorization") clone.Header.Set("Authorization", "Bearer "+tok) return b.base.RoundTrip(clone) } @@ -1612,6 +1731,16 @@ var toolhiveStatusHints = map[string]string{ statusEmpty: "your ToolHive gateway credential lists no models — ask your platform admin or re-run `thv llm setup`", } +var toolhiveDirectStatusHints = map[string]string{ + statusUnreachable: "check gateway connectivity or use `--toolhive-llm-mode proxy`", + statusUnauthorized: "re-auth with `mecatui llm login` or `thv llm setup`", + statusEmpty: toolhiveStatusHints[statusEmpty], +} + +func isToolhiveProvider(pid string) bool { + return pid == providerToolhive || pid == providerToolhiveAnthropic +} + // openAICodexStatusHints keeps manual-token remediation distinct from the // ToolHive gateway. Codex is the only non-intent-driven provider whose live // inventory is also the account entitlement boundary, so its listing outcome @@ -1623,13 +1752,18 @@ var openAICodexStatusHints = map[string]string{ } // statusHintFor returns provider-specific remediation for ToolHive and Codex. -// Custom-provider listing failures are also projected through provider_status, -// but deliberately receive no endpoint-specific hint. Ordinary provider outages -// (for example OpenRouter) get "". Keep each vendor's copy in its own table so -// gateway and manual-token remedies cannot cross-contaminate. -func statusHintFor(pid, state string) string { - switch pid { - case providerToolhive: +// It consumes the whole entry so ToolHive routing mode is structural data, not +// something callers infer from the resulting prose. Custom-provider listing +// failures are also projected through provider_status, but deliberately receive +// no endpoint-specific hint. Ordinary provider outages (for example OpenRouter) +// get "". Keep each vendor's copy in its own table so gateway and manual-token +// remedies cannot cross-contaminate. +func statusHintFor(entry providerEntry, state string) string { + switch entry.id { + case providerToolhive, providerToolhiveAnthropic: + if entry.toolhiveMode == toolhiveModeDirect { + return toolhiveDirectStatusHints[state] + } return toolhiveStatusHints[state] case providerOpenAICodex: return openAICodexStatusHints[state] @@ -1676,59 +1810,76 @@ func classifyLiveListError(err error) string { // every other outcome is diagnosed, never fatal (the §1 accepted deviation: a // down/unauthorized proxy must never brick Build when toolhive is sole). func probeToolhive(reg *providerRegistry, cfg Config) error { - entry, ok := reg.Lookup(providerToolhive) - if !ok || entry.lister == nil { + type probeResult struct { + pid string + entry providerEntry + models []modelEntry + err error + } + + var targets []probeResult + for _, pid := range []string{providerToolhive, providerToolhiveAnthropic} { + if entry, ok := reg.Lookup(pid); ok && entry.lister != nil { + targets = append(targets, probeResult{pid: pid, entry: entry}) + } + } + if len(targets) == 0 { return nil } + diag := cfg.diag() ctx, cancel := context.WithTimeout(context.Background(), toolhiveProbeTimeout) defer cancel() - - models, err := entry.lister.ListModels(ctx) - switch { - case err != nil: - state := classifyLiveListError(err) - hint := toolhiveStatusHints[state] - reg.outcomes.recordFailure(providerToolhive, state, hint) - // An explicit --toolhive-llm-base-url is the operator asking directly for - // THIS endpoint, so an unreachable probe is upgraded to WARN (never - // silent on a deliberate ask); an unauthorized credential is always a - // WARN (a stale/rejected credential is actionable right now) regardless - // of source. - level := port.LevelInfo - if entry.intentExplicit || state == statusUnauthorized { - level = port.LevelWarn - } - diag.Log(ctx, level, "toolhive LLM gateway: probe failed — "+hint, - "provider", providerToolhive, "base_url", entry.baseURL, "state", state) - case len(models) == 0: - reg.outcomes.recordSuccess(providerToolhive, nil) - diag.Log(ctx, port.LevelWarn, "toolhive LLM gateway: "+toolhiveStatusHints[statusEmpty], - "provider", providerToolhive, "base_url", entry.baseURL) - if reg.defaultID == providerToolhive { - return errToolhiveNoModels - } - default: - // models is ALREADY []modelEntry (entry.lister is the composition - // modelLister interface; the concrete gatewayLister already stamped - // ToolCall:true per entry) — use it directly, never re-map it (that - // would duplicate the ToolCall business rule in a second place). - reg.outcomes.recordSuccess(providerToolhive, models) - diag.Log(ctx, port.LevelInfo, "toolhive LLM gateway: registered and reachable", - "provider", providerToolhive, "base_url", entry.baseURL, "gateway_url", entry.intentGatewayURL, "models", len(models)) - if reg.defaultID == providerToolhive && reg.defaultModel == "" { - reg.defaultModel = models[0].ID - reg.defaultModelAutoSelected = true // issue #262 review finding 7 - // Re-run the T7 caps fixup for the toolhive entry now that a real - // default model is known — via the SAME shared remintEntry helper - // buildProviderRegistry's post-assembly fixup loop and - // healDefaultModel use (issue #262 review finding 4: the three - // re-mint sites cannot drift on the caps/effort computation). - reg.remintEntry(providerToolhive, reg.defaultModel) - diag.Log(ctx, port.LevelInfo, "toolhive LLM gateway: default model (auto-selected)", - "provider", providerToolhive, "model", reg.defaultModel, - "base_url", entry.baseURL, "gateway_url", entry.intentGatewayURL) + results := make(chan probeResult, len(targets)) + for _, target := range targets { + go func(target probeResult) { + target.models, target.err = target.entry.lister.ListModels(ctx) + results <- target + }(target) + } + byID := make(map[string]probeResult, len(targets)) + for range targets { + result := <-results + byID[result.pid] = result + } + + defaultEmpty := false + // Process in stable family order even though the fetches complete concurrently. + for _, target := range targets { + result := byID[target.pid] + pid, entry, models, err := result.pid, result.entry, result.models, result.err + switch { + case err != nil: + state := classifyLiveListError(err) + hint := statusHintFor(entry, state) + reg.outcomes.recordFailure(pid, state, hint) + level := port.LevelInfo + if entry.intentExplicit || state == statusUnauthorized { + level = port.LevelWarn + } + diag.Log(ctx, level, "toolhive LLM gateway: probe failed — "+hint, + "provider", pid, "base_url", entry.baseURL, "state", state) + case len(models) == 0: + reg.outcomes.recordSuccess(entry, nil) + diag.Log(ctx, port.LevelWarn, "toolhive LLM gateway: "+statusHintFor(entry, statusEmpty), + "provider", pid, "base_url", entry.baseURL) + defaultEmpty = defaultEmpty || reg.defaultID == pid + default: + reg.outcomes.recordSuccess(entry, models) + diag.Log(ctx, port.LevelInfo, "toolhive LLM gateway: registered and reachable", + "provider", pid, "base_url", entry.baseURL, "gateway_url", entry.intentGatewayURL, "models", len(models)) + if reg.defaultID == pid && reg.defaultModel == "" { + reg.defaultModel = models[0].ID + reg.defaultModelAutoSelected = true + reg.remintEntry(pid, reg.defaultModel) + diag.Log(ctx, port.LevelInfo, "toolhive LLM gateway: default model (auto-selected)", + "provider", pid, "model", reg.defaultModel, + "base_url", entry.baseURL, "gateway_url", entry.intentGatewayURL) + } } } + if defaultEmpty { + return errToolhiveNoModels + } return nil } diff --git a/internal/app/registry_toolhive_anthropic_test.go b/internal/app/registry_toolhive_anthropic_test.go new file mode 100644 index 000000000..07aad214b --- /dev/null +++ b/internal/app/registry_toolhive_anthropic_test.go @@ -0,0 +1,533 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stacklok/mecatl/engine/port" + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/internal/adapter/toolhivellm" + "github.com/stacklok/mecatl/provider/anthropic" +) + +const toolhiveCompletedMessageSSE = "event: message_start\n" + + `data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-6","content":[],"usage":{"input_tokens":1,"output_tokens":0}}}` + "\n\n" + + "event: message_delta\n" + + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}` + "\n\n" + + "event: message_stop\n" + + `data: {"type":"message_stop"}` + "\n\n" + +type toolhiveWireCapture struct { + mu sync.Mutex + paths []string + authorizations []string + xAPIKeys []string + nativeBody string + responsesHits int +} + +func (c *toolhiveWireCapture) handler(w http.ResponseWriter, r *http.Request) { + c.mu.Lock() + c.paths = append(c.paths, r.Method+" "+r.URL.Path) + c.authorizations = append(c.authorizations, r.Header.Get("Authorization")) + c.xAPIKeys = append(c.xAPIKeys, r.Header.Get("X-Api-Key")) + if strings.HasSuffix(r.URL.Path, "/v1/responses") { + c.responsesHits++ + } + c.mu.Unlock() + + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/anthropic/v1/models"): + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, toolhiveAnthropicFixtureJSON) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/v1/models"): + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, toolhiveFixtureJSON) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/anthropic/v1/messages"): + body, _ := io.ReadAll(r.Body) + c.mu.Lock() + c.nativeBody = string(body) + c.mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, toolhiveCompletedMessageSSE) + default: + http.Error(w, "unexpected test path", http.StatusNotFound) + } +} + +func (c *toolhiveWireCapture) snapshot() (paths, auth, xAPI []string, body string, responses int) { + c.mu.Lock() + defer c.mu.Unlock() + return append([]string(nil), c.paths...), append([]string(nil), c.authorizations...), + append([]string(nil), c.xAPIKeys...), c.nativeBody, c.responsesHits +} + +func TestADR_0325_ProtocolSpecificWireRouting(t *testing.T) { + capture := &toolhiveWireCapture{} + server := httptest.NewServer(http.HandlerFunc(capture.handler)) + defer server.Close() + + reg, err := buildProviderRegistry(Config{ + ToolhiveLLMBaseURL: server.URL + "/v1", + liveModelHTTPClient: server.Client(), + LLMMaxAttempts: 1, + }, fakeEnv(nil)) + if err != nil { + t.Fatalf("buildProviderRegistry: %v", err) + } + if got, want := reg.Available(), []string{providerToolhive, providerToolhiveAnthropic}; !equalStrings(got, want) { + t.Fatalf("Available() = %v, want %v", got, want) + } + if reg.Default() != providerToolhive { + t.Fatalf("Default() = %q, want legacy %q", reg.Default(), providerToolhive) + } + + native, ok := reg.Lookup(providerToolhiveAnthropic) + if !ok { + t.Fatal("toolhive-anthropic entry missing") + } + models, ok := reg.outcomes.getLastGood(providerToolhiveAnthropic) + if !ok || len(models) != 1 { + t.Fatalf("native last-known-good = %+v, ok=%v", models, ok) + } + reg.meta.mergeSwap(map[string][]modelEntry{providerToolhiveAnthropic: models}) + seq, err := native.provider.Stream(context.Background(), port.LLMRequest{ + Model: "claude-sonnet-4-6", + Messages: []session.Message{session.NewUserMessage("hi")}, + }) + if err != nil { + t.Fatalf("construct native Anthropic stream: %v", err) + } + for _, streamErr := range seq { + if streamErr != nil { + t.Fatalf("native Anthropic stream: %v", streamErr) + } + } + + paths, auth, xAPI, body, responses := capture.snapshot() + for _, want := range []string{"GET /v1/models", "GET /anthropic/v1/models", "POST /anthropic/v1/messages"} { + if !containsString(paths, want) { + t.Errorf("requests %v missing %q", paths, want) + } + } + if responses != 0 { + t.Fatalf("native selection issued %d /v1/responses requests, want 0", responses) + } + if !strings.Contains(body, `"messages"`) || strings.Contains(body, `"input"`) { + t.Fatalf("native request is not Anthropic Messages wire format: %s", body) + } + var requestBody struct { + MaxTokens int64 `json:"max_tokens"` + Thinking struct { + Type string `json:"type"` + } `json:"thinking"` + } + if err := json.Unmarshal([]byte(body), &requestBody); err != nil { + t.Fatalf("decode native request: %v", err) + } + if requestBody.MaxTokens != 64_000 || requestBody.Thinking.Type != "adaptive" { + t.Fatalf("native request metadata controls = max_tokens:%d thinking:%q, want 64000/adaptive; body=%s", + requestBody.MaxTokens, requestBody.Thinking.Type, body) + } + for i := range auth { + if paths[i] == "GET /v1/models" { + continue // legacy OpenAI proxy behavior is covered by its existing tests. + } + if auth[i] != "Bearer "+toolhivellm.PlaceholderToken || xAPI[i] != "" { + t.Fatalf("native proxy auth on %s = Authorization %q, X-Api-Key %q", paths[i], auth[i], xAPI[i]) + } + } + + m := models[0] + if m.ContextLimit != 1_000_000 || m.OutputLimit != 64_000 || !m.Reasoning || !m.Thinking.Adaptive { + t.Fatalf("native metadata = %+v", m) + } + projected := projectModelEntry(reg, providerToolhiveAnthropic, m) + if !projected.GetImage() || !projected.GetReasoning() || projected.GetContextLimit() != 1_000_000 { + t.Fatalf("projected native metadata = %+v", projected) + } + if got := reg.meta.outputLimitFor(providerToolhiveAnthropic, m.ID); got != 64_000 { + t.Fatalf("native output limit = %d, want 64000", got) + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func TestToolhiveNativeAnthropic_Scenario1_Registration(t *testing.T) { + reg, err := buildProviderRegistry(Config{ + ToolhiveLLMBaseURL: "http://127.0.0.1:14000/v1", + liveModelHTTPClient: toolhiveModelsClient(t, toolhiveFixtureJSON), + }, fakeEnv(nil)) + if err != nil { + t.Fatalf("buildProviderRegistry: %v", err) + } + if got, want := reg.Available(), []string{providerToolhive, providerToolhiveAnthropic}; !equalStrings(got, want) { + t.Fatalf("Available() = %v, want %v", got, want) + } + if reg.Default() != providerToolhive { + t.Fatalf("Default() = %q, want legacy provider %q", reg.Default(), providerToolhive) + } + if _, ok := resolveToolhiveIntent(Config{}); ok { + t.Fatal("zero/disabled ToolHive intent registered providers") + } +} + +func TestToolhiveNativeAnthropic_Scenario1_Metadata(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(toolhiveAnthropicFixtureJSON)), + }, nil + })} + lister := anthropicLister{inner: anthropic.NewLister("", "https://gateway.example/anthropic", client)} + models, err := lister.ListModels(context.Background()) + if err != nil || len(models) != 1 { + t.Fatalf("ListModels = %+v, %v", models, err) + } + if len(embeddedModels(providerToolhiveAnthropic)) != 0 { + t.Fatal("public Anthropic catalog became unverified ToolHive inventory") + } + meta := newLiveMetaStore() + meta.mergeSwap(map[string][]modelEntry{providerToolhiveAnthropic: models}) + model := models[0] + adaptive, enabled, known := meta.thinkingFor(providerToolhiveAnthropic, model.ID) + if model.ContextLimit != 1_000_000 || meta.outputLimitFor(providerToolhiveAnthropic, model.ID) != 64_000 || + !hasImageModality(model.InputModalities) || !known || !adaptive || enabled { + t.Fatalf("metadata projection = %+v, thinking=(%v,%v,%v)", model, adaptive, enabled, known) + } +} + +func TestToolhiveNativeAnthropic_Scenario2_DirectAuthentication(t *testing.T) { + capture := &toolhiveWireCapture{} + server := httptest.NewServer(http.HandlerFunc(capture.handler)) + defer server.Close() + cfgPath := writeToolhiveConfigWithOIDC(t, server.URL+"/gateway", server.URL+"/issuer", "client-123") + + var factoryCalls atomic.Int32 + var tokenCalls atomic.Int32 + reg, err := buildProviderRegistry(Config{ + ToolhiveLLM: true, + ToolhiveLLMMode: "direct", + toolhiveConfigPath: cfgPath, + liveModelHTTPClient: server.Client(), + LLMMaxAttempts: 1, + toolhiveTokenSourceFactory: func(string, port.Diagnostics) (toolhivellm.TokenSourceFunc, error) { + factoryCalls.Add(1) + return func(context.Context) (string, error) { + return fmt.Sprintf("fresh-%d", tokenCalls.Add(1)), nil + }, nil + }, + }, fakeEnv(nil)) + if err != nil { + t.Fatalf("buildProviderRegistry: %v", err) + } + if factoryCalls.Load() != 1 { + t.Fatalf("token-source factory calls = %d, want 1 shared family source", factoryCalls.Load()) + } + if reg.Default() != providerToolhive { + t.Fatalf("Default() = %q, want %q", reg.Default(), providerToolhive) + } + native, _ := reg.Lookup(providerToolhiveAnthropic) + if _, err := driveStream(native.provider); err != nil { + t.Fatalf("native direct stream: %v", err) + } + + paths, auth, xAPI, _, responses := capture.snapshot() + for _, want := range []string{"GET /gateway/v1/models", "GET /gateway/anthropic/v1/models", "POST /gateway/anthropic/v1/messages"} { + if !containsString(paths, want) { + t.Errorf("requests %v missing %q", paths, want) + } + } + if responses != 0 { + t.Fatalf("native selection issued %d Responses requests", responses) + } + seen := make(map[string]bool) + for i, value := range auth { + if !strings.HasPrefix(value, "Bearer fresh-") || value == "Bearer thv-proxy" || xAPI[i] != "" { + t.Fatalf("direct auth on %s = Authorization %q, X-Api-Key %q", paths[i], value, xAPI[i]) + } + seen[value] = true + } + if len(seen) != len(auth) || int(tokenCalls.Load()) != len(auth) { + t.Fatalf("fresh-token accounting: unique=%d token_calls=%d requests=%d", len(seen), tokenCalls.Load(), len(auth)) + } +} + +func TestToolhiveNativeAnthropic_Scenario2_TransportSecurity(t *testing.T) { + base := &captureTransport{} + client := newToolhiveBearerClient(&http.Client{Transport: base}, + func(context.Context) (string, error) { return toolhivellm.PlaceholderToken, nil }) + req, _ := http.NewRequest(http.MethodGet, "http://127.0.0.1:14000/anthropic/v1/models", nil) + req.Header.Set("Authorization", "Bearer stale") + req.Header.Set("X-Api-Key", "must-not-forward") + resp, err := client.Do(req) + if err != nil { + t.Fatalf("client.Do: %v", err) + } + _ = resp.Body.Close() + if got := base.auth.Load().(string); got != "Bearer "+toolhivellm.PlaceholderToken { + t.Fatalf("Authorization = %q", got) + } + if got := base.xAPI.Load().(string); got != "" { + t.Fatalf("X-Api-Key = %q, want stripped", got) + } + if gatewayURLIsHTTPS("http://gateway.example") { + t.Fatal("non-loopback cleartext gateway passed the direct-mode HTTPS gate") + } +} + +func TestToolhiveNativeAnthropic_Scenario1_DiscoveryPaths(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"http://127.0.0.1:14000/v1", "http://127.0.0.1:14000/anthropic"}, + {"https://gateway.example/prefix/v1/", "https://gateway.example/prefix/anthropic"}, + {"https://gateway.example/prefix/v1/extra", "https://gateway.example/prefix/v1/extra/anthropic"}, + {"https://user:secret@gateway.example/prefix/v1?token=secret#fragment", "https://gateway.example/prefix/anthropic"}, + } { + if got := toolhiveAnthropicBaseURL(tc.in); got != tc.want { + t.Errorf("toolhiveAnthropicBaseURL(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestToolhiveNativeAnthropic_Scenario3_IndependentOutcomes(t *testing.T) { + openAI := &fakeLister{models: []modelEntry{{ID: "openai-good"}}} + native := &fakeLister{err: fmt.Errorf("native down")} + reg := &providerRegistry{ + entries: map[string]providerEntry{ + providerToolhive: {id: providerToolhive, available: true, intentDriven: true, lister: openAI}, + providerToolhiveAnthropic: {id: providerToolhiveAnthropic, available: true, intentDriven: true, lister: native}, + }, + outcomes: newLiveOutcomeStore(), + } + + first := liveModelSnapshot(context.Background(), port.NopDiagnostics{}, reg) + if len(first[providerToolhive]) != 1 || len(first[providerToolhiveAnthropic]) != 0 { + t.Fatalf("first snapshot = %+v", first) + } + openAI.err = fmt.Errorf("openai down") + native.err = nil + native.models = []modelEntry{{ID: "native-good"}} + second := liveModelSnapshot(context.Background(), port.NopDiagnostics{}, reg) + if got := second[providerToolhive]; len(got) != 1 || got[0].ID != "openai-good" { + t.Fatalf("OpenAI last-known-good was erased: %+v", got) + } + if got := second[providerToolhiveAnthropic]; len(got) != 1 || got[0].ID != "native-good" { + t.Fatalf("native healthy catalog missing: %+v", got) + } + if status, _ := reg.outcomes.getStatus(providerToolhive); status.State != statusUnreachable { + t.Fatalf("OpenAI status = %+v, want unreachable", status) + } + if status, _ := reg.outcomes.getStatus(providerToolhiveAnthropic); status.State != statusOK { + t.Fatalf("native status = %+v, want ok", status) + } +} + +func TestToolhiveNativeAnthropic_Scenario3_DefaultCompatibility(t *testing.T) { + reg, err := buildProviderRegistry(Config{ + ToolhiveLLMBaseURL: "http://127.0.0.1:14000/v1", + liveModelHTTPClient: toolhiveModelsClient(t, toolhiveFixtureJSON), + }, fakeEnv(nil)) + if err != nil { + t.Fatalf("healthy build: %v", err) + } + if reg.Default() != providerToolhive || reg.ResolvedDefaultModel() != "claude-sonnet-4-6" { + t.Fatalf("default = (%q,%q), want legacy toolhive first-listed model", reg.Default(), reg.ResolvedDefaultModel()) + } + + emptyOpenAI := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body := `{"object":"list","data":[]}` + if strings.HasSuffix(req.URL.Path, "/anthropic/v1/models") { + body = toolhiveAnthropicFixtureJSON + } + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil + })} + if _, err := buildProviderRegistry(Config{ + ToolhiveLLMBaseURL: "http://127.0.0.1:14000/v1", liveModelHTTPClient: emptyOpenAI, + }, fakeEnv(nil)); err == nil { + t.Fatal("honest empty catalog for default toolhive did not fail Build") + } + + emptyNative := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body := toolhiveFixtureJSON + if strings.HasSuffix(req.URL.Path, "/anthropic/v1/models") { + body = `{"data":[],"has_more":false}` + } + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil + })} + if models, listErr := (anthropicLister{inner: anthropic.NewLister("", "http://127.0.0.1:14000/anthropic", emptyNative)}).ListModels(context.Background()); listErr != nil || len(models) != 0 { + t.Fatalf("empty native fixture did not decode honestly: models=%+v err=%v", models, listErr) + } + nativeReg, err := buildProviderRegistry(Config{ + ToolhiveLLMBaseURL: "http://127.0.0.1:14000/v1", liveModelHTTPClient: emptyNative, + DefaultProvider: providerToolhiveAnthropic, + }, fakeEnv(nil)) + if err == nil { + t.Fatalf("honest empty native catalog for explicit native default did not fail Build: default=%q status=%+v", + nativeReg.Default(), providerStatusProto(nativeReg)) + } +} + +type concurrentProbeLister struct { + id string + started chan<- string + release <-chan struct{} +} + +func (l concurrentProbeLister) ListModels(context.Context) ([]modelEntry, error) { + l.started <- l.id + <-l.release + return []modelEntry{{ID: l.id}}, nil +} + +func TestToolhiveProtocolRefreshesStartConcurrently(t *testing.T) { + started := make(chan string, 2) + release := make(chan struct{}) + reg := &providerRegistry{ + entries: map[string]providerEntry{ + providerToolhive: {id: providerToolhive, available: true, lister: concurrentProbeLister{ + id: "openai", started: started, release: release, + }}, + providerToolhiveAnthropic: {id: providerToolhiveAnthropic, available: true, lister: concurrentProbeLister{ + id: "anthropic", started: started, release: release, + }}, + }, + outcomes: newLiveOutcomeStore(), + } + done := make(chan struct{}) + go func() { + _ = liveModelSnapshot(context.Background(), port.NopDiagnostics{}, reg) + close(done) + }() + for range 2 { + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("both protocol fetches did not start before either was released") + } + } + close(release) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("concurrent refresh did not finish") + } +} + +func TestToolhiveStaleRefreshesStartConcurrently(t *testing.T) { + started := make(chan string, 2) + release := make(chan struct{}) + reg := &providerRegistry{ + entries: map[string]providerEntry{ + providerToolhive: {id: providerToolhive, available: true, intentDriven: true, lister: concurrentProbeLister{ + id: "openai", started: started, release: release, + }}, + providerToolhiveAnthropic: {id: providerToolhiveAnthropic, available: true, intentDriven: true, lister: concurrentProbeLister{ + id: "anthropic", started: started, release: release, + }}, + }, + meta: newLiveMetaStore(), + outcomes: newLiveOutcomeStore(), + } + reg.outcomes.recordFailure(providerToolhive, statusUnreachable, "retry") + reg.outcomes.recordFailure(providerToolhiveAnthropic, statusUnreachable, "retry") + + done := make(chan struct{}) + go func() { + refreshStaleModels(context.Background(), port.NopDiagnostics{}, reg, newFakeSwapper(), &refreshStaleModelsState{}) + close(done) + }() + for range 2 { + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("both stale protocol fetches did not start before either was released") + } + } + close(release) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("concurrent stale refresh did not finish") + } +} + +func TestToolhiveConcurrencyDoesNotGeneralizeToOtherProviders(t *testing.T) { + started := make(chan string, 2) + releaseFirst := make(chan struct{}) + releaseSecond := make(chan struct{}) + reg := &providerRegistry{ + entries: map[string]providerEntry{ + "custom-a": {id: "custom-a", available: true, lister: concurrentProbeLister{ + id: "custom-a", started: started, release: releaseFirst, + }}, + "custom-b": {id: "custom-b", available: true, lister: concurrentProbeLister{ + id: "custom-b", started: started, release: releaseSecond, + }}, + }, + outcomes: newLiveOutcomeStore(), + } + done := make(chan struct{}) + go func() { + _ = liveModelSnapshot(context.Background(), port.NopDiagnostics{}, reg) + close(done) + }() + + select { + case got := <-started: + if got != "custom-a" { + t.Fatalf("first provider = %q, want custom-a", got) + } + case <-time.After(time.Second): + t.Fatal("first provider did not start") + } + select { + case got := <-started: + t.Fatalf("unrelated provider %q started concurrently", got) + case <-time.After(100 * time.Millisecond): + } + + close(releaseFirst) + select { + case got := <-started: + if got != "custom-b" { + t.Fatalf("second provider = %q, want custom-b", got) + } + case <-time.After(time.Second): + t.Fatal("second provider did not start after the first completed") + } + close(releaseSecond) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("sequential provider refresh did not finish") + } +} + +func TestStatusHintForToolhiveUsesRoutingMode(t *testing.T) { + proxy := providerEntry{id: providerToolhive, toolhiveMode: toolhiveModeProxy} + direct := providerEntry{id: providerToolhiveAnthropic, toolhiveMode: toolhiveModeDirect} + + if got := statusHintFor(proxy, statusUnreachable); got != toolhiveStatusHints[statusUnreachable] { + t.Fatalf("proxy hint = %q, want %q", got, toolhiveStatusHints[statusUnreachable]) + } + if got := statusHintFor(direct, statusUnreachable); got != toolhiveDirectStatusHints[statusUnreachable] { + t.Fatalf("direct hint = %q, want %q", got, toolhiveDirectStatusHints[statusUnreachable]) + } +} diff --git a/internal/app/registry_toolhive_direct_test.go b/internal/app/registry_toolhive_direct_test.go index fc750d5c4..763c53ccb 100644 --- a/internal/app/registry_toolhive_direct_test.go +++ b/internal/app/registry_toolhive_direct_test.go @@ -19,6 +19,7 @@ import ( // header rewrite without a real token source. type captureTransport struct { auth atomic.Value // string + xAPI atomic.Value // string calls atomic.Int32 recvErr error } @@ -26,6 +27,7 @@ type captureTransport struct { func (c *captureTransport) RoundTrip(req *http.Request) (*http.Response, error) { c.calls.Add(1) c.auth.Store(req.Header.Get("Authorization")) + c.xAPI.Store(req.Header.Get("X-Api-Key")) if c.recvErr != nil { return nil, c.recvErr } @@ -48,6 +50,7 @@ func TestBearerRoundTripper_RewritesHeader(t *testing.T) { } req, _ := http.NewRequest(http.MethodGet, "https://gw.example/v1/models", nil) req.Header.Set("Authorization", "Bearer thv-proxy") // the SDK's placeholder + req.Header.Set("X-Api-Key", "conflicting-placeholder") resp, err := rt.RoundTrip(req) if err != nil { t.Fatalf("RoundTrip: %v", err) @@ -58,6 +61,9 @@ func TestBearerRoundTripper_RewritesHeader(t *testing.T) { if got := ct.auth.Load().(string); got != "Bearer "+fakeToken { t.Errorf("Authorization = %q, want %q", got, "Bearer "+fakeToken) } + if got := ct.xAPI.Load().(string); got != "" { + t.Errorf("X-Api-Key = %q, want stripped", got) + } if ct.calls.Load() != 1 { t.Errorf("base transport calls = %d, want 1", ct.calls.Load()) } @@ -100,13 +106,11 @@ func TestBearerRoundTripper_SanitisedError(t *testing.T) { } } -// TestBearerRoundTripper_OnlyMutatesAuth is the static guard for the "never log -// the Authorization header" discipline (AGENTS.md security): the RoundTripper -// has no log path of its own, and this test pins that the ONLY mutation it -// performs on the request is the Authorization header Del+Set (the clone's -// other headers are untouched). It is the falsifiable pin against a future -// change that logs or copies the header elsewhere. -func TestBearerRoundTripper_OnlyMutatesAuth(t *testing.T) { +// TestBearerRoundTripper_OnlyMutatesAuthentication is the static guard for the +// "never log credentials" discipline (AGENTS.md security): the RoundTripper has +// no log path of its own, clones before removing the conflicting authentication +// headers, and leaves the caller's request untouched. +func TestBearerRoundTripper_OnlyMutatesAuthentication(t *testing.T) { rt := &bearerRoundTripper{ base: &captureTransport{}, token: func(context.Context) (string, error) { return "tok", nil }, @@ -175,10 +179,10 @@ func TestDirectBaseURL(t *testing.T) { "https://gw.example.com/toolhive": "https://gw.example.com/toolhive/v1", "https://gw.example.com/toolhive/": "https://gw.example.com/toolhive/v1", "": "", - // A query must survive on the QUERY, not be concatenated into: string - // concatenation produced "https://gw.example.com?x=1/v1", swallowing the - // path segment into the query value. - "https://gw.example.com?x=1": "https://gw.example.com/v1?x=1", + // Credential-shaped query/userinfo/fragment material is stripped rather + // than concatenated into a request or diagnostic URL. + "https://gw.example.com?x=1": "https://gw.example.com/v1", + "https://user:secret@gw.example.com/prefix?token=secret#fragment": "https://gw.example.com/prefix/v1", } { if got := directBaseURL(in); got != want { t.Errorf("directBaseURL(%q) = %q, want %q", in, got, want) @@ -378,12 +382,12 @@ func TestToolhiveDirectRemintSurvival(t *testing.T) { Diagnostics: diag, } intent := toolhiveIntent{ - mode: toolhiveModeDirect, - baseURL: "https://gw.example.com/v1", - gatewayURL: "https://gw.example.com", - oidcConfigured: true, + mode: toolhiveModeDirect, + baseURL: "https://gw.example.com/v1", + gatewayURL: "https://gw.example.com", } - entry := newDirectGatewayEntry(cfg, providerToolhive, intent, cfgPath) + entry := newDirectGatewayEntry(cfg, providerToolhive, intent, + newDirectGatewayClient(cfg, intent, cfgPath)) if entry.remint == nil { t.Fatal("direct-mode entry has no remint closure") diff --git a/internal/app/registry_toolhive_test.go b/internal/app/registry_toolhive_test.go index 9f8e9da4a..6ccbac668 100644 --- a/internal/app/registry_toolhive_test.go +++ b/internal/app/registry_toolhive_test.go @@ -63,11 +63,15 @@ func (d *toolhiveLevelDiag) has(sub string) bool { // an injected transport — never a real network call. func toolhiveModelsClient(t *testing.T, body string) *http.Client { t.Helper() - return &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + responseBody := body + if strings.HasSuffix(req.URL.Path, "/anthropic/v1/models") { + responseBody = toolhiveAnthropicFixtureJSON + } return &http.Response{ StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(body)), - Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(responseBody)), + Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil })} } @@ -83,6 +87,13 @@ const toolhiveFixtureJSON = `{"object":"list","data":[ {"id":"gpt-5","display_name":"GPT-5"} ]}` +const toolhiveAnthropicFixtureJSON = `{"data":[{ + "id":"claude-sonnet-4-6","type":"model","display_name":"Claude Sonnet 4.6", + "created_at":"2026-01-01T00:00:00Z","max_input_tokens":1000000,"max_tokens":64000, + "capabilities":{"image_input":{"supported":true},"thinking":{"supported":true, + "types":{"adaptive":{"supported":true},"enabled":{"supported":false}}}} +}],"has_more":false,"first_id":"claude-sonnet-4-6","last_id":"claude-sonnet-4-6"}` + // writeToolhiveConfig writes a minimal ToolHive config.yaml fixture (an `llm:` // block only) and returns its path — the toolhiveConfigPath test seam always // points here, never the real home directory. @@ -148,6 +159,9 @@ func TestToolhiveIntent_ProbeOK_Registered(t *testing.T) { if !diag.hasAtLevel(port.LevelInfo, "registered and reachable") { t.Error("expected an INFO 'registered and reachable' diagnostic") } + if native, ok := reg.Lookup(providerToolhiveAnthropic); !ok || native.lister == nil || !native.intentDriven { + t.Fatal("toolhive-anthropic entry not registered with its native lister") + } } // (2) intent + probe-down (mock transport refuses) ⇒ entry STILL registered; @@ -558,7 +572,7 @@ func TestProviderStatusProto_ToolhiveScopedOnly(t *testing.T) { outcomes: newLiveOutcomeStore(), } reg.outcomes.recordFailure(providerOpenRouter, statusUnreachable, "should never surface") - reg.outcomes.recordSuccess(providerToolhive, []modelEntry{{ID: "m1"}}) + reg.outcomes.recordSuccess(reg.entries[providerToolhive], []modelEntry{{ID: "m1"}}) out := providerStatusProto(reg) if len(out) != 1 { @@ -575,7 +589,7 @@ func TestProviderStatusProto_AllFourStates(t *testing.T) { outcomes: newLiveOutcomeStore(), } - reg.outcomes.recordSuccess(providerToolhive, []modelEntry{{ID: "m1"}}) + reg.outcomes.recordSuccess(reg.entries[providerToolhive], []modelEntry{{ID: "m1"}}) if got := providerStatusProto(reg)[0].GetState(); got != statusOK { t.Errorf("state = %q, want ok", got) } @@ -590,7 +604,7 @@ func TestProviderStatusProto_AllFourStates(t *testing.T) { t.Errorf("row = %+v, want unauthorized with a hint", got) } - reg.outcomes.recordSuccess(providerToolhive, nil) // empty embedded catalog too + reg.outcomes.recordSuccess(reg.entries[providerToolhive], nil) // empty embedded catalog too if got := providerStatusProto(reg)[0]; got.GetState() != statusEmpty || got.GetHint() == "" { t.Errorf("row = %+v, want empty with a hint", got) } @@ -615,8 +629,8 @@ func TestProviderStatusProto_AutoSelectedBit(t *testing.T) { t.Fatalf("buildProviderRegistry: %v", err) } rows := providerStatusProto(reg) - if len(rows) != 1 || !rows[0].GetDefaultModelAutoSelected() { - t.Fatalf("rows = %+v, want exactly one toolhive row with DefaultModelAutoSelected=true", rows) + if len(rows) != 2 || rows[0].GetProviderId() != providerToolhive || !rows[0].GetDefaultModelAutoSelected() { + t.Fatalf("rows = %+v, want both ToolHive rows with only toolhive auto-selected", rows) } }) @@ -632,8 +646,8 @@ func TestProviderStatusProto_AutoSelectedBit(t *testing.T) { t.Fatalf("Default() = %q, want toolhive (still the sole provider)", reg.Default()) } rows := providerStatusProto(reg) - if len(rows) != 1 || rows[0].GetDefaultModelAutoSelected() { - t.Fatalf("rows = %+v, want exactly one toolhive row with DefaultModelAutoSelected=false (operator-configured)", rows) + if len(rows) != 2 || rows[0].GetDefaultModelAutoSelected() || rows[1].GetDefaultModelAutoSelected() { + t.Fatalf("rows = %+v, want both ToolHive rows with DefaultModelAutoSelected=false (operator-configured)", rows) } }) } @@ -658,8 +672,8 @@ func TestProviderStatusProto_AvailableNotDefault_TrueWhenKeyedProviderIsDefault( t.Fatalf("Default() = %q, want %q (keyed provider must outrank intent-driven)", reg.Default(), providerOpenRouter) } rows := providerStatusProto(reg) - if len(rows) != 1 { - t.Fatalf("rows = %d, want 1 (toolhive only): %+v", len(rows), rows) + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2 (both ToolHive protocols): %+v", len(rows), rows) } row := rows[0] if row.GetProviderId() != providerToolhive { @@ -698,8 +712,8 @@ func TestProviderStatusProto_AvailableNotDefault_FalseWhenSoleProviderIsDefault( t.Fatalf("Default() = %q, want %q (sole provider)", reg.Default(), providerToolhive) } rows := providerStatusProto(reg) - if len(rows) != 1 { - t.Fatalf("rows = %d, want 1: %+v", len(rows), rows) + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2: %+v", len(rows), rows) } row := rows[0] if row.GetAvailableNotDefault() { @@ -724,8 +738,8 @@ func TestProviderStatusProto_AvailableNotDefault_FalseWhenUnreachable(t *testing t.Fatalf("buildProviderRegistry: %v", err) } rows := providerStatusProto(reg) - if len(rows) != 1 { - t.Fatalf("rows = %d, want 1: %+v", len(rows), rows) + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2: %+v", len(rows), rows) } row := rows[0] if row.GetAvailableNotDefault() { diff --git a/internal/app/rehydrate_toolhive_test.go b/internal/app/rehydrate_toolhive_test.go index 5ee7f8312..68c815cb2 100644 --- a/internal/app/rehydrate_toolhive_test.go +++ b/internal/app/rehydrate_toolhive_test.go @@ -53,7 +53,7 @@ func TestToolhiveRehydrationWithProxyDownE2E(t *testing.T) { cfg1 := baseCfg() cfg1.liveModelHTTPClient = toolhiveModelsClient(t, toolhiveFixtureJSON) cfg1.providerConstructor = func(_ Config, id, _, _ string) port.LLMProvider { - if id != providerToolhive { + if !isToolhiveProvider(id) { t.Fatalf("unexpected provider constructed: %q", id) } return mockllm.New(mockllm.TextTurn("pre-restart-done")) @@ -82,7 +82,7 @@ func TestToolhiveRehydrationWithProxyDownE2E(t *testing.T) { cfg2 := baseCfg() cfg2.liveModelHTTPClient = offlineHTTPClient() cfg2.providerConstructor = func(_ Config, id, _, _ string) port.LLMProvider { - if id != providerToolhive { + if !isToolhiveProvider(id) { t.Fatalf("unexpected provider constructed: %q", id) } return mockllm.New(mockllm.ErrorTurn(errors.New("connection refused: proxy not running"))) diff --git a/user-docs/building/deployment/mecated.md b/user-docs/building/deployment/mecated.md index 787f8febd..9099084a4 100644 --- a/user-docs/building/deployment/mecated.md +++ b/user-docs/building/deployment/mecated.md @@ -350,15 +350,17 @@ is ordinary JSON. Turns are consumed in order across model calls: #### The ToolHive LLM gateway (no API key needed) -If you have [ToolHive](https://docs.stacklok.com/toolhive/)'s local LLM proxy running, `--toolhive-llm` -(on by default) auto-detects it and registers it as provider id `toolhive` — no API key -required, since ToolHive holds the credential. `/models` (or the mecatui welcome splash) -tells you when it's available but not your default, so you can opt in with `/models` or -`--default-provider toolhive` without unsetting whatever key-based provider you already -have. On a host other operators also use, pass `--toolhive-llm=false` — a per-user -ToolHive config detected by one operator's process shouldn't surprise another. - -There are two routing modes for how the `toolhive` provider reaches the gateway, +If you have configured [ToolHive](https://docs.stacklok.com/toolhive/)'s LLM gateway, +`--toolhive-llm` (on by default) auto-detects it and registers two +protocol-specific provider IDs: +`toolhive` uses OpenAI Responses, while `toolhive-anthropic` uses native Anthropic +Messages. No API key is required, since ToolHive holds the credential. `/models` +(or the mecatui welcome splash) tells you when either is available but not your +default, so you can opt in without unsetting a key-based provider. On a host other +operators also use, pass `--toolhive-llm=false` — a per-user ToolHive config detected +by one operator's process shouldn't surprise another. + +There are two routing modes for how both ToolHive providers reach the gateway, selected by `--toolhive-llm-mode` (default `auto`): - **Proxy mode** (the original path): Mecatl talks to a local reverse proxy @@ -392,20 +394,11 @@ to print the authorization URL for an SSH session. This command writes the refresh-token reference to ToolHive's configuration and does not start a Mecatl session. -Two things worth knowing before you rely on it: - -- **The proxy has to actually be reachable.** `/models` names the exact fix when it isn't: - `thv llm proxy start` if the proxy isn't running, `thv llm setup` if your credential was - rejected. An empty model list from a *valid* credential is an organizational problem - (ask your platform admin), not a local one. -- **A model that lists fine can still fail at request time.** Some gateways expose "friendly" - model aliases that have no cost route configured, so a request to one 5xxs with a - cost-enforcement error even though `/models` reported the gateway healthy. If requests are - failing but the gateway looks fine, pick a fully-qualified or provider-namespaced model - slug instead (via `/models`, `--model`, or mecatui's `ctrl+g` global default) — or ask - whoever runs the gateway to add a cost route for the alias. A session created before you - fix this keeps failing on every turn even after the fix lands; open a fresh session rather - than waiting for it to self-heal. +The proxy must be reachable in proxy mode. `/models` names the exact fix when it +isn't: `thv llm proxy start` when the proxy is down, or `thv llm setup` when the +credential was rejected. For provider selection, protocol paths, independent +catalog status, and model-routing troubleshooting, see +[Choose models and providers](/features/choose-models.md#select-toolhive-gateway-models). ### Posture diff --git a/user-docs/features/choose-models.md b/user-docs/features/choose-models.md index 072105ca5..6e61b2822 100644 --- a/user-docs/features/choose-models.md +++ b/user-docs/features/choose-models.md @@ -103,6 +103,43 @@ provider status; endpoints, credentials, and raw listing errors or response bodi are never published to clients. Built-in `--*-base-url` flags still take precedence over eligible built-in endpoint overrides. See the [provider configuration reference](/reference/configuration.md#providers) for the accepted flavors and fields. +### Select ToolHive gateway models + +When ToolHive gateway discovery is enabled, one configured gateway identity appears +as two protocol-specific Mecatl providers: + +| Provider ID | Model discovery | Inference | +| --- | --- | --- | +| `toolhive` | `GET /v1/models` | `POST /v1/responses` | +| `toolhive-anthropic` | `GET /anthropic/v1/models` | `POST /anthropic/v1/messages` | + +Select a native Anthropic model under `toolhive-anthropic`. Mecatl keeps the +inventories separate so that model always uses Anthropic Messages rather than the +OpenAI Responses adapter. The native catalog retains the model's context and output +limits plus image and thinking capabilities. + +`toolhive` remains the automatic default between the two gateway providers. A +configured key-driven provider still takes precedence unless the operator explicitly +sets `toolhive` or `toolhive-anthropic` as the server default. When the gateway is +available but not selected, `/models` shows both protocol inventories so you can +choose one without removing another provider's credential. + +Each provider has its own model count, availability state, and process-local +last-known-good catalog. A failure or empty response from one protocol endpoint does +not erase the healthy sibling inventory. Client-visible status contains no endpoint, +credential, raw response body, or authentication header. + +If `/models` reports an unreachable provider, follow its routing-mode-specific hint: +start the local proxy in proxy mode, or check direct gateway connectivity and OIDC +setup in direct mode. A valid credential that lists no models requires the gateway +administrator to grant access. If a listed alias later fails with a routing or cost +enforcement error, select a fully qualified or provider-namespaced model slug or ask +the gateway administrator to add a route. Create a fresh session after correcting a +previously unresolved default model. + +For proxy/direct routing, OIDC setup, TLS constraints, and daemon flags, see +[Run mecated standalone](/building/deployment/mecated.md#the-toolhive-llm-gateway-no-api-key-needed). + ### Configure aliases, slots, and task routing For a deployment with several kinds of work, use the operator-global