feat: multi-gateway profile support, provider management overhaul, and model settings tab
- Profile-aware proxy: inject API key from profile-specific .env, route requests via X-Hermes-Profile header - Remove auth.json dependency: built-in providers use .env, custom providers use config.yaml - Add allProviders field to available-models response with all hardcoded provider catalogs - Add Models tab in Settings for editing provider API keys (built-in → .env, custom → config.yaml) - Add PUT /api/config/providers/:poolKey for updating provider credentials - ProviderFormModal uses backend allProviders for preset dropdown - Gateway log format support: parse both agent and gateway log formats - Add webui server.log to log viewer with log rotation at 3MB - Fix provider delete loading state and OAuth provider cleanup - Setup script: require Node.js 23+, auto-upgrade if version too low Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+15
-1
@@ -2,7 +2,7 @@
|
|||||||
import { spawn, execSync } from 'child_process'
|
import { spawn, execSync } from 'child_process'
|
||||||
import { resolve, dirname, join } from 'path'
|
import { resolve, dirname, join } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync, chmodSync } from 'fs'
|
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync, chmodSync, statSync } from 'fs'
|
||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
|
|
||||||
@@ -118,6 +118,20 @@ function startDaemon(port) {
|
|||||||
ensureNativeModules()
|
ensureNativeModules()
|
||||||
const token = ensureToken()
|
const token = ensureToken()
|
||||||
|
|
||||||
|
// Rotate log if over 3MB — keep last 2000 lines
|
||||||
|
const MAX_LOG_SIZE = 3 * 1024 * 1024
|
||||||
|
const MAX_LOG_LINES = 2000
|
||||||
|
try {
|
||||||
|
const stat = statSync(LOG_FILE)
|
||||||
|
if (stat.size > MAX_LOG_SIZE) {
|
||||||
|
const content = readFileSync(LOG_FILE, 'utf-8')
|
||||||
|
const lines = content.split('\n')
|
||||||
|
const kept = lines.slice(-MAX_LOG_LINES)
|
||||||
|
writeFileSync(LOG_FILE, kept.join('\n'), 'utf-8')
|
||||||
|
console.log(` ↻ Log rotated (${(stat.size / 1024 / 1024).toFixed(1)}MB → ${kept.length} lines)`)
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
const logStream = openSync(LOG_FILE, 'a')
|
const logStream = openSync(LOG_FILE, 'a')
|
||||||
const child = spawn(process.execPath, [serverEntry], {
|
const child = spawn(process.execPath, [serverEntry], {
|
||||||
detached: true,
|
detached: true,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hermes-web-ui",
|
"name": "hermes-web-ui",
|
||||||
"version": "0.3.6",
|
"version": "0.3.7",
|
||||||
"description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)",
|
"description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ export async function request<T>(path: string, options: RequestInit = {}): Promi
|
|||||||
headers['Authorization'] = `Bearer ${apiKey}`
|
headers['Authorization'] = `Bearer ${apiKey}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inject active profile header for proxied gateway requests
|
||||||
|
const profileName = localStorage.getItem('hermes_active_profile_name')
|
||||||
|
if (profileName && profileName !== 'default') {
|
||||||
|
headers['X-Hermes-Profile'] = profileName
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(url, { ...options, headers })
|
const res = await fetch(url, { ...options, headers })
|
||||||
|
|
||||||
// Global 401 handler — only redirect to login for local BFF endpoints
|
// Global 401 handler — only redirect to login for local BFF endpoints
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ export function streamRunEvents(
|
|||||||
) {
|
) {
|
||||||
const baseUrl = getBaseUrlValue()
|
const baseUrl = getBaseUrlValue()
|
||||||
const token = getApiKey()
|
const token = getApiKey()
|
||||||
const url = `${baseUrl}/api/hermes/v1/runs/${runId}/events${token ? `?token=${encodeURIComponent(token)}` : ''}`
|
const profile = localStorage.getItem('hermes_active_profile_name')
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (token) params.set('token', token)
|
||||||
|
if (profile && profile !== 'default') params.set('profile', profile)
|
||||||
|
const qs = params.toString()
|
||||||
|
const url = `${baseUrl}/api/hermes/v1/runs/${runId}/events${qs ? `?${qs}` : ''}`
|
||||||
|
|
||||||
let closed = false
|
let closed = false
|
||||||
const source = new EventSource(url)
|
const source = new EventSource(url)
|
||||||
|
|||||||
@@ -29,12 +29,14 @@ export interface AvailableModelGroup {
|
|||||||
label: string // display name (e.g. "zai", "subrouter.ai")
|
label: string // display name (e.g. "zai", "subrouter.ai")
|
||||||
base_url: string
|
base_url: string
|
||||||
models: string[]
|
models: string[]
|
||||||
|
api_key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AvailableModelsResponse {
|
export interface AvailableModelsResponse {
|
||||||
default: string
|
default: string
|
||||||
default_provider: string
|
default_provider: string
|
||||||
groups: AvailableModelGroup[]
|
groups: AvailableModelGroup[]
|
||||||
|
allProviders: AvailableModelGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomProvider {
|
export interface CustomProvider {
|
||||||
@@ -85,3 +87,15 @@ export async function removeCustomProvider(name: string): Promise<void> {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateProvider(poolKey: string, data: {
|
||||||
|
name?: string
|
||||||
|
base_url?: string
|
||||||
|
api_key?: string
|
||||||
|
model?: string
|
||||||
|
}): Promise<void> {
|
||||||
|
await request(`/api/hermes/config/providers/${encodeURIComponent(poolKey)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { NButton, useMessage, useDialog } from 'naive-ui'
|
import { NButton, useMessage, useDialog } from 'naive-ui'
|
||||||
import type { AvailableModelGroup } from '@/api/hermes/system'
|
import type { AvailableModelGroup } from '@/api/hermes/system'
|
||||||
import { useModelsStore } from '@/stores/hermes/models'
|
import { useModelsStore } from '@/stores/hermes/models'
|
||||||
@@ -14,6 +14,7 @@ const dialog = useDialog()
|
|||||||
|
|
||||||
const isCustom = computed(() => props.provider.provider.startsWith('custom:'))
|
const isCustom = computed(() => props.provider.provider.startsWith('custom:'))
|
||||||
const displayName = computed(() => props.provider.label)
|
const displayName = computed(() => props.provider.label)
|
||||||
|
const deleting = ref(false)
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
dialog.warning({
|
dialog.warning({
|
||||||
@@ -22,11 +23,14 @@ async function handleDelete() {
|
|||||||
positiveText: t('common.delete'),
|
positiveText: t('common.delete'),
|
||||||
negativeText: t('common.cancel'),
|
negativeText: t('common.cancel'),
|
||||||
onPositiveClick: async () => {
|
onPositiveClick: async () => {
|
||||||
|
deleting.value = true
|
||||||
try {
|
try {
|
||||||
await modelsStore.removeProvider(props.provider.provider)
|
await modelsStore.removeProvider(props.provider.provider)
|
||||||
message.success(t('models.providerDeleted'))
|
message.success(t('models.providerDeleted'))
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message)
|
message.error(e.message)
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -54,7 +58,7 @@ async function handleDelete() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<NButton size="tiny" quaternary type="error" @click="handleDelete">{{ t('common.delete') }}</NButton>
|
<NButton size="tiny" quaternary type="error" :loading="deleting" @click="handleDelete">{{ t('common.delete') }}</NButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed } from 'vue'
|
import { ref, watch, computed, onMounted } from 'vue'
|
||||||
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, useMessage } from 'naive-ui'
|
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, useMessage } from 'naive-ui'
|
||||||
import { useModelsStore } from '@/stores/hermes/models'
|
import { useModelsStore } from '@/stores/hermes/models'
|
||||||
import { PROVIDER_PRESETS } from '@/shared/providers'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import CodexLoginModal from './CodexLoginModal.vue'
|
import CodexLoginModal from './CodexLoginModal.vue'
|
||||||
|
|
||||||
@@ -32,12 +31,14 @@ const formData = ref({
|
|||||||
|
|
||||||
const modelOptions = ref<Array<{ label: string; value: string }>>([])
|
const modelOptions = ref<Array<{ label: string; value: string }>>([])
|
||||||
|
|
||||||
const PRESET_PROVIDERS = PROVIDER_PRESETS as any[]
|
|
||||||
|
|
||||||
const CODEX_KEY = 'openai-codex'
|
const CODEX_KEY = 'openai-codex'
|
||||||
|
|
||||||
const isCodex = computed(() => selectedPreset.value === CODEX_KEY)
|
const isCodex = computed(() => selectedPreset.value === CODEX_KEY)
|
||||||
|
|
||||||
|
const presetOptions = computed(() =>
|
||||||
|
modelsStore.allProviders.map(g => ({ label: g.label, value: g.provider })),
|
||||||
|
)
|
||||||
|
|
||||||
function autoGenerateName(url: string): string {
|
function autoGenerateName(url: string): string {
|
||||||
const clean = url.replace(/^https?:\/\//, '').replace(/\/v1\/?$/, '')
|
const clean = url.replace(/^https?:\/\//, '').replace(/\/v1\/?$/, '')
|
||||||
const host = clean.split('/')[0]
|
const host = clean.split('/')[0]
|
||||||
@@ -50,13 +51,13 @@ function autoGenerateName(url: string): string {
|
|||||||
watch(selectedPreset, (val) => {
|
watch(selectedPreset, (val) => {
|
||||||
formData.value.model = ''
|
formData.value.model = ''
|
||||||
if (val) {
|
if (val) {
|
||||||
const preset = PRESET_PROVIDERS.find(p => p.value === val)
|
const group = modelsStore.allProviders.find(g => g.provider === val)
|
||||||
if (preset) {
|
if (group) {
|
||||||
formData.value.name = preset.label
|
formData.value.name = group.label
|
||||||
formData.value.base_url = preset.base_url
|
formData.value.base_url = group.base_url
|
||||||
modelOptions.value = preset.models.map((m: string) => ({ label: m, value: m }))
|
modelOptions.value = group.models.map((m: string) => ({ label: m, value: m }))
|
||||||
if (preset.models.length > 0) {
|
if (group.models.length > 0) {
|
||||||
formData.value.model = preset.models[0]
|
formData.value.model = group.models[0]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +75,12 @@ watch(providerType, () => {
|
|||||||
selectedPreset.value = null
|
selectedPreset.value = null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (modelsStore.providers.length === 0) {
|
||||||
|
modelsStore.fetchProviders()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function fetchModels() {
|
async function fetchModels() {
|
||||||
const { base_url } = formData.value
|
const { base_url } = formData.value
|
||||||
if (!base_url.trim()) {
|
if (!base_url.trim()) {
|
||||||
@@ -133,7 +140,7 @@ async function handleSave() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const providerKey = providerType.value === 'preset'
|
const providerKey = providerType.value === 'preset'
|
||||||
? (PRESET_PROVIDERS.find(p => p.value === selectedPreset.value)?.value || null)
|
? selectedPreset.value
|
||||||
: null
|
: null
|
||||||
|
|
||||||
await modelsStore.addProvider({
|
await modelsStore.addProvider({
|
||||||
@@ -196,7 +203,7 @@ function handleClose() {
|
|||||||
<NFormItem v-if="providerType === 'preset'" :label="t('models.selectProvider')" required>
|
<NFormItem v-if="providerType === 'preset'" :label="t('models.selectProvider')" required>
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="selectedPreset"
|
v-model:value="selectedPreset"
|
||||||
:options="PRESET_PROVIDERS"
|
:options="presetOptions"
|
||||||
:placeholder="t('models.chooseProvider')"
|
:placeholder="t('models.chooseProvider')"
|
||||||
filterable
|
filterable
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { NInput, NButton, NSpin, NEmpty, useMessage } from 'naive-ui'
|
||||||
|
import { useModelsStore } from '@/stores/hermes/models'
|
||||||
|
import { updateProvider } from '@/api/hermes/system'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const modelsStore = useModelsStore()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const savingKey = ref<string | null>(null)
|
||||||
|
const editKeys = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (modelsStore.providers.length === 0) {
|
||||||
|
modelsStore.fetchProviders()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isCustom = (provider: string) => provider.startsWith('custom:')
|
||||||
|
|
||||||
|
function getEditKey(provider: string): string {
|
||||||
|
if (!(provider in editKeys.value)) {
|
||||||
|
const g = modelsStore.providers.find(p => p.provider === provider)
|
||||||
|
editKeys.value[provider] = g?.api_key || ''
|
||||||
|
}
|
||||||
|
return editKeys.value[provider]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveApiKey(providerKey: string) {
|
||||||
|
const key = getEditKey(providerKey)
|
||||||
|
if (!key.trim()) {
|
||||||
|
message.warning(t('settings.models.apiKeyPlaceholder'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
savingKey.value = providerKey
|
||||||
|
try {
|
||||||
|
await updateProvider(providerKey, { api_key: key.trim() })
|
||||||
|
message.success(t('settings.models.saved'))
|
||||||
|
await modelsStore.fetchProviders()
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || t('settings.models.saveFailed'))
|
||||||
|
} finally {
|
||||||
|
savingKey.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveCustom(providerKey: string) {
|
||||||
|
const key = getEditKey(providerKey)
|
||||||
|
savingKey.value = providerKey
|
||||||
|
try {
|
||||||
|
await updateProvider(providerKey, { api_key: key.trim() })
|
||||||
|
message.success(t('settings.models.saved'))
|
||||||
|
await modelsStore.fetchProviders()
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || t('settings.models.saveFailed'))
|
||||||
|
} finally {
|
||||||
|
savingKey.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="settings-section">
|
||||||
|
<NSpin :show="modelsStore.loading">
|
||||||
|
<div v-if="modelsStore.providers.length === 0" class="empty-hint">
|
||||||
|
<NEmpty :description="t('settings.models.noProviders')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="g in modelsStore.providers" :key="g.provider" class="provider-section">
|
||||||
|
<div class="provider-header">
|
||||||
|
<h4 class="provider-name">{{ g.label }}</h4>
|
||||||
|
<span class="type-badge" :class="isCustom(g.provider) ? 'custom' : 'builtin'">
|
||||||
|
{{ isCustom(g.provider) ? t('models.customType') : t('models.builtIn') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Built-in provider: only API key -->
|
||||||
|
<div v-if="!isCustom(g.provider)" class="provider-fields">
|
||||||
|
<div class="field-row">
|
||||||
|
<NInput
|
||||||
|
:value="getEditKey(g.provider)"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
:placeholder="t('settings.models.apiKeyPlaceholder')"
|
||||||
|
autocomplete="off"
|
||||||
|
@update:value="v => editKeys[g.provider] = v"
|
||||||
|
/>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="savingKey === g.provider"
|
||||||
|
@click="handleSaveApiKey(g.provider)"
|
||||||
|
>
|
||||||
|
{{ t('settings.models.save') }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom provider: API key -->
|
||||||
|
<div v-else class="provider-fields">
|
||||||
|
<div class="field-row">
|
||||||
|
<NInput
|
||||||
|
:value="getEditKey(g.provider)"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
:placeholder="t('settings.models.apiKeyPlaceholder')"
|
||||||
|
autocomplete="off"
|
||||||
|
@update:value="v => editKeys[g.provider] = v"
|
||||||
|
/>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="savingKey === g.provider"
|
||||||
|
@click="handleSaveCustom(g.provider)"
|
||||||
|
>
|
||||||
|
{{ t('settings.models.save') }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NSpin>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use '@/styles/variables' as *;
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-section {
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: $bg-card;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-primary;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&.builtin {
|
||||||
|
background: rgba(var(--accent-primary-rgb), 0.12);
|
||||||
|
color: $accent-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.custom {
|
||||||
|
background: rgba(var(--success-rgb), 0.12);
|
||||||
|
color: $success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.n-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -306,6 +306,15 @@ export default {
|
|||||||
session: 'Session',
|
session: 'Session',
|
||||||
privacy: 'Privacy',
|
privacy: 'Privacy',
|
||||||
apiServer: 'API Server',
|
apiServer: 'API Server',
|
||||||
|
models: 'Models',
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
apiKey: 'API Key',
|
||||||
|
apiKeyPlaceholder: 'Enter API key',
|
||||||
|
save: 'Save',
|
||||||
|
saved: 'Saved',
|
||||||
|
saveFailed: 'Save failed',
|
||||||
|
noProviders: 'No providers configured',
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
streaming: 'Stream Responses',
|
streaming: 'Stream Responses',
|
||||||
|
|||||||
@@ -298,6 +298,15 @@ export default {
|
|||||||
session: '会话',
|
session: '会话',
|
||||||
privacy: '隐私',
|
privacy: '隐私',
|
||||||
apiServer: 'API 服务器',
|
apiServer: 'API 服务器',
|
||||||
|
models: '模型',
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
apiKey: 'API Key',
|
||||||
|
apiKeyPlaceholder: '输入 API Key',
|
||||||
|
save: '保存',
|
||||||
|
saved: '已保存',
|
||||||
|
saveFailed: '保存失败',
|
||||||
|
noProviders: '暂无已配置的模型',
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
streaming: '流式响应',
|
streaming: '流式响应',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAppStore } from './app'
|
|||||||
|
|
||||||
export const useModelsStore = defineStore('models', () => {
|
export const useModelsStore = defineStore('models', () => {
|
||||||
const providers = ref<AvailableModelGroup[]>([])
|
const providers = ref<AvailableModelGroup[]>([])
|
||||||
|
const allProviders = ref<AvailableModelGroup[]>([])
|
||||||
const defaultModel = ref('')
|
const defaultModel = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ export const useModelsStore = defineStore('models', () => {
|
|||||||
try {
|
try {
|
||||||
const res = await systemApi.fetchAvailableModels()
|
const res = await systemApi.fetchAvailableModels()
|
||||||
providers.value = res.groups
|
providers.value = res.groups
|
||||||
|
allProviders.value = res.allProviders
|
||||||
defaultModel.value = res.default
|
defaultModel.value = res.default
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch providers:', err)
|
console.error('Failed to fetch providers:', err)
|
||||||
@@ -65,6 +67,7 @@ export const useModelsStore = defineStore('models', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
providers,
|
providers,
|
||||||
|
allProviders,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
loading,
|
loading,
|
||||||
customProviders,
|
customProviders,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import AgentSettings from "@/components/hermes/settings/AgentSettings.vue";
|
|||||||
import MemorySettings from "@/components/hermes/settings/MemorySettings.vue";
|
import MemorySettings from "@/components/hermes/settings/MemorySettings.vue";
|
||||||
import SessionSettings from "@/components/hermes/settings/SessionSettings.vue";
|
import SessionSettings from "@/components/hermes/settings/SessionSettings.vue";
|
||||||
import PrivacySettings from "@/components/hermes/settings/PrivacySettings.vue";
|
import PrivacySettings from "@/components/hermes/settings/PrivacySettings.vue";
|
||||||
|
import ModelSettings from "@/components/hermes/settings/ModelSettings.vue";
|
||||||
|
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -49,6 +50,9 @@ onMounted(() => {
|
|||||||
<NTabPane name="privacy" :tab="t('settings.tabs.privacy')">
|
<NTabPane name="privacy" :tab="t('settings.tabs.privacy')">
|
||||||
<PrivacySettings />
|
<PrivacySettings />
|
||||||
</NTabPane>
|
</NTabPane>
|
||||||
|
<NTabPane name="models" :tab="t('settings.tabs.models')">
|
||||||
|
<ModelSettings />
|
||||||
|
</NTabPane>
|
||||||
</NTabs>
|
</NTabs>
|
||||||
</NSpin>
|
</NSpin>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import { readdir, readFile, stat, writeFile, mkdir, copyFile } from 'fs/promises'
|
import { readdir, readFile, stat, writeFile, mkdir, copyFile } from 'fs/promises'
|
||||||
|
import { existsSync, readFileSync } from 'fs'
|
||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import YAML from 'js-yaml'
|
import YAML from 'js-yaml'
|
||||||
import { getActiveProfileDir, getActiveConfigPath, getActiveAuthPath, getActiveEnvPath } from '../../services/hermes/hermes-profile'
|
import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath } from '../../services/hermes/hermes-profile'
|
||||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||||
|
|
||||||
// --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) ---
|
// --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) ---
|
||||||
// Maps provider key → { api_key_envs: all env var aliases for API key, base_url_env: env var for base URL }
|
// Maps provider key → { api_key_envs: all env var aliases for API key, base_url_env: env var for base URL }
|
||||||
const PROVIDER_ENV_MAP: Record<string, { api_key_env: string; base_url_env: string }> = {
|
const PROVIDER_ENV_MAP: Record<string, { api_key_env: string; base_url_env: string }> = {
|
||||||
openrouter: { api_key_env: 'OPENROUTER_API_KEY', base_url_env: 'OPENROUTER_BASE_URL' },
|
openrouter: { api_key_env: 'OPENROUTER_API_KEY', base_url_env: '' },
|
||||||
zai: { api_key_env: 'ZAI_API_KEY', base_url_env: '' },
|
zai: { api_key_env: 'GLM_API_KEY', base_url_env: '' },
|
||||||
'kimi-coding': { api_key_env: 'KIMI_API_KEY', base_url_env: '' },
|
'kimi-coding-cn': { api_key_env: 'KIMI_CN_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: { api_key_env: 'MOONSHOT_API_KEY', base_url_env: 'MOONSHOT_BASE_URL' },
|
minimax: { api_key_env: 'MINIMAX_API_KEY', base_url_env: '' },
|
||||||
minimax: { api_key_env: 'MINIMAX_API_KEY', base_url_env: 'MINIMAX_BASE_URL' },
|
'minimax-cn': { api_key_env: 'MINIMAX_CN_API_KEY', base_url_env: '' },
|
||||||
'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: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: 'DEEPSEEK_BASE_URL' },
|
alibaba: { api_key_env: 'DASHSCOPE_API_KEY', base_url_env: '' },
|
||||||
alibaba: { api_key_env: 'DASHSCOPE_API_KEY', base_url_env: 'DASHSCOPE_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_BASE_URL' },
|
xai: { api_key_env: 'XAI_API_KEY', base_url_env: '' },
|
||||||
xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: 'XIAOMI_BASE_URL' },
|
xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: '' },
|
||||||
gemini: { api_key_env: 'GEMINI_API_KEY', base_url_env: '' },
|
gemini: { api_key_env: 'GEMINI_API_KEY', base_url_env: '' },
|
||||||
kilocode: { api_key_env: 'KILO_API_KEY', base_url_env: 'KILOCODE_BASE_URL' },
|
kilocode: { api_key_env: 'KILO_API_KEY', base_url_env: '' },
|
||||||
'ai-gateway': { api_key_env: 'AI_GATEWAY_API_KEY', base_url_env: '' },
|
'ai-gateway': { api_key_env: 'AI_GATEWAY_API_KEY', base_url_env: '' },
|
||||||
'opencode-zen': { api_key_env: 'OPENCODE_API_KEY', base_url_env: 'OPENCODE_ZEN_BASE_URL' },
|
'opencode-zen': { api_key_env: 'OPENCODE_API_KEY', base_url_env: '' },
|
||||||
'opencode-go': { api_key_env: 'OPENCODE_API_KEY', base_url_env: 'OPENCODE_GO_BASE_URL' },
|
'opencode-go': { api_key_env: 'OPENCODE_API_KEY', base_url_env: '' },
|
||||||
huggingface: { api_key_env: 'HF_TOKEN', base_url_env: 'HF_BASE_URL' },
|
huggingface: { api_key_env: 'HF_TOKEN', base_url_env: '' },
|
||||||
arcee: { api_key_env: 'ARCEE_API_KEY', base_url_env: '' },
|
arcee: { api_key_env: 'ARCEE_API_KEY', base_url_env: '' },
|
||||||
'openai-codex': { api_key_env: '', base_url_env: 'HERMES_CODEX_BASE_URL' },
|
'openai-codex': { api_key_env: '', base_url_env: '' },
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveEnvValue(key: string, value: string): Promise<void> {
|
async function saveEnvValue(key: string, value: string): Promise<void> {
|
||||||
@@ -66,33 +66,6 @@ async function saveEnvValue(key: string, value: string): Promise<void> {
|
|||||||
|
|
||||||
// --- Auth / Credential Pool ---
|
// --- Auth / Credential Pool ---
|
||||||
|
|
||||||
interface CredentialPoolEntry {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
base_url: string
|
|
||||||
access_token: string
|
|
||||||
last_status?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthJson {
|
|
||||||
credential_pool?: Record<string, CredentialPoolEntry[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
const authPath = () => getActiveAuthPath()
|
|
||||||
|
|
||||||
async function loadAuthJson(): Promise<AuthJson | null> {
|
|
||||||
try {
|
|
||||||
const raw = await readFile(authPath(), 'utf-8')
|
|
||||||
return JSON.parse(raw) as AuthJson
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveAuthJson(auth: AuthJson): Promise<void> {
|
|
||||||
await writeFile(authPath(), JSON.stringify(auth, null, 2) + '\n', 'utf-8')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchProviderModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
async function fetchProviderModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const url = baseUrl.replace(/\/+$/, '') + '/models'
|
const url = baseUrl.replace(/\/+$/, '') + '/models'
|
||||||
@@ -101,12 +74,12 @@ async function fetchProviderModels(baseUrl: string, apiKey: string): Promise<str
|
|||||||
signal: AbortSignal.timeout(8000),
|
signal: AbortSignal.timeout(8000),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(`[available-models] ${baseUrl} returned ${res.status}`)
|
console.warn(`[available-models] ${baseUrl} returned ${res.status}`)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const data = await res.json() as { data?: Array<{ id: string }> }
|
const data = await res.json() as { data?: Array<{ id: string }> }
|
||||||
if (!Array.isArray(data.data)) {
|
if (!Array.isArray(data.data)) {
|
||||||
console.error(`[available-models] ${baseUrl} returned unexpected format`)
|
console.warn(`[available-models] ${baseUrl} returned unexpected format`)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return data.data.map(m => m.id).sort()
|
return data.data.map(m => m.id).sort()
|
||||||
@@ -449,20 +422,12 @@ function buildModelGroups(config: Record<string, any>): { default: string; group
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Add current default model (if not already in custom_providers)
|
|
||||||
if (defaultModel && !allModelIds.has(defaultModel)) {
|
|
||||||
groups.unshift({ provider: 'Current', models: [{ id: defaultModel, label: defaultModel }] })
|
|
||||||
}
|
|
||||||
|
|
||||||
return { default: defaultModel, groups }
|
return { default: defaultModel, groups }
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/available-models — fetch models from all credential pool endpoints
|
// GET /api/available-models — resolve models from .env authorized providers + credential pool + custom providers
|
||||||
fsRoutes.get('/api/hermes/available-models', async (ctx) => {
|
fsRoutes.get('/api/hermes/available-models', async (ctx) => {
|
||||||
try {
|
try {
|
||||||
const auth = await loadAuthJson()
|
|
||||||
const pool = auth?.credential_pool || {}
|
|
||||||
|
|
||||||
const config = await readConfigYaml()
|
const config = await readConfigYaml()
|
||||||
const modelSection = config.model
|
const modelSection = config.model
|
||||||
let currentDefault = ''
|
let currentDefault = ''
|
||||||
@@ -474,127 +439,125 @@ fsRoutes.get('/api/hermes/available-models', async (ctx) => {
|
|||||||
currentDefault = modelSection.trim()
|
currentDefault = modelSection.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect unique endpoints from credential pool
|
const groups: Array<{ provider: string; label: string; base_url: string; models: string[]; api_key: string }> = []
|
||||||
const endpoints: Array<{ key: string; label: string; base_url: string; token: string }> = []
|
const seenProviders = new Set<string>()
|
||||||
const seenUrls = new Set<string>()
|
|
||||||
|
|
||||||
for (const [providerKey, entries] of Object.entries(pool)) {
|
// 1. Read .env to discover authorized providers via PROVIDER_ENV_MAP
|
||||||
if (!Array.isArray(entries) || entries.length === 0) continue
|
let envContent = ''
|
||||||
const entry = entries.find(e => e.last_status !== 'exhausted') || entries[0]
|
try {
|
||||||
if (!entry?.base_url || !entry?.access_token) continue
|
envContent = await readFile(getActiveEnvPath(), 'utf-8')
|
||||||
const baseUrl = entry.base_url.replace(/\/+$/, '')
|
} catch { }
|
||||||
if (seenUrls.has(baseUrl)) continue
|
|
||||||
seenUrls.add(baseUrl)
|
const envHasValue = (key: string): boolean => {
|
||||||
endpoints.push({
|
if (!key) return false
|
||||||
key: providerKey,
|
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
|
||||||
label: providerKey.replace(/^custom:/, '') || entry.label || baseUrl,
|
return !!match && match[1].trim() !== '' && !match[1].trim().startsWith('#')
|
||||||
base_url: baseUrl,
|
|
||||||
token: entry.access_token,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve models: hardcoded catalog first, live probe as fallback
|
const envGetValue = (key: string): string => {
|
||||||
const groups: Array<{ provider: string; label: string; base_url: string; models: string[] }> = []
|
if (!key) return ''
|
||||||
const liveEndpoints: typeof endpoints = []
|
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
|
||||||
|
return match?.[1]?.trim() || ''
|
||||||
|
}
|
||||||
|
|
||||||
for (const ep of endpoints) {
|
const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string) => {
|
||||||
const catalogModels = PROVIDER_MODEL_CATALOG[ep.key]
|
if (seenProviders.has(provider)) return
|
||||||
if (catalogModels && catalogModels.length > 0) {
|
seenProviders.add(provider)
|
||||||
groups.push({ provider: ep.key, label: ep.label, base_url: ep.base_url, models: catalogModels })
|
groups.push({ provider, label, base_url, models: [...models], api_key })
|
||||||
} else {
|
}
|
||||||
liveEndpoints.push(ep)
|
|
||||||
|
// Import PROVIDER_PRESETS for label + base_url lookup
|
||||||
|
const { PROVIDER_PRESETS } = await import('../../shared/providers')
|
||||||
|
|
||||||
|
// 1. Authorized providers from .env + OAuth-based providers (no api_key_env)
|
||||||
|
// Check OAuth auth (e.g. openai-codex) via auth.json
|
||||||
|
const isOAuthAuthorized = (providerKey: string): boolean => {
|
||||||
|
try {
|
||||||
|
const authPath = getActiveAuthPath()
|
||||||
|
if (!existsSync(authPath)) return false
|
||||||
|
const auth = JSON.parse(readFileSync(authPath, 'utf-8'))
|
||||||
|
return !!auth.providers?.[providerKey]?.tokens?.access_token
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (liveEndpoints.length > 0) {
|
for (const [providerKey, envMapping] of Object.entries(PROVIDER_ENV_MAP)) {
|
||||||
const results = await Promise.allSettled(
|
// Skip providers that require API key but don't have one configured
|
||||||
liveEndpoints.map(async ep => {
|
if (envMapping.api_key_env && !envHasValue(envMapping.api_key_env)) continue
|
||||||
const models = await fetchProviderModels(ep.base_url, ep.token)
|
// Skip OAuth providers that haven't been authenticated
|
||||||
return { ...ep, models }
|
if (!envMapping.api_key_env && !isOAuthAuthorized(providerKey)) continue
|
||||||
}),
|
const preset = PROVIDER_PRESETS.find(p => p.value === providerKey)
|
||||||
)
|
const label = preset?.label || providerKey.replace(/^custom:/, '')
|
||||||
|
const baseUrl = preset?.base_url || ''
|
||||||
|
const catalogModels = PROVIDER_MODEL_CATALOG[providerKey]
|
||||||
|
if (catalogModels && catalogModels.length > 0) {
|
||||||
|
const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : ''
|
||||||
|
addGroup(providerKey, label, baseUrl, catalogModels, apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const result of results) {
|
// 2. Custom providers from config.yaml — dynamically fetch models
|
||||||
if (result.status === 'fulfilled' && result.value.models.length > 0) {
|
const customProviders = Array.isArray(config.custom_providers)
|
||||||
const { key, label, base_url, models } = result.value
|
? config.custom_providers as Array<{ name: string; base_url: string; model: string; api_key?: string }>
|
||||||
groups.push({ provider: key, label, base_url, models: Array.from(new Set(models)) })
|
: []
|
||||||
} else if (result.status === 'rejected') {
|
|
||||||
console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`)
|
const customFetches = await Promise.allSettled(
|
||||||
|
customProviders.map(async cp => {
|
||||||
|
if (!cp.base_url) return null
|
||||||
|
const providerKey = `custom:${cp.name.trim().toLowerCase().replace(/ /g, '-')}`
|
||||||
|
const baseUrl = cp.base_url.replace(/\/+$/, '')
|
||||||
|
let models = [cp.model] // always include the statically configured model
|
||||||
|
if (cp.api_key) {
|
||||||
|
try {
|
||||||
|
const fetched = await fetchProviderModels(baseUrl, cp.api_key)
|
||||||
|
if (fetched.length > 0) models = fetched
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || '' }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const result of customFetches) {
|
||||||
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
|
const { providerKey, label, base_url, models, api_key: cpApiKey } = result.value
|
||||||
|
const existing = groups.find(g => g.base_url.replace(/\/+$/, '') === base_url)
|
||||||
|
if (existing) {
|
||||||
|
for (const m of models) {
|
||||||
|
if (!existing.models.includes(m)) existing.models.push(m)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addGroup(providerKey, label, base_url, models, cpApiKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate models within each group and merge groups with the same provider key
|
// Deduplicate models within each group
|
||||||
const dedupedGroups: typeof groups = []
|
|
||||||
const seenProviders = new Map<string, number>()
|
|
||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
g.models = Array.from(new Set(g.models))
|
g.models = Array.from(new Set(g.models))
|
||||||
const existingIdx = seenProviders.get(g.provider)
|
|
||||||
if (existingIdx !== undefined) {
|
|
||||||
// Merge models into existing group
|
|
||||||
const existing = dedupedGroups[existingIdx]
|
|
||||||
const existingSet = new Set(existing.models)
|
|
||||||
for (const m of g.models) {
|
|
||||||
if (!existingSet.has(m)) existing.models.push(m)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
seenProviders.set(g.provider, dedupedGroups.length)
|
|
||||||
dedupedGroups.push(g)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
// Fallback: if still no providers, fall back to config.yaml parsing
|
||||||
if (dedupedGroups.length === 0) {
|
if (groups.length === 0) {
|
||||||
const fallback = buildModelGroups(config)
|
const fallback = buildModelGroups(config)
|
||||||
ctx.body = fallback
|
const allProviders = PROVIDER_PRESETS.map(p => ({
|
||||||
|
provider: p.value,
|
||||||
|
label: p.label,
|
||||||
|
base_url: p.base_url,
|
||||||
|
models: p.models,
|
||||||
|
}))
|
||||||
|
ctx.body = { ...fallback, allProviders }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.body = { default: currentDefault, default_provider: currentDefaultProvider, groups: dedupedGroups }
|
const allProviders = PROVIDER_PRESETS.map(p => ({
|
||||||
|
provider: p.value,
|
||||||
|
label: p.label,
|
||||||
|
base_url: p.base_url,
|
||||||
|
models: p.models,
|
||||||
|
}))
|
||||||
|
|
||||||
|
ctx.body = { default: currentDefault, default_provider: currentDefaultProvider, groups, allProviders }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.status = 500
|
ctx.status = 500
|
||||||
ctx.body = { error: err.message }
|
ctx.body = { error: err.message }
|
||||||
@@ -683,21 +646,6 @@ fsRoutes.post('/api/hermes/config/providers', async (ctx) => {
|
|||||||
await writeConfigYaml(config)
|
await writeConfigYaml(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to auth.json credential_pool (all providers)
|
|
||||||
const auth = await loadAuthJson() || { credential_pool: {} }
|
|
||||||
if (!auth.credential_pool) auth.credential_pool = {}
|
|
||||||
if (!auth.credential_pool[poolKey]) {
|
|
||||||
auth.credential_pool[poolKey] = []
|
|
||||||
}
|
|
||||||
auth.credential_pool[poolKey].push({
|
|
||||||
id: `${poolKey}-${Date.now()}`,
|
|
||||||
label: name,
|
|
||||||
base_url,
|
|
||||||
access_token: api_key,
|
|
||||||
last_status: null,
|
|
||||||
})
|
|
||||||
await saveAuthJson(auth)
|
|
||||||
|
|
||||||
// Write API key to .env (built-in providers only)
|
// Write API key to .env (built-in providers only)
|
||||||
const envMapping = PROVIDER_ENV_MAP[poolKey] || PROVIDER_ENV_MAP[providerKey || '']
|
const envMapping = PROVIDER_ENV_MAP[poolKey] || PROVIDER_ENV_MAP[providerKey || '']
|
||||||
if (envMapping) {
|
if (envMapping) {
|
||||||
@@ -730,75 +678,134 @@ fsRoutes.post('/api/hermes/config/providers', async (ctx) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// PUT /api/config/providers/:poolKey — update existing provider
|
||||||
|
fsRoutes.put('/api/hermes/config/providers/:poolKey', async (ctx) => {
|
||||||
|
const poolKey = decodeURIComponent(ctx.params.poolKey)
|
||||||
|
const { name, base_url, api_key, model } = ctx.request.body as {
|
||||||
|
name?: string
|
||||||
|
base_url?: string
|
||||||
|
api_key?: string
|
||||||
|
model?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isCustom = poolKey.startsWith('custom:')
|
||||||
|
|
||||||
|
if (isCustom) {
|
||||||
|
// Update custom provider in config.yaml
|
||||||
|
const config = await readConfigYaml()
|
||||||
|
if (!Array.isArray(config.custom_providers)) {
|
||||||
|
ctx.status = 404
|
||||||
|
ctx.body = { error: `Custom provider "${poolKey}" not found` }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const entry = (config.custom_providers as any[]).find((e: any) => {
|
||||||
|
const key = `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}`
|
||||||
|
return key === poolKey
|
||||||
|
})
|
||||||
|
if (!entry) {
|
||||||
|
ctx.status = 404
|
||||||
|
ctx.body = { error: `Custom provider "${poolKey}" not found` }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name !== undefined) entry.name = name
|
||||||
|
if (base_url !== undefined) entry.base_url = base_url
|
||||||
|
if (api_key !== undefined) entry.api_key = api_key
|
||||||
|
if (model !== undefined) entry.model = model
|
||||||
|
await writeConfigYaml(config)
|
||||||
|
} else {
|
||||||
|
// Built-in provider: update API key in .env
|
||||||
|
const envMapping = PROVIDER_ENV_MAP[poolKey]
|
||||||
|
if (!envMapping?.api_key_env) {
|
||||||
|
// OAuth provider — cannot update key
|
||||||
|
ctx.status = 400
|
||||||
|
ctx.body = { error: `Cannot update credentials for "${poolKey}"` }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (api_key !== undefined) {
|
||||||
|
await saveEnvValue(envMapping.api_key_env, api_key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart gateway to pick up changes
|
||||||
|
try {
|
||||||
|
await hermesCli.restartGateway()
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[Provider] Gateway restart failed:', e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = { success: true }
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.status = 500
|
||||||
|
ctx.body = { error: err.message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// DELETE /api/config/providers/:poolKey
|
// DELETE /api/config/providers/:poolKey
|
||||||
fsRoutes.delete('/api/hermes/config/providers/:poolKey', async (ctx) => {
|
fsRoutes.delete('/api/hermes/config/providers/:poolKey', async (ctx) => {
|
||||||
const poolKey = decodeURIComponent(ctx.params.poolKey)
|
const poolKey = decodeURIComponent(ctx.params.poolKey)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = await loadAuthJson()
|
const config = await readConfigYaml()
|
||||||
if (!auth?.credential_pool) {
|
const isCustom = poolKey.startsWith('custom:')
|
||||||
ctx.status = 404
|
|
||||||
ctx.body = { error: 'No credential pool found' }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = Object.keys(auth.credential_pool)
|
if (isCustom) {
|
||||||
|
// Delete from config.yaml custom_providers
|
||||||
if (keys.length <= 1) {
|
const idx = Array.isArray(config.custom_providers)
|
||||||
ctx.status = 400
|
? (config.custom_providers as any[]).findIndex((e: any) => {
|
||||||
ctx.body = { error: 'Cannot delete the last provider' }
|
const key = `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}`
|
||||||
return
|
return key === poolKey
|
||||||
}
|
})
|
||||||
|
: -1
|
||||||
// Case-insensitive key lookup: normalize poolKey to match credential_pool
|
if (idx === -1) {
|
||||||
let resolvedKey = poolKey
|
|
||||||
if (!(poolKey in auth.credential_pool)) {
|
|
||||||
const normalized = poolKey.toLowerCase()
|
|
||||||
const match = Object.keys(auth.credential_pool).find(k => k.toLowerCase() === normalized)
|
|
||||||
if (!match) {
|
|
||||||
ctx.status = 404
|
ctx.status = 404
|
||||||
ctx.body = { error: `Provider "${poolKey}" not found` }
|
ctx.body = { error: `Custom provider "${poolKey}" not found` }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resolvedKey = match
|
(config.custom_providers as any[]).splice(idx, 1)
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is the current active provider
|
|
||||||
const config = await readConfigYaml()
|
|
||||||
const currentProvider = config.model?.provider
|
|
||||||
const isCurrent = currentProvider === poolKey || currentProvider === resolvedKey
|
|
||||||
|
|
||||||
// Save base_url before deleting
|
|
||||||
const deletedBaseUrl = auth.credential_pool[resolvedKey]?.[0]?.base_url
|
|
||||||
|
|
||||||
// 1. Delete from auth.json
|
|
||||||
delete auth.credential_pool[resolvedKey]
|
|
||||||
await saveAuthJson(auth)
|
|
||||||
|
|
||||||
// 2. Remove matching entry from config.yaml custom_providers
|
|
||||||
if (deletedBaseUrl && Array.isArray(config.custom_providers)) {
|
|
||||||
config.custom_providers = (config.custom_providers as any[]).filter(
|
|
||||||
(entry: any) => entry.base_url !== deletedBaseUrl,
|
|
||||||
)
|
|
||||||
await writeConfigYaml(config)
|
await writeConfigYaml(config)
|
||||||
|
} else {
|
||||||
|
// Built-in provider: remove API key from .env
|
||||||
|
const envMapping = PROVIDER_ENV_MAP[poolKey]
|
||||||
|
if (envMapping?.api_key_env) {
|
||||||
|
await saveEnvValue(envMapping.api_key_env, '')
|
||||||
|
} else if (!envMapping?.api_key_env) {
|
||||||
|
// OAuth provider (e.g. openai-codex): clear tokens from auth.json
|
||||||
|
try {
|
||||||
|
const authPath = getActiveAuthPath()
|
||||||
|
if (existsSync(authPath)) {
|
||||||
|
const auth = JSON.parse(readFileSync(authPath, 'utf-8'))
|
||||||
|
if (auth.providers?.[poolKey]) {
|
||||||
|
delete auth.providers[poolKey]
|
||||||
|
}
|
||||||
|
if (auth.credential_pool?.[poolKey]) {
|
||||||
|
delete auth.credential_pool[poolKey]
|
||||||
|
}
|
||||||
|
const { writeFile: wfs } = await import('fs/promises')
|
||||||
|
await wfs(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8')
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[Provider] Failed to clear OAuth tokens for ${poolKey}:`, err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. If was the current provider, switch to first remaining
|
// If was the current provider, switch to first remaining
|
||||||
|
const currentProvider = config.model?.provider
|
||||||
|
const isCurrent = currentProvider === poolKey
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
const remainingKeys = Object.keys(auth.credential_pool)
|
// Find fallback from .env authorized providers or remaining custom_providers
|
||||||
if (remainingKeys.length > 0) {
|
const freshConfig = await readConfigYaml()
|
||||||
const fallback = remainingKeys[0]
|
const remaining = Array.isArray(freshConfig.custom_providers) ? freshConfig.custom_providers as any[] : []
|
||||||
const fallbackEntry = auth.credential_pool[fallback]?.[0]
|
const fallbackCp = remaining[0]
|
||||||
const catalogModels = PROVIDER_MODEL_CATALOG[fallback] || []
|
if (fallbackCp) {
|
||||||
const fallbackModel = catalogModels[0] || fallbackEntry?.label || fallback
|
const fallbackKey = `custom:${fallbackCp.name.trim().toLowerCase().replace(/ /g, '-')}`
|
||||||
|
if (typeof freshConfig.model !== 'object' || freshConfig.model === null) {
|
||||||
const config2 = await readConfigYaml()
|
freshConfig.model = {}
|
||||||
if (typeof config2.model !== 'object' || config2.model === null) {
|
|
||||||
config2.model = {}
|
|
||||||
}
|
}
|
||||||
config2.model.default = fallbackModel
|
freshConfig.model.default = fallbackCp.model
|
||||||
config2.model.provider = fallback
|
freshConfig.model.provider = fallbackKey
|
||||||
await writeConfigYaml(config2)
|
await writeConfigYaml(freshConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,29 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
|
import { existsSync, statSync } from 'fs'
|
||||||
|
import { readFile } from 'fs/promises'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { homedir } from 'os'
|
||||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||||
|
|
||||||
export const logRoutes = new Router()
|
export const logRoutes = new Router()
|
||||||
|
|
||||||
|
const WEBUI_LOG_FILE = join(homedir(), '.hermes-web-ui', 'server.log')
|
||||||
|
|
||||||
// List available log files
|
// List available log files
|
||||||
logRoutes.get('/api/hermes/logs', async (ctx) => {
|
logRoutes.get('/api/hermes/logs', async (ctx) => {
|
||||||
const files = await hermesCli.listLogFiles()
|
const files = await hermesCli.listLogFiles()
|
||||||
|
|
||||||
|
if (existsSync(WEBUI_LOG_FILE)) {
|
||||||
|
try {
|
||||||
|
const stat = statSync(WEBUI_LOG_FILE)
|
||||||
|
const size = stat.size > 1024 * 1024
|
||||||
|
? `${(stat.size / 1024 / 1024).toFixed(1)}MB`
|
||||||
|
: `${(stat.size / 1024).toFixed(1)}KB`
|
||||||
|
const modified = stat.mtime.toLocaleString()
|
||||||
|
files.push({ name: 'webui', size, modified })
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
ctx.body = { files }
|
ctx.body = { files }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -18,20 +36,19 @@ interface LogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse a single log line into structured entry
|
// Parse a single log line into structured entry
|
||||||
function parseLine(line: string): LogEntry | null {
|
function parseLine(line: string): LogEntry {
|
||||||
// Match: 2026-04-11 20:16:16,289 INFO aiohttp.access: message
|
// Match: 2026-04-11 20:16:16,289 INFO aiohttp.access: message (agent log format)
|
||||||
const match = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s(.*)$/)
|
let match = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s(.*)$/)
|
||||||
if (match) {
|
if (match) {
|
||||||
return {
|
return { timestamp: match[1], level: match[2], logger: match[3], message: match[4], raw: line }
|
||||||
timestamp: match[1],
|
|
||||||
level: match[2],
|
|
||||||
logger: match[3],
|
|
||||||
message: match[4],
|
|
||||||
raw: line,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Unparseable line (e.g. traceback continuation)
|
// Match: [Lark] [2026-04-19 18:46:54,864] [INFO] message (gateway log format)
|
||||||
return null
|
match = line.match(/^\[(\S+?)\]\s+\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\]\s+\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\]\s(.*)$/)
|
||||||
|
if (match) {
|
||||||
|
return { timestamp: match[2], level: match[3], logger: match[1], message: match[4], raw: line }
|
||||||
|
}
|
||||||
|
// Unparseable line — keep as raw entry so nothing is lost
|
||||||
|
return { timestamp: '', level: '', logger: '', message: line, raw: line }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read log lines (parsed)
|
// Read log lines (parsed)
|
||||||
@@ -42,6 +59,29 @@ logRoutes.get('/api/hermes/logs/:name', async (ctx) => {
|
|||||||
const session = (ctx.query.session as string) || undefined
|
const session = (ctx.query.session as string) || undefined
|
||||||
const since = (ctx.query.since as string) || undefined
|
const since = (ctx.query.since as string) || undefined
|
||||||
|
|
||||||
|
// Handle hermes-web-ui's own server log
|
||||||
|
if (logName === 'webui') {
|
||||||
|
try {
|
||||||
|
if (!existsSync(WEBUI_LOG_FILE)) {
|
||||||
|
ctx.body = { entries: [] }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const content = await readFile(WEBUI_LOG_FILE, 'utf-8')
|
||||||
|
const rawLines = content.split('\n')
|
||||||
|
const sliced = rawLines.length > lines ? rawLines.slice(-lines) : rawLines
|
||||||
|
const entries: LogEntry[] = []
|
||||||
|
for (const line of sliced) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
entries.push(parseLine(line))
|
||||||
|
}
|
||||||
|
ctx.body = { entries }
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.status = 500
|
||||||
|
ctx.body = { error: err.message }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await hermesCli.readLogs(logName, lines, level, session, since)
|
const content = await hermesCli.readLogs(logName, lines, level, session, since)
|
||||||
const rawLines = content.split('\n')
|
const rawLines = content.split('\n')
|
||||||
|
|||||||
@@ -28,23 +28,26 @@ async function waitForGatewayReady(upstream: string, timeoutMs: number = 5000):
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve profile name from request */
|
||||||
|
function resolveProfile(ctx: Context): string {
|
||||||
|
return ctx.get('x-hermes-profile') || (ctx.query.profile as string) || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve upstream URL for a request based on profile header/query */
|
/** Resolve upstream URL for a request based on profile header/query */
|
||||||
function resolveUpstream(ctx: Context): string {
|
function resolveUpstream(ctx: Context): string {
|
||||||
const mgr = getGatewayManager()
|
const mgr = getGatewayManager()
|
||||||
if (mgr) {
|
if (mgr) {
|
||||||
// Check X-Hermes-Profile header or ?profile= query param
|
const profile = resolveProfile(ctx)
|
||||||
const profile = ctx.get('x-hermes-profile') || (ctx.query.profile as string)
|
if (profile && profile !== 'default') {
|
||||||
if (profile) {
|
|
||||||
return mgr.getUpstream(profile)
|
return mgr.getUpstream(profile)
|
||||||
}
|
}
|
||||||
// Default to active profile's upstream
|
|
||||||
return mgr.getUpstream()
|
return mgr.getUpstream()
|
||||||
}
|
}
|
||||||
// Fallback: static upstream from config
|
|
||||||
return config.upstream.replace(/\/$/, '')
|
return config.upstream.replace(/\/$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function proxy(ctx: Context) {
|
export async function proxy(ctx: Context) {
|
||||||
|
const profile = resolveProfile(ctx)
|
||||||
const upstream = resolveUpstream(ctx)
|
const upstream = resolveUpstream(ctx)
|
||||||
// Rewrite path for upstream gateway:
|
// Rewrite path for upstream gateway:
|
||||||
// /api/hermes/v1/* -> /v1/* (upstream uses /v1/ prefix)
|
// /api/hermes/v1/* -> /v1/* (upstream uses /v1/ prefix)
|
||||||
@@ -59,7 +62,7 @@ export async function proxy(ctx: Context) {
|
|||||||
const lower = key.toLowerCase()
|
const lower = key.toLowerCase()
|
||||||
if (lower === 'host') {
|
if (lower === 'host') {
|
||||||
headers['host'] = new URL(upstream).host
|
headers['host'] = new URL(upstream).host
|
||||||
} else if (lower === 'authorization' || lower === 'origin' || lower === 'referer' || lower === 'connection') {
|
} else if (lower === 'origin' || lower === 'referer' || lower === 'connection') {
|
||||||
continue
|
continue
|
||||||
} else {
|
} else {
|
||||||
const v = Array.isArray(value) ? value[0] : value
|
const v = Array.isArray(value) ? value[0] : value
|
||||||
@@ -67,6 +70,15 @@ export async function proxy(ctx: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inject Hermes gateway API key from profile's .env
|
||||||
|
const mgr = getGatewayManager()
|
||||||
|
if (mgr) {
|
||||||
|
const apiKey = mgr.getApiKey(profile)
|
||||||
|
if (apiKey) {
|
||||||
|
headers['authorization'] = `Bearer ${apiKey}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build request body from raw body
|
// Build request body from raw body
|
||||||
let body: string | undefined
|
let body: string | undefined
|
||||||
|
|||||||
@@ -314,6 +314,20 @@ export class GatewayManager {
|
|||||||
return `http://${host}:${port}`
|
return `http://${host}:${port}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取 profile 的 API_SERVER_KEY(从 .env 文件) */
|
||||||
|
getApiKey(profileName?: string): string | null {
|
||||||
|
const name = profileName || this.activeProfile
|
||||||
|
try {
|
||||||
|
const envPath = join(this.profileDir(name), '.env')
|
||||||
|
if (!existsSync(envPath)) return null
|
||||||
|
const content = readFileSync(envPath, 'utf-8')
|
||||||
|
const match = content.match(/^API_SERVER_KEY\s*=\s*"?([^"\n]+)"?/m)
|
||||||
|
return match?.[1]?.trim() || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getActiveProfile(): string {
|
getActiveProfile(): string {
|
||||||
return this.activeProfile
|
return this.activeProfile
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,3 +54,14 @@ export function getActiveProfileName(): string {
|
|||||||
return 'default'
|
return 'default'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get profile directory by name.
|
||||||
|
* default → ~/.hermes/
|
||||||
|
* other → ~/.hermes/profiles/{name}/
|
||||||
|
*/
|
||||||
|
export function getProfileDir(name: string): string {
|
||||||
|
if (!name || name === 'default') return HERMES_BASE
|
||||||
|
const dir = join(HERMES_BASE, 'profiles', name)
|
||||||
|
return existsSync(dir) ? dir : HERMES_BASE
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,22 +55,10 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Kimi for Coding',
|
label: 'Kimi for Coding',
|
||||||
value: 'kimi-coding',
|
|
||||||
base_url: 'https://api.kimi.com/coding/v1',
|
|
||||||
models: [
|
|
||||||
'kimi-for-coding',
|
|
||||||
'kimi-k2.5',
|
|
||||||
'kimi-k2-thinking',
|
|
||||||
'kimi-k2-thinking-turbo',
|
|
||||||
'kimi-k2-turbo-preview',
|
|
||||||
'kimi-k2-0905-preview',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Kimi for Coding (CN)',
|
|
||||||
value: 'kimi-coding-cn',
|
value: 'kimi-coding-cn',
|
||||||
base_url: 'https://api.kimi.com/coding/v1',
|
base_url: 'https://api.kimi.com/coding/v1',
|
||||||
models: [
|
models: [
|
||||||
|
'kimi-for-coding',
|
||||||
'kimi-k2.5',
|
'kimi-k2.5',
|
||||||
'kimi-k2-thinking',
|
'kimi-k2-thinking',
|
||||||
'kimi-k2-turbo-preview',
|
'kimi-k2-turbo-preview',
|
||||||
|
|||||||
+22
-2
@@ -14,7 +14,7 @@ err() { echo -e "${RED} ✗${NC} $1"; }
|
|||||||
install_node_deb() {
|
install_node_deb() {
|
||||||
echo ""
|
echo ""
|
||||||
warn "Node.js is not installed, installing via NodeSource..."
|
warn "Node.js is not installed, installing via NodeSource..."
|
||||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - >/dev/null 2>&1
|
curl -fsSL https://deb.nodesource.com/setup_23.x | sudo -E bash - >/dev/null 2>&1
|
||||||
sudo apt install -y nodejs >/dev/null 2>&1
|
sudo apt install -y nodejs >/dev/null 2>&1
|
||||||
info "Node.js $(node -v) installed"
|
info "Node.js $(node -v) installed"
|
||||||
}
|
}
|
||||||
@@ -30,9 +30,29 @@ install_node_mac() {
|
|||||||
info "Node.js $(node -v) installed"
|
info "Node.js $(node -v) installed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MIN_NODE_MAJOR=23
|
||||||
|
|
||||||
check_node() {
|
check_node() {
|
||||||
if command -v node &>/dev/null; then
|
if command -v node &>/dev/null; then
|
||||||
info "Node.js $(node -v) found ($(which node))"
|
local major
|
||||||
|
major=$(node -v | sed 's/^v//' | cut -d. -f1)
|
||||||
|
if [ "$major" -lt "$MIN_NODE_MAJOR" ] 2>/dev/null; then
|
||||||
|
warn "Node.js $(node -v) found but v${MIN_NODE_MAJOR}+ is required, upgrading..."
|
||||||
|
# Auto-upgrade based on OS
|
||||||
|
if grep -qi microsoft /proc/version 2>/dev/null; then
|
||||||
|
install_node_deb
|
||||||
|
elif command -v apt &>/dev/null; then
|
||||||
|
install_node_deb
|
||||||
|
elif command -v brew &>/dev/null || [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
install_node_mac
|
||||||
|
else
|
||||||
|
err "Node.js upgrade not supported on this system"
|
||||||
|
echo " Install manually: https://nodejs.org/"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info "Node.js $(node -v) found ($(which node))"
|
||||||
|
fi
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user