Skip to content

Add Claude Desktop as an LLM gateway client#5712

Open
jerm-dro wants to merge 2 commits into
mainfrom
feat/llm-claude-desktop
Open

Add Claude Desktop as an LLM gateway client#5712
jerm-dro wants to merge 2 commits into
mainfrom
feat/llm-claude-desktop

Conversation

@jerm-dro

@jerm-dro jerm-dro commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

thv llm setup routes Claude Code and several editors through an OIDC-protected LLM gateway, but not Claude Desktop. Claude Desktop exposes a different configuration surface — its "third-party inference" (Cowork 3P) model — which uses a configLibrary config document plus a _meta.json selector and a credential-helper executable, none of which fit the existing JSON-key-patching path.

This adds a credential-helper client mode that reuses thv llm token:

  • New ClaudeDesktop client (LLMGatewayMode: "credential-helper"). Writes a <uuid>.json config document (inferenceProvider: gateway, base URL, inferenceCredentialKind: helper-script, optional inferenceModels) and merges a _meta.json selector, preserving any user-owned entries. Generates a no-arg shim that execs thv llm token (browser only in the interactive helper context; silent otherwise).
  • Detection via Claude Desktop's app-support directory (a GUI app, not on $PATH); warns when a managed-preferences profile would override the local config; tells the user to relaunch (config is read only at launch).
  • Teardown removes the entry, config document, and shim, and clears appliedId only when it pointed at ToolHive's config.
  • New --models flag feeds inferenceModels. The key is omitted when empty so Claude Desktop can fall back to gateway-side model discovery once the gateway serves it (tracked in stacklok/stacklok-enterprise-platform#2077 — the gateway's /anthropic/v1/models currently 404s, so --models is the interim path).

Type of change

  • New feature

Test plan

  • Unit tests (task test) — new writer/_meta merge/shim/teardown/detection tests in pkg/client
  • E2E tests (task test-e2e) — new claude-desktop setup→verify→teardown spec in the all-client matrix
  • Linting (task lint-fix)
  • Manual testing (describe below)

Manual testing: Ran thv llm setup --client claude-desktop --models … against the staging gateway on a real macOS Claude Desktop install. Verified the written configLibrary/<uuid>.json, _meta.json selector, and generated shim match Claude Desktop's own schema; relaunched the app and confirmed the "ToolHive Gateway" config applied and live inference worked on all models. Ran thv llm teardown claude-desktop and confirmed the entry, config document, and shim were removed and appliedId cleared.

Special notes for reviewers

  • Open item — Developer Mode: setup writes the configLibrary files directly (no in-app UI), so setup itself doesn't need Claude Desktop's Developer Mode. Whether the app applies the local config at launch with Developer Mode off is not yet confirmed — the live test above was run with Developer Mode on. Worth a quick verification before we document "no Developer Mode required."
  • Windows is not supported yet: the credential-helper shim is a POSIX /bin/sh script, consistent with the rest of the LLM-gateway token-helper feature (buildTokenHelperCommand is already POSIX-only). Setup returns a clear error on Windows.
  • The credential-helper document/selector model is deliberately kept out of the LLMGatewayKeys JSON-pointer machinery — it's a separate writer (pkg/client/llm_gateway_credential_helper.go) dispatched on a new LLMCredentialHelper capability flag.

🤖 Generated with Claude Code

thv llm setup can route Claude Code and several editors through an
OIDC-protected LLM gateway, but not Claude Desktop. Claude Desktop
exposes a different configuration surface — its "third-party inference"
(Cowork 3P) model — which uses a configLibrary document plus a
_meta.json selector and a credential-helper executable, none of which
fit the existing JSON-key-patching path.

Add a credential-helper client mode that reuses "thv llm token":

- New ClaudeDesktop client with LLMGatewayMode "credential-helper".
  Writes a <uuid>.json config document (inferenceProvider/gateway URL/
  helper-script kind/models) and merges a _meta.json selector, preserving
  any user-owned entries. Generates a no-arg shim that execs
  "thv llm token" (browser only in the interactive helper context).
- Detect Claude Desktop by its app-support directory (a GUI app, not on
  $PATH); warn when a managed-preferences profile would override the
  local config; tell the user to relaunch (config is read at launch).
- Teardown removes the entry, config document, and shim, and clears
  appliedId only when it pointed at ToolHive's config.
- New --models flag feeds inferenceModels; the key is omitted when empty
  so the app can fall back to gateway model discovery once available.

Verified end-to-end against a real Claude Desktop install (config
applied, live inference on all models) plus unit and e2e coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

This PR extends thv llm setup to support configuring Claude Desktop as an LLM gateway client using Claude Desktop’s third-party inference credential-helper configuration model (configLibrary <id>.json + _meta.json selector + helper executable), rather than the existing JSON-pointer patching approach used by other tools. It introduces a dedicated credential-helper writer, adds a --models flag to optionally populate inferenceModels, and updates docs/tests to cover setup/teardown and detection.

Changes:

  • Add a new Claude Desktop LLM gateway client mode (credential-helper) with dedicated configLibrary writer, shim generation, detection, and teardown.
  • Thread a new --models flag through CLI → setup pipeline and persist it into credential-helper configs (omitted when empty).
  • Add unit + E2E coverage for Claude Desktop setup/verify/teardown and update docs/CLI help text.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
test/e2e/cli_llm_all_clients_test.go Adds an E2E scenario covering Claude Desktop credential-helper setup, verification, and teardown.
pkg/llmgateway/config.go Introduces Claude Desktop helper TTL/timeout constants and adds Models to ApplyConfig.
pkg/llm/setup.go Threads models into setup, applies Anthropic base URL logic to credential-helper mode, and adds credential-helper warnings.
pkg/llm/setup_test.go Updates setup tests for the new Setup signature and GatewayManager interface.
pkg/client/llm_gateway.go Dispatches Configure/Revert to the credential-helper writer, adds managed-profile detection, and improves GUI app detection paths.
pkg/client/llm_gateway_credential_helper.go New credential-helper implementation: writes shim, config doc, and _meta.json selector; supports teardown and managed-profile detection.
pkg/client/llm_gateway_credential_helper_test.go Unit tests for config doc writing, _meta.json merge behavior, shim creation, idempotency, and teardown behavior.
pkg/client/config.go Registers claude-desktop client integration metadata (paths, detection, managed profile domain, credential-helper capability).
docs/cli/thv_llm_setup.md Documents Claude Desktop support and the new --models flag semantics.
cmd/thv/app/llm.go Adds --models flag and passes it into llm.Setup; updates command long description to include Claude Desktop.
cmd/thv/app/llm_test.go Updates CLI tests for the new runLLMSetup signature.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +75 to +83
shimPath, err := cm.writeCredentialHelperShim(cfg.TokenHelperCommand)
if err != nil {
return "", err
}

baseURL := cfg.AnthropicBaseURL
if baseURL == "" {
baseURL = cfg.GatewayURL
}
Comment on lines +134 to +141
// Always attempt shim removal — it is ToolHive-owned and lives at a fixed path.
shimPath := cm.credentialHelperShimPath()
if err := os.Remove(shimPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("removing credential helper shim %s: %w", shimPath, err)
}

if configPath == "" {
return nil
Comment thread pkg/llm/setup.go
Comment on lines +521 to +523
_, _ = fmt.Fprintf(out,
"%s reads its configuration only at launch — fully quit (Cmd-Q) and reopen it "+
"for the gateway change to take effect.\n", tc.Tool)
@jerm-dro

jerm-dro commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Developer Mode (open): Setup writes the configLibrary files directly, no UI. Confirmed live inference works with the app's allowDevTools flag off — but that flag is not the Developer Mode gate (the Developer menu persisted; it only controls the DevTools inspector), and the real Developer Mode toggle isn't a discoverable config flag, so we couldn't test a true Developer-Mode-off state. Recommend verifying on a clean/never-enabled install before documenting Developer Mode as not-required.

- revertCredentialHelper: de-reference _meta.json and delete the config
  document before removing the shim, so a mid-revert failure never leaves
  Claude Desktop pointing at a config that references a missing helper.
  Treat an empty configPath as a no-op (do not touch the shim when we
  cannot confirm the selector no longer references it).
- configureCredentialHelper: best-effort cleanup of the config document and
  a freshly-created shim when setup fails, so a failure leaves no partial
  state. Skip shim removal when an earlier setup already created it (setup
  is idempotent).
- Make the relaunch note platform-neutral (drop macOS-specific "Cmd-Q").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Jul 2, 2026
@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 59.78261% with 74 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.59%. Comparing base (2aca563) to head (3b25f05).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
pkg/client/llm_gateway_credential_helper.go 60.13% 40 Missing and 21 partials ⚠️
pkg/llm/setup.go 57.89% 8 Missing ⚠️
pkg/client/llm_gateway.go 58.33% 4 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5712      +/-   ##
==========================================
- Coverage   70.63%   70.59%   -0.04%     
==========================================
  Files         667      668       +1     
  Lines       67607    67773     +166     
==========================================
+ Hits        47752    47843      +91     
- Misses      16399    16456      +57     
- Partials     3456     3474      +18     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jhrozek jhrozek 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.

A few things I ran into going through the Claude Desktop credential-helper code — mostly small, one worth a closer look before merging.

if err != nil {
return fmt.Errorf("encoding Claude Desktop config: %w", err)
}
configPath = filepath.Join(dir, id+".json")

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.

id here comes straight from _meta.json via metaEntryID with no validation before it's joined into a path (filepath.Join(dir, id+".json")). _meta.json lives in a directory Claude Desktop also writes to, so a corrupted or hand-edited entry like {"id": "../../../../.ssh/authorized_keys", "name": "ToolHive Gateway"} would make this write land outside configLibrary. The same untrusted value flows into revertCredentialHelper's metaPath derivation too.

Probably worth rejecting anything that isn't a bare filename in metaEntryID before returning it:

if id == "" || filepath.Base(id) != id || strings.Contains(id, "..") {
    return "" // treat as absent; caller mints a fresh UUID
}


// Track whether the shim already existed so failure cleanup does not delete a
// shim an earlier successful setup still depends on (setup is idempotent).
shimExisted := fileExistsAt(cm.credentialHelperShimPath())

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.

The shim (shimExisted / writeCredentialHelperShim) is created before WithFileLock(metaPath, ...) is acquired, and removed after the lock is released in revertCredentialHelper (around line 193). So the shim isn't actually covered by the lock that's supposed to serialize concurrent thv llm setup/teardown runs. Two overlapping invocations could interleave such that one process's failure-cleanup removes a shim the other's now-committed _meta.json entry depends on. Might be worth moving the shim create/remove inside the same lock scope as the _meta.json update.

return "", err
}

baseURL := cfg.AnthropicBaseURL

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.

This reimplements the same "AnthropicBaseURL else GatewayURL" fallback that resolveApplyConfigField already computes in llm_gateway.go (case "AnthropicBaseURL"). Might be simpler to call that instead of duplicating the logic here, so the two don't drift if the fallback rule ever changes.

meta["entries"] = removeMetaEntry(metaEntries(meta), id)
// Only clear appliedId if it still points at our config; leave a
// user's own active config selection alone.
if applied, _ := meta["appliedId"].(string); applied == id {

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.

applied, _ := meta["appliedId"].(string) treats a present-but-non-string appliedId the same as absent, and leaves it untouched with no signal that the file was malformed. Not a big deal in practice, but checking ok here (and maybe logging) would make a corrupted _meta.json easier to diagnose later.

Comment thread pkg/client/config.go
Description: "Claude Desktop",
LLMGatewayOnly: true,
LLMGatewayMode: "credential-helper",
LLMCredentialHelper: true,

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.

This entry sets both LLMGatewayMode: "credential-helper" and LLMCredentialHelper: true to encode the same fact. llm_gateway.go dispatches on the bool, while pkg/llm/setup.go's usesAnthropicBaseURL/warnCredentialHelperTools dispatch on the string — for the same client. Nothing keeps these in sync, so a future credential-helper-style client could set one without the other. Could probably drop LLMCredentialHelper and dispatch everywhere on LLMGatewayMode == "credential-helper".

// local config, so "thv llm setup" warns when one is detected (the local config
// it writes would be ignored). macOS only; returns false elsewhere or when the
// client declares no managed-profile domain.
func managedProfilePresent(domain 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.

managedProfilePresent has no test coverage — the darwin gate, the direct-path check, and the per-user glob fallback are all unexercised. Since this drives the MDM-override warning in warnCredentialHelperTools, a regression here (e.g. an inverted check or a broken glob) would silently disable that warning. Might be worth a small overridable root path (e.g. var managedPreferencesRoot = "/Library/Managed Preferences") so this can be unit tested against a temp dir.

Comment thread pkg/llm/setup.go
// (Claude Desktop) need: they read config only at launch, so the user must fully
// quit and relaunch; and a managed-preferences profile, if present, overrides
// the local config setup just wrote.
func warnCredentialHelperTools(out, errOut io.Writer, gm GatewayManager, configured []ToolConfig) {

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.

warnCredentialHelperTools isn't covered by any test — all three stub GatewayManagers in setup_test.go hardcode IsManaged to false, so the managed-profile warning branch is never exercised. Worth a test with a stub returning true for one tool to confirm the warning lands on errOut for that tool only, and the relaunch note prints on out for all credential-helper tools.

// The _meta.json update is done under a file lock and preserves every entry and
// key ToolHive does not own, so a user's other saved configurations are left
// intact. Writes are crash-safe via AtomicWriteFile.
func (cm *ClientManager) configureCredentialHelper(appCfg *clientAppConfig, cfg llmgateway.ApplyConfig) (string, error) {

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.

None of the tests in llm_gateway_credential_helper_test.go inject a failure inside the locked section of configureCredentialHelper, so the rollback/cleanup logic (conditional shim removal, conditional config removal) is only exercised by inspection. A test that makes the configLibrary dir read-only before calling ConfigureLLMGateway (so AtomicWriteFile fails) would confirm the cleanup actually behaves as the comments claim, and that a retry afterward succeeds cleanly.

@aponcedeleonch aponcedeleonch 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.

If _meta.json is valid JSON but entries isn't an array (say an object, or absent-and-later-set-wrong), configure then does meta["entries"] = append(nil, ourEntry) and silently drops whatever was there. That quietly breaks the "preserve every entry ToolHive does not own" contract the writer is built around. Fully-malformed JSON already aborts safely on the parse error, so this is the narrow "valid JSON, wrong shape" case.

Same input class shows up in metaEntryID: if a name-matching entry has a non-string id, it returns "", and configure then generates a fresh UUID and appends a second "ToolHive Gateway" entry, which defeats the idempotency the stable name is there to provide.

Both are low-likelihood, but since the whole point of reading into a generic map is to not stomp on user data, I'd lean toward bailing with an error when entries is present and not a []any, rather than overwriting. A small test with a non-array entries would lock it in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants