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