feat(providers): add Nous Portal (Nous Research) OAuth provider — device grant + free/paid live catalog - #1397
Conversation
…ice grant + free/paid live catalog (Closes lidge-jun#1148)
|
📝 WalkthroughWalkthroughThis change adds Nous Portal device-grant OAuth, rotating refresh tokens, identity extraction, provider registration, live model discovery, parity tests, credential-store tests, and documentation updates. ChangesNous Portal integration
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant User
participant OAuthController
participant NousPortal
participant CredentialStore
participant NousInference
User->>OAuthController: ocx login nous
OAuthController->>NousPortal: request device authorization
NousPortal-->>OAuthController: return verification URL and user code
OAuthController->>NousPortal: poll for authorization
NousPortal-->>OAuthController: return access and rotated refresh tokens
OAuthController->>CredentialStore: save account credentials
User->>NousInference: use nous provider
NousInference-->>User: return OpenAI-compatible model response
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
⏳ DRAFT
What to do
Review readiness checklist
0/4 boxes ticked. This PR stays in draft until every box above is ticked. |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs-site/src/content/docs/ru/guides/providers.md`:
- Line 63: Update the Russian providers documentation sections around the preset
count, login commands, and provider table to match the English source: change
the OAuth total to eight, add the ocx login nous device-grant command, and add
the Nous provider row covering the openai-chat adapter, inference endpoint, live
paid/free model discovery, and rotated refresh tokens.
In `@src/oauth/nous.ts`:
- Around line 67-69: Update resolvePortalBaseUrl in src/oauth/nous.ts (lines
67-69) to parse the configured URL and reject non-HTTPS schemes, embedded
credentials, query strings, and fragments before returning the normalized base
URL. Update tests/nous-oauth.test.ts (lines 8-9) to use an HTTPS TEST_PORTAL and
add coverage proving an HTTP override fails before fetch is invoked.
- Around line 149-167: Update parseTokenPayload to remove refreshFallback and
require a non-empty refresh_token in every response; reject a returned token
equal to the refreshToken supplied to the refresh flow, and adjust that caller
to pass no fallback while preserving initial token parsing. Add a regression
test covering an omitted replacement refresh token and the consumed-token reuse
case.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: bd2bf16e-886e-40d2-8c88-40912a1f2872
📒 Files selected for processing (10)
docs-site/src/content/docs/guides/providers.mddocs-site/src/content/docs/ja/guides/providers.mddocs-site/src/content/docs/ko/guides/providers.mddocs-site/src/content/docs/ru/guides/providers.mddocs-site/src/content/docs/zh-cn/guides/providers.mdsrc/oauth/index.tssrc/oauth/nous.tssrc/providers/registry.tstests/nous-oauth.test.tstests/provider-registry-parity.test.ts
| | `key` | Отправляет ваш API-ключ (`Authorization: Bearer …` либо `x-api-key` / `api-key` в зависимости от адаптера). Ключ может быть литералом или ссылкой вида `${ENV_VAR}`. | Большинство провайдеров. | | ||
| | `forward` | Передаёт провайдеру **входящие заголовки аутентификации Codex** без изменений — ключ не хранится. Это сквозной режим (passthrough) входа через ChatGPT. | OpenAI (адаптер `openai-responses`). | | ||
| | `oauth` | Берёт сохранённый OAuth-токен доступа (автоматически обновляется до истечения срока) и использует его как bearer-ключ. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, GitHub Copilot. | | ||
| | `oauth` | Берёт сохранённый OAuth-токен доступа (автоматически обновляется до истечения срока) и использует его как bearer-ключ. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, GitHub Copilot, Nous Portal. | |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Complete the Russian Nous documentation.
This line adds Nous Portal to the OAuth provider list, but docs-site/src/content/docs/ru/guides/providers.md still says “Семь пресетов” on Lines 96-99, omits ocx login nous from Lines 101-112, and omits the nous provider row from Lines 114-122.
Update those sections to match the English source: use eight OAuth presets, add the device-grant login command, and document the openai-chat adapter, inference endpoint, live paid/free model discovery, and rotated refresh tokens.
As per path instructions, translated provider documentation must not contradict the English source.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs-site/src/content/docs/ru/guides/providers.md` at line 63, Update the
Russian providers documentation sections around the preset count, login
commands, and provider table to match the English source: change the OAuth total
to eight, add the ocx login nous device-grant command, and add the Nous provider
row covering the openai-chat adapter, inference endpoint, live paid/free model
discovery, and rotated refresh tokens.
Source: Path instructions
| function resolvePortalBaseUrl(): string { | ||
| return (process.env.NOUS_PORTAL_BASE_URL || NOUS_PORTAL_BASE_URL).replace(/\/+$/, ""); | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win
Require HTTPS for the Nous Portal OAuth endpoint.
Line 68 accepts http: values from NOUS_PORTAL_BASE_URL. refreshNousToken then sends x-nous-refresh-token to that endpoint, and token responses contain the bearer access token. A cleartext override exposes both credentials to a network attacker.
src/oauth/nous.ts#L67-L69: Parse the configured URL. Reject non-HTTPS schemes, embedded credentials, query strings, and fragments before constructing OAuth requests.tests/nous-oauth.test.ts#L8-L9: ChangeTEST_PORTALto an HTTPS URL. Add a test that an HTTP override fails beforefetchruns.
Based on learnings: OAuth adapters that attach Bearer credentials must enforce HTTPS separately whenever cleartext transmission is unacceptable.
📍 Affects 2 files
src/oauth/nous.ts#L67-L69(this comment)tests/nous-oauth.test.ts#L8-L9
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/oauth/nous.ts` around lines 67 - 69, Update resolvePortalBaseUrl in
src/oauth/nous.ts (lines 67-69) to parse the configured URL and reject non-HTTPS
schemes, embedded credentials, query strings, and fragments before returning the
normalized base URL. Update tests/nous-oauth.test.ts (lines 8-9) to use an HTTPS
TEST_PORTAL and add coverage proving an HTTP override fails before fetch is
invoked.
Source: Learnings
| function parseTokenPayload(payload: NousTokenResponse, refreshFallback?: string): OAuthCredentials { | ||
| const access = nonEmptyString(payload.access_token); | ||
| if (!access) throw new Error("Nous Portal token response did not include an access token"); | ||
| const refresh = nonEmptyString(payload.refresh_token) ?? refreshFallback; | ||
| if (!refresh) throw new Error("Nous Portal token response did not include a refresh token"); | ||
|
|
||
| const jwtPayload = decodeJwtPayload(access); | ||
| const expMs = jwtExpiryMs(jwtPayload); | ||
| const expiresInMs = typeof payload.expires_in === "number" ? payload.expires_in * 1000 : undefined; | ||
| // Prefer the JWT `exp` claim when present (it is the authoritative inference | ||
| // JWT lifetime), else fall back to `expires_in`. | ||
| const expires = (expMs ?? (expiresInMs !== undefined ? Date.now() + expiresInMs : Date.now() + DEFAULT_DEVICE_FLOW_TTL_MS)) | ||
| - OAUTH_EXPIRY_SKEW_MS; | ||
| return { | ||
| access, | ||
| refresh, | ||
| expires, | ||
| ...identityFromNousTokens(access), | ||
| }; |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Require a replacement refresh token after refresh.
Line 152 accepts the old refresh token when the refresh response omits refresh_token. Line 278 then persists that consumed token. The next refresh reuses it and can revoke the Portal session.
Remove refreshFallback from parseTokenPayload. Reject a refresh response that has no new refresh_token. Also reject a returned token that equals refreshToken. Add a regression test for this response shape.
Proposed fix
-function parseTokenPayload(payload: NousTokenResponse, refreshFallback?: string): OAuthCredentials {
+function parseTokenPayload(payload: NousTokenResponse): OAuthCredentials {
const access = nonEmptyString(payload.access_token);
if (!access) throw new Error("Nous Portal token response did not include an access token");
- const refresh = nonEmptyString(payload.refresh_token) ?? refreshFallback;
+ const refresh = nonEmptyString(payload.refresh_token);
if (!refresh) throw new Error("Nous Portal token response did not include a refresh token");- return parseTokenPayload((await response.json()) as NousTokenResponse, refreshToken);
+ const credentials = parseTokenPayload((await response.json()) as NousTokenResponse);
+ if (credentials.refresh === refreshToken) {
+ throw new Error("Nous Portal refresh response did not rotate the refresh token");
+ }
+ return credentials;Also applies to: 278-278
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/oauth/nous.ts` around lines 149 - 167, Update parseTokenPayload to remove
refreshFallback and require a non-empty refresh_token in every response; reject
a returned token equal to the refreshToken supplied to the refresh flow, and
adjust that caller to pass no fallback while preserving initial token parsing.
Add a regression test covering an omitted replacement refresh token and the
consumed-token reuse case.
…hy3, laguna-s/xs, step-3.7-flash)
Code review (manual pass, 2026-08-10)Reviewed Non-blocking findings (no code change required before merge)
Windows / platform questionNo platform-specific code here: the flow is a pure RFC 8628 device grant — OpenCodex displays the verification URL + code ( CI note
|
…lback - access_denied / expired_token surface as terminal NousTokenError - slow_down backs off (interval bump) then resumes polling to success - authorization_pending until deadline raises a timed-out error - refresh omitting a new refresh_token keeps the previous one (header sent)
|
Gaps de tests identifies en review : couverts dans le commit 6989a2e.
Verification : |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tests/nous-oauth.test.ts`:
- Around line 157-171: The tests around loginNous must assert the NousTokenError
contract, not only matching messages. Update the access_denied and expired_token
cases to verify rejection with NousTokenError and confirm the error’s oauthError
value preserves the corresponding OAuth code, while retaining the existing
message assertions.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 17ba1740-a7de-466c-81bd-baf695a97538
📒 Files selected for processing (2)
src/providers/registry.tstests/nous-oauth.test.ts
| test("access_denied surfaces as a terminal NousTokenError", async () => { | ||
| globalThis.fetch = deviceFlowFetch(() => | ||
| new Response(JSON.stringify({ error: "access_denied", error_description: "User denied the request" }), { status: 400 }), | ||
| ); | ||
| const ctrl: OAuthController = { onAuth() {} }; | ||
| await expect(loginNous(ctrl)).rejects.toThrow("Nous Portal device authorization denied"); | ||
| }); | ||
|
|
||
| test("expired_token surfaces as a terminal NousTokenError", async () => { | ||
| globalThis.fetch = deviceFlowFetch(() => | ||
| new Response(JSON.stringify({ error: "expired_token", error_description: "Code expired" }), { status: 400 }), | ||
| ); | ||
| const ctrl: OAuthController = { onAuth() {} }; | ||
| await expect(loginNous(ctrl)).rejects.toThrow("Nous Portal device authorization expired"); | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the token-error type, its terminal API, and the polling error path.
ast-grep outline src/oauth/nous.ts --items all --type class,function --match 'NousTokenError|readTokenError|pollForToken'
rg -n -C 8 '\bNousTokenError\b|\bterminal\b|\breadTokenError\b|\bpollForToken\b' \
src/oauth/nous.ts tests/nous-oauth.test.tsRepository: lidge-jun/opencodex
Length of output: 10441
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the terminal classification contract and existing token-error assertions.
rg -n -C 4 '\bterminal\b|\bterminalError\b|\bOAuthError' src tests
rg -n -C 3 'NousTokenError|toThrowError|toThrow\(' tests src
fd -i 'oauth|nous' tests src -t fRepository: lidge-jun/opencodex
Length of output: 50376
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Check whether the codebase defines a shared terminal-error API for OAuth failures.
rg -n '\bterminal\b|\bterminalError\b|\btimedOut\b|\boauthError\b' src tests --iglob '*oauth*' --iglob '*terminal*' --iglob '*error*' | head -200Repository: lidge-jun/opencodex
Length of output: 3788
Assert the NousTokenError contract.
tests/nous-oauth.test.ts:157-170 only asserts the error message strings, so a plain Error with the same message passes. Check the source path in src/oauth/nous.ts:238-242, then assert that access_denied and expired_token reject with NousTokenError and preserve the oauthError code so callers cannot drift from the terminal-token-error contract.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/nous-oauth.test.ts` around lines 157 - 171, The tests around loginNous
must assert the NousTokenError contract, not only matching messages. Update the
access_denied and expired_token cases to verify rejection with NousTokenError
and confirm the error’s oauthError value preserves the corresponding OAuth code,
while retaining the existing message assertions.
Closes #1148
What
Adds Nous Portal (Nous Research) as a first-class OAuth provider, matching the device-grant flow Hermes Agent uses against the same backend.
OAuth flow (RFC 8628 device authorization grant)
POST https://portal.nousresearch.com/api/oauth/device/code(client_id=hermes-cli,scope=inference:invoke) →user_code+ verification URL surfaced via the controller (onAuth), same UX as Kimi/Kiro.POST .../api/oauth/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_code, handlingauthorization_pending,slow_down(backoff),access_denied,expired_token.inference:invoke) → used directly asAuthorization: Beareragainst the OpenAI-compatible endpointhttps://inference-api.nousresearch.com/v1(adapter: openai-chat).Refresh (single-use rotation)
x-nous-refresh-tokenheader (not the body) withgrant_type=refresh_token+client_id.refresh_token_reused). The refresh path persists the rotated token immediately and stays on the default lazy-only refresh policy — no proactive background refresh for this provider.Registry & catalog
nousregistry entry: featured,freeTier: true,liveModels: truewith discovery on/v1/models(max 512 models). Catalog is a mix of paid models and:freeslugs; free-tier gating is decided live by the Portal per account, with a static fallback seed for the logged-out state (see below).NousTokenErrormapped to terminal refresh errors (invalid_grant,refresh_token_reused,revoked,revoked_token,expired_token).Free model seed (verified against the live Portal list, 2026-08-10)
The registry ships a static fallback seed with the 4
:freemodels currently advertised by the Portal — confirmed against the public endpoint Hermes Agent uses (https://portal.nousresearch.com/api/nous/recommended-models):tencent/hy3:freepoolside/laguna-s-2.1:freestepfun/step-3.7-flash:freepoolside/laguna-xs-2.1:freeNote:
inclusionai/ling-3.0-flash:freewas removed from the Portal's free list (404 on the inference API since 2026-08-07) and is therefore not seeded.Paid catalog value
Beyond the free tier, the Nous Portal paid catalog is significant. Nous Research's own announcements:
With
liveModels: truediscovery, all paid models (including the discounted DeepSeek V4 Flash 0731) show up automatically once a Portal account is connected.Multiauth
sub→ accountId, lowercasedemailwhen present); multiple Portal accounts are stored/upserted persublike other OAuth providers.Tests & docs
tests/nous-oauth.test.ts(7 tests): JWT identity, refresh header/rotation wiring, mocked device-grant login, multiauth (append/upsert). All network calls mocked viaNOUS_PORTAL_BASE_URL— no real login performed.tests/provider-registry-parity.test.ts(featured set + freeTier list) and provider docs (en/ja/ko/ru/zh-cn).Verification
bun run typecheck✅bun run test tests/nous-oauth.test.ts tests/oauth-provider-reconcile.test.ts tests/catalog-oauth-observation.test.ts tests/provider-registry-parity.test.ts tests/kimi-oauth-identity.test.ts tests/kiro-oauth.test.ts tests/oauth-public-surface.test.ts tests/cli-provider.test.ts tests/oauth-login-summary.test.ts tests/repo-hygiene.test.ts✅ (all green)Note: no live Nous login was executed during development (credentials/OAuth state untouched); the flow is verified against Hermes Agent's
hermes_cli/auth.pyimplementation and mocked responses.Review readiness checklist
This PR stays in draft until every box below is ticked. Tick all four boxes once the requirements are met:
All CI tests are green on my local testing.
I pushed my PR to the latest dev commit.
I resolved all correct Codex and CodeRabbit findings.
My PR is ready for review.
Summary by CodeRabbit
New Features
Bug Fixes