From df797d09b2772236f6dbf30ace2d921778570286 Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:39:19 +0800 Subject: [PATCH] feat: add StepFun and Nous Portal provider support (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add StepFun provider (API key auth, STEPFUN_API_KEY) - Add Nous Portal provider with full OAuth device code flow (device code request → poll for token → mint agent key → save to auth.json) - Add NousLoginModal component for OAuth UI (user code display + verification link) - Update ProviderFormModal to handle Nous OAuth flow (hide API key fields) - Add nous-auth backend controller and routes - Update PROVIDER_ENV_MAP with stepfun and nous entries - Add i18n translations for Nous OAuth in all 8 locales Co-authored-by: Claude Opus 4.6 --- packages/client/src/api/hermes/nous-auth.ts | 29 ++ .../hermes/models/NousLoginModal.vue | 241 +++++++++++++++ .../hermes/models/ProviderFormModal.vue | 28 +- packages/client/src/i18n/locales/de.ts | 7 + packages/client/src/i18n/locales/en.ts | 7 + packages/client/src/i18n/locales/es.ts | 7 + packages/client/src/i18n/locales/fr.ts | 7 + packages/client/src/i18n/locales/ja.ts | 7 + packages/client/src/i18n/locales/ko.ts | 7 + packages/client/src/i18n/locales/pt.ts | 7 + packages/client/src/i18n/locales/zh.ts | 7 + .../src/controllers/hermes/nous-auth.ts | 283 ++++++++++++++++++ .../server/src/routes/hermes/nous-auth.ts | 8 + packages/server/src/routes/index.ts | 2 + .../server/src/services/config-helpers.ts | 2 + packages/server/src/shared/providers.ts | 46 +++ 16 files changed, 692 insertions(+), 3 deletions(-) create mode 100644 packages/client/src/api/hermes/nous-auth.ts create mode 100644 packages/client/src/components/hermes/models/NousLoginModal.vue create mode 100644 packages/server/src/controllers/hermes/nous-auth.ts create mode 100644 packages/server/src/routes/hermes/nous-auth.ts diff --git a/packages/client/src/api/hermes/nous-auth.ts b/packages/client/src/api/hermes/nous-auth.ts new file mode 100644 index 0000000..890ccd7 --- /dev/null +++ b/packages/client/src/api/hermes/nous-auth.ts @@ -0,0 +1,29 @@ +import { request } from '../client' + +export interface NousStartResult { + session_id: string + user_code: string + verification_url: string + expires_in: number +} + +export interface NousPollResult { + status: 'pending' | 'approved' | 'denied' | 'expired' | 'error' + error: string | null +} + +export interface NousStatusResult { + authenticated: boolean +} + +export async function startNousLogin(): Promise { + return request('/api/hermes/auth/nous/start', { method: 'POST' }) +} + +export async function pollNousLogin(sessionId: string): Promise { + return request(`/api/hermes/auth/nous/poll/${sessionId}`) +} + +export async function getNousAuthStatus(): Promise { + return request('/api/hermes/auth/nous/status') +} diff --git a/packages/client/src/components/hermes/models/NousLoginModal.vue b/packages/client/src/components/hermes/models/NousLoginModal.vue new file mode 100644 index 0000000..5f9d006 --- /dev/null +++ b/packages/client/src/components/hermes/models/NousLoginModal.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/packages/client/src/components/hermes/models/ProviderFormModal.vue b/packages/client/src/components/hermes/models/ProviderFormModal.vue index 32194fa..fc945d7 100644 --- a/packages/client/src/components/hermes/models/ProviderFormModal.vue +++ b/packages/client/src/components/hermes/models/ProviderFormModal.vue @@ -4,6 +4,7 @@ import { NModal, NForm, NFormItem, NInput, NButton, NSelect, useMessage } from ' import { useModelsStore } from '@/stores/hermes/models' import { useI18n } from 'vue-i18n' import CodexLoginModal from './CodexLoginModal.vue' +import NousLoginModal from './NousLoginModal.vue' const { t } = useI18n() @@ -19,6 +20,7 @@ const showModal = ref(true) const loading = ref(false) const fetchingModels = ref(false) const showCodexLogin = ref(false) +const showNousLogin = ref(false) const providerType = ref<'preset' | 'custom'>('preset') const selectedPreset = ref(null) @@ -32,8 +34,10 @@ const formData = ref({ const modelOptions = ref>([]) const CODEX_KEY = 'openai-codex' +const NOUS_KEY = 'nous' const isCodex = computed(() => selectedPreset.value === CODEX_KEY) +const isNous = computed(() => selectedPreset.value === NOUS_KEY) const presetOptions = computed(() => modelsStore.allProviders.map(g => ({ label: g.label, value: g.provider })), @@ -125,6 +129,12 @@ async function handleSave() { return } + // Nous: 弹出 OAuth 设备码弹窗 + if (isNous.value) { + showNousLogin.value = true + return + } + if (!formData.value.base_url.trim()) { message.warning(t('models.baseUrlRequired')) return @@ -166,6 +176,12 @@ async function handleCodexSuccess() { emit('saved') } +async function handleNousSuccess() { + showNousLogin.value = false + message.success(t('models.providerAdded')) + emit('saved') +} + function handleClose() { showModal.value = false setTimeout(() => emit('close'), 200) @@ -178,7 +194,7 @@ function handleClose() { preset="card" :title="t('models.addProvider')" :style="{ width: 'min(520px, calc(100vw - 32px))' }" - :mask-closable="!loading && !showCodexLogin" + :mask-closable="!loading && !showCodexLogin && !showNousLogin" @after-leave="emit('close')" > @@ -217,7 +233,7 @@ function handleClose() { /> - + - + + + diff --git a/packages/client/src/i18n/locales/de.ts b/packages/client/src/i18n/locales/de.ts index 1c3ab25..5379296 100644 --- a/packages/client/src/i18n/locales/de.ts +++ b/packages/client/src/i18n/locales/de.ts @@ -243,6 +243,13 @@ export default { codexOpenLink: 'Autorisierungsseite öffnen', codexApproved: 'Anmeldung erfolgreich', codexExpired: 'Die Autorisierung ist abgelaufen. Bitte versuchen Sie es erneut.', + nousLoginTitle: 'Nous Portal Login', + nousWaiting: 'Geben Sie diesen Code auf der Autorisierungsseite ein:', + nousCopyCode: 'Code kopiert', + nousOpenLink: 'Autorisierungsseite öffnen', + nousApproved: 'Login erfolgreich', + nousDenied: 'Autorisierung wurde abgelehnt', + nousExpired: 'Autorisierung abgelaufen', noProviders: 'Keine Anbieter gefunden. Fugen Sie einen benutzerdefinierten Anbieter hinzu, um zu beginnen.', builtIn: 'Integriert', customType: 'Benutzerdefiniert', diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index b1858f6..f6a195e 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -268,6 +268,13 @@ export default { codexOpenLink: 'Open authorization page', codexApproved: 'Login successful', codexExpired: 'Authorization expired. Please try again.', + nousLoginTitle: 'Nous Portal Login', + nousWaiting: 'Enter this code at the authorization page to complete login:', + nousCopyCode: 'Code copied', + nousOpenLink: 'Open authorization page', + nousApproved: 'Login successful', + nousDenied: 'Authorization was denied. Please try again.', + nousExpired: 'Authorization expired. Please try again.', noProviders: 'No providers found. Add a custom provider to get started.', builtIn: 'Built-in', customType: 'Custom', diff --git a/packages/client/src/i18n/locales/es.ts b/packages/client/src/i18n/locales/es.ts index fc521b1..d9063ea 100644 --- a/packages/client/src/i18n/locales/es.ts +++ b/packages/client/src/i18n/locales/es.ts @@ -243,6 +243,13 @@ export default { codexOpenLink: 'Abrir página de autorización', codexApproved: 'Inicio de sesión exitoso', codexExpired: 'La autorización ha expirado. Por favor, inténtelo de nuevo.', + nousLoginTitle: 'Inicio de sesión de Nous Portal', + nousWaiting: 'Ingrese este código en la página de autorización:', + nousCopyCode: 'Código copiado', + nousOpenLink: 'Abrir página de autorización', + nousApproved: 'Inicio de sesión exitoso', + nousDenied: 'Autorización denegada', + nousExpired: 'Autorización expirada', noProviders: 'No se encontraron proveedores. Anade un proveedor personalizado para comenzar.', builtIn: 'Integrado', customType: 'Personalizado', diff --git a/packages/client/src/i18n/locales/fr.ts b/packages/client/src/i18n/locales/fr.ts index 539d0c5..dcfda12 100644 --- a/packages/client/src/i18n/locales/fr.ts +++ b/packages/client/src/i18n/locales/fr.ts @@ -243,6 +243,13 @@ export default { codexOpenLink: 'Ouvrir la page d\'autorisation', codexApproved: 'Connexion réussie', codexExpired: 'L\'autorisation a expiré. Veuillez réessayer.', + nousLoginTitle: 'Connexion Nous Portal', + nousWaiting: 'Entrez ce code sur la page d\'autorisation:', + nousCopyCode: 'Code copié', + nousOpenLink: 'Ouvrir la page d\'autorisation', + nousApproved: 'Connexion réussie', + nousDenied: 'Autorisation refusée', + nousExpired: 'Autorisation expirée', noProviders: 'Aucun fournisseur trouve. Ajoutez un fournisseur personnalise pour commencer.', builtIn: 'Integre', customType: 'Personnalise', diff --git a/packages/client/src/i18n/locales/ja.ts b/packages/client/src/i18n/locales/ja.ts index 36cdb4f..c9bbb05 100644 --- a/packages/client/src/i18n/locales/ja.ts +++ b/packages/client/src/i18n/locales/ja.ts @@ -243,6 +243,13 @@ export default { codexOpenLink: '認証ページを開く', codexApproved: 'ログイン成功', codexExpired: '認証の有効期限が切れました。もう一度お試しください。', + nousLoginTitle: 'Nous Portal ログイン', + nousWaiting: '認証ページでこのコードを入力してください:', + nousCopyCode: 'コードをコピーしました', + nousOpenLink: '認証ページを開く', + nousApproved: 'ログイン成功', + nousDenied: '認証が拒否されました', + nousExpired: '認証の有効期限が切れました', noProviders: 'プロバイダーがありません。カスタムプロバイダーを追加して始めましょう。', builtIn: '組み込み', customType: 'カスタム', diff --git a/packages/client/src/i18n/locales/ko.ts b/packages/client/src/i18n/locales/ko.ts index 01c8a2a..6b8a830 100644 --- a/packages/client/src/i18n/locales/ko.ts +++ b/packages/client/src/i18n/locales/ko.ts @@ -243,6 +243,13 @@ export default { codexOpenLink: '인증 페이지 열기', codexApproved: '로그인 성공', codexExpired: '인증이 만료되었습니다. 다시 시도해주세요.', + nousLoginTitle: 'Nous Portal 로그인', + nousWaiting: '인증 페이지에서 이 코드를 입력하세요:', + nousCopyCode: '코드 복사됨', + nousOpenLink: '인증 페이지 열기', + nousApproved: '로그인 성공', + nousDenied: '인증이 거부되었습니다', + nousExpired: '인증이 만료되었습니다', noProviders: 'Provider가 없습니다. 사용자 지정 Provider를 추가하여 시작하세요.', builtIn: '내장', customType: '사용자 지정', diff --git a/packages/client/src/i18n/locales/pt.ts b/packages/client/src/i18n/locales/pt.ts index 24d2b8e..a694b33 100644 --- a/packages/client/src/i18n/locales/pt.ts +++ b/packages/client/src/i18n/locales/pt.ts @@ -243,6 +243,13 @@ export default { codexOpenLink: 'Abrir página de autorização', codexApproved: 'Login bem-sucedido', codexExpired: 'A autorização expirou. Por favor, tente novamente.', + nousLoginTitle: 'Login do Nous Portal', + nousWaiting: 'Insira este código na página de autorização:', + nousCopyCode: 'Código copiado', + nousOpenLink: 'Abrir página de autorização', + nousApproved: 'Login bem-sucedido', + nousDenied: 'Autorização negada', + nousExpired: 'Autorização expirada', noProviders: 'Nenhum provedor encontrado. Adicione um provedor personalizado para comecar.', builtIn: 'Integrado', customType: 'Personalizado', diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index 6b917a8..53113e3 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -268,6 +268,13 @@ export default { codexOpenLink: '打开授权页面', codexApproved: '登录成功', codexExpired: '授权已过期,请重试。', + nousLoginTitle: 'Nous Portal 登录', + nousWaiting: '在授权页面输入此代码完成登录:', + nousCopyCode: '代码已复制', + nousOpenLink: '打开授权页面', + nousApproved: '登录成功', + nousDenied: '授权被拒绝,请重试。', + nousExpired: '授权已过期,请重试。', noProviders: '暂无 Provider,添加一个开始吧。', builtIn: '内置', customType: '自定义', diff --git a/packages/server/src/controllers/hermes/nous-auth.ts b/packages/server/src/controllers/hermes/nous-auth.ts new file mode 100644 index 0000000..6240396 --- /dev/null +++ b/packages/server/src/controllers/hermes/nous-auth.ts @@ -0,0 +1,283 @@ +import { randomUUID } from 'crypto' +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' +import { getActiveAuthPath } from '../../services/hermes/hermes-profile' +import { logger } from '../../services/logger' + +// --- Nous Portal OAuth Constants --- +const NOUS_PORTAL_URL = 'https://portal.nousresearch.com' +const NOUS_CLIENT_ID = 'hermes-cli' +const NOUS_SCOPE = 'inference:mint_agent_key' +const POLL_MAX_DURATION = 15 * 60 * 1000 +const POLL_DEFAULT_INTERVAL = 5000 + +// --- Session Store --- +interface NousSession { + id: string + deviceCode: string + userCode: string + verificationUrl: string + verificationUrlComplete: string + expiresIn: number + interval: number + status: 'pending' | 'approved' | 'denied' | 'expired' | 'error' + error?: string + createdAt: number +} + +const sessions = new Map() + +function cleanupExpiredSessions() { + const now = Date.now() + sessions.forEach((s, id) => { if (now - s.createdAt > POLL_MAX_DURATION + 60000) sessions.delete(id) }) +} + +// --- Auth file helpers --- +interface AuthJson { + version?: number + active_provider?: string + providers?: Record + credential_pool?: Record + updated_at?: string +} + +function loadAuthJson(authPath: string): AuthJson { + try { return JSON.parse(readFileSync(authPath, 'utf-8')) as AuthJson } catch { return { version: 1 } } +} + +function saveAuthJson(authPath: string, data: AuthJson): void { + data.updated_at = new Date().toISOString() + const dir = authPath.substring(0, authPath.lastIndexOf('/')) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + writeFileSync(authPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }) +} + +// --- Background poll worker --- +async function nousLoginWorker(session: NousSession, authPath: string): Promise { + const startTime = Date.now() + let interval = session.interval || POLL_DEFAULT_INTERVAL + + while (Date.now() - startTime < POLL_MAX_DURATION) { + await new Promise(resolve => setTimeout(resolve, interval)) + if (session.status !== 'pending') return + + try { + const res = await fetch(`${NOUS_PORTAL_URL}/api/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + client_id: NOUS_CLIENT_ID, + device_code: session.deviceCode, + }).toString(), + signal: AbortSignal.timeout(15000), + }) + + if (res.ok) { + const tokenData = await res.json() as { + access_token: string + refresh_token?: string + expires_in?: number + inference_base_url?: string + } + + // Mint agent key + const inferenceBaseUrl = tokenData.inference_base_url || 'https://inference-api.nousresearch.com/v1' + let agentKey = '' + let agentKeyExpiresAt = '' + try { + const mintRes = await fetch(`${NOUS_PORTAL_URL}/api/oauth/agent-key`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${tokenData.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ min_ttl_seconds: 1800 }), + signal: AbortSignal.timeout(15000), + }) + if (mintRes.ok) { + const mintData = await mintRes.json() as { + api_key: string + expires_at: string + inference_base_url?: string + } + agentKey = mintData.api_key + agentKeyExpiresAt = mintData.expires_at + if (mintData.inference_base_url) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + void mintData.inference_base_url + } + } + } catch (err: any) { + logger.warn(err, 'Nous agent key minting failed, proceeding without') + } + + // Save to auth.json + const auth = loadAuthJson(authPath) + if (!auth.providers) auth.providers = {} + const now = new Date() + auth.providers['nous'] = { + portal_base_url: NOUS_PORTAL_URL, + inference_base_url: inferenceBaseUrl, + client_id: NOUS_CLIENT_ID, + scope: NOUS_SCOPE, + token_type: 'Bearer', + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token || null, + obtained_at: now.toISOString(), + expires_at: tokenData.expires_in ? new Date(now.getTime() + tokenData.expires_in * 1000).toISOString() : null, + agent_key: agentKey || null, + agent_key_expires_at: agentKeyExpiresAt || null, + agent_key_obtained_at: agentKey ? now.toISOString() : null, + } + + // Credential pool entry + if (!auth.credential_pool) auth.credential_pool = {} + auth.credential_pool['nous'] = [{ + id: `nous-${Date.now()}`, + label: 'Nous Portal', + auth_type: 'oauth', + source: 'device_code', + priority: 0, + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token || null, + portal_base_url: NOUS_PORTAL_URL, + inference_base_url: inferenceBaseUrl, + agent_key: agentKey || null, + agent_key_expires_at: agentKeyExpiresAt || null, + base_url: inferenceBaseUrl, + }] + + saveAuthJson(authPath, auth) + session.status = 'approved' + logger.info('Nous login successful') + return + } + + // Parse error + const errData = await res.json().catch(() => ({})) + const errorCode = errData.error + + if (errorCode === 'authorization_pending') { + continue + } + if (errorCode === 'slow_down') { + interval = Math.min(interval + 1000, 30000) + continue + } + if (errorCode === 'access_denied' || errorCode === 'expired_token') { + session.status = errorCode === 'access_denied' ? 'denied' : 'expired' + return + } + + logger.error('Nous poll error: %s %s', res.status, errorCode) + session.status = 'error' + session.error = `OAuth error: ${errorCode}` + return + } catch (err: any) { + if (err.name === 'TimeoutError' || err.name === 'AbortError') continue + logger.error(err, 'Nous poll error') + session.status = 'error' + session.error = err.message + return + } + } + + session.status = 'expired' +} + +// --- Controller functions --- + +export async function start(ctx: any) { + try { + cleanupExpiredSessions() + + const res = await fetch(`${NOUS_PORTAL_URL}/api/oauth/device/code`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, + body: new URLSearchParams({ + client_id: NOUS_CLIENT_ID, + scope: NOUS_SCOPE, + }).toString(), + signal: AbortSignal.timeout(15000), + }) + + if (!res.ok) { + let errorBody: any = null + try { errorBody = await res.json() } catch { } + logger.error('Nous device code request failed: %d %s', res.status, errorBody) + ctx.status = 502 + ctx.body = { error: `Nous Portal error: ${res.status}` } + return + } + + const data = await res.json() as { + device_code: string + user_code: string + verification_uri: string + verification_uri_complete: string + expires_in: number + interval: number + } + + const sessionId = randomUUID() + const session: NousSession = { + id: sessionId, + deviceCode: data.device_code, + userCode: data.user_code, + verificationUrl: data.verification_uri, + verificationUrlComplete: data.verification_uri_complete, + expiresIn: data.expires_in, + interval: data.interval, + status: 'pending', + createdAt: Date.now(), + } + sessions.set(sessionId, session) + + const authPath = getActiveAuthPath() + nousLoginWorker(session, authPath).catch(err => { + logger.error(err, 'Nous login worker error') + session.status = 'error' + session.error = err.message + }) + + ctx.body = { + session_id: sessionId, + user_code: data.user_code, + verification_url: data.verification_uri_complete, + expires_in: data.expires_in, + } + } catch (err: any) { + if (err.name === 'TimeoutError' || err.name === 'AbortError') { + ctx.status = 504 + ctx.body = { error: 'Nous Portal timeout' } + return + } + ctx.status = 500 + ctx.body = { error: err.message } + } +} + +export async function poll(ctx: any) { + const session = sessions.get(ctx.params.sessionId) + if (!session) { + ctx.status = 404 + ctx.body = { error: 'Session not found' } + return + } + ctx.body = { status: session.status, error: session.error || null } +} + +export async function status(ctx: any) { + try { + const authPath = getActiveAuthPath() + const auth = loadAuthJson(authPath) + const nousProvider = auth.providers?.['nous'] + if (!nousProvider?.access_token) { + ctx.body = { authenticated: false } + return + } + ctx.body = { authenticated: true } + } catch { + ctx.body = { authenticated: false } + } +} diff --git a/packages/server/src/routes/hermes/nous-auth.ts b/packages/server/src/routes/hermes/nous-auth.ts new file mode 100644 index 0000000..68eaae4 --- /dev/null +++ b/packages/server/src/routes/hermes/nous-auth.ts @@ -0,0 +1,8 @@ +import Router from '@koa/router' +import * as ctrl from '../../controllers/hermes/nous-auth' + +export const nousAuthRoutes = new Router() + +nousAuthRoutes.post('/api/hermes/auth/nous/start', ctrl.start) +nousAuthRoutes.get('/api/hermes/auth/nous/poll/:sessionId', ctrl.poll) +nousAuthRoutes.get('/api/hermes/auth/nous/status', ctrl.status) diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 088e435..418b98d 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -17,6 +17,7 @@ import { providerRoutes } from './hermes/providers' import { configRoutes } from './hermes/config' import { logRoutes } from './hermes/logs' import { codexAuthRoutes } from './hermes/codex-auth' +import { nousAuthRoutes } from './hermes/nous-auth' import { gatewayRoutes } from './hermes/gateways' import { weixinRoutes } from './hermes/weixin' import { proxyRoutes, proxyMiddleware } from './hermes/proxy' @@ -48,6 +49,7 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next) app.use(configRoutes.routes()) app.use(logRoutes.routes()) app.use(codexAuthRoutes.routes()) + app.use(nousAuthRoutes.routes()) app.use(gatewayRoutes.routes()) app.use(weixinRoutes.routes()) app.use(proxyRoutes.routes()) diff --git a/packages/server/src/services/config-helpers.ts b/packages/server/src/services/config-helpers.ts index f4d5247..7bbd30d 100644 --- a/packages/server/src/services/config-helpers.ts +++ b/packages/server/src/services/config-helpers.ts @@ -27,6 +27,8 @@ export const PROVIDER_ENV_MAP: Record