Skip to content

Add experimental ug claude-cowork for Claude Desktop via AI Gateway - #619

Open
masonc08 wants to merge 7 commits into
mainfrom
mason.cao/claude-desktop
Open

masonc08 wants to merge 7 commits into
mainfrom
mason.cao/claude-desktop

Conversation

@masonc08

Copy link
Copy Markdown
Collaborator

What & why

Claude Desktop and its Cowork mode can route through Databricks AI Gateway, but unlike Claude Code (a CLI ug spawns, whose lifetime bounds the refresh proxy) Desktop is a standalone GUI with a static config file. Writing tokens into that file leaves them to go stale — the Databricks OAuth token expires in ~1h. So ug claude-cowork keeps the refresh-proxy model ug claude already uses.

ug claude-cowork --provider <catalog.schema.name>:

  1. Establishes both auth sessions — the Databricks OAuth session (so the proxy can mint swap credentials) and the Anthropic subscription OAuth via claude setup-token (long-lived, relayed by the proxy). CLAUDE_CODE_OAUTH_TOKEN is honored to skip the browser flow.
  2. Starts the loopback refresh proxy, which live-refreshes the Databricks credential into the X-Databricks-AI-Gateway-Token swap header and stamps the fixed Anthropic Authorization + the Databricks-Model-Provider-Service routing header on every request. The Desktop client's own credential/auth-scheme is irrelevant: the proxy drops it and owns the upstream auth.
  3. Writes a Claude Desktop gateway config (Claude-3p/configLibrary, one stable entry per workspace) pointing at that proxy — holding no real tokens, so nothing on disk can go stale.
  4. Stays foreground to keep the proxy alive while Desktop is used.

The proxy gains two backward-compatible knobs to support a non-Claude-Code client:

  • extra_headers — fixed headers the proxy owns and stamps per request (the injected Authorization + the MPS routing header).
  • strip_client_headers — client headers to drop, so a stray x-api-key can't reach Anthropic alongside the injected Authorization.

Status / caveats

Experimental. The Desktop gateway-config schema (Claude-3p configLibrary) is not a public contract; the keys here are the observed-working set and may drift across Desktop releases. Discovery relies on --model populating inferenceModels so Desktop skips its GET /v1/models probe, which the relayed path does not serve yet (a companion gateway route change is in flight).

Follow-ups: promote the foreground proxy to a background daemon so Desktop can run independently; refresh the Anthropic OAuth in-proxy; drop the manual --model list once relayed /v1/models ships.

How do you know it works?

  • uv run ruff check / ruff format clean.
  • New tests: config rendering (points at proxy, no real token on disk), per-workspace config path (macOS/Windows, unsupported-OS raise), setup-token/env auth resolution, and the two proxy knobs (extra_headers overwrite + strip_client_headers).
  • Full non-e2e suite green (uv run pytest -k "not e2e") except one pre-existing PTY test (test_restores_model_captured_immediately_before_switch) that fails identically on clean main.
  • ug claude-cowork --help registers and parses.

This pull request and its description were written by Isaac.

masonc08 and others added 6 commits September 14, 2026 20:55
Claude Desktop and its Cowork mode can route through Databricks AI Gateway,
but unlike Claude Code (a CLI ug spawns, whose lifetime bounds the refresh
proxy) Desktop is a standalone GUI with a static config file. Writing tokens
into that file leaves them to go stale — the Databricks OAuth token expires in
~1h. So `ug claude-cowork` keeps the refresh-proxy model:

- Establishes both auth sessions: the Databricks OAuth session (so the proxy
  can mint swap credentials) and the Anthropic subscription OAuth via
  `claude setup-token` (long-lived, relayed by the proxy).
- Starts the loopback refresh proxy, which live-refreshes the Databricks
  credential into the X-Databricks-AI-Gateway-Token swap header and stamps the
  fixed Anthropic Authorization + the Databricks-Model-Provider-Service routing
  header on every request. The Desktop client's own credential is irrelevant:
  the proxy drops it and owns the upstream auth.
- Writes a Claude Desktop gateway config pointing at that proxy, holding no real
  tokens, so nothing on disk can go stale.
- Stays in the foreground to keep the proxy alive while Desktop is used.

The proxy gains two backward-compatible knobs to support a non-Claude-Code
client: `extra_headers` (fixed headers the proxy owns and stamps per request)
and `strip_client_headers` (client headers to drop so a stray x-api-key can't
reach Anthropic alongside the injected Authorization).

Experimental: the Desktop gateway-config schema (Claude-3p configLibrary) is not
a public contract; keys are the observed-working set and may drift.

Testing: `uv run ruff check` clean; new tests for config rendering, per-workspace
config path, setup-token/env auth resolution, and the two proxy knobs; full
non-e2e suite green (`uv run pytest -k "not e2e"`).

Co-authored-by: Isaac <no-reply@databricks.com>
A missing `databricks` binary raised a raw FileNotFoundError out of
get_databricks_token (which _ensure_databricks_session only guarded with
`except RuntimeError`), so the launch crashed with a traceback instead of a
clear message. Check `shutil.which("databricks")` up front with an install
hint, and also catch FileNotFoundError on the probe so a CLI that vanishes
after the check still falls through to the explicit login.

Co-authored-by: Isaac <no-reply@databricks.com>
…h Desktop

Dropping a file in configLibrary/ wasn't enough — Desktop only sees an entry
listed in configLibrary/_meta.json ("entries") and only uses the one set as
"appliedId". So the gateway never came up. Register the ug entry and apply it,
preserving existing entries (e.g. the user's Default) and restoring the prior
appliedId on exit so Desktop's next restart doesn't boot into the dead-proxy
config.

Also correct the config schema against a Desktop-authored entry: the discovery
toggle is modelDiscoveryEnabled:false (not the invented disableDeploymentModeChooser),
and chatTabEnabled/coworkTabEnabled/modelPrefer1mContext aren't real keys — drop
them. Write the file mode 0600 to match Desktop's own entries. Credentials/headers
stay out of the file; the proxy injects them.

Co-authored-by: Isaac <no-reply@databricks.com>
The launch parked the main thread on signal.pause() while a daemon thread served
the proxy. signal.pause() returns on ANY caught signal, so a stray SIGCHLD/SIGPIPE/
SIGWINCH during the first inference woke it, the main thread fell through, and the
proxy was torn down mid-session ("connection broke as soon as I did an inference").

Serve on the main thread with server.serve_forever(), which unwinds only via
shutdown() or the KeyboardInterrupt SIGINT raises, and drop the now-unused signal
and threading imports.

Co-authored-by: Isaac <no-reply@databricks.com>
…dispatch

Add `--open/--no-open` (default on) so `ug claude-cowork` brings Desktop up with
no manual step. Since Desktop reads configLibrary only at startup, a running
instance is gracefully quit (a normal Quit AppleEvent) and reopened.

Platform dispatch, because Desktop is macOS/Windows-only:
- macOS: quit-if-running + `open -b com.anthropic.claudefordesktop` (implemented,
  verified).
- WSL: the app is the Windows one — print guidance (interop launch is a follow-up
  needing a real WSL box).
- Windows: print guidance (follow-up needing a Windows box).
- native Linux: no Desktop exists — point at `--proxy-only` + tunnel to a
  Mac/Windows Desktop.

Co-authored-by: Isaac <no-reply@databricks.com>
"Claude Desktop" is the app (Cowork is a mode within it), so name the command
after the app. Renames the command + handler and the user-facing strings/UA.
The internal configLibrary entry-id seed is left unchanged (with a comment) so
existing registered entries keep their stable ids across the rename. Also drops
a dangling `--proxy-only` reference from the Linux message (that flag isn't
implemented).

Co-authored-by: Isaac <no-reply@databricks.com>
@masonc08
masonc08 marked this pull request as ready for review September 15, 2026 00:03
Drop comments that narrate what the code already says and compress the verbose
module/constant docstrings; keep only the non-obvious rationale (the signal.pause
regression, the _meta.json visibility contract, the credential-swap header drops).
No behavior change.

Co-authored-by: Isaac <no-reply@databricks.com>
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.

1 participant