Skip to content

Commit 887401e

Browse files
elkaixclaude-architect
andauthored
feat(auth): add xAI, GitHub Copilot, DigitalOcean, and Snowflake Cortex OAuth login providers (#215)
* docs(tasks): plan opencode auth-provider + dynamic-catalog + effort adoption * candidate f1ff6f28-db9c-4236-8656-7ee20256fcee * fix(auth): update models.dev catalog consumer test and genericize module header The refactor moved the best-effort fetch/timeout into the shared models.dev catalog module, so the provider test now asserts graceful degradation via the shared loader instead of the removed inline timeout constant. * docs(tasks): mark dynamic catalog phase done; generic framing * candidate 55be2461-0989-4a1e-8602-102703271165 * fix(auth): make oauth_flows strict-clean and UTF-8 explicit - Use utf-8 (not ascii) for the base64url decode to satisfy the explicit-encoding static requirement (base64url output is ASCII, so equivalent). - Cast socket getsockname() before len/index so strict pyright has a known arg type. * docs(tasks): mark shared OAuth flow helper done; P3 sub-phases * docs(tasks): finalize P3b GitHub Copilot design (primary-source verified) * feat(auth): add GitHub Copilot device-code OAuth login provider Add a 'copilot' managed provider for individual github.com accounts. Login runs the GitHub device-code flow, exchanges the OAuth token for a short-lived Copilot bearer via copilot_internal/v2/token, and stores the GitHub token as the refresh credential so OAuthManager can re-exchange on expiry. Chat routes through openai_legacy to api.githubcopilot.com with the Copilot integration headers. Wired into shell /login /logout and CLI login/logout --copilot. Business/Enterprise routing is out of scope (individual host only). * docs(tasks): mark P3b GitHub Copilot delivered (pending live verify) * feat(auth): add xAI Grok OAuth login provider Add an 'xai' managed provider with two OAuth methods: browser loopback-PKCE (pinned redirect 127.0.0.1:56121, plan=generic + OIDC nonce) and RFC 8628 device-code. Tokens exchange/refresh against auth.x.ai with rotating refresh tokens persisted by OAuthManager; chat routes through openai_legacy to api.x.ai/v1. Wired into shell /login /logout and CLI login --xai/--xai-device, logout --xai. * docs(tasks): mark P3c xAI Grok delivered (pending live verify) * docs(tasks): P3d DigitalOcean full-build scope + verified constants * chore(tasks): split P3d DigitalOcean into implicit-helper + provider lanes * feat(auth): add run_loopback_implicit_flow OAuth helper Add a reusable OAuth 2.0 implicit-flow loopback helper for providers whose token arrives in the URL fragment (response_type=token). Serves an HTML bootstrap page on GET <callback_path> whose inline JS posts the parsed fragment to a pinned-port POST <token_path>; validates state, requires a non-empty access_token, and coerces expires_in with a 30-day fallback. Binds on a caller-pinned host (default localhost) and port so the redirect URI exact-matches an upstream registration. Existing device-code and authorization-code/PKCE helpers are unchanged. * feat(auth): add DigitalOcean Gradient AI browser OAuth login Add DigitalOcean as a login provider using the OAuth implicit flow. The returned access token is stored as a bare API-key provider on inference.do-ai.run/v1 (no refresh; re-login on ~30-day expiry), and the model catalog is seeded dynamically from the Gradient Inference Routers API as 'router:<name>' aliases. Login still succeeds when the router catalog is unavailable (info event, zero models). Wires platform registration, the managed-models refresh skip-guard, shell /login /logout selectors, and CLI --digitalocean flags, mirroring the xAI provider. * chore(tasks): mark P3d DigitalOcean delivered * chore(tasks): record P3e Snowflake full account-scoped build scope * feat(auth): add Snowflake Cortex browser OAuth login Add Snowflake Cortex as an account-scoped browser-OAuth login provider. Login prompts for a Snowflake account identifier (and optional role), runs loopback-PKCE against the account's https://<account>.snowflakecomputing.com OAuth endpoints (HTTP Basic client creds, role-scoped), and stores an openai_legacy provider on the account's Cortex OpenAI-compatible base_url with a curated model catalog. The account is encoded into the OAuth ref key (oauth/snowflake-cortex/<account>) and parsed back in the refresh dispatch to build the account-scoped token URL. Wires platform registration, the refresh skip-guard, shell /login /logout, and CLI --snowflake --account --role. * chore(tasks): mark P3e Snowflake delivered; P3 provider set complete * docs(changelog): add DigitalOcean and Snowflake Cortex login entries * fix(auth): harden OAuth flow helpers and CLI login validation Address review findings on the shared OAuth helpers and login command: - poll_device_token: reject a 2xx success payload that carries no usable access token instead of returning it as success. - Implicit loopback callback: catch truncated request bodies (IncompleteReadError) and fail closed with HTTP 400 rather than hanging until timeout; scrub the bearer token from the browser URL fragment via history.replaceState before it is posted. - Implicit expiry: represent missing/malformed/nonpositive expires_in as unknown (None) rather than fabricating a trusted 30-day lifetime. - run_loopback_implicit_flow: require a genuine loopback redirect_host and a nonzero port, failing closed on non-loopback binds. - login: reject --account/--role unless --snowflake is selected. Also make three implicit-callback assertions effectful to satisfy the static analyzer. * feat(auth): make models.dev catalog a typed, portable, provider-neutral API Rework the shared models.dev catalog so any provider can consume it and so degraded data is never mistaken for authoritative: - get_models_dev_catalog now returns a typed CatalogResult (catalog + status + source), distinguishing fresh, cached, stale, disabled, and unavailable outcomes instead of collapsing every state into an empty dict. - Replace the unconditional Unix-only fcntl import and blocking, uninterruptible lock acquisition with a cross-platform (fcntl/msvcrt) non-blocking lock that is bounded by a deadline, cancellation-safe, and never orphans a descriptor. - Add a provider-neutral chat-model filter (text-output modality, excluding embedding/reranker/moderation ids) and build_catalog_models() so each provider can resolve its own catalog entries without opencode-specific coupling. opencode_go consumes the new CatalogResult shape. * feat(auth): drive provider models from the catalog with atomic, hardened logins Rewire xAI, GitHub Copilot, Snowflake Cortex, and DigitalOcean onto the shared, provider-neutral models.dev catalog and make their login/logout robust: - Each provider now discovers its own models generically (xAI/Copilot/Snowflake from their models.dev provider id, filtered to chat models; DigitalOcean from its native Inference Routers API), falling back to a curated list when the catalog is unavailable or degraded — no opencode coupling. - Persist login/logout atomically via shared helpers: a login writes tokens, applies config, then saves; on failure it rolls back the token and restores the in-memory config. Logout persists the config removal before deleting credentials so an interruption leaves recoverable creds, not orphaned config. - xAI/Snowflake: map 400 invalid_grant to unauthorized so rejected refresh tokens are suppressed; require a refresh token before reporting login success. - Snowflake: validate the account locator (reject authority/path/port/userinfo/ query/fragment payloads) before building any URL; register models without making Snowflake the default, since its Cortex chat adapter is not yet wired. - DigitalOcean: report router-discovery outcomes (unauthorized, outage, malformed, empty) distinctly instead of collapsing them into an empty catalog. Expand tests to cover malformed/transport-failure responses, hostile account input, missing refresh tokens, invalid_grant, catalog-driven registration, and the distinct router-discovery outcomes. Also revert an unrelated prompt.py change that had been picked up inadvertently. * fix(auth): drop accidental prompt-subsystem sweep-in from the auth branch An in-progress prompt_toolkit screen-mode refactor (prompt.py, config.py sticky_input docs, and the prompt/erase-when-done tests) was captured from the working tree by an earlier auth commit, leaving the tests ahead of the reverted source and failing four prompt tests. Restore the entire prompt subsystem and the config docstring to origin/main so the auth PR carries only auth changes. * fix(auth): preserve prior credential on re-login failure and log persistence errors - persist_login now snapshots the existing token via load_tokens and restores it (rather than blind-deleting) when the config save fails, so an unrelated save error during re-login no longer destroys a still-valid credential; a fresh login with no prior token still deletes on failure. Adds direct regression tests for the atomicity helper (fails before, passes after). - Log provider login/logout persistence failures across all four providers so unexpected errors leave a diagnosable trail alongside the safe user message. - Log the curated-model fallback in xai/copilot discovery when the models.dev catalog is not authoritative, matching DigitalOcean's degraded-discovery signal. - Add a success-path test proving _fetch_models_dev_metadata populates metadata from an authoritative catalog, and a default-context fallback test for build_catalog_models. - Drop the changelog entry for the reverted prompt-fullscreen feature and note the credential-preservation fix. * docs(auth): design PR 215 review fixes * fix(auth): namespace nested OAuth credential files * fix(config): replace saved config atomically * fix(auth): serialize OAuth persistence transactions * fix(auth): validate implicit OAuth state before errors * fix(auth): surface catalog and router degradation * test(config): cover atomic symlink saves * fix(auth): use UTF-8 for credential key encoding * fix(auth): hide persistence diagnostics from events --------- Co-authored-by: claude-architect <runtime@claude-architect.invalid>
1 parent 7e41080 commit 887401e

32 files changed

Lines changed: 6374 additions & 105 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- Add xAI Grok OAuth login (browser loopback and device-code).
19+
- Add GitHub Copilot device-code OAuth login for individual github.com accounts.
20+
- Add DigitalOcean Gradient AI browser OAuth login with dynamically discovered Inference Routers; router-discovery failures (unauthorized, outage, malformed, empty) are now reported distinctly instead of silently yielding no models.
21+
- Add Snowflake Cortex account-scoped browser OAuth login (`pythinker login --snowflake`); the account identifier is validated before any request, and models register without becoming the default until Cortex chat support ships.
22+
- Discover each provider's models from a cached, typed, provider-neutral models.dev catalog with curated fallbacks, so xAI, GitHub Copilot, and Snowflake pick up new models automatically; the catalog is now portable across platforms (no longer Unix-only) and distinguishes fresh, cached, stale, disabled, and unavailable results.
23+
- Persist provider login and logout atomically so a failed save never leaves orphaned credentials or a half-applied configuration; a re-login whose save fails now restores the previous credential instead of deleting it, and provider persistence failures are logged.
1824
- Fix queued follow-up input showing a bordered ghost; pressing Enter during an active turn now shows one intentional queued row.
1925

2026
## 0.60.0 (2026-07-18)

docs/en/release-notes/changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ GitHub Releases page; `0.8.0` is the new starting line.
1717

1818
## Unreleased
1919

20+
- Add xAI Grok OAuth login (browser loopback and device-code).
21+
- Add GitHub Copilot device-code OAuth login for individual github.com accounts.
22+
- Add DigitalOcean Gradient AI browser OAuth login with dynamically discovered Inference Routers; router-discovery failures (unauthorized, outage, malformed, empty) are now reported distinctly instead of silently yielding no models.
23+
- Add Snowflake Cortex account-scoped browser OAuth login (`pythinker login --snowflake`); the account identifier is validated before any request, and models register without becoming the default until Cortex chat support ships.
24+
- Discover each provider's models from a cached, typed, provider-neutral models.dev catalog with curated fallbacks, so xAI, GitHub Copilot, and Snowflake pick up new models automatically; the catalog is now portable across platforms (no longer Unix-only) and distinguishes fresh, cached, stale, disabled, and unavailable results.
25+
- Persist provider login and logout atomically so a failed save never leaves orphaned credentials or a half-applied configuration; a re-login whose save fails now restores the previous credential instead of deleting it, and provider persistence failures are logged.
2026
- Fix queued follow-up input showing a bordered ghost; pressing Enter during an active turn now shows one intentional queued row.
2127

2228
## 0.60.0 (2026-07-18)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# PR #215 auth review fixes design
2+
3+
## Goal
4+
5+
Resolve every validated review defect on PR #215 without broad auth or configuration refactoring. Preserve existing provider behavior except where it is unsafe, misleading, or violates the repository's failure-truthfulness and async-runtime contracts.
6+
7+
## Scope
8+
9+
1. Prevent nested OAuth references such as `oauth/snowflake-cortex/<account>` from sharing credential or lock files with flat references such as `oauth/xai`.
10+
2. Make the new login/logout persistence unit:
11+
- serialized across processes at the shared config-file boundary;
12+
- executed outside the asyncio event loop;
13+
- safe against partial config-file writes;
14+
- explicit when rollback or credential deletion fails;
15+
- unable to report logout success while credentials remain undeleted.
16+
3. Validate the implicit-flow `state` before accepting either success or error payloads.
17+
4. Keep DigitalOcean's intentional degraded credential persistence, but never claim that an unrelated or empty default model is a configured DigitalOcean model.
18+
5. Log curated-model fallback both when catalog status is non-authoritative and when an authoritative catalog yields no usable provider models.
19+
6. Preserve the existing OpenCode Go unavailable-catalog regression test and resolve its stale review thread with evidence.
20+
21+
Out of scope: provider protocol redesigns, replacing DigitalOcean's provider-required implicit grant, Snowflake Cortex chat transforms, global config merge semantics unrelated to these auth transactions, and new dependencies.
22+
23+
## Design
24+
25+
### Credential filenames
26+
27+
Keep the legacy filename for ordinary one-segment OAuth keys so existing credentials remain readable. Encode the complete relative key for multi-segment keys into a deterministic, filesystem-safe filename, and apply the same mapping to lock files. This makes the mapping injective without changing released flat-key paths.
28+
29+
### Persistence transaction
30+
31+
Expose async persistence helpers to provider login/logout callers. Each helper offloads one complete synchronous transaction to a worker thread. The transaction acquires a bounded, fail-closed inter-process lock derived from the default config path before it snapshots credentials/config, mutates state, writes, or rolls back.
32+
33+
Config writes use a temporary file in the target directory, flush and fsync it, apply private permissions, and atomically replace the destination. A failed write therefore leaves the previous config file intact.
34+
35+
Login order remains token write then config write. On failure, restore the previous token (or remove the new one), restore the in-memory snapshot, and re-raise. If rollback also fails, raise an explicit persistence error carrying both failure contexts and log the rollback failure without secrets.
36+
37+
Logout writes the config removal first, then deletes credentials. If deletion fails, restore and persist the previous config before raising. It must never emit success after failed credential deletion.
38+
39+
The config-scoped lock covers snapshot through rollback, so a failed concurrent login cannot restore a token observed before another successful transaction.
40+
41+
### OAuth callback validation
42+
43+
For implicit callbacks, parse `state` and compare it with the expected value before interpreting `error`. RFC 6749 requires the original state on both successful and error responses. A wrong-state error is rejected as `OAuthStateMismatch`, not accepted as a user denial.
44+
45+
### Provider messaging and fallback visibility
46+
47+
DigitalOcean continues to persist a valid OAuth token when router discovery is empty or unavailable, matching the approved PR scope. Its terminal success text distinguishes “router configured” from “credentials saved with no routers configured.”
48+
49+
Copilot, xAI, and Snowflake log when they use curated models because the catalog is non-authoritative or because authoritative conversion is empty. Logs include status/source only, never credentials.
50+
51+
## Tests
52+
53+
Use red-green TDD for each behavior:
54+
55+
- nested Snowflake/xAI credential and lock paths differ;
56+
- concurrent failed login cannot overwrite a successful token;
57+
- failed config save preserves prior file bytes;
58+
- rollback/delete failures are surfaced and logout does not report success;
59+
- async callers do not run persistence I/O on the event-loop thread;
60+
- wrong-state implicit error yields `OAuthStateMismatch`;
61+
- DigitalOcean degraded success does not name an unrelated model;
62+
- empty authoritative catalogs emit fallback logs;
63+
- unavailable OpenCode Go catalog coverage remains green.
64+
65+
After focused tests, run `make check-pythinker-code`, `make test-pythinker-code`, and `git diff --check`.
66+
67+
## GitHub review completion
68+
69+
Push the verified commit to `feat/auth-login-providers`. Reply inside each CodeRabbit thread with the specific fix and test evidence. For the already-satisfied OpenCode Go coverage thread, cite the existing unavailable-catalog test. Resolve threads only after the pushed head and checks reflect the fixes.

src/pythinker_code/auth/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,23 @@
99
MOONSHOT_PLATFORM_ID = "moonshot"
1010
KIMI_PLATFORM_ID = "kimi"
1111
DEEPSEEK_PLATFORM_ID = "deepseek"
12+
DIGITALOCEAN_PLATFORM_ID = "digitalocean"
13+
GITHUB_COPILOT_PLATFORM_ID = "copilot"
1214
ANTHROPIC_PLATFORM_ID = "anthropic"
1315
OPENROUTER_PLATFORM_ID = "openrouter"
16+
SNOWFLAKE_CORTEX_PLATFORM_ID = "snowflake-cortex"
1417
LM_STUDIO_PLATFORM_ID = "lm-studio"
1518
OLLAMA_PLATFORM_ID = "ollama"
19+
XAI_PLATFORM_ID = "xai"
1620
ZAI_CODING_PLATFORM_ID = "z-ai-coding"
1721
ZAI_API_PLATFORM_ID = "z-ai-api"
1822

1923
__all__ = [
2024
"ALIBABA_PLATFORM_ID",
2125
"ANTHROPIC_PLATFORM_ID",
2226
"DEEPSEEK_PLATFORM_ID",
27+
"DIGITALOCEAN_PLATFORM_ID",
28+
"GITHUB_COPILOT_PLATFORM_ID",
2329
"KIMI_PLATFORM_ID",
2430
"LM_STUDIO_PLATFORM_ID",
2531
"MINIMAX_PLATFORM_ID",
@@ -30,6 +36,8 @@
3036
"OPENCODE_GO_PLATFORM_ID",
3137
"OPENROUTER_PLATFORM_ID",
3238
"PYTHINKER_CODE_PLATFORM_ID",
39+
"SNOWFLAKE_CORTEX_PLATFORM_ID",
40+
"XAI_PLATFORM_ID",
3341
"ZAI_API_PLATFORM_ID",
3442
"ZAI_CODING_PLATFORM_ID",
3543
]

0 commit comments

Comments
 (0)