Add Hermes Agent package fallback and xAI OAuth (#808)
This commit is contained in:
@@ -205,6 +205,14 @@ Open **http://localhost:6060**
|
|||||||
|
|
||||||
For detailed notes and troubleshooting, see [`docs/docker.md`](./docs/docker.md).
|
For detailed notes and troubleshooting, see [`docs/docker.md`](./docs/docker.md).
|
||||||
|
|
||||||
|
### Hermes Agent Runtime Discovery
|
||||||
|
|
||||||
|
When Web UI starts backend chat features, it prefers a source checkout that
|
||||||
|
contains `run_agent.py` such as `~/.hermes/hermes-agent`. If no source checkout
|
||||||
|
is found, it falls back to the Python environment used by the installed
|
||||||
|
`hermes` command, then the system Python. This supports both source installs
|
||||||
|
and package installs such as `pip install hermes-agent`.
|
||||||
|
|
||||||
## Web UI Environment Variables
|
## Web UI Environment Variables
|
||||||
|
|
||||||
These variables configure Hermes Web UI itself. Provider API keys and Hermes Agent settings are managed separately through Hermes profiles.
|
These variables configure Hermes Web UI itself. Provider API keys and Hermes Agent settings are managed separately through Hermes profiles.
|
||||||
|
|||||||
@@ -213,6 +213,13 @@ docker compose logs -f hermes-webui
|
|||||||
|
|
||||||
更详细的说明与排错见:[`docs/docker.md`](./docs/docker.md)
|
更详细的说明与排错见:[`docs/docker.md`](./docs/docker.md)
|
||||||
|
|
||||||
|
### Hermes Agent 运行时发现
|
||||||
|
|
||||||
|
Web UI 启动后端聊天能力时,会优先使用包含 `run_agent.py` 的源码目录,例如
|
||||||
|
`~/.hermes/hermes-agent`。如果找不到源码目录,会退回到已安装 `hermes` 命令所使用
|
||||||
|
的 Python 环境,再退到系统 Python。因此源码安装和 `pip install hermes-agent` 这类
|
||||||
|
包安装方式都可以兼容。
|
||||||
|
|
||||||
## Web UI 环境变量
|
## Web UI 环境变量
|
||||||
|
|
||||||
这些变量只用于配置 Hermes Web UI 自身。Provider API Key 和 Hermes Agent 相关设置仍通过 Hermes profile 管理。
|
这些变量只用于配置 Hermes Web UI 自身。Provider API Key 和 Hermes Agent 相关设置仍通过 Hermes profile 管理。
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { request } from '../client'
|
||||||
|
|
||||||
|
export interface XaiStartResult {
|
||||||
|
session_id: string
|
||||||
|
authorization_url: string
|
||||||
|
expires_in: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XaiPollResult {
|
||||||
|
status: 'pending' | 'approved' | 'expired' | 'error'
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XaiStatusResult {
|
||||||
|
authenticated: boolean
|
||||||
|
last_refresh?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startXaiLogin(): Promise<XaiStartResult> {
|
||||||
|
return request<XaiStartResult>('/api/hermes/auth/xai/start', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollXaiLogin(sessionId: string): Promise<XaiPollResult> {
|
||||||
|
return request<XaiPollResult>(`/api/hermes/auth/xai/poll/${sessionId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getXaiAuthStatus(): Promise<XaiStatusResult> {
|
||||||
|
return request<XaiStatusResult>('/api/hermes/auth/xai/status')
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import CodexLoginModal from './CodexLoginModal.vue'
|
import CodexLoginModal from './CodexLoginModal.vue'
|
||||||
import NousLoginModal from './NousLoginModal.vue'
|
import NousLoginModal from './NousLoginModal.vue'
|
||||||
import CopilotLoginModal from './CopilotLoginModal.vue'
|
import CopilotLoginModal from './CopilotLoginModal.vue'
|
||||||
|
import XaiOAuthLoginModal from './XaiOAuthLoginModal.vue'
|
||||||
import { checkCopilotToken, enableCopilot, type CopilotTokenSource } from '@/api/hermes/copilot-auth'
|
import { checkCopilotToken, enableCopilot, type CopilotTokenSource } from '@/api/hermes/copilot-auth'
|
||||||
import { fetchProviderModels } from '@/api/hermes/system'
|
import { fetchProviderModels } from '@/api/hermes/system'
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ const fetchingModels = ref(false)
|
|||||||
const showCodexLogin = ref(false)
|
const showCodexLogin = ref(false)
|
||||||
const showNousLogin = ref(false)
|
const showNousLogin = ref(false)
|
||||||
const showCopilotLogin = ref(false)
|
const showCopilotLogin = ref(false)
|
||||||
|
const showXaiLogin = ref(false)
|
||||||
const copilotChecking = ref(false)
|
const copilotChecking = ref(false)
|
||||||
|
|
||||||
const providerType = ref<'preset' | 'custom'>('preset')
|
const providerType = ref<'preset' | 'custom'>('preset')
|
||||||
@@ -44,6 +46,7 @@ const CODEX_KEY = 'openai-codex'
|
|||||||
const NOUS_KEY = 'nous'
|
const NOUS_KEY = 'nous'
|
||||||
const COPILOT_KEY = 'copilot'
|
const COPILOT_KEY = 'copilot'
|
||||||
const CLIPROXYAPI_KEY = 'cliproxyapi'
|
const CLIPROXYAPI_KEY = 'cliproxyapi'
|
||||||
|
const XAI_OAUTH_KEY = 'xai-oauth'
|
||||||
const ALIBABA_CODING_KEY = 'alibaba-coding-plan'
|
const ALIBABA_CODING_KEY = 'alibaba-coding-plan'
|
||||||
const ALIBABA_CODING_REGIONS = {
|
const ALIBABA_CODING_REGIONS = {
|
||||||
intl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
intl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||||
@@ -54,6 +57,7 @@ const isCodex = computed(() => selectedPreset.value === CODEX_KEY)
|
|||||||
const isNous = computed(() => selectedPreset.value === NOUS_KEY)
|
const isNous = computed(() => selectedPreset.value === NOUS_KEY)
|
||||||
const isCopilot = computed(() => selectedPreset.value === COPILOT_KEY)
|
const isCopilot = computed(() => selectedPreset.value === COPILOT_KEY)
|
||||||
const isCliproxyApi = computed(() => selectedPreset.value === CLIPROXYAPI_KEY)
|
const isCliproxyApi = computed(() => selectedPreset.value === CLIPROXYAPI_KEY)
|
||||||
|
const isXaiOAuth = computed(() => selectedPreset.value === XAI_OAUTH_KEY)
|
||||||
const isAlibabaCoding = computed(() => selectedPreset.value === ALIBABA_CODING_KEY)
|
const isAlibabaCoding = computed(() => selectedPreset.value === ALIBABA_CODING_KEY)
|
||||||
const alibabaCodingRegion = ref<'intl' | 'cn'>('intl')
|
const alibabaCodingRegion = ref<'intl' | 'cn'>('intl')
|
||||||
|
|
||||||
@@ -93,6 +97,8 @@ watch(selectedPreset, (val) => {
|
|||||||
if (val === COPILOT_KEY) {
|
if (val === COPILOT_KEY) {
|
||||||
// 判断是否已能解析到 token:有 → 弹简单确认;无 → 走 in-app device flow
|
// 判断是否已能解析到 token:有 → 弹简单确认;无 → 走 in-app device flow
|
||||||
void triggerCopilotAdd()
|
void triggerCopilotAdd()
|
||||||
|
} else if (val === XAI_OAUTH_KEY) {
|
||||||
|
showXaiLogin.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -170,11 +176,16 @@ async function handleSave() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isXaiOAuth.value) {
|
||||||
|
showXaiLogin.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!formData.value.base_url.trim()) {
|
if (!formData.value.base_url.trim()) {
|
||||||
message.warning(t('models.baseUrlRequired'))
|
message.warning(t('models.baseUrlRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!formData.value.api_key.trim() && !isCliproxyApi.value) {
|
if (!formData.value.api_key.trim() && !isCliproxyApi.value && !isXaiOAuth.value) {
|
||||||
message.warning(t('models.apiKeyRequired'))
|
message.warning(t('models.apiKeyRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -225,6 +236,12 @@ async function handleCopilotSuccess() {
|
|||||||
emit('saved')
|
emit('saved')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleXaiSuccess() {
|
||||||
|
showXaiLogin.value = false
|
||||||
|
message.success(t('models.providerAdded'))
|
||||||
|
emit('saved')
|
||||||
|
}
|
||||||
|
|
||||||
function copilotSourceLabel(source: CopilotTokenSource): string {
|
function copilotSourceLabel(source: CopilotTokenSource): string {
|
||||||
if (source === 'env') return t('models.copilotAddSourceEnv')
|
if (source === 'env') return t('models.copilotAddSourceEnv')
|
||||||
if (source === 'gh-cli') return t('models.copilotAddSourceGhCli')
|
if (source === 'gh-cli') return t('models.copilotAddSourceGhCli')
|
||||||
@@ -281,6 +298,11 @@ function handleCopilotClose() {
|
|||||||
selectedPreset.value = null
|
selectedPreset.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleXaiClose() {
|
||||||
|
showXaiLogin.value = false
|
||||||
|
selectedPreset.value = null
|
||||||
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
showModal.value = false
|
showModal.value = false
|
||||||
setTimeout(() => emit('close'), 200)
|
setTimeout(() => emit('close'), 200)
|
||||||
@@ -293,7 +315,7 @@ function handleClose() {
|
|||||||
preset="card"
|
preset="card"
|
||||||
:title="t('models.addProvider')"
|
:title="t('models.addProvider')"
|
||||||
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
|
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
|
||||||
:mask-closable="!loading && !showCodexLogin && !showNousLogin && !showCopilotLogin"
|
:mask-closable="!loading && !showCodexLogin && !showNousLogin && !showCopilotLogin && !showXaiLogin"
|
||||||
@after-leave="emit('close')"
|
@after-leave="emit('close')"
|
||||||
>
|
>
|
||||||
<NForm label-placement="top">
|
<NForm label-placement="top">
|
||||||
@@ -353,7 +375,7 @@ function handleClose() {
|
|||||||
/>
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
<NFormItem v-if="!isCodex && !isNous" :label="t('models.apiKey')" :required="!isCliproxyApi">
|
<NFormItem v-if="!isCodex && !isNous" :label="t('models.apiKey')" :required="!isCliproxyApi && !isXaiOAuth">
|
||||||
<NInput
|
<NInput
|
||||||
v-model:value="formData.api_key"
|
v-model:value="formData.api_key"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -420,6 +442,12 @@ function handleClose() {
|
|||||||
@close="handleCopilotClose"
|
@close="handleCopilotClose"
|
||||||
@success="handleCopilotSuccess"
|
@success="handleCopilotSuccess"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<XaiOAuthLoginModal
|
||||||
|
v-if="showXaiLogin"
|
||||||
|
@close="handleXaiClose"
|
||||||
|
@success="handleXaiSuccess"
|
||||||
|
/>
|
||||||
</NModal>
|
</NModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { startXaiLogin, pollXaiLogin } from '@/api/hermes/xai-auth'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const emit = defineEmits<{ close: []; success: [] }>()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const showModal = ref(true)
|
||||||
|
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
|
||||||
|
const authorizationUrl = ref('')
|
||||||
|
const sessionId = ref('')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
async function startLogin() {
|
||||||
|
status.value = 'loading'
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
const data = await startXaiLogin()
|
||||||
|
authorizationUrl.value = data.authorization_url
|
||||||
|
sessionId.value = data.session_id
|
||||||
|
status.value = 'waiting'
|
||||||
|
window.open(authorizationUrl.value, '_blank')
|
||||||
|
startPolling()
|
||||||
|
} catch (err: any) {
|
||||||
|
status.value = 'error'
|
||||||
|
errorMessage.value = err?.message || String(err)
|
||||||
|
message.error(errorMessage.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling()
|
||||||
|
pollTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const result = await pollXaiLogin(sessionId.value)
|
||||||
|
if (result.status === 'pending') {
|
||||||
|
startPolling()
|
||||||
|
} else if (result.status === 'approved') {
|
||||||
|
status.value = 'approved'
|
||||||
|
message.success(t('models.xaiApproved'))
|
||||||
|
setTimeout(() => {
|
||||||
|
showModal.value = false
|
||||||
|
setTimeout(() => emit('success'), 200)
|
||||||
|
}, 1000)
|
||||||
|
} else if (result.status === 'expired') {
|
||||||
|
status.value = 'expired'
|
||||||
|
} else if (result.status === 'error') {
|
||||||
|
status.value = 'error'
|
||||||
|
errorMessage.value = result.error || 'Unknown error'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) clearTimeout(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
stopPolling()
|
||||||
|
showModal.value = false
|
||||||
|
setTimeout(() => emit('close'), 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLink() {
|
||||||
|
window.open(authorizationUrl.value, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
function retry() {
|
||||||
|
status.value = 'idle'
|
||||||
|
authorizationUrl.value = ''
|
||||||
|
sessionId.value = ''
|
||||||
|
errorMessage.value = ''
|
||||||
|
startLogin()
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(stopPolling)
|
||||||
|
startLogin()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NModal
|
||||||
|
v-model:show="showModal"
|
||||||
|
preset="card"
|
||||||
|
:title="t('models.xaiLoginTitle')"
|
||||||
|
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
|
||||||
|
:mask-closable="status !== 'waiting'"
|
||||||
|
@after-leave="emit('close')"
|
||||||
|
>
|
||||||
|
<div class="xai-login">
|
||||||
|
<div v-if="status === 'idle' || status === 'loading'" class="xai-login__state">
|
||||||
|
<NSpin size="small" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="status === 'waiting'" class="xai-login__state">
|
||||||
|
<p class="xai-login__hint">{{ t('models.xaiWaiting') }}</p>
|
||||||
|
<NButton type="primary" block @click="openLink">
|
||||||
|
{{ t('models.xaiOpenLink') }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="status === 'approved'" class="xai-login__state xai-login__state--success">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||||
|
<p>{{ t('models.xaiApproved') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="status === 'expired'" class="xai-login__state">
|
||||||
|
<p class="xai-login__error">{{ t('models.xaiExpired') }}</p>
|
||||||
|
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="status === 'error'" class="xai-login__state">
|
||||||
|
<p class="xai-login__error">{{ errorMessage }}</p>
|
||||||
|
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.xai-login {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xai-login__state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 120px;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xai-login__hint {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--n-text-color, inherit);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xai-login__state--success {
|
||||||
|
color: #18a058;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
stroke: #18a058;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.xai-login__error {
|
||||||
|
color: #d03050;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -573,6 +573,11 @@ export default {
|
|||||||
copilotDeleteHintEnv: 'This will clear COPILOT_GITHUB_TOKEN in ~/.hermes/.env. Other tools are not affected.',
|
copilotDeleteHintEnv: 'This will clear COPILOT_GITHUB_TOKEN in ~/.hermes/.env. Other tools are not affected.',
|
||||||
copilotDeleteHintGhCli: 'Copilot will be hidden from Hermes. Your gh CLI login is not affected — `gh auth status` will still show you signed in.',
|
copilotDeleteHintGhCli: 'Copilot will be hidden from Hermes. Your gh CLI login is not affected — `gh auth status` will still show you signed in.',
|
||||||
copilotDeleteHintAppsJson: 'Copilot will be hidden from Hermes. Your VS Code Copilot extension login is not affected.',
|
copilotDeleteHintAppsJson: 'Copilot will be hidden from Hermes. Your VS Code Copilot extension login is not affected.',
|
||||||
|
xaiLoginTitle: 'xAI Grok OAuth Login',
|
||||||
|
xaiWaiting: 'Complete authorization in the opened xAI page. This window will close automatically once approved.',
|
||||||
|
xaiOpenLink: 'Open xAI authorization page',
|
||||||
|
xaiApproved: 'Sign-in succeeded!',
|
||||||
|
xaiExpired: 'The authorization link has expired. Please retry.',
|
||||||
customBadge: 'CUSTOM',
|
customBadge: 'CUSTOM',
|
||||||
previewBadge: 'PREVIEW',
|
previewBadge: 'PREVIEW',
|
||||||
disabledBadge: 'UNAVAILABLE',
|
disabledBadge: 'UNAVAILABLE',
|
||||||
|
|||||||
@@ -573,6 +573,11 @@ export default {
|
|||||||
copilotDeleteHintEnv: '此操作会清除 ~/.hermes/.env 中的 COPILOT_GITHUB_TOKEN,不影响其他工具。',
|
copilotDeleteHintEnv: '此操作会清除 ~/.hermes/.env 中的 COPILOT_GITHUB_TOKEN,不影响其他工具。',
|
||||||
copilotDeleteHintGhCli: 'Copilot 将从 Hermes 列表移除。不会影响 gh CLI —— `gh auth status` 仍显示已登录。',
|
copilotDeleteHintGhCli: 'Copilot 将从 Hermes 列表移除。不会影响 gh CLI —— `gh auth status` 仍显示已登录。',
|
||||||
copilotDeleteHintAppsJson: 'Copilot 将从 Hermes 列表移除。不会影响 VS Code Copilot 插件的登录。',
|
copilotDeleteHintAppsJson: 'Copilot 将从 Hermes 列表移除。不会影响 VS Code Copilot 插件的登录。',
|
||||||
|
xaiLoginTitle: 'xAI Grok OAuth 登录',
|
||||||
|
xaiWaiting: '请在打开的 xAI 页面完成授权。授权完成后窗口会自动关闭。',
|
||||||
|
xaiOpenLink: '打开 xAI 授权页',
|
||||||
|
xaiApproved: '登录成功!',
|
||||||
|
xaiExpired: '授权链接已过期,请重试。',
|
||||||
customBadge: '自定义',
|
customBadge: '自定义',
|
||||||
previewBadge: '预览',
|
previewBadge: '预览',
|
||||||
disabledBadge: '不可用',
|
disabledBadge: '不可用',
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { updateConfigYaml, saveEnvValue, PROVIDER_ENV_MAP } from '../../services
|
|||||||
import { PROVIDER_PRESETS } from '../../shared/providers'
|
import { PROVIDER_PRESETS } from '../../shared/providers'
|
||||||
import { logger } from '../../services/logger'
|
import { logger } from '../../services/logger'
|
||||||
|
|
||||||
const OPTIONAL_API_KEY_PROVIDERS = new Set(['cliproxyapi'])
|
const OPTIONAL_API_KEY_PROVIDERS = new Set(['cliproxyapi', 'xai-oauth'])
|
||||||
|
const DIRECT_CONFIG_PROVIDERS = new Set(['xai-oauth'])
|
||||||
|
|
||||||
async function clearStoredAuthProvider(poolKey: string) {
|
async function clearStoredAuthProvider(poolKey: string) {
|
||||||
try {
|
try {
|
||||||
@@ -82,6 +83,10 @@ export async function create(ctx: any) {
|
|||||||
if (PROVIDER_ENV_MAP[poolKey].base_url_env) { await saveEnvValue(PROVIDER_ENV_MAP[poolKey].base_url_env, base_url) }
|
if (PROVIDER_ENV_MAP[poolKey].base_url_env) { await saveEnvValue(PROVIDER_ENV_MAP[poolKey].base_url_env, base_url) }
|
||||||
config.model.default = model
|
config.model.default = model
|
||||||
config.model.provider = poolKey
|
config.model.provider = poolKey
|
||||||
|
} else if (DIRECT_CONFIG_PROVIDERS.has(poolKey)) {
|
||||||
|
if (PROVIDER_ENV_MAP[poolKey].base_url_env) { await saveEnvValue(PROVIDER_ENV_MAP[poolKey].base_url_env, base_url) }
|
||||||
|
config.model.default = model
|
||||||
|
config.model.provider = poolKey
|
||||||
} else {
|
} else {
|
||||||
if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] }
|
if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] }
|
||||||
const existing = (config.custom_providers as any[]).find(
|
const existing = (config.custom_providers as any[]).find(
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { createHash, randomBytes, randomUUID } from 'crypto'
|
||||||
|
import { createServer, type Server } from 'http'
|
||||||
|
import { request as httpsRequest, type RequestOptions } from 'https'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||||
|
import { URL } from 'url'
|
||||||
|
import { getActiveAuthPath } from '../../services/hermes/hermes-profile'
|
||||||
|
import { logger } from '../../services/logger'
|
||||||
|
import { updateConfigYaml } from '../../services/config-helpers'
|
||||||
|
|
||||||
|
const XAI_OAUTH_ISSUER = 'https://auth.x.ai'
|
||||||
|
const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`
|
||||||
|
const XAI_OAUTH_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828'
|
||||||
|
const XAI_OAUTH_SCOPE = 'openid profile email offline_access grok-cli:access api:access'
|
||||||
|
const XAI_DEFAULT_BASE_URL = 'https://api.x.ai/v1'
|
||||||
|
const XAI_REDIRECT_HOST = '127.0.0.1'
|
||||||
|
const XAI_REDIRECT_PORT = 56121
|
||||||
|
const XAI_REDIRECT_PATH = '/callback'
|
||||||
|
const POLL_MAX_DURATION = 15 * 60 * 1000
|
||||||
|
|
||||||
|
interface XaiSession {
|
||||||
|
id: string
|
||||||
|
status: 'pending' | 'approved' | 'expired' | 'error'
|
||||||
|
authorizeUrl: string
|
||||||
|
redirectUri: string
|
||||||
|
codeVerifier: string
|
||||||
|
state: string
|
||||||
|
tokenEndpoint: string
|
||||||
|
discovery: Record<string, string>
|
||||||
|
server: Server
|
||||||
|
error?: string
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthJson {
|
||||||
|
version?: number
|
||||||
|
active_provider?: string
|
||||||
|
providers?: Record<string, any>
|
||||||
|
credential_pool?: Record<string, any[]>
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map<string, XaiSession>()
|
||||||
|
|
||||||
|
function cleanupExpiredSessions() {
|
||||||
|
const now = Date.now()
|
||||||
|
sessions.forEach((session, id) => {
|
||||||
|
if (now - session.createdAt > POLL_MAX_DURATION + 60000) {
|
||||||
|
closeServer(session)
|
||||||
|
sessions.delete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeServer(session: XaiSession) {
|
||||||
|
try { session.server.close() } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64Url(input: Buffer): string {
|
||||||
|
return input.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCodeVerifier(): string {
|
||||||
|
return base64Url(randomBytes(48))
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCodeChallenge(verifier: string): string {
|
||||||
|
return base64Url(createHash('sha256').update(verifier).digest())
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateXaiEndpoint(raw: string, field: string): string {
|
||||||
|
const url = new URL(raw)
|
||||||
|
if (url.protocol !== 'https:') throw new Error(`xAI discovery returned non-HTTPS ${field}`)
|
||||||
|
const host = url.hostname.toLowerCase()
|
||||||
|
if (host !== 'x.ai' && !host.endsWith('.x.ai')) {
|
||||||
|
throw new Error(`xAI discovery ${field} host is not on x.ai`)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(url: string, options: {
|
||||||
|
method?: string
|
||||||
|
headers?: Record<string, string>
|
||||||
|
body?: string
|
||||||
|
timeoutMs?: number
|
||||||
|
} = {}): Promise<{ status: number; text: string; json: any }> {
|
||||||
|
const target = new URL(url)
|
||||||
|
const timeoutMs = options.timeoutMs || 15000
|
||||||
|
const body = options.body || ''
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(options.headers || {}),
|
||||||
|
}
|
||||||
|
if (body && !headers['Content-Length']) headers['Content-Length'] = Buffer.byteLength(body).toString()
|
||||||
|
|
||||||
|
const requestOptions: RequestOptions = {
|
||||||
|
hostname: target.hostname,
|
||||||
|
port: Number(target.port || 443),
|
||||||
|
path: `${target.pathname}${target.search}`,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers,
|
||||||
|
timeout: timeoutMs,
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const req = httpsRequest(requestOptions, (res) => {
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
res.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
|
||||||
|
res.on('end', () => {
|
||||||
|
const text = Buffer.concat(chunks).toString('utf-8')
|
||||||
|
let json: any = null
|
||||||
|
try { json = text ? JSON.parse(text) : null } catch {}
|
||||||
|
resolve({ status: res.statusCode || 0, text, json })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
req.once('timeout', () => req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)))
|
||||||
|
req.once('error', reject)
|
||||||
|
if (body) req.write(body)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverXai(): Promise<Record<string, string>> {
|
||||||
|
const res = await requestJson(XAI_OAUTH_DISCOVERY_URL, { timeoutMs: 15000 })
|
||||||
|
if (res.status < 200 || res.status >= 300) throw new Error(`xAI discovery failed: ${res.status}`)
|
||||||
|
const payload = res.json as Record<string, unknown>
|
||||||
|
if (!payload || typeof payload !== 'object') throw new Error('xAI discovery returned invalid JSON')
|
||||||
|
const authorizationEndpoint = String(payload.authorization_endpoint || '').trim()
|
||||||
|
const tokenEndpoint = String(payload.token_endpoint || '').trim()
|
||||||
|
if (!authorizationEndpoint || !tokenEndpoint) throw new Error('xAI discovery missing endpoints')
|
||||||
|
return {
|
||||||
|
authorization_endpoint: validateXaiEndpoint(authorizationEndpoint, 'authorization_endpoint'),
|
||||||
|
token_endpoint: validateXaiEndpoint(tokenEndpoint, 'token_endpoint'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTokens(session: XaiSession, tokenData: any) {
|
||||||
|
const accessToken = String(tokenData.access_token || '').trim()
|
||||||
|
const refreshToken = String(tokenData.refresh_token || '').trim()
|
||||||
|
if (!accessToken || !refreshToken) throw new Error('xAI token response missing access_token or refresh_token')
|
||||||
|
|
||||||
|
const lastRefresh = new Date().toISOString()
|
||||||
|
const tokens = {
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
id_token: String(tokenData.id_token || '').trim(),
|
||||||
|
expires_in: tokenData.expires_in,
|
||||||
|
token_type: String(tokenData.token_type || 'Bearer').trim() || 'Bearer',
|
||||||
|
}
|
||||||
|
|
||||||
|
const authPath = getActiveAuthPath()
|
||||||
|
const auth = loadAuthJson(authPath)
|
||||||
|
if (!auth.providers) auth.providers = {}
|
||||||
|
auth.providers['xai-oauth'] = {
|
||||||
|
tokens,
|
||||||
|
last_refresh: lastRefresh,
|
||||||
|
auth_mode: 'oauth_pkce',
|
||||||
|
discovery: session.discovery,
|
||||||
|
redirect_uri: session.redirectUri,
|
||||||
|
}
|
||||||
|
if (!auth.credential_pool) auth.credential_pool = {}
|
||||||
|
auth.credential_pool['xai-oauth'] = [{
|
||||||
|
id: `xai-oauth-${Date.now()}`,
|
||||||
|
label: 'xAI Grok OAuth (SuperGrok Subscription)',
|
||||||
|
auth_type: 'oauth',
|
||||||
|
source: 'loopback_pkce',
|
||||||
|
priority: 0,
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
base_url: XAI_DEFAULT_BASE_URL,
|
||||||
|
}]
|
||||||
|
saveAuthJson(authPath, auth)
|
||||||
|
|
||||||
|
await updateConfigYaml((config) => {
|
||||||
|
if (typeof config.model !== 'object' || config.model === null) config.model = {}
|
||||||
|
config.model.provider = 'xai-oauth'
|
||||||
|
config.model.default = config.model.default || 'grok-4.3'
|
||||||
|
delete config.model.base_url
|
||||||
|
delete config.model.api_key
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeCode(session: XaiSession, code: string) {
|
||||||
|
const res = await requestJson(session.tokenEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: session.redirectUri,
|
||||||
|
client_id: XAI_OAUTH_CLIENT_ID,
|
||||||
|
code_verifier: session.codeVerifier,
|
||||||
|
}).toString(),
|
||||||
|
timeoutMs: 20000,
|
||||||
|
})
|
||||||
|
if (res.status < 200 || res.status >= 300) {
|
||||||
|
throw new Error(`xAI token exchange failed: ${res.status}${res.text ? ` ${res.text}` : ''}`)
|
||||||
|
}
|
||||||
|
await saveTokens(session, res.json)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCallbackServer(sessionId: string, preferredPort = XAI_REDIRECT_PORT): Promise<{ server: Server; redirectUri: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
const session = sessions.get(sessionId)
|
||||||
|
const url = new URL(req.url || '/', `http://${XAI_REDIRECT_HOST}`)
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.writeHead(204, {
|
||||||
|
'Access-Control-Allow-Origin': 'https://auth.x.ai',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
|
})
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!session || url.pathname !== XAI_REDIRECT_PATH) {
|
||||||
|
res.writeHead(404)
|
||||||
|
res.end('Not found.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||||
|
res.end('<html><body><h1>xAI authorization received.</h1>You can close this tab.</body></html>')
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const error = url.searchParams.get('error')
|
||||||
|
if (error) throw new Error(url.searchParams.get('error_description') || error)
|
||||||
|
if (url.searchParams.get('state') !== session.state) throw new Error('xAI OAuth state mismatch')
|
||||||
|
const code = url.searchParams.get('code')
|
||||||
|
if (!code) throw new Error('xAI OAuth callback missing code')
|
||||||
|
await exchangeCode(session, code)
|
||||||
|
session.status = 'approved'
|
||||||
|
closeServer(session)
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(err, 'xAI OAuth callback failed')
|
||||||
|
session.status = 'error'
|
||||||
|
session.error = err?.message || String(err)
|
||||||
|
closeServer(session)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
server.once('error', (err: any) => {
|
||||||
|
if (preferredPort !== 0 && err?.code === 'EADDRINUSE') {
|
||||||
|
startCallbackServer(sessionId, 0).then(resolve, reject)
|
||||||
|
} else {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
server.listen(preferredPort, XAI_REDIRECT_HOST, () => {
|
||||||
|
const address = server.address()
|
||||||
|
const port = typeof address === 'object' && address ? address.port : preferredPort
|
||||||
|
resolve({ server, redirectUri: `http://${XAI_REDIRECT_HOST}:${port}${XAI_REDIRECT_PATH}` })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function start(ctx: any) {
|
||||||
|
try {
|
||||||
|
cleanupExpiredSessions()
|
||||||
|
const sessionId = randomUUID()
|
||||||
|
const discovery = await discoverXai()
|
||||||
|
const codeVerifier = makeCodeVerifier()
|
||||||
|
const state = randomUUID().replace(/-/g, '')
|
||||||
|
const nonce = randomUUID().replace(/-/g, '')
|
||||||
|
const { server, redirectUri } = await startCallbackServer(sessionId)
|
||||||
|
const authorizeUrl = `${discovery.authorization_endpoint}?${new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: XAI_OAUTH_CLIENT_ID,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: XAI_OAUTH_SCOPE,
|
||||||
|
code_challenge: makeCodeChallenge(codeVerifier),
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
plan: 'generic',
|
||||||
|
referrer: 'hermes-web-ui',
|
||||||
|
}).toString()}`
|
||||||
|
sessions.set(sessionId, {
|
||||||
|
id: sessionId,
|
||||||
|
status: 'pending',
|
||||||
|
authorizeUrl,
|
||||||
|
redirectUri,
|
||||||
|
codeVerifier,
|
||||||
|
state,
|
||||||
|
tokenEndpoint: discovery.token_endpoint,
|
||||||
|
discovery,
|
||||||
|
server,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
})
|
||||||
|
ctx.body = { session_id: sessionId, authorization_url: authorizeUrl, expires_in: 900 }
|
||||||
|
} catch (err: any) {
|
||||||
|
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 }
|
||||||
|
if (Date.now() - session.createdAt > POLL_MAX_DURATION) {
|
||||||
|
session.status = 'expired'
|
||||||
|
closeServer(session)
|
||||||
|
}
|
||||||
|
ctx.body = { status: session.status, error: session.error || null }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function status(ctx: any) {
|
||||||
|
try {
|
||||||
|
const auth = loadAuthJson(getActiveAuthPath())
|
||||||
|
const provider = auth.providers?.['xai-oauth']
|
||||||
|
const pool = auth.credential_pool?.['xai-oauth']
|
||||||
|
ctx.body = {
|
||||||
|
authenticated: !!(
|
||||||
|
provider?.tokens?.access_token ||
|
||||||
|
provider?.access_token ||
|
||||||
|
(Array.isArray(pool) && pool.some((entry: any) => entry?.access_token))
|
||||||
|
),
|
||||||
|
last_refresh: provider?.last_refresh,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ctx.body = { authenticated: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import Router from '@koa/router'
|
||||||
|
import * as ctrl from '../../controllers/hermes/xai-auth'
|
||||||
|
|
||||||
|
export const xaiAuthRoutes = new Router()
|
||||||
|
|
||||||
|
xaiAuthRoutes.post('/api/hermes/auth/xai/start', ctrl.start)
|
||||||
|
xaiAuthRoutes.get('/api/hermes/auth/xai/poll/:sessionId', ctrl.poll)
|
||||||
|
xaiAuthRoutes.get('/api/hermes/auth/xai/status', ctrl.status)
|
||||||
@@ -20,6 +20,7 @@ import { logRoutes } from './hermes/logs'
|
|||||||
import { codexAuthRoutes } from './hermes/codex-auth'
|
import { codexAuthRoutes } from './hermes/codex-auth'
|
||||||
import { nousAuthRoutes } from './hermes/nous-auth'
|
import { nousAuthRoutes } from './hermes/nous-auth'
|
||||||
import { copilotAuthRoutes } from './hermes/copilot-auth'
|
import { copilotAuthRoutes } from './hermes/copilot-auth'
|
||||||
|
import { xaiAuthRoutes } from './hermes/xai-auth'
|
||||||
import { gatewayRoutes } from './hermes/gateways'
|
import { gatewayRoutes } from './hermes/gateways'
|
||||||
import { weixinRoutes } from './hermes/weixin'
|
import { weixinRoutes } from './hermes/weixin'
|
||||||
import { fileRoutes } from './hermes/files'
|
import { fileRoutes } from './hermes/files'
|
||||||
@@ -62,6 +63,7 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
|
|||||||
app.use(codexAuthRoutes.routes())
|
app.use(codexAuthRoutes.routes())
|
||||||
app.use(nousAuthRoutes.routes())
|
app.use(nousAuthRoutes.routes())
|
||||||
app.use(copilotAuthRoutes.routes())
|
app.use(copilotAuthRoutes.routes())
|
||||||
|
app.use(xaiAuthRoutes.routes())
|
||||||
app.use(gatewayRoutes.routes())
|
app.use(gatewayRoutes.routes())
|
||||||
app.use(weixinRoutes.routes())
|
app.use(weixinRoutes.routes())
|
||||||
app.use(groupChatRoutes.routes()) // Must be before proxy
|
app.use(groupChatRoutes.routes()) // Must be before proxy
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const PROVIDER_ENV_MAP: Record<string, { api_key_env: string; base_url_en
|
|||||||
'alibaba-coding-plan': { api_key_env: 'ALIBABA_CODING_PLAN_API_KEY', base_url_env: 'ALIBABA_CODING_PLAN_BASE_URL' },
|
'alibaba-coding-plan': { api_key_env: 'ALIBABA_CODING_PLAN_API_KEY', base_url_env: 'ALIBABA_CODING_PLAN_BASE_URL' },
|
||||||
anthropic: { api_key_env: 'ANTHROPIC_API_KEY', base_url_env: '' },
|
anthropic: { api_key_env: 'ANTHROPIC_API_KEY', base_url_env: '' },
|
||||||
xai: { api_key_env: 'XAI_API_KEY', base_url_env: '' },
|
xai: { api_key_env: 'XAI_API_KEY', base_url_env: '' },
|
||||||
|
'xai-oauth': { api_key_env: '', base_url_env: '' },
|
||||||
xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: '' },
|
xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: '' },
|
||||||
'xiaomi-token-plan': { api_key_env: '', base_url_env: '' },
|
'xiaomi-token-plan': { api_key_env: '', base_url_env: '' },
|
||||||
gemini: { api_key_env: 'GEMINI_API_KEY', base_url_env: '' },
|
gemini: { api_key_env: 'GEMINI_API_KEY', base_url_env: '' },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Agent Bridge
|
# Agent Bridge
|
||||||
|
|
||||||
Optional backend-side bridge for talking to `~/.hermes/hermes-agent` by
|
Optional backend-side bridge for talking to Hermes Agent by instantiating
|
||||||
instantiating `run_agent.AIAgent` directly in a Python process.
|
`run_agent.AIAgent` directly in a Python process.
|
||||||
|
|
||||||
This is intentionally separate from the current Web UI chat path.
|
This is intentionally separate from the current Web UI chat path.
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ The service discovers Hermes Agent in this order:
|
|||||||
3. the installed `hermes` command path
|
3. the installed `hermes` command path
|
||||||
4. current working directory and parent directories
|
4. current working directory and parent directories
|
||||||
5. common locations such as `~/.hermes/hermes-agent`, `~/hermes-agent`, and `/opt/hermes-agent`
|
5. common locations such as `~/.hermes/hermes-agent`, `~/hermes-agent`, and `/opt/hermes-agent`
|
||||||
|
6. the `hermes-agent` package installed in the selected Python environment
|
||||||
|
|
||||||
Hermes home is resolved from `--hermes-home`, `HERMES_HOME`, then `~/.hermes`.
|
Hermes home is resolved from `--hermes-home`, `HERMES_HOME`, then `~/.hermes`.
|
||||||
|
|
||||||
@@ -54,6 +55,12 @@ python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py \
|
|||||||
--hermes-home ~/.hermes
|
--hermes-home ~/.hermes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If no source checkout containing `run_agent.py` is found, the bridge falls back
|
||||||
|
to importing `run_agent` from the Python environment. This supports package
|
||||||
|
installs such as `pip install hermes-agent`. The Node manager prefers the source
|
||||||
|
checkout's virtualenv when a checkout exists, then the Python interpreter from
|
||||||
|
the installed `hermes` command, then the system Python.
|
||||||
|
|
||||||
The socket transport uses Python and Node standard libraries. No ZMQ dependency
|
The socket transport uses Python and Node standard libraries. No ZMQ dependency
|
||||||
is required.
|
is required.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import copy
|
import copy
|
||||||
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
@@ -116,10 +117,17 @@ def _candidate_agent_roots(raw: str | None = None) -> list[Path]:
|
|||||||
return unique
|
return unique
|
||||||
|
|
||||||
|
|
||||||
def _discover_agent_root(raw: str | None = None) -> Path:
|
def _find_agent_root(raw: str | None = None) -> Path | None:
|
||||||
for candidate in _candidate_agent_roots(raw):
|
for candidate in _candidate_agent_roots(raw):
|
||||||
if (candidate / "run_agent.py").exists():
|
if (candidate / "run_agent.py").exists():
|
||||||
return candidate
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_agent_root(raw: str | None = None) -> Path:
|
||||||
|
root = _find_agent_root(raw)
|
||||||
|
if root is not None:
|
||||||
|
return root
|
||||||
attempted = ", ".join(str(path) for path in _candidate_agent_roots(raw))
|
attempted = ", ".join(str(path) for path in _candidate_agent_roots(raw))
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"hermes-agent run_agent.py not found. Pass --agent-root or set "
|
"hermes-agent run_agent.py not found. Pass --agent-root or set "
|
||||||
@@ -154,8 +162,8 @@ def _jsonable(value: Any) -> Any:
|
|||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
def _agent_root() -> Path:
|
def _agent_root() -> Path | None:
|
||||||
return _discover_agent_root(os.environ.get("HERMES_AGENT_ROOT"))
|
return _find_agent_root(os.environ.get("HERMES_AGENT_ROOT"))
|
||||||
|
|
||||||
|
|
||||||
def _hermes_home() -> Path:
|
def _hermes_home() -> Path:
|
||||||
@@ -216,7 +224,11 @@ def _profile_dotenv_keys() -> set[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _set_path_env(agent_root: str | None = None, hermes_home: str | None = None) -> None:
|
def _set_path_env(agent_root: str | None = None, hermes_home: str | None = None) -> None:
|
||||||
os.environ["HERMES_AGENT_ROOT"] = str(_discover_agent_root(agent_root))
|
resolved_root = _discover_agent_root(agent_root) if agent_root else _find_agent_root()
|
||||||
|
if resolved_root is not None:
|
||||||
|
os.environ["HERMES_AGENT_ROOT"] = str(resolved_root)
|
||||||
|
else:
|
||||||
|
os.environ.pop("HERMES_AGENT_ROOT", None)
|
||||||
resolved_home = _discover_hermes_home(hermes_home)
|
resolved_home = _discover_hermes_home(hermes_home)
|
||||||
os.environ["HERMES_HOME"] = str(resolved_home)
|
os.environ["HERMES_HOME"] = str(resolved_home)
|
||||||
os.environ["HERMES_AGENT_BRIDGE_BASE_HOME"] = str(_normalize_base_home(resolved_home))
|
os.environ["HERMES_AGENT_BRIDGE_BASE_HOME"] = str(_normalize_base_home(resolved_home))
|
||||||
@@ -224,11 +236,16 @@ def _set_path_env(agent_root: str | None = None, hermes_home: str | None = None)
|
|||||||
|
|
||||||
def _ensure_agent_imports() -> None:
|
def _ensure_agent_imports() -> None:
|
||||||
root = _agent_root()
|
root = _agent_root()
|
||||||
if not (root / "run_agent.py").exists():
|
if root is not None:
|
||||||
raise RuntimeError(f"hermes-agent run_agent.py not found under {root}")
|
|
||||||
root_s = str(root)
|
root_s = str(root)
|
||||||
if root_s not in sys.path:
|
if root_s not in sys.path:
|
||||||
sys.path.insert(0, root_s)
|
sys.path.insert(0, root_s)
|
||||||
|
elif importlib.util.find_spec("run_agent") is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"hermes-agent run_agent.py not found in source locations and the "
|
||||||
|
"current Python environment cannot import run_agent. Install "
|
||||||
|
"hermes-agent or pass --agent-root/HERMES_AGENT_ROOT."
|
||||||
|
)
|
||||||
os.environ.setdefault("HERMES_HOME", str(_hermes_home()))
|
os.environ.setdefault("HERMES_HOME", str(_hermes_home()))
|
||||||
os.environ.setdefault("HERMES_AGENT_BRIDGE_BASE_HOME", str(_hermes_home()))
|
os.environ.setdefault("HERMES_AGENT_BRIDGE_BASE_HOME", str(_hermes_home()))
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ function pathCandidates(agentRoot?: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function uvCandidates(agentRoot?: string): string[] {
|
function uvCandidates(agentRoot?: string): string[] {
|
||||||
|
if (!agentRoot) {
|
||||||
|
return [
|
||||||
|
process.env.HERMES_AGENT_BRIDGE_UV,
|
||||||
|
process.env.UV,
|
||||||
|
].filter((value): value is string => !!value && value.trim().length > 0)
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
process.env.HERMES_AGENT_BRIDGE_UV,
|
process.env.HERMES_AGENT_BRIDGE_UV,
|
||||||
process.env.UV,
|
process.env.UV,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { resolve, join } from 'path'
|
import { resolve, join } from 'path'
|
||||||
import { homedir } from 'os'
|
|
||||||
import { readFileSync, existsSync, statSync } from 'fs'
|
import { readFileSync, existsSync, statSync } from 'fs'
|
||||||
import yaml from 'js-yaml'
|
import yaml from 'js-yaml'
|
||||||
import { PROVIDER_PRESETS } from '../../shared/providers'
|
import { PROVIDER_PRESETS } from '../../shared/providers'
|
||||||
@@ -44,6 +43,7 @@ const MODEL_CACHE_PROVIDER_ALIASES: Record<string, string[]> = {
|
|||||||
'glm-coding-plan': ['zai-coding-plan'],
|
'glm-coding-plan': ['zai-coding-plan'],
|
||||||
'kimi-coding': ['kimi-for-coding'],
|
'kimi-coding': ['kimi-for-coding'],
|
||||||
'kimi-coding-cn': ['kimi-for-coding'],
|
'kimi-coding-cn': ['kimi-for-coding'],
|
||||||
|
'xai-oauth': ['xai'],
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Config YAML helpers (js-yaml) ---
|
// --- Config YAML helpers (js-yaml) ---
|
||||||
|
|||||||
@@ -222,15 +222,31 @@ function extractError(err: any): string {
|
|||||||
export async function listHermesPlugins(): Promise<HermesPluginsResponse> {
|
export async function listHermesPlugins(): Promise<HermesPluginsResponse> {
|
||||||
const command = resolveAgentBridgeCommand()
|
const command = resolveAgentBridgeCommand()
|
||||||
const agentRoot = command.agentRoot || ''
|
const agentRoot = command.agentRoot || ''
|
||||||
const env = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
HERMES_AGENT_ROOT_RESOLVED: agentRoot,
|
HERMES_AGENT_ROOT_RESOLVED: agentRoot,
|
||||||
HERMES_HOME: getActiveProfileDir(),
|
HERMES_HOME: getActiveProfileDir(),
|
||||||
}
|
}
|
||||||
|
if (!agentRoot) {
|
||||||
|
delete env.PYTHONHOME
|
||||||
|
delete env.PYTHONPATH
|
||||||
|
}
|
||||||
|
const pythonArgs = [
|
||||||
|
...command.argsPrefix,
|
||||||
|
...(agentRoot ? ['-I'] : []),
|
||||||
|
'-c',
|
||||||
|
PYTHON_BRIDGE,
|
||||||
|
]
|
||||||
|
const displayArgs = [
|
||||||
|
...command.argsPrefix,
|
||||||
|
...(agentRoot ? ['-I'] : []),
|
||||||
|
'-c',
|
||||||
|
'<plugin-discovery>',
|
||||||
|
].join(' ')
|
||||||
|
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
try {
|
try {
|
||||||
const { stdout, stderr } = await execFileAsync(command.command, [...command.argsPrefix, '-I', '-c', PYTHON_BRIDGE], {
|
const { stdout, stderr } = await execFileAsync(command.command, pythonArgs, {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
env,
|
env,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
@@ -246,8 +262,7 @@ export async function listHermesPlugins(): Promise<HermesPluginsResponse> {
|
|||||||
}
|
}
|
||||||
return parsed
|
return parsed
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const args = [...command.argsPrefix, '-I', '-c', '<plugin-discovery>'].join(' ')
|
errors.push(`${command.command} ${displayArgs}: ${extractError(err)}`)
|
||||||
errors.push(`${command.command} ${args}: ${extractError(err)}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Failed to discover Hermes plugins.\n${errors.join('\n')}`)
|
throw new Error(`Failed to discover Hermes plugins.\n${errors.join('\n')}`)
|
||||||
|
|||||||
@@ -120,15 +120,22 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
builtin: true,
|
builtin: true,
|
||||||
base_url: 'https://api.x.ai/v1',
|
base_url: 'https://api.x.ai/v1',
|
||||||
models: [
|
models: [
|
||||||
|
'grok-4.3',
|
||||||
|
'grok-4.20-0309-reasoning',
|
||||||
|
'grok-4.20-0309-non-reasoning',
|
||||||
|
'grok-4.20-multi-agent-0309',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'xAI Grok OAuth (SuperGrok Subscription)',
|
||||||
|
value: 'xai-oauth',
|
||||||
|
builtin: true,
|
||||||
|
base_url: 'https://api.x.ai/v1',
|
||||||
|
models: [
|
||||||
|
'grok-4.3',
|
||||||
'grok-4.20-0309-reasoning',
|
'grok-4.20-0309-reasoning',
|
||||||
'grok-4.20-0309-non-reasoning',
|
'grok-4.20-0309-non-reasoning',
|
||||||
'grok-4.20-multi-agent-0309',
|
'grok-4.20-multi-agent-0309',
|
||||||
'grok-4-1-fast',
|
|
||||||
'grok-4-1-fast-non-reasoning',
|
|
||||||
'grok-4-fast',
|
|
||||||
'grok-4-fast-non-reasoning',
|
|
||||||
'grok-4',
|
|
||||||
'grok-code-fast-1',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
describe('agent bridge manager command resolution', () => {
|
||||||
|
const originalEnv = { ...process.env }
|
||||||
|
let tempDir = ''
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'hermes-agent-bridge-manager-'))
|
||||||
|
process.env = { ...originalEnv }
|
||||||
|
delete process.env.HERMES_AGENT_ROOT
|
||||||
|
delete process.env.HERMES_AGENT_BRIDGE_PYTHON
|
||||||
|
delete process.env.HERMES_AGENT_BRIDGE_UV
|
||||||
|
delete process.env.UV
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv }
|
||||||
|
if (tempDir) rmSync(tempDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the installed hermes command Python when no source root exists', async () => {
|
||||||
|
const binDir = join(tempDir, 'bin')
|
||||||
|
const homeDir = join(tempDir, 'home')
|
||||||
|
const fakePython = join(binDir, 'python')
|
||||||
|
const fakeHermes = join(binDir, 'hermes')
|
||||||
|
mkdirSync(binDir, { recursive: true })
|
||||||
|
mkdirSync(homeDir, { recursive: true })
|
||||||
|
writeFileSync(fakePython, '#!/bin/sh\n')
|
||||||
|
chmodSync(fakePython, 0o755)
|
||||||
|
writeFileSync(fakeHermes, `#!${fakePython}\n`)
|
||||||
|
chmodSync(fakeHermes, 0o755)
|
||||||
|
process.env.HERMES_HOME = homeDir
|
||||||
|
process.env.HERMES_BIN = fakeHermes
|
||||||
|
|
||||||
|
const { resolveAgentBridgeCommand } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
||||||
|
const command = resolveAgentBridgeCommand()
|
||||||
|
|
||||||
|
expect(command).toEqual({
|
||||||
|
command: fakePython,
|
||||||
|
argsPrefix: [],
|
||||||
|
agentRoot: undefined,
|
||||||
|
hermesHome: homeDir,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to system Python instead of uv when no source root exists', async () => {
|
||||||
|
const homeDir = join(tempDir, 'home')
|
||||||
|
const fakePython = join(tempDir, 'python3')
|
||||||
|
mkdirSync(homeDir, { recursive: true })
|
||||||
|
writeFileSync(fakePython, '#!/bin/sh\n')
|
||||||
|
chmodSync(fakePython, 0o755)
|
||||||
|
process.env.HERMES_HOME = homeDir
|
||||||
|
process.env.HERMES_BIN = join(tempDir, 'missing-hermes')
|
||||||
|
process.env.PYTHON = fakePython
|
||||||
|
|
||||||
|
const { resolveAgentBridgeCommand } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
||||||
|
const command = resolveAgentBridgeCommand()
|
||||||
|
|
||||||
|
expect(command).toEqual({
|
||||||
|
command: fakePython,
|
||||||
|
argsPrefix: [],
|
||||||
|
agentRoot: undefined,
|
||||||
|
hermesHome: homeDir,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -144,6 +144,51 @@ print(json.dumps({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('falls back to package imports when no Hermes Agent source root exists', async () => {
|
||||||
|
const packageDir = join(tempDir, 'site-packages')
|
||||||
|
const hermesHome = join(tempDir, 'home')
|
||||||
|
await mkdir(packageDir, { recursive: true })
|
||||||
|
await mkdir(hermesHome, { recursive: true })
|
||||||
|
await writeFile(join(packageDir, 'run_agent.py'), 'class AIAgent: pass\n', 'utf-8')
|
||||||
|
const expectedHermesHome = await realpath(hermesHome)
|
||||||
|
|
||||||
|
const result = await runBridgeProbe(`
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("hermes_bridge", os.environ["BRIDGE_PATH"])
|
||||||
|
bridge = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules["hermes_bridge"] = bridge
|
||||||
|
spec.loader.exec_module(bridge)
|
||||||
|
|
||||||
|
package_dir = os.path.join(os.environ["TEST_HERMES_HOME"], "site-packages")
|
||||||
|
hermes_home = os.path.join(os.environ["TEST_HERMES_HOME"], "home")
|
||||||
|
sys.path.insert(0, package_dir)
|
||||||
|
bridge._candidate_agent_roots = lambda raw=None: []
|
||||||
|
os.environ.pop("HERMES_AGENT_ROOT", None)
|
||||||
|
|
||||||
|
bridge._set_path_env(None, hermes_home)
|
||||||
|
bridge._ensure_agent_imports()
|
||||||
|
from run_agent import AIAgent
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"agent_root": os.environ.get("HERMES_AGENT_ROOT"),
|
||||||
|
"home": os.environ.get("HERMES_HOME"),
|
||||||
|
"base": os.environ.get("HERMES_AGENT_BRIDGE_BASE_HOME"),
|
||||||
|
"agent_class": AIAgent.__name__,
|
||||||
|
}))
|
||||||
|
`)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
agent_root: null,
|
||||||
|
home: expectedHermesHome,
|
||||||
|
base: expectedHermesHome,
|
||||||
|
agent_class: 'AIAgent',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps inherited profile env keys for default profile compatibility', async () => {
|
it('keeps inherited profile env keys for default profile compatibility', async () => {
|
||||||
await mkdir(join(tempDir, 'profiles', 'work'), { recursive: true })
|
await mkdir(join(tempDir, 'profiles', 'work'), { recursive: true })
|
||||||
await writeFile(join(tempDir, '.env'), 'OPENAI_API_KEY=default-openai\n', 'utf-8')
|
await writeFile(join(tempDir, '.env'), 'OPENAI_API_KEY=default-openai\n', 'utf-8')
|
||||||
|
|||||||
@@ -57,4 +57,42 @@ describe('Hermes plugin discovery environment', () => {
|
|||||||
expect(secondArg).toBe('-c')
|
expect(secondArg).toBe('-c')
|
||||||
expect(resolvedRoot).toBe(agentRoot)
|
expect(resolvedRoot).toBe(agentRoot)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses package Python without isolated mode when no source root is resolved', async () => {
|
||||||
|
const binDir = join(tempDir, 'bin')
|
||||||
|
const captureFile = join(tempDir, 'capture-package.txt')
|
||||||
|
const fakePython = join(binDir, 'python')
|
||||||
|
const fakeHermes = join(binDir, 'hermes')
|
||||||
|
|
||||||
|
mkdirSync(binDir, { recursive: true })
|
||||||
|
writeFileSync(fakePython, [
|
||||||
|
'#!/bin/sh',
|
||||||
|
'printf "%s\\n%s\\n%s\\n%s\\n" "$0" "$1" "${PYTHONPATH-unset}" "${PYTHONHOME-unset}" > "$CAPTURE_FILE"',
|
||||||
|
'printf "%s\\n" \'{"plugins":[],"warnings":[],"metadata":{"hermesAgentRoot":"","pythonExecutable":"","cwd":"","projectPluginsEnabled":false}}\'',
|
||||||
|
'',
|
||||||
|
].join('\n'))
|
||||||
|
chmodSync(fakePython, 0o755)
|
||||||
|
writeFileSync(fakeHermes, `#!${fakePython}\n`)
|
||||||
|
chmodSync(fakeHermes, 0o755)
|
||||||
|
|
||||||
|
delete process.env.HERMES_AGENT_ROOT
|
||||||
|
delete process.env.HERMES_AGENT_BRIDGE_PYTHON
|
||||||
|
delete process.env.HERMES_AGENT_BRIDGE_UV
|
||||||
|
delete process.env.UV
|
||||||
|
delete process.env.HERMES_PYTHON
|
||||||
|
process.env.HERMES_HOME = join(tempDir, 'home')
|
||||||
|
process.env.HERMES_BIN = fakeHermes
|
||||||
|
process.env.CAPTURE_FILE = captureFile
|
||||||
|
process.env.PYTHONPATH = join(tempDir, 'shadow-path')
|
||||||
|
process.env.PYTHONHOME = join(tempDir, 'shadow-home')
|
||||||
|
|
||||||
|
const { listHermesPlugins } = await import('../../packages/server/src/services/hermes/plugins')
|
||||||
|
await expect(listHermesPlugins()).resolves.toMatchObject({ plugins: [] })
|
||||||
|
|
||||||
|
const [command, firstArg, pythonPath, pythonHome] = readFileSync(captureFile, 'utf8').trim().split('\n')
|
||||||
|
expect(command).toBe(fakePython)
|
||||||
|
expect(firstArg).toBe('-c')
|
||||||
|
expect(pythonPath).toBe('unset')
|
||||||
|
expect(pythonHome).toBe('unset')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
|||||||
buildModelGroups: mockBuildModelGroups,
|
buildModelGroups: mockBuildModelGroups,
|
||||||
PROVIDER_ENV_MAP: {
|
PROVIDER_ENV_MAP: {
|
||||||
deepseek: { api_key_env: 'DEEPSEEK_API_KEY' },
|
deepseek: { api_key_env: 'DEEPSEEK_API_KEY' },
|
||||||
|
'xai-oauth': { api_key_env: '', base_url_env: 'XAI_BASE_URL' },
|
||||||
openrouter: {},
|
openrouter: {},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@@ -39,6 +40,7 @@ vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
|||||||
vi.mock('../../packages/server/src/shared/providers', () => ({
|
vi.mock('../../packages/server/src/shared/providers', () => ({
|
||||||
buildProviderModelMap: () => ({
|
buildProviderModelMap: () => ({
|
||||||
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
|
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
|
||||||
|
'xai-oauth': ['grok-4.3', 'grok-4.20-0309-reasoning'],
|
||||||
openrouter: ['openrouter/auto'],
|
openrouter: ['openrouter/auto'],
|
||||||
}),
|
}),
|
||||||
PROVIDER_PRESETS: [
|
PROVIDER_PRESETS: [
|
||||||
@@ -54,6 +56,12 @@ vi.mock('../../packages/server/src/shared/providers', () => ({
|
|||||||
base_url: 'https://openrouter.ai/api/v1',
|
base_url: 'https://openrouter.ai/api/v1',
|
||||||
models: ['openrouter/auto'],
|
models: ['openrouter/auto'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'xai-oauth',
|
||||||
|
label: 'xAI Grok OAuth (SuperGrok Subscription)',
|
||||||
|
base_url: 'https://api.x.ai/v1',
|
||||||
|
models: ['grok-4.3', 'grok-4.20-0309-reasoning'],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -138,6 +146,30 @@ describe('models controller — model visibility', () => {
|
|||||||
]))
|
]))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows xAI Grok OAuth when SuperGrok credentials exist in auth.json', async () => {
|
||||||
|
mockExistsSync.mockReturnValue(true)
|
||||||
|
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||||
|
providers: {
|
||||||
|
'xai-oauth': {
|
||||||
|
tokens: { access_token: 'xai-token' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const ctx = makeCtx()
|
||||||
|
await ctrl.getAvailable(ctx)
|
||||||
|
|
||||||
|
expect(ctx.status).toBe(200)
|
||||||
|
expect(ctx.body.groups).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: 'xai-oauth',
|
||||||
|
label: 'xAI Grok OAuth (SuperGrok Subscription)',
|
||||||
|
base_url: 'https://api.x.ai/v1',
|
||||||
|
models: ['grok-4.3', 'grok-4.20-0309-reasoning'],
|
||||||
|
}),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
it('fails open for stale include rules so a provider can be recovered in the UI', async () => {
|
it('fails open for stale include rules so a provider can be recovered in the UI', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user