Skip to content

feat(oauth): paste a claude setup-token into the Claude Code provider - #15093

Merged
diegosouzapw merged 9 commits into
diegosouzapw:release/v3.8.52from
dpozimski:feat/claude-setup-token
Oct 7, 2026
Merged

diegosouzapw merged 9 commits into
diegosouzapw:release/v3.8.52from
dpozimski:feat/claude-setup-token

Conversation

@dpozimski

@dpozimski dpozimski commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

claude setup-token prints a one-year OAuth token (sk-ant-oat01-...) for a Claude Pro/Max subscription. It is the simplest way to connect a headless or Docker OmniRoute to Claude Code, because nobody has to finish a browser login on the server. Until this PR, OmniRoute could run such a connection but had no way to create one:

  • POST /api/oauth/claude/import-token answered import-token not supported for provider: claude.
  • Import auth rejected the token with missing_refresh_token, because a setup-token has no refresh token.

The runtime was already ready for it. Since #14287 the health sweep leaves refresh-less Claude rows alone, and #14261 shows people were already running them. This PR adds the missing way in, on the existing claude provider, so there is no new provider to maintain.

What changes for the user:

  • Claude Code, then Add Connection, opens on a new Setup Token tab. Paste the token and save. OmniRoute contacts claude.ai only if the user switches to Browser Login, which works exactly as before.
  • The token is checked before anything is stored. It must start with sk-ant-oat, and OmniRoute sends one max_tokens: 1 request with it. If Anthropic rejects it, the user sees the reason and no connection is created.
  • The connection is named "Setup token". The token only carries the user:inference scope, so Anthropic will not return an email for it.
  • docs/providers/CLAUDE_CODE_SETUP_TOKEN.md explains the flow and what an inference-only token cannot do (no quota bars, no email).
  • Three new oauthModal strings, translated in all 66 locales.

Under the hood:

  • claude joins the import-token allowlist, and a provider can now expose an async importToken() for upstream verification.
  • A token without an identity is matched on the token itself, so pasting the same token twice updates one connection instead of adding a second. A connectionId re-auth now works without an email too.
  • A re-import keeps the stored cliUserID, so Anthropic keeps seeing the same device (fix(oauth): send required CLI headers in claude-auth import bootstrap call #10143).
  • Import errors return their own 4xx instead of a blanket 500.

Opening on the paste form exposed a small modal bug, fixed here as well. After a successful import, reopening the dialog showed the old "Connection successful!" screen and kept the previous token in the field. The dialog now resets its step and clears the field every time it opens, for every provider, including the import-only Devin ones.

Related Issues

Evidence: it works end to end

Everything below comes from this branch built with the repo Dockerfile, started with a fresh volume and REQUIRE_API_KEY=true, using a real claude setup-token token (masked). There are two runs on the same image. Section 1 is a scripted run against the import API (connection 50370877). Sections 2 and 3 are a second run where the token was pasted in the dashboard popup (connection 50f6e853). Every model call went through an OmniRoute API key.

1. Before and after

Before: diegosouzapw/omniroute:latest (3.8.50)
### Claude "Import auth" with the setup-token as credentials JSON
{"error":"refreshToken is missing or empty in claudeAiOauth","code":"missing_refresh_token"}
HTTP 400

### import-token with the setup-token
{"error":"import-token not supported for provider: claude. Supported: devin-desktop, devin-cli, grok-cli"}
HTTP 400
After: this branch (3.8.52)
### an Anthropic API key is refused before any upstream call
{"success":false,"error":"Expected a token from `claude setup-token` (starts with sk-ant-oat)"}
HTTP 400

### a well-formed but fake setup-token is refused after the live check
{"success":false,"error":"Could not verify this setup token with Anthropic (Invalid OAuth token)"}
HTTP 400

### the real setup-token is imported
{"success":true,"connection":{"id":"50370877-3164-4193-b071-d9b9521ec0e4","provider":"claude"}}
HTTP 200

### pasting the same token again updates the same connection
{"success":true,"connection":{"id":"50370877-3164-4193-b071-d9b9521ec0e4","provider":"claude"}}
HTTP 200

### stored connection
[{"id":"50370877-3164-4193-b071-d9b9521ec0e4","authType":"oauth","testStatus":"active","isActive":true,"hasRefreshToken":false,"expiresAt":null}]

### connection state after two health-check ticks (150 s)
[{"testStatus":"active","isActive":true,"lastErrorType":null}]
[ModelSync] Setup token (50370877): ✓ 13 models

2. Every Opus and Sonnet model answers through the setup-token connection

One POST /v1/chat/completions per model with the prompt "Compute 17*23. Reply with exactly: MODEL-OK ". The answer is checkable, so a 200 alone is not taken as proof.

requested model                    HTTP  upstream model               latency  tokens  answer
cc/claude-opus-5                   200   claude-opus-5                3.21s    150     MODEL-OK 391
cc/claude-opus-4-8                 200   claude-opus-4-8              1.73s    67      MODEL-OK 391
cc/claude-opus-4-7                 200   claude-opus-4-7              1.58s    74      MODEL-OK 391
cc/claude-opus-4-6                 200   claude-opus-4-6              2.23s    47      MODEL-OK 391
cc/claude-opus-4-5-20251101        200   claude-opus-4-5-20251101     1.93s    46      MODEL-OK 391
cc/claude-sonnet-5                 200   claude-sonnet-5              2.41s    78      MODEL-OK 391
cc/claude-sonnet-4-6               200   claude-sonnet-4-6            2.08s    82      17 * 23 = 17 * 20 + 17 * 3 = 340 + 51 = 391  MODEL-OK 391
cc/claude-sonnet-4-5-20250929      200   claude-sonnet-4-5-20250929   2.12s    46      MODEL-OK 391
cc/claude-opus-5-high              200   claude-opus-5-high           2.67s    126     MODEL-OK 391
cc/claude-sonnet-5-low             200   claude-sonnet-5-low          2.15s    67      MODEL-OK 391

Streaming and the Anthropic Messages format both work too:

### streaming /v1/chat/completions  cc/claude-opus-5
SSE data frames: 7   last frame: data: [DONE]
assembled text: 2, 3, 5, 7, 11

### streaming /v1/chat/completions  cc/claude-sonnet-5
SSE data frames: 7   last frame: data: [DONE]
assembled text: 2, 3, 5, 7, 11

### Anthropic Messages /v1/messages  cc/claude-opus-5
{"model":"claude-opus-5","stop_reason":"end_turn","text":"Warsaw","usage":{"input_tokens":43,"output_tokens":7}}
HTTP 200

### Anthropic Messages /v1/messages  cc/claude-sonnet-5
{"model":"claude-sonnet-5","stop_reason":"end_turn","text":"Warsaw"}
HTTP 200

The call log confirms every request ran on the setup-token connection (conn=50f6e853, the row named "Setup token"):

13:07:04  200  /v1/chat/completions  claude-opus-5               conn=50f6e853
13:07:07  200  /v1/chat/completions  claude-opus-4-8             conn=50f6e853
13:07:09  200  /v1/chat/completions  claude-opus-4-7             conn=50f6e853
13:07:11  200  /v1/chat/completions  claude-opus-4-6             conn=50f6e853
13:07:13  200  /v1/chat/completions  claude-opus-4-5-20251101    conn=50f6e853
13:07:15  200  /v1/chat/completions  claude-sonnet-5             conn=50f6e853
13:07:17  200  /v1/chat/completions  claude-sonnet-4-6           conn=50f6e853
13:07:20  200  /v1/chat/completions  claude-sonnet-4-5-20250929  conn=50f6e853
13:08:23  200  /v1/messages          claude-opus-5               conn=50f6e853

3. The dashboard flow

Playwright against the same image, in a fresh browser profile: risk notice, then the dialog.

Opens on the paste form, no claude.ai popup A token Anthropic rejects After saving Reopened for a second token
setup token tab rejected row reopen

The four screenshots are part of this PR under docs/screenshots/claude-setup-token-*.png, and the provider guide shows the first one.

I also scripted 16 opens across reloads, re-logins and three fresh browser profiles. Every one landed on the paste form, and none opened a popup.

4. What an inference-only token cannot do

The token has one scope, user:inference. Anthropic answers 403 oauth_scope_insufficient on bootstrap, profile and usage (direct probe: GET /api/claude_cli/bootstrap asks for user:ccr_inference or user:profile). So the connection shows no email and no quota bars; the usage call returns "Claude connected. Usage API requires admin permissions." Requests are not affected.

Fable returned 404 on the test account. That comes from Anthropic, not from this change: a direct call to api.anthropic.com with the same token gives not_found_error: model: claude-fable-5-1 (and the same for claude-fable-5), while claude-opus-5 returns 200 on the same call. The test subscription simply has no Fable access.

Validation

  • Change type: provider, UI, i18n
  • Focused tests and category gates from the golden path
  • npm run lint (lint-staged on every commit), plus typecheck:core, check:dashboard-typecheck, check:file-size and check:complexity-ratchets in PR mode, check:cycles, check:dead-code, i18n:check-new-keys, check:docs-symbols, check:doc-links, check:fabricated-docs, check:env-doc-sync, check:changelog-integrity: all green locally
  • Reconciled with the active release base (release/v3.8.52 at 666ea59b4c; release/v3.8.51 is frozen, 🔒 release-freeze: v3.8.51 in progress #15080)
  • Production-code changes include new automated tests

Local test runs:

  • New and neighbouring node suites (import-token route, OAuth provider config, Claude OAuth provider, health-sweep expiry, grok and devin paste, i18n completeness and ratio): 186/186
  • npm run test:vitest: 54 files, 493 tests passed
  • Test-first: 6 of the 8 tests in claude-setup-token-import.test.ts fail on the base (the other two guard existing behaviour). All 4 tests in ui/claude-setup-token-oauth-modal.test.tsx fail against the modal as it was before this PR.

CI on this PR: API Route Typecheck, No new ESLint warnings, Vitest, Merge integrity and semgrep pass. ⚠️ base-red inherited on two gates:

Tests Added Or Updated

  • tests/unit/claude-setup-token-import.test.ts (new, route level, Anthropic stubbed): a valid token creates a refresh-less OAuth row; the health sweep leaves it alone; a wrong prefix returns 400 without any upstream call; a non-string token returns 400; an Anthropic 401 returns 400 and stores nothing; the same token twice gives one row with the same cliUserID; connectionId re-auth replaces the token; a different token creates a second row
  • tests/unit/ui/claude-setup-token-oauth-modal.test.tsx (new, jsdom): the dialog opens on the form with no fetch and no window.open; Save posts the trimmed token; Browser Login starts the flow only when clicked; reopening after success shows an empty form
  • tests/unit/oauth-modal-paste-token-copy.test.ts (new): per-provider tab, description and placeholder keys, and every key exists in en.json
  • tests/unit/devin-providers.test.ts (updated): same move for the Devin Desktop description key; the test now reads the modal and oauthModal/pasteTokenCopy.ts. The Devin copy is unchanged.
  • tests/unit/oauth-modal-grok-cli-paste-7610.test.ts (updated): the grok copy keys moved from the modal into oauthModal/pasteTokenCopy.ts, so the source contract now reads both files. The grok-cli keys themselves are unchanged.

Coverage Notes

  • src/lib/oauth/providers/claude.ts (importSetupToken) and the import-token branch plus keepDeviceIdentity in src/app/api/oauth/[provider]/[action]/route.ts are covered by the route test.
  • src/shared/components/OAuthModal.tsx (paste-first providers, on-open reset) and oauthModal/pasteTokenCopy.ts are covered by the jsdom and copy tests.

Reviewer Notes

  • OAuthModal.tsx gets shorter (1194 to 1191 lines). The per-provider tab, description and placeholder ternaries moved into a small key table in oauthModal/pasteTokenCopy.ts, following the helper-module precedent for frozen files.
  • The import-token match now applies to every provider. Before, it only ran when the token carried an email, which also meant a connectionId re-auth was ignored for tokens without one. The order is now connectionId, then email when there is one, then the token itself. For Devin and Grok the only visible difference is that pasting the same token again updates the row instead of adding a duplicate.
  • One behaviour change for Browser Login users: opening the Claude Code dialog no longer starts the browser flow on its own. It takes one click on Browser Login.
  • Left for a follow-up PR: setup-token rows still show the manual token refresh button and Export/Apply auth. Both need a refresh token, so for these rows they fail harmlessly with an error toast, and the connection stays connected. Hiding them for refresh-less rows is a separate change.
  • Not caused by this PR, seen while testing: after a Fable model returns 404, OmniRoute locks claude-sonnet-5 on the same connection for 120 s (model_cooldown), even though the logged upstream request was for claude-fable-5 only. Sonnet 4.6 is unaffected. The lock comes from the existing MODEL_NOT_FOUND branch in open-sse/handlers/chatCore.ts. I can open a separate issue with the logs.
  • Building the image locally: on this cycle npm run build inside the Dockerfile runs out of V8 heap at the default OMNIROUTE_BUILD_MEMORY_MB=6144 (and at 4096), even in a 16 GB Docker VM. It builds with --build-arg OMNIROUTE_BUILD_MEMORY_MB=10240. Unrelated to this change, but anyone reproducing the image will hit it.

`claude setup-token` mints a one-year OAuth token (sk-ant-oat01-...) scoped to
user:inference only: no refresh token, and bootstrap/profile answer 403, so it
carries no email or account UUID. The request path and the health sweep already
handle such a connection (diegosouzapw#14287), but nothing could create one: the Claude
auth-file import requires a refresh token and the generic import-token action
was not open to `claude`.

- claude provider: importToken() checks the sk-ant-oat prefix, verifies the
  token with one max_tokens=1 request before anything is stored, and returns a
  refresh-less OAuth payload with a fresh cliUserID.
- import-token route: accepts `claude`, awaits a provider's async importToken()
  when present, matches identity-less tokens on the token itself (re-paste
  updates instead of duplicating; connectionId reauth now works without an
  email), keeps an existing cliUserID (diegosouzapw#10143), and returns the error's own
  4xx instead of a blanket 500.
- OAuthModal: Claude Code gets a "Setup Token" tab next to Browser Login. The
  per-provider tab/description/placeholder keys move into
  oauthModal/pasteTokenCopy.ts, so the frozen modal shrinks by 2 lines.
- i18n: 3 new oauthModal keys, translated in all 66 locales.
Adding a Claude Code connection used to start the browser OAuth flow on open,
popping claude.ai before the user could reach the Setup Token tab. Claude now
opens in paste mode like the import-only providers; Browser Login stays one
tab away and starts the flow only when clicked.

Setup-token connections are named "Setup token" so they are told apart from
browser-login rows (they carry no email), and the provider guide documents the
flow, the verification request and what the inference-only scope cannot do.
…ry open

With Claude Code opening on the setup-token form, nothing restarted the modal
on reopen: adding a second connection after a successful import showed the
previous "Connection successful!" screen, and once that was fixed the token
field still held the last pasted token. The render-time reset now restores the
first step for every provider (GitLab Duo keeps its setup step) and clears the
pasted token, which also covers the import-only Devin providers.
@dpozimski

Copy link
Copy Markdown
Contributor Author

@diegosouzapw please CR, thanks of OAUTH api tokens we dont have to refresh them so often (they are valid fo 1 year). the regular oauth tokens expiring after few weeks

…py table

The source contract in devin-providers.test.ts grepped OAuthModal.tsx for
devinDesktopPasteDescription; the per-provider paste keys now live in
oauthModal/pasteTokenCopy.ts, so the test reads both files. The Devin Desktop
copy itself is unchanged.
Four screenshots from a Docker build of this branch: the dialog opening on
the Setup Token tab, a token Anthropic rejects, the saved connection row and
the empty form on reopen. The provider guide shows the dialog.

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

LGTM 😎👍🏻

diegosouzapw and others added 3 commits October 6, 2026 18:55
…ocales

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Keep the tip's ICU-escaped catalogs and add the three setup-token
message keys from the pull request. Register check:ui-icu in the
gate manifest so the pre-commit hook accepts the script added earlier.

Co-authored-by: Damian Pozimski <damian.pozimski@gmail.com>
@diegosouzapw
diegosouzapw merged commit 0628e2b into diegosouzapw:release/v3.8.52 Oct 7, 2026
3 checks passed
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