diff --git a/package.json b/package.json index 4cb5391..be2df30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-web-ui", - "version": "0.3.2", + "version": "0.3.4", "description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)", "repository": { "type": "git", diff --git a/packages/client/src/api/hermes/codex-auth.ts b/packages/client/src/api/hermes/codex-auth.ts new file mode 100644 index 0000000..2102e8f --- /dev/null +++ b/packages/client/src/api/hermes/codex-auth.ts @@ -0,0 +1,30 @@ +import { request } from '../client' + +export interface CodexStartResult { + session_id: string + user_code: string + verification_url: string + expires_in: number +} + +export interface CodexPollResult { + status: 'pending' | 'approved' | 'expired' | 'error' + error: string | null +} + +export interface CodexStatusResult { + authenticated: boolean + last_refresh?: string +} + +export async function startCodexLogin(): Promise { + return request('/api/hermes/auth/codex/start', { method: 'POST' }) +} + +export async function pollCodexLogin(sessionId: string): Promise { + return request(`/api/hermes/auth/codex/poll/${sessionId}`) +} + +export async function getCodexAuthStatus(): Promise { + return request('/api/hermes/auth/codex/status') +} diff --git a/packages/client/src/components/hermes/models/CodexLoginModal.vue b/packages/client/src/components/hermes/models/CodexLoginModal.vue new file mode 100644 index 0000000..fede904 --- /dev/null +++ b/packages/client/src/components/hermes/models/CodexLoginModal.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/packages/client/src/components/hermes/models/ProviderFormModal.vue b/packages/client/src/components/hermes/models/ProviderFormModal.vue index d9f9408..e29ab7b 100644 --- a/packages/client/src/components/hermes/models/ProviderFormModal.vue +++ b/packages/client/src/components/hermes/models/ProviderFormModal.vue @@ -1,9 +1,10 @@ diff --git a/packages/server/src/routes/hermes/codex-auth.ts b/packages/server/src/routes/hermes/codex-auth.ts new file mode 100644 index 0000000..7bf00c4 --- /dev/null +++ b/packages/server/src/routes/hermes/codex-auth.ts @@ -0,0 +1,347 @@ +import Router from '@koa/router' +import { randomUUID } from 'crypto' +import { join } from 'path' +import { homedir } from 'os' +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' +import { getActiveAuthPath } from '../../services/hermes/hermes-profile' + +// --- OAuth Constants --- +const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' +const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/api/accounts/deviceauth/usercode' +const CODEX_DEVICE_TOKEN_URL = 'https://auth.openai.com/api/accounts/deviceauth/token' +const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token' +const CODEX_DEFAULT_BASE_URL = 'https://chatgpt.com/backend-api/codex' +const CODEX_REDIRECT_URI = 'https://auth.openai.com/deviceauth/callback' +const CODEX_VERIFICATION_URL = 'https://auth.openai.com/codex/device' +const CODEX_HOME = join(homedir(), '.codex') +const POLL_MAX_DURATION = 15 * 60 * 1000 // 15 minutes +const POLL_DEFAULT_INTERVAL = 5000 // 5 seconds + +// --- Session Store --- +interface CodexSession { + id: string + userCode: string + deviceAuthId: string + status: 'pending' | 'approved' | 'expired' | 'error' + error?: string + accessToken?: string + refreshToken?: string + createdAt: number +} + +const sessions = new Map() + +function cleanupExpiredSessions() { + const now = Date.now() + sessions.forEach((session, id) => { + if (now - session.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 { + const raw = readFileSync(authPath, 'utf-8') + return JSON.parse(raw) 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 }) +} + +function saveCodexCliTokens(accessToken: string, refreshToken: string): void { + const codexHome = process.env.CODEX_HOME || CODEX_HOME + const codexAuthPath = join(codexHome, 'auth.json') + const dir = codexAuthPath.substring(0, codexAuthPath.lastIndexOf('/')) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + const data = { + tokens: { access_token: accessToken, refresh_token: refreshToken }, + last_refresh: new Date().toISOString(), + } + writeFileSync(codexAuthPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }) +} + +function decodeJwtExp(token: string): number | null { + try { + const parts = token.split('.') + if (parts.length !== 3) return null + const payload = Buffer.from(parts[1], 'base64url').toString('utf-8') + const claims = JSON.parse(payload) + return typeof claims.exp === 'number' ? claims.exp : null + } catch { + return null + } +} + +// --- Background login worker --- +async function codexLoginWorker(session: CodexSession, authPath: string): Promise { + const startTime = Date.now() + const interval = POLL_DEFAULT_INTERVAL + + while (Date.now() - startTime < POLL_MAX_DURATION) { + await new Promise(resolve => setTimeout(resolve, interval)) + + if (session.status !== 'pending') return + + try { + // Step 3: Poll for authorization + const pollRes = await fetch(CODEX_DEVICE_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + device_auth_id: session.deviceAuthId, + user_code: session.userCode, + }), + signal: AbortSignal.timeout(10000), + }) + + if (pollRes.status === 200) { + const pollData = await pollRes.json() as { authorization_code: string; code_verifier: string } + + // Step 4: Exchange authorization code for tokens + const tokenRes = await fetch(CODEX_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: pollData.authorization_code, + redirect_uri: CODEX_REDIRECT_URI, + client_id: CODEX_CLIENT_ID, + code_verifier: pollData.code_verifier, + }).toString(), + signal: AbortSignal.timeout(15000), + }) + + if (!tokenRes.ok) { + const errText = await tokenRes.text() + console.error('[Codex Auth] Token exchange failed:', tokenRes.status, errText) + session.status = 'error' + session.error = `Token exchange failed: ${tokenRes.status}` + return + } + + const tokenData = await tokenRes.json() as { access_token: string; refresh_token?: string } + const refreshToken = tokenData.refresh_token || '' + + session.accessToken = tokenData.access_token + session.refreshToken = refreshToken + session.status = 'approved' + + // Save to auth.json + const auth = loadAuthJson(authPath) + if (!auth.providers) auth.providers = {} + auth.providers['openai-codex'] = { + tokens: { + access_token: tokenData.access_token, + refresh_token: refreshToken, + }, + last_refresh: new Date().toISOString(), + auth_mode: 'chatgpt', + } + + // Add to credential pool + if (!auth.credential_pool) auth.credential_pool = {} + auth.credential_pool['openai-codex'] = [{ + id: `openai-codex-${Date.now()}`, + label: 'OpenAI Codex', + base_url: CODEX_DEFAULT_BASE_URL, + access_token: tokenData.access_token, + last_status: null, + }] + + saveAuthJson(authPath, auth) + + // Save to ~/.codex/auth.json for CLI sync + saveCodexCliTokens(tokenData.access_token, refreshToken) + + console.log('[Codex Auth] Login successful') + return + } + + if (pollRes.status === 403 || pollRes.status === 404) { + // Not yet authorized, keep polling + continue + } + + // Other error status + console.error('[Codex Auth] Poll failed:', pollRes.status) + session.status = 'error' + session.error = `Poll failed: ${pollRes.status}` + return + } catch (err: any) { + if (err.name === 'TimeoutError' || err.name === 'AbortError') { + continue + } + console.error('[Codex Auth] Poll error:', err.message) + session.status = 'error' + session.error = err.message + return + } + } + + // Timeout + session.status = 'expired' +} + +// --- Routes --- +export const codexAuthRoutes = new Router() + +codexAuthRoutes.post('/api/hermes/auth/codex/start', async (ctx) => { + try { + cleanupExpiredSessions() + + // Step 1: Request device code + const res = await fetch(CODEX_DEVICE_AUTH_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'node-fetch', + }, + body: JSON.stringify({ client_id: CODEX_CLIENT_ID }), + signal: AbortSignal.timeout(10000), + }) + + if (!res.ok) { + let errorBody: any = null + try { errorBody = await res.json() } catch { /* ignore */ } + console.error(`[codex-auth] Device code request failed: ${res.status}`, errorBody) + + let errorMessage = `Device code request failed: ${res.status}` + if (errorBody?.error?.code === 'unsupported_country_region_territory') { + errorMessage = 'OpenAI does not support your region. You may need to use a proxy or VPN to access Codex.' + } + + ctx.status = 502 + ctx.body = { error: errorMessage, code: errorBody?.error?.code } + return + } + + const data = await res.json() as { user_code: string; device_auth_id: string; interval?: string } + + const sessionId = randomUUID() + const session: CodexSession = { + id: sessionId, + userCode: data.user_code, + deviceAuthId: data.device_auth_id, + status: 'pending', + createdAt: Date.now(), + } + sessions.set(sessionId, session) + + // Start background worker + const authPath = getActiveAuthPath() + codexLoginWorker(session, authPath).catch(err => { + console.error('[Codex Auth] Worker error:', err) + session.status = 'error' + session.error = err.message + }) + + ctx.body = { + session_id: sessionId, + user_code: data.user_code, + verification_url: CODEX_VERIFICATION_URL, + expires_in: 900, // 15 minutes + } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +}) + +codexAuthRoutes.get('/api/hermes/auth/codex/poll/:sessionId', async (ctx) => { + 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, + } +}) + +codexAuthRoutes.get('/api/hermes/auth/codex/status', async (ctx) => { + try { + const authPath = getActiveAuthPath() + const auth = loadAuthJson(authPath) + const tokens = auth.providers?.['openai-codex']?.tokens + + if (!tokens?.access_token || !auth.providers) { + ctx.body = { authenticated: false } + return + } + + const codexProvider = auth.providers['openai-codex']! + + // Check if token is expired + const exp = decodeJwtExp(tokens.access_token) + if (exp && exp <= Date.now() / 1000 + 120) { + // Try refresh + if (tokens.refresh_token) { + try { + const refreshRes = await fetch(CODEX_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: tokens.refresh_token, + client_id: CODEX_CLIENT_ID, + }).toString(), + signal: AbortSignal.timeout(15000), + }) + + if (refreshRes.ok) { + const newTokens = await refreshRes.json() as { access_token: string; refresh_token?: string } + codexProvider.tokens.access_token = newTokens.access_token + if (newTokens.refresh_token) { + codexProvider.tokens.refresh_token = newTokens.refresh_token + } + codexProvider.last_refresh = new Date().toISOString() + saveAuthJson(authPath, auth) + saveCodexCliTokens(newTokens.access_token, newTokens.refresh_token || tokens.refresh_token) + + // Update credential pool too + if (auth.credential_pool?.['openai-codex']?.[0]) { + auth.credential_pool['openai-codex'][0].access_token = newTokens.access_token + saveAuthJson(authPath, auth) + } + + ctx.body = { authenticated: true, last_refresh: codexProvider.last_refresh } + return + } + } catch { + // Refresh failed + } + } + + ctx.body = { authenticated: false } + return + } + + ctx.body = { + authenticated: true, + last_refresh: codexProvider.last_refresh, + } + } catch { + ctx.body = { authenticated: false } + } +}) diff --git a/packages/server/src/routes/hermes/filesystem.ts b/packages/server/src/routes/hermes/filesystem.ts index 7d427ab..f0e007b 100644 --- a/packages/server/src/routes/hermes/filesystem.ts +++ b/packages/server/src/routes/hermes/filesystem.ts @@ -10,7 +10,9 @@ import * as hermesCli from '../../services/hermes/hermes-cli' const PROVIDER_ENV_MAP: Record = { openrouter: { api_key_env: 'OPENROUTER_API_KEY', base_url_env: 'OPENROUTER_BASE_URL' }, zai: { api_key_env: 'ZAI_API_KEY', base_url_env: '' }, - 'kimi-for-coding': { api_key_env: 'KIMI_API_KEY', base_url_env: '' }, + 'kimi-coding': { api_key_env: 'KIMI_API_KEY', base_url_env: '' }, + 'kimi-coding-cn': { api_key_env: 'KIMI_API_KEY', base_url_env: '' }, + moonshot: { api_key_env: 'MOONSHOT_API_KEY', base_url_env: 'MOONSHOT_BASE_URL' }, minimax: { api_key_env: 'MINIMAX_API_KEY', base_url_env: 'MINIMAX_BASE_URL' }, 'minimax-cn': { api_key_env: 'MINIMAX_API_KEY', base_url_env: 'MINIMAX_CN_BASE_URL' }, deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: 'DEEPSEEK_BASE_URL' }, @@ -19,11 +21,13 @@ const PROVIDER_ENV_MAP: Record { @@ -415,7 +419,6 @@ interface ModelGroup { // Build model list from user's actual config.yaml using js-yaml function buildModelGroups(config: Record): { default: string; groups: ModelGroup[] } { let defaultModel = '' - let defaultProvider = '' const groups: ModelGroup[] = [] const allModelIds = new Set() @@ -423,7 +426,6 @@ function buildModelGroups(config: Record): { default: string; group const modelSection = config.model if (typeof modelSection === 'object' && modelSection !== null) { defaultModel = String(modelSection.default || '').trim() - defaultProvider = String(modelSection.provider || '').trim() } else if (typeof modelSection === 'string') { defaultModel = modelSection.trim() } @@ -539,7 +541,51 @@ fsRoutes.get('/api/hermes/available-models', async (ctx) => { } } - // Fallback: if no providers returned models, fall back to config.yaml parsing + // Merge custom_providers from config.yaml (ensures manually-input model names appear) + const customProviders = Array.isArray(config.custom_providers) + ? config.custom_providers as Array<{ name: string; base_url: string; model: string }> + : [] + for (const cp of customProviders) { + if (!cp.base_url || !cp.model) continue + const baseUrl = cp.base_url.replace(/\/+$/, '') + // Check if we already have a group for this base_url + const existing = dedupedGroups.find(g => g.base_url.replace(/\/+$/, '') === baseUrl) + if (existing) { + if (!existing.models.includes(cp.model)) { + existing.models.push(cp.model) + } + } else { + dedupedGroups.push({ + provider: `custom:${cp.name.trim().toLowerCase().replace(/ /g, '-')}`, + label: cp.name, + base_url: baseUrl, + models: [cp.model], + }) + } + } + + // Ensure config's current default model appears in the model list + if (currentDefault) { + const currentProvider = typeof config.model === 'object' ? String(config.model.provider || '').trim() : '' + if (currentProvider) { + const targetGroup = dedupedGroups.find(g => g.provider === currentProvider) + if (targetGroup && !targetGroup.models.includes(currentDefault)) { + targetGroup.models.unshift(currentDefault) + } + } else { + // No provider specified — add to the first group that matches via base_url + // or just prepend to all groups + let found = false + for (const g of dedupedGroups) { + if (!found && !g.models.includes(currentDefault)) { + g.models.unshift(currentDefault) + found = true + } + } + } + } + + // Fallback: if still no providers, fall back to config.yaml parsing if (dedupedGroups.length === 0) { const fallback = buildModelGroups(config) ctx.body = fallback @@ -702,22 +748,29 @@ fsRoutes.delete('/api/hermes/config/providers/:poolKey', async (ctx) => { return } + // Case-insensitive key lookup: normalize poolKey to match credential_pool + let resolvedKey = poolKey if (!(poolKey in auth.credential_pool)) { - ctx.status = 404 - ctx.body = { error: `Provider "${poolKey}" not found` } - return + const normalized = poolKey.toLowerCase() + const match = Object.keys(auth.credential_pool).find(k => k.toLowerCase() === normalized) + if (!match) { + ctx.status = 404 + ctx.body = { error: `Provider "${poolKey}" not found` } + return + } + resolvedKey = match } // Check if this is the current active provider const config = await readConfigYaml() const currentProvider = config.model?.provider - const isCurrent = currentProvider === poolKey + const isCurrent = currentProvider === poolKey || currentProvider === resolvedKey // Save base_url before deleting - const deletedBaseUrl = auth.credential_pool[poolKey]?.[0]?.base_url + const deletedBaseUrl = auth.credential_pool[resolvedKey]?.[0]?.base_url // 1. Delete from auth.json - delete auth.credential_pool[poolKey] + delete auth.credential_pool[resolvedKey] await saveAuthJson(auth) // 2. Remove matching entry from config.yaml custom_providers diff --git a/packages/server/src/routes/hermes/index.ts b/packages/server/src/routes/hermes/index.ts index d75564b..1cba783 100644 --- a/packages/server/src/routes/hermes/index.ts +++ b/packages/server/src/routes/hermes/index.ts @@ -5,6 +5,7 @@ import { configRoutes } from './config' import { fsRoutes } from './filesystem' import { logRoutes } from './logs' import { weixinRoutes } from './weixin' +import { codexAuthRoutes } from './codex-auth' import { proxyRoutes, proxyMiddleware } from './proxy' import { setupTerminalWebSocket } from './terminal' @@ -16,6 +17,7 @@ hermesRoutes.use(configRoutes.routes()) hermesRoutes.use(fsRoutes.routes()) hermesRoutes.use(logRoutes.routes()) hermesRoutes.use(weixinRoutes.routes()) +hermesRoutes.use(codexAuthRoutes.routes()) hermesRoutes.use(proxyRoutes.routes()) export { setupTerminalWebSocket, proxyMiddleware } diff --git a/packages/server/src/shared/providers.ts b/packages/server/src/shared/providers.ts index f8a757c..3b9ae29 100644 --- a/packages/server/src/shared/providers.ts +++ b/packages/server/src/shared/providers.ts @@ -16,6 +16,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ value: 'anthropic', base_url: 'https://api.anthropic.com', models: [ + 'claude-opus-4-7', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-opus-4-5-20251101', @@ -50,11 +51,11 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ label: 'Z.AI / GLM', value: 'zai', base_url: 'https://api.z.ai/api/paas/v4', - models: ['glm-5', 'glm-5-turbo', 'glm-4.7', 'glm-4.5', 'glm-4.5-flash'], + models: ['glm-5.1', 'glm-5', 'glm-5v-turbo', 'glm-5-turbo', 'glm-4.7', 'glm-4.5', 'glm-4.5-flash'], }, { label: 'Kimi for Coding', - value: 'kimi-for-coding', + value: 'kimi-coding', base_url: 'https://api.kimi.com/coding/v1', models: [ 'kimi-for-coding', @@ -65,22 +66,33 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ 'kimi-k2-0905-preview', ], }, + { + label: 'Kimi for Coding (CN)', + value: 'kimi-coding-cn', + base_url: 'https://api.kimi.com/coding/v1', + models: [ + 'kimi-k2.5', + 'kimi-k2-thinking', + 'kimi-k2-turbo-preview', + 'kimi-k2-0905-preview', + ], + }, + { + label: 'Moonshot', + value: 'moonshot', + base_url: 'https://api.moonshot.cn/v1', + models: [ + 'kimi-k2.5', + 'kimi-k2-thinking', + 'kimi-k2-turbo-preview', + 'kimi-k2-0905-preview', + ], + }, { label: 'xAI', value: 'xai', base_url: 'https://api.x.ai/v1', - models: [ - 'grok-4.20-0309-reasoning', - 'grok-4.20-0309-non-reasoning', - 'grok-4-1-fast-reasoning', - 'grok-4-1-fast-non-reasoning', - 'grok-4-fast-reasoning', - 'grok-4-fast-non-reasoning', - 'grok-4-0709', - 'grok-code-fast-1', - 'grok-3', - 'grok-3-mini', - ], + models: ['grok-4.20-reasoning', 'grok-4-1-fast-reasoning'], }, { label: 'MiniMax', @@ -131,7 +143,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ }, { label: 'Kilo Code', - value: 'kilo', + value: 'kilocode', base_url: 'https://api.kilo.ai/api/gateway', models: [ 'anthropic/claude-opus-4.6', @@ -143,7 +155,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ }, { label: 'Vercel AI Gateway', - value: 'vercel', + value: 'ai-gateway', base_url: 'https://ai-gateway.vercel.sh/v1', models: [ 'anthropic/claude-opus-4.6', @@ -162,32 +174,64 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ }, { label: 'OpenCode Zen', - value: 'opencode', + value: 'opencode-zen', base_url: 'https://opencode.ai/zen/v1', models: [ 'gpt-5.4-pro', 'gpt-5.4', 'gpt-5.3-codex', + 'gpt-5.3-codex-spark', 'gpt-5.2', + 'gpt-5.2-codex', 'gpt-5.1', + 'gpt-5.1-codex', + 'gpt-5.1-codex-max', + 'gpt-5.1-codex-mini', + 'gpt-5', + 'gpt-5-codex', + 'gpt-5-nano', 'claude-opus-4-6', + 'claude-opus-4-5', + 'claude-opus-4-1', 'claude-sonnet-4-6', + 'claude-sonnet-4-5', + 'claude-sonnet-4', 'claude-haiku-4-5', + 'claude-3-5-haiku', 'gemini-3.1-pro', 'gemini-3-pro', 'gemini-3-flash', 'minimax-m2.7', 'minimax-m2.5', + 'minimax-m2.5-free', + 'minimax-m2.1', 'glm-5', 'glm-4.7', + 'glm-4.6', 'kimi-k2.5', + 'kimi-k2-thinking', + 'kimi-k2', + 'qwen3-coder', + 'big-pickle', ], }, { label: 'OpenCode Go', value: 'opencode-go', base_url: 'https://opencode.ai/zen/go/v1', - models: ['glm-5', 'kimi-k2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'minimax-m2.7', 'minimax-m2.5'], + models: ['glm-5.1', 'glm-5', 'kimi-k2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'minimax-m2.7', 'minimax-m2.5'], + }, + { + label: 'OpenAI Codex', + value: 'openai-codex', + base_url: 'https://chatgpt.com/backend-api/codex', + models: ['gpt-5.4-mini', 'gpt-5.4', 'gpt-5.3-codex', 'gpt-5.2-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini'], + }, + { + label: 'Arcee AI', + value: 'arcee', + base_url: 'https://api.arcee.ai/v1', + models: ['trinity-large-thinking', 'trinity-large-preview', 'trinity-mini'], }, { label: 'OpenRouter',