Skip to content

feat: add native Anthropic support to ToolHive gateway - #1339

Merged
samuv merged 1 commit into
mainfrom
feat/toolhive-anthropic
Sep 14, 2026
Merged

feat: add native Anthropic support to ToolHive gateway#1339
samuv merged 1 commit into
mainfrom
feat/toolhive-anthropic

Conversation

@samuv

@samuv samuv commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • register toolhive-anthropic as a distinct native Anthropic Messages provider while preserving toolhive as the OpenAI Responses default
  • derive proxy/direct Anthropic paths safely, share the direct-mode OIDC token source, and strip conflicting authentication headers
  • keep protocol catalogs, status, metadata, and last-known-good state independent, with deterministic model inventory and ToolHive-family UI handling

Protocols

  • toolhive: GET /v1/models, POST /v1/responses
  • toolhive-anthropic: GET /anthropic/v1/models, POST /anthropic/v1/messages

Verification

  • task fmt
  • task docs
  • task site:build
  • task api:check
  • task lint
  • task test
  • go run ./cmd/mecademo
  • focused internal/app and cmd/mecatui/ui tests

task ac-trace-strict currently stops on the pre-existing docs/acceptance/callmcpwithquery-broker-support.md status proposed, which the checker does not recognize.

@samuv
samuv force-pushed the feat/toolhive-anthropic branch from 8236635 to bb9b5a5 Compare September 10, 2026 12:20
@samuv samuv changed the title Add native Anthropic support to ToolHive gateway feat: add native Anthropic support to ToolHive gateway Sep 10, 2026
@kantord

kantord commented Sep 10, 2026

Copy link
Copy Markdown
Member

Cursory review (Standards + Spec)

Fully or partially written by an AI agent.

Two-axis review of main...HEAD: does the diff follow this repo's documented standards, and does it match the acceptance plan / ADR 0326?

Standards

Hard violations

  1. docs/design/PRODUCTION-READINESS.md — the sole shipped/deferred tracker per AGENTS.md's doc-lifecycle header — isn't updated even though a new provider identity (toolhive-anthropic) ships here. docs/acceptance/toolhive-native-anthropic.md waives the process spine, but that doesn't cover the tracker update itself.

Judgement-call smells

  • Feature Envycmd/mecatui/ui/models_surface.go:253-255 infers routing mode via strings.Contains(hint, "--toolhive-llm-mode proxy"), reaching into a free-text diagnostic string owned by internal/app/registry.go. A copy edit to the hint silently breaks this with no compiler signal.
  • Duplicated/drifting hint logicstatusHintFor (mode-unaware) vs. the new toolhiveStatusHint (mode-aware) in registry.go. modellister.go:637-640 computes one then overrides with the other; recordSuccess:722 still only calls the old one. Landmine if the two hint maps ever diverge.
  • Duplicated predicate across packagesisToolhiveProviderID (TUI) and isToolhiveProvider (internal/app) reimplement the same "is this ToolHive" check with independent string literals; a third protocol member needs both updated in lockstep.
  • Speculative Generality (pre-existing)toolhiveIntent.oidcConfigured is set but never read by production code, only referenced in test literals.

Spec

(a) Missing/partial: None found. All three acceptance scenarios (registration, discovery-path derivation, metadata threading, shared token source, transport stripping, independent outcomes, default compatibility, status/UI presentation) have direct test coverage, including the requirement to carry output/thinking limits into the emitted Anthropic max_tokens/thinking mode.

(b) Scope creep — The spec asks for concurrent fetch only across the ToolHive pair ("Fetch both ToolHive protocol entries concurrently under the existing operation-wide bounds"). probeToolhive correctly scopes to that pair, but modellister.go's liveModelSnapshot/refreshStaleModels were rewritten to launch a goroutine per every provider in reg.Available() — openai, anthropic, openai-codex, custom providers included. Neither the ADR nor the acceptance plan asks for this, and only the ToolHive case (TestToolhiveProtocolRefreshesStartConcurrently) is tested.

(c) Implemented-but-questionable: Same fragile substring-match flagged under Standards — providerStatusLine derives proxy-vs-direct UI state from matching "--toolhive-llm-mode proxy" inside the hint string rather than a structural field (e.g. a toolhiveMode on client.ProviderStatus). No test ties the two together.


Summary: Standards — 1 hard violation (missing tracker update) + 4 judgement-call smells, worst being the hint-string coupling between registry.go and models_surface.go. Spec — 0 missing requirements, 1 scope-creep item (concurrency widened to all providers, untested beyond ToolHive), 1 questionable implementation (same fragile substring-match issue), worst being the untested concurrency generalization to non-ToolHive providers.

Worth a decision: was widening background/on-demand refresh concurrency to all providers (not just ToolHive) intentional? If so, it probably deserves its own test coverage and a line in the ADR/acceptance doc; if not, scope it back to the ToolHive pair.

@kantord kantord left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry, see the comments above

@samuv
samuv force-pushed the feat/toolhive-anthropic branch from bb9b5a5 to 2113e55 Compare September 10, 2026 14:14
@samuv

samuv commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review findings in the rebased branch:

  • added ADR 0326's shipped state to PRODUCTION-READINESS.md;
  • removed TUI inference from remediation text and made the unreachable label routing-neutral;
  • consolidated proxy/direct remediation through one entry-aware status helper;
  • scoped concurrent catalog fetches to the two ToolHive protocol providers, with regression coverage proving unrelated providers remain sequential;
  • added stale-refresh concurrency coverage and removed the unused intent field.

The app and TUI keep package-local ToolHive-family predicates because the TUI cannot import internal/app; both IDs remain directly covered by tests.

Verified with task test:golden, task docs, task lint, task test, focused package tests, and go run ./cmd/mecademo.

@samuv
samuv force-pushed the feat/toolhive-anthropic branch from 2113e55 to 7674de7 Compare September 10, 2026 14:45
@samuv

samuv commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current origin/main (1264f5ad2) and aligned the PR with ADR 0321's documentation ownership:

  • docs/usage.md and docs/usage/ remain unchanged compatibility pointers;
  • shared provider-selection behavior now lives in user-docs/features/choose-models.md;
  • proxy/direct routing, OIDC, TLS, and daemon flags remain in user-docs/building/deployment/mecated.md.

Verified with task docs, task site:build, task lint, task test, and the offline demo.

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Panel review of 803bd07, taking the unified provider configuration/commands (#1448) and follow-up #1456 into account. The separate ToolHive protocol identities and shared externally owned credential lifecycle fit the current architecture. Requesting changes for one correctness blocker: startup-discovered native metadata is not published before the default provider/session can be minted. Other comments are explicitly non-blocking improvements or optional coverage/polish. The duplicate Spec/Standards recovery-command finding is posted once. CI was green; live e2e was skipped. No local tests or linters were run. AI-assisted review.

Comment thread internal/app/registry.go
diag.Log(ctx, port.LevelWarn, "toolhive LLM gateway: "+statusHintFor(entry, statusEmpty),
"provider", pid, "base_url", entry.baseURL)
defaultEmpty = defaultEmpty || reg.defaultID == pid
default:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker — publish native probe metadata before reminting the default. This successful probe records models in reg.outcomes, but does not publish them into reg.meta before the default is selected/reminted below. Build initially uses modelSnapshot (inventory floors), and the later live refresh is asynchronous, so an immediate session can run without the authoritative metadata that startup already fetched. For a gateway-specific alias absent from the embedded catalog, that means a 4096 output-token fallback and prefix-guessed thinking instead of the returned output/thinking descriptor; manual thinking for an adaptive-only model can produce a provider rejection. A text-only model can also receive adapter-passthrough image capability. Please publish successful probe metadata before remint/session construction and add a real Build-to-immediate-request regression. The existing native metadata/wire test manually calls reg.meta.mergeSwap, which bypasses this gap (AC1.3).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 947cf06: successful startup probes now publish per-protocol metadata before default selection/remint. The new real Build-to-immediate-native-request test verifies max_tokens=64000 and adaptive thinking without a manual metadata merge.

Comment thread internal/app/registry.go Outdated

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`",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important, non-blocking — this recovery command was removed by #1448. Direct-mode unauthorized status now recommends mecatui llm login, but the supported command is mecatui providers login toolhive. Both protocol identities share that existing ToolHive credential flow, so no new login identity is needed. Please update the hint to that command or retain only the working thv llm setup alternative. This is both the AC3.3 actionable-remediation gap and the ADR0333 command-surface mismatch; posting it once rather than duplicating the two panel axes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the direct-mode hint to the supported mecatui providers login toolhive command, while retaining thv llm setup as the alternative.

Comment thread internal/app/registry.go
anthropicoption.WithHTTPClient(client),
anthropicoption.WithMaxRetries(0),
))
entry.lister = anthropicLister{inner: anthropic.NewLister("", baseURL, client)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important, non-blocking — keep native catalog error bodies out of diagnostics (CWE-532). Wiring the Anthropic lister here adds an OIDC-bearing catalog request to a path whose listerStatusError.Error() includes the SDK error text/response body. resolveProviderModels subsequently logs that error verbatim during refresh (internal/app/modellister.go:695). If a gateway or intermediary diagnostic response reflects the received Authorization header, the ToolHive bearer is persisted in Mecatl logs. Preserve the HTTP status for classification but expose a response-body-free diagnostic error. A regression can return a 401 body containing a sentinel credential and assert that diagnostics include provider/state but not the sentinel or response body.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: Anthropic catalog API errors now expose only a sanitized HTTP status while preserving StatusCode(). Provider-level diagnostics coverage reflects a bearer sentinel in a 401 body and proves it is not logged.

if reg.Default() != providerToolhive {
t.Fatalf("Default() = %q, want %q", reg.Default(), providerToolhive)
}
native, _ := reg.Lookup(providerToolhiveAnthropic)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important, non-blocking test coverage — also drive the legacy direct-mode Responses entry (AC2.1/AC3.4). This test lists both protocols but only streams through toolhive-anthropic. Please also select toolhive in direct mode and assert Responses JSON reaches /gateway/v1/responses with the authoritative bearer. The existing proxy inference coverage does not protect the refactored direct entry against an adapter/path wiring regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added direct-mode legacy coverage: toolhive now streams Responses JSON through /gateway/v1/responses with the fresh authoritative bearer and no x-api-key.

}
}

func TestToolhiveNativeAnthropic_Scenario2_TransportSecurity(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important, non-blocking test coverage — exercise transport policy after remint (AC2.3). The direct remint coverage currently checks only that the legacy remint returns a non-nil provider; the native remint is not driven. This helper-level transport test does not prove either adapter's remint closure retains the bearer client and redirect policy. Please drive reminted providers through capture transports and assert protocol-correct paths, authoritative bearer-only authentication, and redirect refusal. Dropping an option from a remint closure should fail the test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added coverage that drives both reminted providers and verifies protocol-correct paths, bearer-only authentication, and redirect refusal via a zero-hit redirect target.

return []modelEntry{{ID: l.id}}, nil
}

func TestToolhiveProtocolRefreshesStartConcurrently(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important, non-blocking test coverage — simultaneous starts do not prove the deadline contract (AC3.1). These tests manually release both listers, and concurrentProbeLister ignores its context. They would stay green if the Build/background/stale-refresh timeout contexts disappeared. Please add context-aware stalled-sibling cases that verify the required bounds (1.5s/10s/2s, without necessarily waiting the full duration) and that the healthy protocol's result survives when its sibling reaches the deadline.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added context-aware coverage for the 1.5s startup probe, 10s background operation, and 2s stale-refresh bounds. The startup and stale tests stall one protocol through deadline expiry and verify the healthy sibling is still published.

}
}

func TestToolhiveNativeAnthropic_Scenario3_IndependentOutcomes(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory — extend the independent last-known-good transition proof (AC3.1). This covers OpenAI success followed by OpenAI failure/native success. An additional step failing both protocols after each has acquired a distinct successful catalog would verify that both retained catalogs and counts remain separate, catching cross-overwrite/aliasing after native success. Optional coverage improvement, not a merge blocker.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extended the transition proof: after each protocol acquires a distinct successful catalog, both endpoints fail and each provider retains only its own last-known-good inventory.

return false
}

func TestToolhiveNativeAnthropic_Scenario1_Registration(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory — add a native persisted-selector regression. Registration and direct adapter streaming do not exercise Service-level persistence/rehydration. The existing rehydrate_toolhive_test.go selects only providerToolhive. Consider extending it to select, persist, reload, and run toolhive-anthropic, including temporarily unavailable discovery, so a future native-only rejection in the factory/rehydration path is caught. Optional coverage improvement, not a merge blocker.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extended the two-Build persistence/rehydration test to cover both toolhive and toolhive-anthropic selectors, including request-time behavior after discovery becomes unavailable.

}
}

func containsString(values []string, want string) bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory / optional polish: slices.Contains already implements this helper. Import slices, replace the two calls, and remove containsString; no new abstraction is needed. Not a merge blocker.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: replaced the local helper with slices.Contains.

@samuv

samuv commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the panel review on the latest origin/main:

  • startup probe successes now publish protocol-specific metadata before default selection/remint; the real Build → immediate native request regression no longer injects metadata manually;
  • updated the direct-mode recovery command to mecatui providers login toolhive;
  • native Anthropic list errors retain HTTP classification but omit response bodies, with bearer-reflection diagnostics coverage;
  • added direct legacy Responses, reminted auth/path/redirect, deadline, dual last-known-good, and native selector rehydration coverage;
  • replaced the local membership helper with slices.Contains.

Update: rebased again after #1461 corrected the duplicate ADR on main; both task docs and the complete task test gate now pass. The provider and full internal/app suites, task site:build, Linux task lint, and the offline demo also pass.

Co-Authored-By: OpenAI Codex <codex@openai.com>

Signed-off-by: Samuele Verzi <samu@stacklok.com>
@samuv
samuv dismissed kantord’s stale review September 14, 2026 13:12

I'm going with @JAORMX review

@samuv
samuv merged commit cc18dac into main Sep 14, 2026
33 checks passed
@samuv
samuv deleted the feat/toolhive-anthropic branch September 14, 2026 13:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants