mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-05-25 05:20:15 +00:00
feat(copilot): integrate GitHub Copilot provider with dynamic model list / 集成 GitHub Copilot provider 与动态模型列表 (#239)
* feat(copilot): integrate GitHub Copilot provider with dynamic model list 集成 GitHub Copilot provider 与动态模型列表 EN: - New copilot-models service: fetch live model list from GitHub /models API - Filter noise IDs (accounts/, text-embedding, rerank prefixes) - Pass through preview/disabled metadata to frontend - Cache isolated per OAuth token (FNV-1a hash key) to prevent cross-account leak - Multi-source token resolution: env > apps.json > gh CLI - ModelSelector renders PREVIEW (orange) and UNAVAILABLE (gray, non-selectable) badges with tooltips - ProviderFormModal exposes Copilot OAuth login entry - New CopilotLoginModal component: guides gh auth login device flow - ProviderCard hides delete button for OAuth-only builtin providers (copilot/codex/nous) since their credentials live outside auth.json ZH: - 新增 copilot-models 服务:从 GitHub /models live API 拉取模型列表 - 噪音 ID 过滤(accounts/、text-embedding、rerank 前缀) - preview/disabled 元数据透传至前端 - 缓存按 OAuth token 隔离(FNV-1a hash key),避免切换 profile 串账号 - 多源 token 解析优先级:env > apps.json > gh CLI - ModelSelector 渲染 PREVIEW(橙色)/ UNAVAILABLE(灰色、不可选)badge,附 tooltip - ProviderFormModal 提供 Copilot OAuth 登录入口 - 新增 CopilotLoginModal 组件:引导 gh auth login 设备流程 - ProviderCard 对 OAuth-only builtin(copilot/codex/nous)隐藏删除按钮 其凭证不在 auth.json,删除按钮原本无效 Tests / 测试: new copilot-models suite (cache isolation, noise filter, preview/disabled passthrough) + copilot-login-modal — 24/24 passed. Pre-existing sessions-db-lineage failure on upstream/main is unrelated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(copilot): switch to explicit opt-in per maintainer feedback 回应 PR #239 review:上一版会自动把系统级 GitHub OAuth 凭证(VS Code Copilot 插件、gh CLI 登录态)当作 hermes provider 拉到列表里,对未在 hermes 中注册过 Copilot 的用户造成困扰。本次改为显式 opt-in:用户必须通过 Add Provider 主动添加, 删除时按 token 来源决定是否清 ~/.hermes/.env,并避免误清理 VS Code / gh CLI 用户的 全局凭证。 Address PR #239 review feedback. Previously Copilot would silently appear in the provider list whenever the host had any GitHub OAuth token (VS Code plugin, gh CLI login). This caused confusion for users who never explicitly registered Copilot in hermes. Now Copilot requires explicit opt-in via Add Provider; on delete we only clear ~/.hermes/.env when the token actually originated there, leaving VS Code / gh CLI credentials untouched. What changed - 新增 ~/.hermes-web-ui/config.json 的 copilotEnabled flag 控制可见性 - 即便能解析到 token,未启用时也不在列表中显示 - resolveCopilotOAuthTokenWithSource 区分 token 来源(env / gh-cli / apps-json) - ProviderFormModal 增加 GitHub Copilot 入口;无 token 时进 device flow modal - CopilotLoginModal 重写为 in-app device flow 状态机(不再要求用户在终端跑 gh) - 删除 Copilot 时仅 source='env' 才清 ~/.hermes/.env,并自动 fallback 默认模型 - 老用户升级兼容:若 default 仍指向已禁用的 copilot,后端清空 default 让前端兜底 API - POST /api/hermes/copilot-auth/check-token - POST /api/hermes/copilot-auth/enable - POST /api/hermes/copilot-auth/disable - POST /api/hermes/copilot-auth/start (device flow) - POST /api/hermes/copilot-auth/poll (device flow) Tests - tests/server/copilot-auth-controller.test.ts (11 cases) - tests/server/copilot-device-flow.test.ts (12 cases) - tests/client/copilot-login-modal.test.ts 重写覆盖状态机 Follow-ups (留作后续 PR) - device flow session 未绑定 profile,登录中切 profile 会写到错的 .env - copilot device-code 接口的 expires_in 字段未使用,硬编码 15 分钟超时 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { request } from '../client'
|
||||
|
||||
export type CopilotTokenSource = 'env' | 'gh-cli' | 'apps-json' | null
|
||||
|
||||
export interface CopilotStartResult {
|
||||
session_id: string
|
||||
user_code: string
|
||||
verification_url: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
export interface CopilotPollResult {
|
||||
status: 'pending' | 'approved' | 'denied' | 'expired' | 'error'
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface CopilotCheckTokenResult {
|
||||
has_token: boolean
|
||||
source: CopilotTokenSource
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export async function startCopilotLogin(): Promise<CopilotStartResult> {
|
||||
return request<CopilotStartResult>('/api/hermes/auth/copilot/start', { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function pollCopilotLogin(sessionId: string): Promise<CopilotPollResult> {
|
||||
return request<CopilotPollResult>(`/api/hermes/auth/copilot/poll/${sessionId}`)
|
||||
}
|
||||
|
||||
export async function checkCopilotToken(): Promise<CopilotCheckTokenResult> {
|
||||
return request<CopilotCheckTokenResult>('/api/hermes/auth/copilot/check-token')
|
||||
}
|
||||
|
||||
export async function enableCopilot(): Promise<{ ok: boolean }> {
|
||||
return request<{ ok: boolean }>('/api/hermes/auth/copilot/enable', { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function disableCopilot(): Promise<{ ok: boolean; cleared_env: boolean; cleared_default?: boolean }> {
|
||||
return request<{ ok: boolean; cleared_env: boolean; cleared_default?: boolean }>('/api/hermes/auth/copilot/disable', { method: 'POST' })
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export interface AvailableModelGroup {
|
||||
base_url: string
|
||||
models: string[]
|
||||
api_key: string
|
||||
/** 可选:模型 ID -> 元数据(preview/disabled)。目前仅 Copilot 提供。 */
|
||||
model_meta?: Record<string, { preview?: boolean; disabled?: boolean }>
|
||||
}
|
||||
|
||||
export interface AvailableModelsResponse {
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { startCopilotLogin, pollCopilotLogin } from '@/api/hermes/copilot-auth'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
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 userCode = ref('')
|
||||
const verificationUrl = 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 startCopilotLogin()
|
||||
userCode.value = data.user_code
|
||||
verificationUrl.value = data.verification_url
|
||||
sessionId.value = data.session_id
|
||||
status.value = 'waiting'
|
||||
startPolling()
|
||||
} catch (err: any) {
|
||||
status.value = 'error'
|
||||
const msg = err?.message || ''
|
||||
try {
|
||||
const match = msg.match(/\{[\s\S]*\}$/)
|
||||
if (match) {
|
||||
const body = JSON.parse(match[0])
|
||||
errorMessage.value = body.error || msg
|
||||
} else {
|
||||
errorMessage.value = msg
|
||||
}
|
||||
} catch {
|
||||
errorMessage.value = msg
|
||||
}
|
||||
message.error(errorMessage.value)
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await pollCopilotLogin(sessionId.value)
|
||||
if (result.status === 'pending') {
|
||||
startPolling()
|
||||
} else if (result.status === 'approved') {
|
||||
status.value = 'approved'
|
||||
message.success(t('models.copilotApproved'))
|
||||
setTimeout(() => {
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('success'), 200)
|
||||
}, 1000)
|
||||
} else if (result.status === 'expired') {
|
||||
status.value = 'expired'
|
||||
} else if (result.status === 'denied') {
|
||||
status.value = 'error'
|
||||
errorMessage.value = t('models.copilotDenied')
|
||||
} else if (result.status === 'error') {
|
||||
status.value = 'error'
|
||||
errorMessage.value = result.error || 'Unknown error'
|
||||
}
|
||||
} catch {
|
||||
startPolling()
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
stopPolling()
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('close'), 200)
|
||||
}
|
||||
|
||||
async function copyCode() {
|
||||
const ok = await copyToClipboard(userCode.value)
|
||||
if (ok) message.success(t('models.copilotCopyCode'))
|
||||
else message.error(t('models.copilotCopyCode') + ' ✗')
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
window.open(verificationUrl.value, '_blank')
|
||||
}
|
||||
|
||||
function retry() {
|
||||
status.value = 'idle'
|
||||
userCode.value = ''
|
||||
verificationUrl.value = ''
|
||||
sessionId.value = ''
|
||||
errorMessage.value = ''
|
||||
startLogin()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
// Auto-start when modal opens
|
||||
startLogin()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="showModal"
|
||||
preset="card"
|
||||
:title="t('models.copilotLoginTitle')"
|
||||
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
|
||||
:mask-closable="status !== 'waiting'"
|
||||
@after-leave="emit('close')"
|
||||
>
|
||||
<div class="copilot-login">
|
||||
<!-- Idle / Loading -->
|
||||
<div v-if="status === 'idle' || status === 'loading'" class="copilot-login__state">
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
|
||||
<!-- Waiting for authorization -->
|
||||
<div v-else-if="status === 'waiting'" class="copilot-login__state">
|
||||
<p class="copilot-login__hint">{{ t('models.copilotWaiting') }}</p>
|
||||
<div class="copilot-login__code" @click="copyCode">
|
||||
<span class="copilot-login__code-text">{{ userCode }}</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
</div>
|
||||
<NButton type="primary" block @click="openLink">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
</template>
|
||||
{{ t('models.copilotOpenLink') }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- Approved -->
|
||||
<div v-else-if="status === 'approved'" class="copilot-login__state copilot-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.copilotApproved') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Expired -->
|
||||
<div v-else-if="status === 'expired'" class="copilot-login__state">
|
||||
<p class="copilot-login__error">{{ t('models.copilotExpired') }}</p>
|
||||
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="status === 'error'" class="copilot-login__state">
|
||||
<p class="copilot-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">
|
||||
.copilot-login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.copilot-login__state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 120px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.copilot-login__hint {
|
||||
font-size: 14px;
|
||||
color: var(--n-text-color, inherit);
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.copilot-login__code {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border: 1px solid var(--n-border-color, #e0e0e6);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
background: var(--n-color, #fafafa);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--n-primary-color, #18a058);
|
||||
}
|
||||
}
|
||||
|
||||
.copilot-login__code-text {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
letter-spacing: 4px;
|
||||
color: var(--n-text-color, inherit);
|
||||
}
|
||||
|
||||
.copilot-login__state--success {
|
||||
color: #18a058;
|
||||
|
||||
svg {
|
||||
stroke: #18a058;
|
||||
}
|
||||
}
|
||||
|
||||
.copilot-login__error {
|
||||
color: #d03050;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,29 +3,65 @@ import { ref, computed } from 'vue'
|
||||
import { NButton, useMessage, useDialog } from 'naive-ui'
|
||||
import type { AvailableModelGroup } from '@/api/hermes/system'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
import { useChatStore } from '@/stores/hermes/chat'
|
||||
import { checkCopilotToken, disableCopilot } from '@/api/hermes/copilot-auth'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{ provider: AvailableModelGroup }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const modelsStore = useModelsStore()
|
||||
const appStore = useAppStore()
|
||||
const chatStore = useChatStore()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const isCustom = computed(() => props.provider.provider.startsWith('custom:'))
|
||||
const isCopilot = computed(() => props.provider.provider === 'copilot')
|
||||
const displayName = computed(() => props.provider.label)
|
||||
const deleting = ref(false)
|
||||
|
||||
async function handleDelete() {
|
||||
let copilotMsg = ''
|
||||
if (isCopilot.value) {
|
||||
// 提前查 source,让用户清楚移除会不会影响 VS Code/gh CLI 等其他工具的登录态
|
||||
try {
|
||||
const status = await checkCopilotToken()
|
||||
if (status.source === 'env') copilotMsg = t('models.copilotDeleteHintEnv')
|
||||
else if (status.source === 'gh-cli') copilotMsg = t('models.copilotDeleteHintGhCli')
|
||||
else if (status.source === 'apps-json') copilotMsg = t('models.copilotDeleteHintAppsJson')
|
||||
} catch { /* ignore — fall back to generic confirm copy */ }
|
||||
}
|
||||
dialog.warning({
|
||||
title: t('models.deleteProvider'),
|
||||
content: t('models.deleteConfirm', { name: displayName.value }),
|
||||
content: isCopilot.value && copilotMsg
|
||||
? `${t('models.deleteConfirm', { name: displayName.value })}\n\n${copilotMsg}`
|
||||
: t('models.deleteConfirm', { name: displayName.value }),
|
||||
positiveText: t('common.delete'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: async () => {
|
||||
deleting.value = true
|
||||
try {
|
||||
await modelsStore.removeProvider(props.provider.provider)
|
||||
if (isCopilot.value) {
|
||||
// Copilot 走显式 opt-in 模型:disable 把 enabled 置 false,
|
||||
// 仅当 token 来自 ~/.hermes/.env 时才清掉,gh-cli / apps.json 不动。
|
||||
await disableCopilot()
|
||||
// 服务端会在默认模型属于 copilot 时清掉 model.default,这里再清理本地
|
||||
// 会话级 model/provider,避免 Chat 页继续显示已下架的 copilot 模型。
|
||||
chatStore.clearProviderFromSessions('copilot')
|
||||
await Promise.all([modelsStore.fetchProviders(), appStore.loadModels()])
|
||||
} else {
|
||||
await modelsStore.removeProvider(props.provider.provider)
|
||||
}
|
||||
// 删完之后若已没有默认模型,自动从剩余 provider 里挑一个,避免 chat 页
|
||||
// "无默认模型"的尴尬态。与 hermes CLI `model` 子命令的隐含行为对齐。
|
||||
if (!appStore.selectedModel && appStore.modelGroups.length > 0) {
|
||||
const first = appStore.modelGroups.find(g => g.models.length > 0)
|
||||
if (first) {
|
||||
await appStore.switchModel(first.models[0], first.provider)
|
||||
}
|
||||
}
|
||||
message.success(t('models.providerDeleted'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import { NModal, NForm, NFormItem, NInput, NInputNumber, NButton, NSelect, NRadioGroup, NRadioButton, useMessage } from 'naive-ui'
|
||||
import { NModal, NForm, NFormItem, NInput, NInputNumber, NButton, NSelect, NRadioGroup, NRadioButton, useMessage, useDialog } from 'naive-ui'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import CodexLoginModal from './CodexLoginModal.vue'
|
||||
import NousLoginModal from './NousLoginModal.vue'
|
||||
import CopilotLoginModal from './CopilotLoginModal.vue'
|
||||
import { checkCopilotToken, enableCopilot, type CopilotTokenSource } from '@/api/hermes/copilot-auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -15,12 +17,15 @@ const emit = defineEmits<{
|
||||
|
||||
const modelsStore = useModelsStore()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const showModal = ref(true)
|
||||
const loading = ref(false)
|
||||
const fetchingModels = ref(false)
|
||||
const showCodexLogin = ref(false)
|
||||
const showNousLogin = ref(false)
|
||||
const showCopilotLogin = ref(false)
|
||||
const copilotChecking = ref(false)
|
||||
|
||||
const providerType = ref<'preset' | 'custom'>('preset')
|
||||
const selectedPreset = ref<string | null>(null)
|
||||
@@ -36,6 +41,7 @@ const modelOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
|
||||
const CODEX_KEY = 'openai-codex'
|
||||
const NOUS_KEY = 'nous'
|
||||
const COPILOT_KEY = 'copilot'
|
||||
const ALIBABA_CODING_KEY = 'alibaba-coding-plan'
|
||||
const ALIBABA_CODING_REGIONS = {
|
||||
intl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
@@ -44,6 +50,7 @@ const ALIBABA_CODING_REGIONS = {
|
||||
|
||||
const isCodex = computed(() => selectedPreset.value === CODEX_KEY)
|
||||
const isNous = computed(() => selectedPreset.value === NOUS_KEY)
|
||||
const isCopilot = computed(() => selectedPreset.value === COPILOT_KEY)
|
||||
const isAlibabaCoding = computed(() => selectedPreset.value === ALIBABA_CODING_KEY)
|
||||
const alibabaCodingRegion = ref<'intl' | 'cn'>('intl')
|
||||
|
||||
@@ -73,6 +80,10 @@ watch(selectedPreset, (val) => {
|
||||
formData.value.model = group.models[0]
|
||||
}
|
||||
}
|
||||
if (val === COPILOT_KEY) {
|
||||
// 判断是否已能解析到 token:有 → 弹简单确认;无 → 走 in-app device flow
|
||||
void triggerCopilotAdd()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -150,6 +161,12 @@ async function handleSave() {
|
||||
return
|
||||
}
|
||||
|
||||
// Copilot: 走 token-aware 的添加流程(已有 token → 确认窗;否则 device flow)
|
||||
if (isCopilot.value) {
|
||||
void triggerCopilotAdd()
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.base_url.trim()) {
|
||||
message.warning(t('models.baseUrlRequired'))
|
||||
return
|
||||
@@ -199,6 +216,68 @@ async function handleNousSuccess() {
|
||||
emit('saved')
|
||||
}
|
||||
|
||||
async function handleCopilotSuccess() {
|
||||
showCopilotLogin.value = false
|
||||
message.success(t('models.providerAdded'))
|
||||
emit('saved')
|
||||
}
|
||||
|
||||
function copilotSourceLabel(source: CopilotTokenSource): string {
|
||||
if (source === 'env') return t('models.copilotAddSourceEnv')
|
||||
if (source === 'gh-cli') return t('models.copilotAddSourceGhCli')
|
||||
if (source === 'apps-json') return t('models.copilotAddSourceAppsJson')
|
||||
return ''
|
||||
}
|
||||
|
||||
async function triggerCopilotAdd() {
|
||||
if (copilotChecking.value) return
|
||||
copilotChecking.value = true
|
||||
try {
|
||||
const status = await checkCopilotToken()
|
||||
if (status.has_token) {
|
||||
// 已能解析到 token:弹确认窗,用户点 [添加] → enable + saved
|
||||
const sourceText = copilotSourceLabel(status.source)
|
||||
dialog.success({
|
||||
title: t('models.copilotAddDetectedTitle'),
|
||||
content: sourceText
|
||||
? `${t('models.copilotAddDetected')}\n\n${sourceText}`
|
||||
: t('models.copilotAddDetected'),
|
||||
positiveText: t('common.add'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await enableCopilot()
|
||||
message.success(t('models.providerAdded'))
|
||||
emit('saved')
|
||||
} catch (e: any) {
|
||||
message.error(e?.message ?? String(e))
|
||||
}
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
selectedPreset.value = null
|
||||
},
|
||||
onClose: () => {
|
||||
selectedPreset.value = null
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// 无 token:device flow
|
||||
showCopilotLogin.value = true
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message ?? String(e))
|
||||
selectedPreset.value = null
|
||||
} finally {
|
||||
copilotChecking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopilotClose() {
|
||||
showCopilotLogin.value = false
|
||||
// 用户取消 Copilot 引导时,清空选择避免卡在无 api_key 状态
|
||||
selectedPreset.value = null
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('close'), 200)
|
||||
@@ -211,7 +290,7 @@ function handleClose() {
|
||||
preset="card"
|
||||
:title="t('models.addProvider')"
|
||||
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
|
||||
:mask-closable="!loading && !showCodexLogin && !showNousLogin"
|
||||
:mask-closable="!loading && !showCodexLogin && !showNousLogin && !showCopilotLogin"
|
||||
@after-leave="emit('close')"
|
||||
>
|
||||
<NForm label-placement="top">
|
||||
@@ -326,6 +405,12 @@ function handleClose() {
|
||||
@close="showNousLogin = false"
|
||||
@success="handleNousSuccess"
|
||||
/>
|
||||
|
||||
<CopilotLoginModal
|
||||
v-if="showCopilotLogin"
|
||||
@close="handleCopilotClose"
|
||||
@success="handleCopilotSuccess"
|
||||
/>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ function isGroupCollapsed(provider: string) {
|
||||
}
|
||||
|
||||
function handleSelect(model: string, provider: string) {
|
||||
const meta = appStore.modelGroups.find(g => g.provider === provider)?.model_meta?.[model]
|
||||
if (meta?.disabled) return
|
||||
appStore.switchModel(model, provider)
|
||||
showModal.value = false
|
||||
searchQuery.value = ''
|
||||
@@ -65,6 +67,9 @@ function handleSelect(model: string, provider: string) {
|
||||
function handleCustomSubmit() {
|
||||
const model = customInput.value.trim()
|
||||
if (!model || !customProvider.value) return
|
||||
// 拦截 disabled 模型,避免 custom input 绕过列表里的灰显限制
|
||||
const meta = appStore.modelGroups.find(g => g.provider === customProvider.value)?.model_meta?.[model]
|
||||
if (meta?.disabled) return
|
||||
appStore.switchModel(model, customProvider.value)
|
||||
showModal.value = false
|
||||
searchQuery.value = ''
|
||||
@@ -122,10 +127,16 @@ function openModal() {
|
||||
v-for="model in group.models"
|
||||
:key="model"
|
||||
class="model-item"
|
||||
:class="{ active: model === appStore.selectedModel && group.provider === appStore.selectedProvider }"
|
||||
:class="{
|
||||
active: model === appStore.selectedModel && group.provider === appStore.selectedProvider,
|
||||
disabled: !!group.model_meta?.[model]?.disabled,
|
||||
}"
|
||||
:title="group.model_meta?.[model]?.disabled ? t('models.disabledTooltip') : ''"
|
||||
@click="handleSelect(model, group.provider)"
|
||||
>
|
||||
<span class="model-item-name">{{ model }}</span>
|
||||
<span v-if="group.model_meta?.[model]?.preview" class="model-badge-preview">{{ t('models.previewBadge') }}</span>
|
||||
<span v-if="group.model_meta?.[model]?.disabled" class="model-badge-disabled">{{ t('models.disabledBadge') }}</span>
|
||||
<span v-if="customModelSet.has(model)" class="model-badge-custom">{{ t('models.customBadge') }}</span>
|
||||
<svg v-if="model === appStore.selectedModel && group.provider === appStore.selectedProvider" class="model-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
@@ -285,6 +296,16 @@ function openModal() {
|
||||
color: $accent-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.model-item-name {
|
||||
@@ -313,6 +334,31 @@ function openModal() {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.model-badge-preview {
|
||||
flex-shrink: 0;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: #d97706;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
margin-right: 4px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.model-badge-disabled {
|
||||
flex-shrink: 0;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
background: transparent;
|
||||
border: 1px solid $border-color;
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
margin-right: 4px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.model-empty {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
|
||||
@@ -264,7 +264,25 @@ export default {
|
||||
nousApproved: 'Login erfolgreich',
|
||||
nousDenied: 'Autorisierung wurde abgelehnt',
|
||||
nousExpired: 'Autorisierung abgelaufen',
|
||||
copilotLoginTitle: 'GitHub Copilot Anmeldung',
|
||||
copilotWaiting: 'Öffnen Sie GitHub und geben Sie den unten angezeigten Gerätecode ein. Das Fenster schließt sich automatisch nach Genehmigung.',
|
||||
copilotCopyCode: 'Code kopiert',
|
||||
copilotOpenLink: 'GitHub-Autorisierungsseite öffnen',
|
||||
copilotApproved: 'Anmeldung erfolgreich!',
|
||||
copilotDenied: 'Autorisierung abgelehnt.',
|
||||
copilotExpired: 'Der Autorisierungslink ist abgelaufen. Bitte erneut versuchen.',
|
||||
copilotAddDetectedTitle: 'GitHub Copilot erkannt',
|
||||
copilotAddDetected: 'Auf diesem Rechner wurde ein GitHub Copilot OAuth-Token erkannt. Klicken Sie auf „Hinzufügen", um Copilot in Hermes zu aktivieren.',
|
||||
copilotAddSourceEnv: 'Quelle: ~/.hermes/.env (COPILOT_GITHUB_TOKEN)',
|
||||
copilotAddSourceGhCli: 'Quelle: gh CLI (gh auth token)',
|
||||
copilotAddSourceAppsJson: 'Quelle: VS Code Copilot-Erweiterung (apps.json)',
|
||||
copilotDeleteHintEnv: 'Dies löscht COPILOT_GITHUB_TOKEN in ~/.hermes/.env. Andere Tools sind nicht betroffen.',
|
||||
copilotDeleteHintGhCli: 'Copilot wird aus Hermes ausgeblendet. Ihre gh CLI-Anmeldung bleibt erhalten — `gh auth status` zeigt weiterhin als angemeldet.',
|
||||
copilotDeleteHintAppsJson: 'Copilot wird aus Hermes ausgeblendet. Ihre VS Code Copilot-Erweiterung bleibt angemeldet.',
|
||||
customBadge: 'BENUTZERDEF.',
|
||||
previewBadge: 'VORSCHAU',
|
||||
disabledBadge: 'NICHT VERFÜGBAR',
|
||||
disabledTooltip: "Dieses Modell ist für Ihr Konto derzeit nicht verfügbar.",
|
||||
customModelPlaceholder: 'Benutzerdefinierter Modellname',
|
||||
customModelHint: 'Enter zum Laden',
|
||||
noProviders: 'Keine Anbieter gefunden. Fugen Sie einen benutzerdefinierten Anbieter hinzu, um zu beginnen.',
|
||||
|
||||
@@ -288,7 +288,25 @@ export default {
|
||||
nousApproved: 'Login successful',
|
||||
nousDenied: 'Authorization was denied. Please try again.',
|
||||
nousExpired: 'Authorization expired. Please try again.',
|
||||
copilotLoginTitle: 'GitHub Copilot Login',
|
||||
copilotWaiting: 'Open GitHub and enter the device code below to authorize. The window will close automatically once approved.',
|
||||
copilotCopyCode: 'Code copied',
|
||||
copilotOpenLink: 'Open GitHub authorization page',
|
||||
copilotApproved: 'Sign-in succeeded!',
|
||||
copilotDenied: 'Authorization denied.',
|
||||
copilotExpired: 'The authorization link has expired. Please retry.',
|
||||
copilotAddDetectedTitle: 'GitHub Copilot detected',
|
||||
copilotAddDetected: 'A GitHub Copilot OAuth token was detected on this machine. Click Add to enable Copilot in Hermes.',
|
||||
copilotAddSourceEnv: 'Source: ~/.hermes/.env (COPILOT_GITHUB_TOKEN)',
|
||||
copilotAddSourceGhCli: 'Source: gh CLI (gh auth token)',
|
||||
copilotAddSourceAppsJson: 'Source: VS Code Copilot extension (apps.json)',
|
||||
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.',
|
||||
copilotDeleteHintAppsJson: 'Copilot will be hidden from Hermes. Your VS Code Copilot extension login is not affected.',
|
||||
customBadge: 'CUSTOM',
|
||||
previewBadge: 'PREVIEW',
|
||||
disabledBadge: 'UNAVAILABLE',
|
||||
disabledTooltip: "This model is currently unavailable for your account.",
|
||||
customModelPlaceholder: 'Custom model name',
|
||||
customModelHint: 'Enter to load',
|
||||
noProviders: 'No providers found. Add a custom provider to get started.',
|
||||
|
||||
@@ -264,7 +264,25 @@ export default {
|
||||
nousApproved: 'Inicio de sesión exitoso',
|
||||
nousDenied: 'Autorización denegada',
|
||||
nousExpired: 'Autorización expirada',
|
||||
copilotLoginTitle: 'Inicio de sesión de GitHub Copilot',
|
||||
copilotWaiting: 'Abra GitHub e introduzca el código de dispositivo a continuación para autorizar. La ventana se cerrará automáticamente tras la aprobación.',
|
||||
copilotCopyCode: 'Código copiado',
|
||||
copilotOpenLink: 'Abrir la página de autorización de GitHub',
|
||||
copilotApproved: '¡Inicio de sesión exitoso!',
|
||||
copilotDenied: 'Autorización denegada.',
|
||||
copilotExpired: 'El enlace de autorización ha caducado. Vuelva a intentarlo.',
|
||||
copilotAddDetectedTitle: 'GitHub Copilot detectado',
|
||||
copilotAddDetected: 'Se detectó un token OAuth de GitHub Copilot en este equipo. Haz clic en Agregar para habilitar Copilot en Hermes.',
|
||||
copilotAddSourceEnv: 'Origen: ~/.hermes/.env (COPILOT_GITHUB_TOKEN)',
|
||||
copilotAddSourceGhCli: 'Origen: gh CLI (gh auth token)',
|
||||
copilotAddSourceAppsJson: 'Origen: extensión Copilot de VS Code (apps.json)',
|
||||
copilotDeleteHintEnv: 'Esto borrará COPILOT_GITHUB_TOKEN en ~/.hermes/.env. Otras herramientas no se verán afectadas.',
|
||||
copilotDeleteHintGhCli: 'Copilot se ocultará de Hermes. Tu sesión de gh CLI no se verá afectada — `gh auth status` seguirá mostrando que estás conectado.',
|
||||
copilotDeleteHintAppsJson: 'Copilot se ocultará de Hermes. La extensión Copilot de VS Code seguirá conectada.',
|
||||
customBadge: 'PERSONALIZADO',
|
||||
previewBadge: 'VISTA PREVIA',
|
||||
disabledBadge: 'NO DISPONIBLE',
|
||||
disabledTooltip: "Este modelo no está disponible para tu cuenta.",
|
||||
customModelPlaceholder: 'Nombre del modelo personalizado',
|
||||
customModelHint: 'Enter para cargar',
|
||||
noProviders: 'No se encontraron proveedores. Anade un proveedor personalizado para comenzar.',
|
||||
|
||||
@@ -264,7 +264,25 @@ export default {
|
||||
nousApproved: 'Connexion réussie',
|
||||
nousDenied: 'Autorisation refusée',
|
||||
nousExpired: 'Autorisation expirée',
|
||||
copilotLoginTitle: 'Connexion GitHub Copilot',
|
||||
copilotWaiting: 'Ouvrez GitHub et saisissez le code ci-dessous pour autoriser. La fenêtre se fermera automatiquement après approbation.',
|
||||
copilotCopyCode: 'Code copié',
|
||||
copilotOpenLink: 'Ouvrir la page d\'autorisation GitHub',
|
||||
copilotApproved: 'Connexion réussie !',
|
||||
copilotDenied: 'Autorisation refusée.',
|
||||
copilotExpired: 'Le lien d\'autorisation a expiré. Veuillez réessayer.',
|
||||
copilotAddDetectedTitle: 'GitHub Copilot détecté',
|
||||
copilotAddDetected: 'Un token OAuth GitHub Copilot a été détecté sur cette machine. Cliquez sur Ajouter pour activer Copilot dans Hermes.',
|
||||
copilotAddSourceEnv: 'Source : ~/.hermes/.env (COPILOT_GITHUB_TOKEN)',
|
||||
copilotAddSourceGhCli: 'Source : gh CLI (gh auth token)',
|
||||
copilotAddSourceAppsJson: 'Source : extension Copilot de VS Code (apps.json)',
|
||||
copilotDeleteHintEnv: 'Cela supprimera COPILOT_GITHUB_TOKEN dans ~/.hermes/.env. Les autres outils ne sont pas affectés.',
|
||||
copilotDeleteHintGhCli: 'Copilot sera masqué dans Hermes. Votre connexion gh CLI n\'est pas affectée — `gh auth status` indiquera toujours que vous êtes connecté.',
|
||||
copilotDeleteHintAppsJson: 'Copilot sera masqué dans Hermes. Votre extension Copilot de VS Code reste connectée.',
|
||||
customBadge: 'PERSONNALISÉ',
|
||||
previewBadge: 'APERÇU',
|
||||
disabledBadge: 'INDISPONIBLE',
|
||||
disabledTooltip: "Ce modèle n'est pas disponible pour votre compte.",
|
||||
customModelPlaceholder: 'Nom du modèle personnalisé',
|
||||
customModelHint: 'Entrée pour charger',
|
||||
noProviders: 'Aucun fournisseur trouve. Ajoutez un fournisseur personnalise pour commencer.',
|
||||
|
||||
@@ -264,7 +264,25 @@ export default {
|
||||
nousApproved: 'ログイン成功',
|
||||
nousDenied: '認証が拒否されました',
|
||||
nousExpired: '認証の有効期限が切れました',
|
||||
copilotLoginTitle: 'GitHub Copilot ログイン',
|
||||
copilotWaiting: 'GitHub を開き、以下のデバイスコードを入力して認証してください。承認後、ウィンドウは自動的に閉じます。',
|
||||
copilotCopyCode: 'コードをコピーしました',
|
||||
copilotOpenLink: 'GitHub 認証ページを開く',
|
||||
copilotApproved: 'ログインに成功しました!',
|
||||
copilotDenied: '認証が拒否されました。',
|
||||
copilotExpired: '認証リンクの有効期限が切れました。もう一度お試しください。',
|
||||
copilotAddDetectedTitle: 'GitHub Copilot を検出しました',
|
||||
copilotAddDetected: 'このマシンで GitHub Copilot OAuth トークンを検出しました。「追加」をクリックして Hermes で Copilot を有効化します。',
|
||||
copilotAddSourceEnv: 'ソース: ~/.hermes/.env (COPILOT_GITHUB_TOKEN)',
|
||||
copilotAddSourceGhCli: 'ソース: gh CLI (gh auth token)',
|
||||
copilotAddSourceAppsJson: 'ソース: VS Code Copilot 拡張機能 (apps.json)',
|
||||
copilotDeleteHintEnv: 'この操作で ~/.hermes/.env の COPILOT_GITHUB_TOKEN を消去します。他のツールには影響しません。',
|
||||
copilotDeleteHintGhCli: 'Copilot は Hermes 上で非表示になります。gh CLI のログインには影響しません — `gh auth status` は引き続きログイン状態を表示します。',
|
||||
copilotDeleteHintAppsJson: 'Copilot は Hermes 上で非表示になります。VS Code Copilot 拡張機能のログインには影響しません。',
|
||||
customBadge: 'カスタム',
|
||||
previewBadge: 'プレビュー',
|
||||
disabledBadge: '利用不可',
|
||||
disabledTooltip: "このモデルは現在のアカウントでは利用できません。",
|
||||
customModelPlaceholder: 'カスタムモデル名',
|
||||
customModelHint: 'Enterで読み込み',
|
||||
noProviders: 'プロバイダーがありません。カスタムプロバイダーを追加して始めましょう。',
|
||||
|
||||
@@ -264,7 +264,25 @@ export default {
|
||||
nousApproved: '로그인 성공',
|
||||
nousDenied: '인증이 거부되었습니다',
|
||||
nousExpired: '인증이 만료되었습니다',
|
||||
copilotLoginTitle: 'GitHub Copilot 로그인',
|
||||
copilotWaiting: 'GitHub을 열고 아래의 디바이스 코드를 입력하여 인증하세요. 승인 후 창이 자동으로 닫힙니다.',
|
||||
copilotCopyCode: '코드가 복사되었습니다',
|
||||
copilotOpenLink: 'GitHub 인증 페이지 열기',
|
||||
copilotApproved: '로그인 성공!',
|
||||
copilotDenied: '인증이 거부되었습니다.',
|
||||
copilotExpired: '인증 링크가 만료되었습니다. 다시 시도하세요.',
|
||||
copilotAddDetectedTitle: 'GitHub Copilot 감지됨',
|
||||
copilotAddDetected: '이 컴퓨터에서 GitHub Copilot OAuth 토큰이 감지되었습니다. 추가를 클릭하여 Hermes에서 Copilot을 활성화하세요.',
|
||||
copilotAddSourceEnv: '출처: ~/.hermes/.env (COPILOT_GITHUB_TOKEN)',
|
||||
copilotAddSourceGhCli: '출처: gh CLI (gh auth token)',
|
||||
copilotAddSourceAppsJson: '출처: VS Code Copilot 확장 (apps.json)',
|
||||
copilotDeleteHintEnv: '이 작업은 ~/.hermes/.env의 COPILOT_GITHUB_TOKEN을 지웁니다. 다른 도구에는 영향이 없습니다.',
|
||||
copilotDeleteHintGhCli: 'Copilot이 Hermes에서 숨겨집니다. gh CLI 로그인에는 영향이 없으며 `gh auth status`는 여전히 로그인 상태를 표시합니다.',
|
||||
copilotDeleteHintAppsJson: 'Copilot이 Hermes에서 숨겨집니다. VS Code Copilot 확장 로그인에는 영향이 없습니다.',
|
||||
customBadge: '커스텀',
|
||||
previewBadge: '프리뷰',
|
||||
disabledBadge: '사용 불가',
|
||||
disabledTooltip: "이 모델은 현재 계정에서 사용할 수 없습니다.",
|
||||
customModelPlaceholder: '사용자 지정 모델 이름',
|
||||
customModelHint: 'Enter로 불러오기',
|
||||
noProviders: 'Provider가 없습니다. 사용자 지정 Provider를 추가하여 시작하세요.',
|
||||
|
||||
@@ -264,7 +264,25 @@ export default {
|
||||
nousApproved: 'Login bem-sucedido',
|
||||
nousDenied: 'Autorização negada',
|
||||
nousExpired: 'Autorização expirada',
|
||||
copilotLoginTitle: 'Login do GitHub Copilot',
|
||||
copilotWaiting: 'Abra o GitHub e insira o código do dispositivo abaixo para autorizar. A janela fechará automaticamente após a aprovação.',
|
||||
copilotCopyCode: 'Código copiado',
|
||||
copilotOpenLink: 'Abrir a página de autorização do GitHub',
|
||||
copilotApproved: 'Login bem-sucedido!',
|
||||
copilotDenied: 'Autorização negada.',
|
||||
copilotExpired: 'O link de autorização expirou. Tente novamente.',
|
||||
copilotAddDetectedTitle: 'GitHub Copilot detectado',
|
||||
copilotAddDetected: 'Foi detectado um token OAuth do GitHub Copilot nesta máquina. Clique em Adicionar para ativar o Copilot no Hermes.',
|
||||
copilotAddSourceEnv: 'Origem: ~/.hermes/.env (COPILOT_GITHUB_TOKEN)',
|
||||
copilotAddSourceGhCli: 'Origem: gh CLI (gh auth token)',
|
||||
copilotAddSourceAppsJson: 'Origem: extensão Copilot do VS Code (apps.json)',
|
||||
copilotDeleteHintEnv: 'Isto irá limpar o COPILOT_GITHUB_TOKEN em ~/.hermes/.env. Outras ferramentas não são afetadas.',
|
||||
copilotDeleteHintGhCli: 'O Copilot ficará oculto no Hermes. Sua sessão no gh CLI não é afetada — `gh auth status` continuará indicando que está conectado.',
|
||||
copilotDeleteHintAppsJson: 'O Copilot ficará oculto no Hermes. A extensão Copilot do VS Code continuará conectada.',
|
||||
customBadge: 'PERSONALIZADO',
|
||||
previewBadge: 'PRÉVIA',
|
||||
disabledBadge: 'INDISPONÍVEL',
|
||||
disabledTooltip: "Este modelo não está disponível para sua conta.",
|
||||
customModelPlaceholder: 'Nome do modelo personalizado',
|
||||
customModelHint: 'Enter para carregar',
|
||||
noProviders: 'Nenhum provedor encontrado. Adicione um provedor personalizado para comecar.',
|
||||
|
||||
@@ -288,7 +288,25 @@ export default {
|
||||
nousApproved: '登录成功',
|
||||
nousDenied: '授权被拒绝,请重试。',
|
||||
nousExpired: '授权已过期,请重试。',
|
||||
copilotLoginTitle: 'GitHub Copilot 登录',
|
||||
copilotWaiting: '请前往 GitHub 输入下方设备代码完成授权。授权完成后窗口会自动关闭。',
|
||||
copilotCopyCode: '代码已复制',
|
||||
copilotOpenLink: '打开 GitHub 授权页',
|
||||
copilotApproved: '登录成功!',
|
||||
copilotDenied: '授权被拒绝。',
|
||||
copilotExpired: '授权链接已过期,请重试。',
|
||||
copilotAddDetectedTitle: '检测到 GitHub Copilot',
|
||||
copilotAddDetected: '已在本机检测到 GitHub Copilot OAuth 凭证,点击「添加」即可在 Hermes 中启用 Copilot。',
|
||||
copilotAddSourceEnv: '来源:~/.hermes/.env(COPILOT_GITHUB_TOKEN)',
|
||||
copilotAddSourceGhCli: '来源:gh CLI(gh auth token)',
|
||||
copilotAddSourceAppsJson: '来源:VS Code Copilot 插件(apps.json)',
|
||||
copilotDeleteHintEnv: '此操作会清除 ~/.hermes/.env 中的 COPILOT_GITHUB_TOKEN,不影响其他工具。',
|
||||
copilotDeleteHintGhCli: 'Copilot 将从 Hermes 列表移除。不会影响 gh CLI —— `gh auth status` 仍显示已登录。',
|
||||
copilotDeleteHintAppsJson: 'Copilot 将从 Hermes 列表移除。不会影响 VS Code Copilot 插件的登录。',
|
||||
customBadge: '自定义',
|
||||
previewBadge: '预览',
|
||||
disabledBadge: '不可用',
|
||||
disabledTooltip: "此模型当前账号不可用",
|
||||
customModelPlaceholder: '自定义模型名称',
|
||||
customModelHint: '按回车加载',
|
||||
noProviders: '暂无 Provider,添加一个开始吧。',
|
||||
|
||||
@@ -259,6 +259,30 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
base_url: 'https://openrouter.ai/api/v1',
|
||||
models: [],
|
||||
},
|
||||
{
|
||||
label: 'GitHub Copilot',
|
||||
value: 'copilot',
|
||||
base_url: 'https://api.githubcopilot.com',
|
||||
models: [
|
||||
'gpt-5.4',
|
||||
'gpt-5.4-mini',
|
||||
'gpt-5-mini',
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.2-codex',
|
||||
'gpt-4.1',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'claude-sonnet-4.6',
|
||||
'claude-sonnet-4',
|
||||
'claude-sonnet-4.5',
|
||||
'claude-haiku-4.5',
|
||||
'gemini-3.1-pro-preview',
|
||||
'gemini-3-pro-preview',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-2.5-pro',
|
||||
'grok-code-fast-1',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** Build a Record<providerKey, models[]> for backend lookup */
|
||||
|
||||
@@ -1205,6 +1205,20 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function clearProviderFromSessions(provider: string) {
|
||||
if (!provider) return
|
||||
const target = provider.toLowerCase()
|
||||
let dirty = false
|
||||
for (const s of sessions.value) {
|
||||
if ((s.provider || '').toLowerCase() === target) {
|
||||
s.model = undefined
|
||||
s.provider = ''
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
if (dirty) persistSessionsList()
|
||||
}
|
||||
|
||||
function clearThinkingObservationFor(_sessionId: string) {
|
||||
// messageId 与 sessionId 的关联未单独持有;方案是切会话时一律清空。
|
||||
// 这符合 spec 定义:observation 是"当前会话范围内"的 transient 状态。
|
||||
@@ -1227,6 +1241,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
newChat,
|
||||
switchSession,
|
||||
switchSessionModel,
|
||||
clearProviderFromSessions,
|
||||
deleteSession,
|
||||
sendMessage,
|
||||
stopStreaming,
|
||||
|
||||
@@ -6,13 +6,17 @@ import ProvidersPanel from '@/components/hermes/models/ProvidersPanel.vue'
|
||||
import ProviderFormModal from '@/components/hermes/models/ProviderFormModal.vue'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
import { checkCopilotToken } from '@/api/hermes/copilot-auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
const modelsStore = useModelsStore()
|
||||
const appStore = useAppStore()
|
||||
const showModal = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
// 先 invalidate 后端 copilot 缓存(gh logout / VS Code 退出后下一次 list 立刻反映),
|
||||
// 再拉 providers。check-token 失败不阻断。
|
||||
try { await checkCopilotToken() } catch { /* ignore */ }
|
||||
modelsStore.fetchProviders()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { startDeviceFlow, pollDeviceFlow } from '../../services/hermes/copilot-device-flow'
|
||||
import { saveEnvValue, readConfigYaml, writeConfigYaml } from '../../services/config-helpers'
|
||||
import {
|
||||
invalidateAllCaches,
|
||||
resolveCopilotOAuthTokenWithSource,
|
||||
type CopilotTokenSource,
|
||||
} from '../../services/hermes/copilot-models'
|
||||
import { getActiveEnvPath } from '../../services/hermes/hermes-profile'
|
||||
import { readAppConfig, writeAppConfig } from '../../services/app-config'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { logger } from '../../services/logger'
|
||||
|
||||
const POLL_MAX_DURATION_MS = 15 * 60 * 1000 // 15 minutes hard ceiling
|
||||
const SESSION_GC_GRACE_MS = 60 * 1000
|
||||
|
||||
interface CopilotLoginSession {
|
||||
id: string
|
||||
deviceCode: string
|
||||
userCode: string
|
||||
verificationUrl: string
|
||||
expiresIn: number
|
||||
interval: number
|
||||
status: 'pending' | 'approved' | 'denied' | 'expired' | 'error'
|
||||
error?: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
const sessions = new Map<string, CopilotLoginSession>()
|
||||
|
||||
function cleanupSessions(): void {
|
||||
const now = Date.now()
|
||||
sessions.forEach((s, id) => {
|
||||
if (now - s.createdAt > POLL_MAX_DURATION_MS + SESSION_GC_GRACE_MS) {
|
||||
sessions.delete(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function persistToken(token: string): Promise<void> {
|
||||
// 与 disable 对称:只动 ~/.hermes/.env,apps.json 是 VS Code 的文件不要碰。
|
||||
// 同时把 enabled 置 true —— device flow 完成后用户已显式同意启用 Copilot。
|
||||
// NOTE: 故意不写 process.env.COPILOT_GITHUB_TOKEN —— 否则该值会跨 profile 持续覆盖
|
||||
// resolveCopilotOAuthTokenWithSource 的 .env 读取,导致切到别的 profile 仍解析到当前
|
||||
// profile 的 token。invalidateAllCaches() + .env 文件本身已能保证下次解析读到新 token。
|
||||
await saveEnvValue('COPILOT_GITHUB_TOKEN', token)
|
||||
await writeAppConfig({ copilotEnabled: true })
|
||||
invalidateAllCaches()
|
||||
}
|
||||
|
||||
async function readEnvContent(): Promise<string> {
|
||||
try { return await readFile(getActiveEnvPath(), 'utf-8') } catch { return '' }
|
||||
}
|
||||
|
||||
async function loginWorker(session: CopilotLoginSession): Promise<void> {
|
||||
const startTime = Date.now()
|
||||
let interval = Math.max(1, session.interval) * 1000
|
||||
|
||||
while (Date.now() - startTime < POLL_MAX_DURATION_MS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, interval))
|
||||
if (session.status !== 'pending') return
|
||||
|
||||
const result = await pollDeviceFlow(session.deviceCode)
|
||||
|
||||
if (result.kind === 'success') {
|
||||
try {
|
||||
await persistToken(result.access_token)
|
||||
session.status = 'approved'
|
||||
logger.info('Copilot OAuth login successful')
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Copilot OAuth: failed to persist token')
|
||||
session.status = 'error'
|
||||
session.error = err?.message ?? 'failed to persist token'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (result.kind === 'pending') continue
|
||||
if (result.kind === 'slow_down') {
|
||||
interval += 5000
|
||||
continue
|
||||
}
|
||||
if (result.kind === 'denied') {
|
||||
session.status = 'denied'
|
||||
return
|
||||
}
|
||||
if (result.kind === 'expired') {
|
||||
session.status = 'expired'
|
||||
return
|
||||
}
|
||||
logger.error('Copilot OAuth poll error: %s %s', result.error, result.description ?? '')
|
||||
session.status = 'error'
|
||||
session.error = result.description ?? result.error
|
||||
return
|
||||
}
|
||||
|
||||
session.status = 'expired'
|
||||
}
|
||||
|
||||
export async function start(ctx: any): Promise<void> {
|
||||
cleanupSessions()
|
||||
try {
|
||||
const data = await startDeviceFlow()
|
||||
const sessionId = randomUUID()
|
||||
const session: CopilotLoginSession = {
|
||||
id: sessionId,
|
||||
deviceCode: data.device_code,
|
||||
userCode: data.user_code,
|
||||
verificationUrl: data.verification_uri,
|
||||
expiresIn: data.expires_in,
|
||||
interval: data.interval,
|
||||
status: 'pending',
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
sessions.set(sessionId, session)
|
||||
|
||||
loginWorker(session).catch((err) => {
|
||||
logger.error(err, 'Copilot login worker error')
|
||||
session.status = 'error'
|
||||
session.error = err?.message ?? String(err)
|
||||
})
|
||||
|
||||
ctx.body = {
|
||||
session_id: sessionId,
|
||||
user_code: data.user_code,
|
||||
verification_url: data.verification_uri,
|
||||
expires_in: data.expires_in,
|
||||
interval: data.interval,
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Copilot OAuth start failed')
|
||||
if (err?.name === 'TimeoutError' || err?.name === 'AbortError') {
|
||||
ctx.status = 504
|
||||
ctx.body = { error: 'GitHub timeout' }
|
||||
return
|
||||
}
|
||||
ctx.status = 502
|
||||
ctx.body = { error: err?.message ?? 'GitHub OAuth start failed' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function poll(ctx: any): Promise<void> {
|
||||
const session = sessions.get(ctx.params.sessionId)
|
||||
if (!session) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Session not found' }
|
||||
return
|
||||
}
|
||||
ctx.body = { status: session.status, error: session.error || null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports current token resolution and whether Copilot is opt-in enabled.
|
||||
* Frontend Add Provider flow uses this to decide whether to show the
|
||||
* "token detected, click Add" confirmation or kick off device flow.
|
||||
*
|
||||
* Side effect: invalidates the model list cache so a subsequent listing
|
||||
* picks up gh-cli logout / VS Code sign-out without server restart.
|
||||
*/
|
||||
export async function checkToken(ctx: any): Promise<void> {
|
||||
invalidateAllCaches()
|
||||
const env = await readEnvContent()
|
||||
const { token, source } = await resolveCopilotOAuthTokenWithSource(env)
|
||||
const cfg = await readAppConfig()
|
||||
ctx.body = {
|
||||
has_token: Boolean(token),
|
||||
source: source as CopilotTokenSource,
|
||||
enabled: cfg.copilotEnabled === true,
|
||||
}
|
||||
}
|
||||
|
||||
export async function enable(ctx: any): Promise<void> {
|
||||
await writeAppConfig({ copilotEnabled: true })
|
||||
invalidateAllCaches()
|
||||
ctx.body = { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* "Soft delete" Copilot from the web-ui provider list.
|
||||
* - Always: copilotEnabled = false (hides provider regardless of token source).
|
||||
* - source='env' → also clear ~/.hermes/.env COPILOT_GITHUB_TOKEN
|
||||
* (this token belongs to the hermes ecosystem).
|
||||
* - source='gh-cli' → leave gh CLI alone (user's terminal sessions).
|
||||
* - source='apps-json' → leave VS Code Copilot plugin alone.
|
||||
* The user can re-add Copilot any time via "Add Provider".
|
||||
*/
|
||||
export async function disable(ctx: any): Promise<void> {
|
||||
const env = await readEnvContent()
|
||||
const { source } = await resolveCopilotOAuthTokenWithSource(env)
|
||||
|
||||
// 步骤 1:先清掉默认模型(最容易失败的一步:写 yaml 可能失败)。
|
||||
// 不能 swallow —— 否则会出现 "list 已隐藏 copilot 但 default 仍是 copilot" 的中间态。
|
||||
let clearedDefault = false
|
||||
try {
|
||||
const cfg = await readConfigYaml()
|
||||
const modelSection = cfg.model
|
||||
if (typeof modelSection === 'object' && modelSection !== null) {
|
||||
const provider = String(modelSection.provider || '').trim().toLowerCase()
|
||||
if (provider === 'copilot') {
|
||||
cfg.model = {}
|
||||
await writeConfigYaml(cfg)
|
||||
clearedDefault = true
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Copilot disable failed: cannot clear default model')
|
||||
ctx.status = 500
|
||||
ctx.body = { error: `failed to clear default model: ${err?.message ?? 'unknown error'}` }
|
||||
return
|
||||
}
|
||||
|
||||
// 步骤 2:清 .env(仅当 source='env')。失败也不能让 enabled flag 偷偷置 false。
|
||||
try {
|
||||
if (source === 'env') {
|
||||
await saveEnvValue('COPILOT_GITHUB_TOKEN', '')
|
||||
delete process.env.COPILOT_GITHUB_TOKEN
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Copilot disable failed: cannot clear .env')
|
||||
ctx.status = 500
|
||||
ctx.body = { error: `failed to clear .env: ${err?.message ?? 'unknown error'}` }
|
||||
return
|
||||
}
|
||||
|
||||
// 步骤 3:最后翻 enabled flag。前两步成功才执行。
|
||||
try {
|
||||
await writeAppConfig({ copilotEnabled: false })
|
||||
invalidateAllCaches()
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Copilot disable failed: cannot persist enabled flag')
|
||||
ctx.status = 500
|
||||
ctx.body = { error: `failed to persist enabled flag: ${err?.message ?? 'unknown error'}` }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.body = { ok: true, cleared_env: source === 'env', cleared_default: clearedDefault }
|
||||
}
|
||||
@@ -3,9 +3,17 @@ import { existsSync, readFileSync } from 'fs'
|
||||
import { getActiveEnvPath, getActiveAuthPath } from '../../services/hermes/hermes-profile'
|
||||
import { readConfigYaml, writeConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
|
||||
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
|
||||
import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models'
|
||||
import { readAppConfig } from '../../services/app-config'
|
||||
|
||||
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
|
||||
|
||||
// Copilot 授权检测:复用同一套 token 解析逻辑(含 ~/.config/github-copilot/apps.json
|
||||
// 与 ghp_ PAT 跳过),与 getCopilotModels 行为一致,避免出现"模型能拉到却被判未授权"。
|
||||
async function isCopilotAuthorized(envContent: string): Promise<boolean> {
|
||||
return !!(await resolveCopilotOAuthToken(envContent))
|
||||
}
|
||||
|
||||
export async function getAvailable(ctx: any) {
|
||||
try {
|
||||
const config = await readConfigYaml()
|
||||
@@ -31,7 +39,7 @@ export async function getAvailable(ctx: any) {
|
||||
currentDefault = modelSection.trim()
|
||||
}
|
||||
|
||||
const groups: Array<{ provider: string; label: string; base_url: string; models: string[]; api_key: string }> = []
|
||||
const groups: Array<{ provider: string; label: string; base_url: string; models: string[]; api_key: string; model_meta?: Record<string, { preview?: boolean; disabled?: boolean }> }> = []
|
||||
const seenProviders = new Set<string>()
|
||||
|
||||
let envContent = ''
|
||||
@@ -47,10 +55,10 @@ export async function getAvailable(ctx: any) {
|
||||
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
|
||||
return match?.[1]?.trim() || ''
|
||||
}
|
||||
const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string) => {
|
||||
const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string, model_meta?: Record<string, { preview?: boolean; disabled?: boolean }>) => {
|
||||
if (seenProviders.has(provider)) return
|
||||
seenProviders.add(provider)
|
||||
groups.push({ provider, label, base_url, models: [...models], api_key })
|
||||
groups.push({ provider, label, base_url, models: [...models], api_key, ...(model_meta ? { model_meta } : {}) })
|
||||
}
|
||||
|
||||
const isOAuthAuthorized = (providerKey: string): boolean => {
|
||||
@@ -69,9 +77,39 @@ export async function getAvailable(ctx: any) {
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
// 同一请求内复用 copilot 动态模型(getCopilotModelsDetailed 内部有 inflight + 缓存,
|
||||
// 这里再缓存到局部变量进一步减少分支)
|
||||
let copilotLiveModels: CopilotModelMeta[] | null = null
|
||||
const getCopilotLive = async (): Promise<CopilotModelMeta[]> => {
|
||||
if (copilotLiveModels !== null) return copilotLiveModels
|
||||
try { copilotLiveModels = await getCopilotModelsDetailed(envContent) }
|
||||
catch { copilotLiveModels = [] }
|
||||
return copilotLiveModels
|
||||
}
|
||||
|
||||
// Copilot 显式 opt-in:即便能解析到 token,未通过 web-ui Add Provider 显式启用
|
||||
// 时也不返回。避免误把 VS Code/gh CLI 用户的全局凭证当作 hermes provider。
|
||||
const appConfig = await readAppConfig()
|
||||
const copilotEnabled = appConfig.copilotEnabled === true
|
||||
|
||||
// 兼容老用户:上一版本会"自动 fallback discovery"出 Copilot;升级后这些用户的
|
||||
// config.yaml 可能仍把 model.default 指向某个 copilot 模型。若此时 copilot 已不
|
||||
// 启用,把返回的 default 清掉,让前端兜底自动选剩余 provider 的第一个 model。
|
||||
if (!copilotEnabled && currentDefaultProvider.toLowerCase() === 'copilot') {
|
||||
currentDefault = ''
|
||||
currentDefaultProvider = ''
|
||||
}
|
||||
|
||||
for (const [providerKey, envMapping] of Object.entries(PROVIDER_ENV_MAP)) {
|
||||
if (envMapping.api_key_env && !envHasValue(envMapping.api_key_env)) continue
|
||||
if (!envMapping.api_key_env && !isOAuthAuthorized(providerKey)) continue
|
||||
if (!envMapping.api_key_env) {
|
||||
if (providerKey === 'copilot') {
|
||||
if (!copilotEnabled) continue
|
||||
if (!(await isCopilotAuthorized(envContent))) continue
|
||||
} else if (!isOAuthAuthorized(providerKey)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
const preset = PROVIDER_PRESETS.find((p: any) => p.value === providerKey)
|
||||
const label = preset?.label || providerKey.replace(/^custom:/, '')
|
||||
let baseUrl = preset?.base_url || ''
|
||||
@@ -79,9 +117,27 @@ export async function getAvailable(ctx: any) {
|
||||
baseUrl = envGetValue(envMapping.base_url_env) || baseUrl
|
||||
}
|
||||
const catalogModels = PROVIDER_MODEL_CATALOG[providerKey]
|
||||
if (catalogModels && catalogModels.length > 0) {
|
||||
let modelsList: string[] = catalogModels && catalogModels.length > 0 ? [...catalogModels] : []
|
||||
let modelMeta: Record<string, { preview?: boolean; disabled?: boolean }> | undefined
|
||||
if (providerKey === 'copilot') {
|
||||
const live = await getCopilotLive()
|
||||
if (live.length > 0) {
|
||||
modelsList = live.map((m) => m.id)
|
||||
modelMeta = {}
|
||||
for (const m of live) {
|
||||
if (m.preview || m.disabled) {
|
||||
modelMeta[m.id] = {
|
||||
...(m.preview ? { preview: true } : {}),
|
||||
...(m.disabled ? { disabled: true } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(modelMeta).length === 0) modelMeta = undefined
|
||||
}
|
||||
}
|
||||
if (modelsList.length > 0) {
|
||||
const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : ''
|
||||
addGroup(providerKey, label, baseUrl, catalogModels, apiKey)
|
||||
addGroup(providerKey, label, baseUrl, modelsList, apiKey, modelMeta)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,15 +171,25 @@ export async function getAvailable(ctx: any) {
|
||||
|
||||
for (const g of groups) { g.models = Array.from(new Set(g.models)) }
|
||||
|
||||
// 动态拉一次 copilot 模型用于 allProviders 展示(同一请求复用缓存)
|
||||
// 未启用 Copilot 时跳过拉取,避免空跑网络请求。
|
||||
const liveCopilotModels = copilotEnabled ? await getCopilotLive() : []
|
||||
const liveCopilotIds = liveCopilotModels.map((m) => m.id)
|
||||
|
||||
const allProvidersBase = PROVIDER_PRESETS.map((p: any) => ({
|
||||
provider: p.value,
|
||||
label: p.label,
|
||||
base_url: p.base_url,
|
||||
models: p.value === 'copilot' && liveCopilotIds.length > 0 ? liveCopilotIds : p.models,
|
||||
}))
|
||||
|
||||
if (groups.length === 0) {
|
||||
const fallback = buildModelGroups(config)
|
||||
const allProviders = PROVIDER_PRESETS.map((p: any) => ({ provider: p.value, label: p.label, base_url: p.base_url, models: p.models }))
|
||||
ctx.body = { ...fallback, allProviders }
|
||||
ctx.body = { ...fallback, allProviders: allProvidersBase }
|
||||
return
|
||||
}
|
||||
|
||||
const allProviders = PROVIDER_PRESETS.map((p: any) => ({ provider: p.value, label: p.label, base_url: p.base_url, models: p.models }))
|
||||
ctx.body = { default: currentDefault, default_provider: currentDefaultProvider, groups, allProviders }
|
||||
ctx.body = { default: currentDefault, default_provider: currentDefaultProvider, groups, allProviders: allProvidersBase }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../../controllers/hermes/copilot-auth'
|
||||
|
||||
export const copilotAuthRoutes = new Router()
|
||||
|
||||
copilotAuthRoutes.post('/api/hermes/auth/copilot/start', ctrl.start)
|
||||
copilotAuthRoutes.get('/api/hermes/auth/copilot/poll/:sessionId', ctrl.poll)
|
||||
copilotAuthRoutes.get('/api/hermes/auth/copilot/check-token', ctrl.checkToken)
|
||||
copilotAuthRoutes.post('/api/hermes/auth/copilot/enable', ctrl.enable)
|
||||
copilotAuthRoutes.post('/api/hermes/auth/copilot/disable', ctrl.disable)
|
||||
@@ -18,6 +18,7 @@ import { configRoutes } from './hermes/config'
|
||||
import { logRoutes } from './hermes/logs'
|
||||
import { codexAuthRoutes } from './hermes/codex-auth'
|
||||
import { nousAuthRoutes } from './hermes/nous-auth'
|
||||
import { copilotAuthRoutes } from './hermes/copilot-auth'
|
||||
import { gatewayRoutes } from './hermes/gateways'
|
||||
import { weixinRoutes } from './hermes/weixin'
|
||||
import { fileRoutes } from './hermes/files'
|
||||
@@ -54,6 +55,7 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
|
||||
app.use(logRoutes.routes())
|
||||
app.use(codexAuthRoutes.routes())
|
||||
app.use(nousAuthRoutes.routes())
|
||||
app.use(copilotAuthRoutes.routes())
|
||||
app.use(gatewayRoutes.routes())
|
||||
app.use(weixinRoutes.routes())
|
||||
app.use(groupChatRoutes.routes()) // Must be before proxy
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
const APP_HOME = join(homedir(), '.hermes-web-ui')
|
||||
const APP_CONFIG_FILE = join(APP_HOME, 'config.json')
|
||||
|
||||
export interface AppConfig {
|
||||
// Whether GitHub Copilot has been explicitly added by the user in web-ui.
|
||||
// Default false: even when COPILOT_GITHUB_TOKEN / gh-cli / apps.json can
|
||||
// resolve a token, the Copilot provider is hidden until the user opts in
|
||||
// via "Add Provider". Mirrors how the user manages Codex/Nous: the web-ui
|
||||
// owns the provider list, system credentials are merely a fallback source.
|
||||
copilotEnabled?: boolean
|
||||
}
|
||||
|
||||
let cache: AppConfig | null = null
|
||||
|
||||
export async function readAppConfig(): Promise<AppConfig> {
|
||||
if (cache) return cache
|
||||
try {
|
||||
const raw = await readFile(APP_CONFIG_FILE, 'utf-8')
|
||||
const parsed = JSON.parse(raw) as AppConfig
|
||||
cache = parsed
|
||||
return parsed
|
||||
} catch {
|
||||
cache = {}
|
||||
return cache
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeAppConfig(patch: Partial<AppConfig>): Promise<AppConfig> {
|
||||
const current = await readAppConfig()
|
||||
const merged: AppConfig = { ...current, ...patch }
|
||||
await mkdir(APP_HOME, { recursive: true })
|
||||
await writeFile(APP_CONFIG_FILE, JSON.stringify(merged, null, 2), { mode: 0o600 })
|
||||
cache = merged
|
||||
return merged
|
||||
}
|
||||
|
||||
export function __resetAppConfigCacheForTest(): void {
|
||||
cache = null
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export const PROVIDER_ENV_MAP: Record<string, { api_key_env: string; base_url_en
|
||||
stepfun: { api_key_env: 'STEPFUN_API_KEY', base_url_env: '' },
|
||||
nous: { api_key_env: '', base_url_env: '' },
|
||||
'openai-codex': { api_key_env: '', base_url_env: '' },
|
||||
copilot: { api_key_env: '', base_url_env: '' },
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* GitHub OAuth Device Flow for Copilot login.
|
||||
*
|
||||
* Mirrors the upstream hermes-agent implementation
|
||||
* (`hermes_cli/copilot_auth.py:155-275`):
|
||||
* - POST https://github.com/login/device/code → device_code, user_code, verification_uri
|
||||
* - POST https://github.com/login/oauth/access_token → access_token (after user approves)
|
||||
* - Polling rules per RFC 8628: authorization_pending, slow_down, expired_token, access_denied
|
||||
*
|
||||
* Client ID `Ov23li8tweQw6odWQebz` is reused from upstream hermes-agent for now;
|
||||
* a dedicated web-ui OAuth App can be registered later without changing the protocol.
|
||||
*/
|
||||
|
||||
const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code'
|
||||
const GITHUB_ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token'
|
||||
export const COPILOT_OAUTH_CLIENT_ID = 'Ov23li8tweQw6odWQebz'
|
||||
export const COPILOT_OAUTH_SCOPE = 'read:user'
|
||||
const FETCH_TIMEOUT_MS = 15_000
|
||||
|
||||
export interface DeviceCodeResponse {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
export interface AccessTokenSuccess {
|
||||
kind: 'success'
|
||||
access_token: string
|
||||
token_type: string
|
||||
scope: string
|
||||
}
|
||||
|
||||
export interface AccessTokenPending {
|
||||
kind: 'pending'
|
||||
}
|
||||
|
||||
export interface AccessTokenSlowDown {
|
||||
kind: 'slow_down'
|
||||
}
|
||||
|
||||
export interface AccessTokenDenied {
|
||||
kind: 'denied'
|
||||
}
|
||||
|
||||
export interface AccessTokenExpired {
|
||||
kind: 'expired'
|
||||
}
|
||||
|
||||
export interface AccessTokenError {
|
||||
kind: 'error'
|
||||
error: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type AccessTokenResult =
|
||||
| AccessTokenSuccess
|
||||
| AccessTokenPending
|
||||
| AccessTokenSlowDown
|
||||
| AccessTokenDenied
|
||||
| AccessTokenExpired
|
||||
| AccessTokenError
|
||||
|
||||
/**
|
||||
* Request a fresh device code from GitHub. Throws on network failure or non-2xx.
|
||||
*/
|
||||
export async function startDeviceFlow(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<DeviceCodeResponse> {
|
||||
const res = await fetchImpl(GITHUB_DEVICE_CODE_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: COPILOT_OAUTH_CLIENT_ID,
|
||||
scope: COPILOT_OAUTH_SCOPE,
|
||||
}).toString(),
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`GitHub device code request failed: ${res.status} ${text}`)
|
||||
}
|
||||
|
||||
const data = await res.json() as Partial<DeviceCodeResponse>
|
||||
if (!data.device_code || !data.user_code || !data.verification_uri) {
|
||||
throw new Error('GitHub device code response missing required fields')
|
||||
}
|
||||
return {
|
||||
device_code: data.device_code,
|
||||
user_code: data.user_code,
|
||||
verification_uri: data.verification_uri,
|
||||
expires_in: typeof data.expires_in === 'number' ? data.expires_in : 900,
|
||||
interval: typeof data.interval === 'number' && data.interval > 0 ? data.interval : 5,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the access-token endpoint once. Caller is responsible for sleeping the
|
||||
* server-suggested `interval` between calls and handling slow_down/expired.
|
||||
*/
|
||||
export async function pollDeviceFlow(
|
||||
deviceCode: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<AccessTokenResult> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetchImpl(GITHUB_ACCESS_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: COPILOT_OAUTH_CLIENT_ID,
|
||||
device_code: deviceCode,
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||
}).toString(),
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
})
|
||||
} catch (err: any) {
|
||||
return { kind: 'error', error: 'network', description: err?.message ?? String(err) }
|
||||
}
|
||||
|
||||
let body: any
|
||||
try {
|
||||
body = await res.json()
|
||||
} catch {
|
||||
return { kind: 'error', error: 'parse', description: `HTTP ${res.status}` }
|
||||
}
|
||||
|
||||
if (body && typeof body.access_token === 'string' && body.access_token) {
|
||||
return {
|
||||
kind: 'success',
|
||||
access_token: body.access_token,
|
||||
token_type: body.token_type ?? 'bearer',
|
||||
scope: body.scope ?? COPILOT_OAUTH_SCOPE,
|
||||
}
|
||||
}
|
||||
|
||||
const code = typeof body?.error === 'string' ? body.error : 'unknown_error'
|
||||
switch (code) {
|
||||
case 'authorization_pending':
|
||||
return { kind: 'pending' }
|
||||
case 'slow_down':
|
||||
return { kind: 'slow_down' }
|
||||
case 'access_denied':
|
||||
return { kind: 'denied' }
|
||||
case 'expired_token':
|
||||
return { kind: 'expired' }
|
||||
default:
|
||||
return { kind: 'error', error: code, description: body?.error_description }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const COPILOT_API_TOKEN_URL = 'https://api.github.com/copilot_internal/v2/token'
|
||||
const COPILOT_MODELS_URL = 'https://api.githubcopilot.com/models'
|
||||
const EDITOR_VERSION = 'vscode/1.104.1'
|
||||
const PLUGIN_VERSION = 'copilot-chat/0.20.0'
|
||||
const USER_AGENT = 'GithubCopilot/1.155.0'
|
||||
const FETCH_TIMEOUT_MS = 8000
|
||||
const POSITIVE_TTL_MS = 60 * 60 * 1000
|
||||
const NEGATIVE_TTL_MS = 60 * 1000
|
||||
|
||||
export interface CopilotModelMeta {
|
||||
id: string
|
||||
preview: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const FALLBACK_MODELS: CopilotModelMeta[] = [
|
||||
{ id: 'gpt-5.4', preview: false, disabled: false },
|
||||
{ id: 'gpt-5.4-mini', preview: false, disabled: false },
|
||||
{ id: 'gpt-5-mini', preview: false, disabled: false },
|
||||
{ id: 'gpt-5.3-codex', preview: false, disabled: false },
|
||||
{ id: 'gpt-5.2-codex', preview: false, disabled: false },
|
||||
{ id: 'gpt-4.1', preview: false, disabled: false },
|
||||
{ id: 'gpt-4o', preview: false, disabled: false },
|
||||
{ id: 'gpt-4o-mini', preview: false, disabled: false },
|
||||
{ id: 'claude-sonnet-4.6', preview: false, disabled: false },
|
||||
{ id: 'claude-sonnet-4', preview: false, disabled: false },
|
||||
{ id: 'claude-sonnet-4.5', preview: false, disabled: false },
|
||||
{ id: 'claude-haiku-4.5', preview: false, disabled: false },
|
||||
{ id: 'gemini-3.1-pro-preview', preview: true, disabled: false },
|
||||
{ id: 'gemini-3-pro-preview', preview: true, disabled: false },
|
||||
{ id: 'gemini-3-flash-preview', preview: true, disabled: false },
|
||||
{ id: 'gemini-2.5-pro', preview: false, disabled: false },
|
||||
{ id: 'grok-code-fast-1', preview: false, disabled: false },
|
||||
]
|
||||
|
||||
interface CacheEntry {
|
||||
value: CopilotModelMeta[]
|
||||
expiresAt: number
|
||||
isFallback: boolean
|
||||
}
|
||||
|
||||
// 缓存按 oauth token 隔离:避免切换 hermes profile(不同 .env / 不同 Copilot 账号)
|
||||
// 时仍命中上一个账号的模型列表 + preview/disabled 状态。key 为 token 的非密码学哈希
|
||||
// (不直接用明文 token 作 key,减少日志/调试时泄漏风险)。无 token 场景使用 "__none__"。
|
||||
const cacheByToken: Map<string, CacheEntry> = new Map()
|
||||
const inflightByToken: Map<string, Promise<CopilotModelMeta[]>> = new Map()
|
||||
|
||||
function tokenCacheKey(oauthToken: string): string {
|
||||
if (!oauthToken) return '__none__'
|
||||
// FNV-1a 32-bit;够用作 cache key
|
||||
let h = 0x811c9dc5
|
||||
for (let i = 0; i < oauthToken.length; i++) {
|
||||
h ^= oauthToken.charCodeAt(i)
|
||||
h = Math.imul(h, 0x01000193)
|
||||
}
|
||||
return (h >>> 0).toString(16)
|
||||
}
|
||||
|
||||
function unquote(raw: string): string {
|
||||
const v = raw.trim()
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
||||
return v.slice(1, -1)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
function readEnvVar(envContent: string, key: string): string {
|
||||
if (process.env[key]) return unquote(process.env[key]!)
|
||||
const m = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
|
||||
if (m && m[1].trim() && !m[1].trim().startsWith('#')) return unquote(m[1])
|
||||
return ''
|
||||
}
|
||||
|
||||
// classic PATs (ghp_) cannot be used as Copilot OAuth tokens — mirror upstream
|
||||
// hermes-agent copilot_auth.py and skip them so callers fall through.
|
||||
function isUsableOAuthToken(token: string): boolean {
|
||||
if (!token) return false
|
||||
if (token.startsWith('ghp_')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
async function readGhAppsToken(): Promise<string> {
|
||||
const candidates = [
|
||||
join(homedir(), '.config', 'github-copilot', 'apps.json'),
|
||||
join(homedir(), '.config', 'github-copilot', 'hosts.json'),
|
||||
]
|
||||
for (const path of candidates) {
|
||||
try {
|
||||
const text = await readFile(path, 'utf-8')
|
||||
const data = JSON.parse(text)
|
||||
for (const v of Object.values(data) as any[]) {
|
||||
const tok = v?.oauth_token
|
||||
if (typeof tok === 'string' && isUsableOAuthToken(tok.trim())) return tok.trim()
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Copilot OAuth token,按 web-ui 的优先级顺序:
|
||||
* 1. COPILOT_GITHUB_TOKEN 2. GH_TOKEN 3. GITHUB_TOKEN
|
||||
* 4. ~/.config/github-copilot/apps.json (VS Code Copilot 插件存储)
|
||||
* 5. `gh auth token` CLI fallback
|
||||
* 跳过 classic PAT (ghp_),与上游 hermes-agent copilot_auth.py 行为对齐。
|
||||
* 这是单一事实来源 —— 授权检测和模型拉取都应使用此函数。
|
||||
*/
|
||||
export type CopilotTokenSource = 'env' | 'gh-cli' | 'apps-json' | null
|
||||
|
||||
export async function resolveCopilotOAuthTokenWithSource(
|
||||
envContent: string,
|
||||
): Promise<{ token: string; source: CopilotTokenSource }> {
|
||||
for (const key of ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN']) {
|
||||
const v = readEnvVar(envContent, key)
|
||||
if (isUsableOAuthToken(v)) return { token: v, source: 'env' }
|
||||
}
|
||||
const appsToken = await readGhAppsToken()
|
||||
if (appsToken) return { token: appsToken, source: 'apps-json' }
|
||||
try {
|
||||
const { stdout } = await execFileAsync('gh', ['auth', 'token'], { timeout: 3000 })
|
||||
const v = stdout.trim()
|
||||
if (isUsableOAuthToken(v)) return { token: v, source: 'gh-cli' }
|
||||
} catch { /* ignore */ }
|
||||
return { token: '', source: null }
|
||||
}
|
||||
|
||||
export async function resolveCopilotOAuthToken(envContent: string): Promise<string> {
|
||||
const { token } = await resolveCopilotOAuthTokenWithSource(envContent)
|
||||
return token
|
||||
}
|
||||
|
||||
async function exchangeForCopilotToken(oauthToken: string): Promise<string> {
|
||||
const res = await fetch(COPILOT_API_TOKEN_URL, {
|
||||
headers: {
|
||||
'Authorization': `token ${oauthToken}`,
|
||||
'Editor-Version': EDITOR_VERSION,
|
||||
'Editor-Plugin-Version': PLUGIN_VERSION,
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
})
|
||||
if (!res.ok) throw new Error(`token exchange ${res.status}`)
|
||||
const data = await res.json() as { token?: string }
|
||||
if (!data.token) throw new Error('no token in response')
|
||||
return data.token
|
||||
}
|
||||
|
||||
// ID 噪音过滤:
|
||||
// - text-embedding-* / *-embedding-* —— 嵌入模型(chat type 已过滤掉,但保留显式清单防御)
|
||||
// - accounts/msft/routers/* —— Copilot 内部路由模型,UI 模型 ID(带斜杠)会破坏 selectbox,且不可读
|
||||
// - rerank* —— rerank 模型
|
||||
// 与 opencode/models.dev 的 curated 思路一致:剔除明显非聊天用途的噪音 ID。
|
||||
const NOISE_ID_PREFIXES = ['accounts/', 'text-embedding', 'rerank']
|
||||
|
||||
function isNoiseModelId(id: string): boolean {
|
||||
const lower = id.toLowerCase()
|
||||
return NOISE_ID_PREFIXES.some((p) => lower.startsWith(p))
|
||||
}
|
||||
|
||||
async function fetchModelsList(copilotToken: string): Promise<CopilotModelMeta[]> {
|
||||
const res = await fetch(COPILOT_MODELS_URL, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${copilotToken}`,
|
||||
'Editor-Version': EDITOR_VERSION,
|
||||
'Copilot-Integration-Id': 'vscode-chat',
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
})
|
||||
if (!res.ok) throw new Error(`models fetch ${res.status}`)
|
||||
const data = await res.json() as { data?: any[] }
|
||||
if (!Array.isArray(data.data)) return []
|
||||
// 与上游 hermes-agent hermes_cli/models.py 对齐:只过滤 chat type 且 supports
|
||||
// /chat/completions endpoint。不强制 model_picker_enabled —— 用户可能想用未在 IDE
|
||||
// picker 里的模型(用户决定全量展示,由用户自行判断订阅是否覆盖)。
|
||||
// 额外去掉噪音 ID(embedding/rerank/router)。
|
||||
const seen = new Set<string>()
|
||||
const out: CopilotModelMeta[] = []
|
||||
for (const m of data.data) {
|
||||
if (m?.capabilities?.type !== 'chat') continue
|
||||
const endpoints = m?.supported_endpoints
|
||||
if (Array.isArray(endpoints) && endpoints.length > 0) {
|
||||
if (!endpoints.includes('/chat/completions')) continue
|
||||
}
|
||||
const id = String(m?.id ?? '').trim()
|
||||
if (!id || seen.has(id)) continue
|
||||
if (isNoiseModelId(id)) continue
|
||||
seen.add(id)
|
||||
out.push({
|
||||
id,
|
||||
preview: m?.preview === true,
|
||||
disabled: m?.policy?.state === 'disabled',
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function loadModelsWithToken(oauth: string): Promise<CopilotModelMeta[]> {
|
||||
if (!oauth) throw new Error('no oauth token')
|
||||
const copilotToken = await exchangeForCopilotToken(oauth)
|
||||
const models = await fetchModelsList(copilotToken)
|
||||
if (models.length === 0) throw new Error('empty model list')
|
||||
return models
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 GitHub Copilot 当前账号可用的 chat 模型列表(含 preview/disabled meta)。
|
||||
* - 缓存按 oauth token 隔离(profile 切换不会串
|
||||
* - 正缓存 1 小时(成功结果)
|
||||
* - 负缓存 60 秒(失败时缓存 fallback,避免抖动重复打慢路径)
|
||||
* - 并发请求合并:同一 token 的同时多次调用复用 inflight Promise
|
||||
*/
|
||||
export async function getCopilotModelsDetailed(envContent: string): Promise<CopilotModelMeta[]> {
|
||||
// 先解析 oauth token —— 这一步本身有 fs 读取,但不会发网络请求;用作 cache key。
|
||||
const oauth = await resolveCopilotOAuthToken(envContent)
|
||||
const key = tokenCacheKey(oauth)
|
||||
const now = Date.now()
|
||||
const hit = cacheByToken.get(key)
|
||||
if (hit && hit.expiresAt > now) return hit.value
|
||||
const existing = inflightByToken.get(key)
|
||||
if (existing) return existing
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const models = await loadModelsWithToken(oauth)
|
||||
cacheByToken.set(key, { value: models, expiresAt: Date.now() + POSITIVE_TTL_MS, isFallback: false })
|
||||
return models
|
||||
} catch {
|
||||
cacheByToken.set(key, { value: FALLBACK_MODELS, expiresAt: Date.now() + NEGATIVE_TTL_MS, isFallback: true })
|
||||
return FALLBACK_MODELS
|
||||
} finally {
|
||||
inflightByToken.delete(key)
|
||||
}
|
||||
})()
|
||||
inflightByToken.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
/** 兼容旧调用:只返回 ID 列表。 */
|
||||
export async function getCopilotModels(envContent: string): Promise<string[]> {
|
||||
const detailed = await getCopilotModelsDetailed(envContent)
|
||||
return detailed.map((m) => m.id)
|
||||
}
|
||||
|
||||
/** 仅供测试使用:清空所有缓存与 inflight 状态。 */
|
||||
export function __resetCopilotModelsCacheForTest(): void {
|
||||
cacheByToken.clear()
|
||||
inflightByToken.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销 / 切换账号后必须调用:清空所有 token 桶下的模型列表缓存与 inflight。
|
||||
* 否则下一次查询仍会命中旧账号的 cache(key 是 token 哈希;删除 token 后
|
||||
* key 变为 "__none__" 不会撞,但旧 key 的旧数据仍残留并继续返回过期模型)。
|
||||
*/
|
||||
export function invalidateAllCaches(): void {
|
||||
cacheByToken.clear()
|
||||
inflightByToken.clear()
|
||||
}
|
||||
|
||||
export const COPILOT_FALLBACK_MODELS = FALLBACK_MODELS
|
||||
@@ -321,6 +321,31 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
base_url: 'https://openrouter.ai/api/v1',
|
||||
models: [],
|
||||
},
|
||||
{
|
||||
label: 'GitHub Copilot',
|
||||
value: 'copilot',
|
||||
builtin: true,
|
||||
base_url: 'https://api.githubcopilot.com',
|
||||
models: [
|
||||
'gpt-5.4',
|
||||
'gpt-5.4-mini',
|
||||
'gpt-5-mini',
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.2-codex',
|
||||
'gpt-4.1',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'claude-sonnet-4.6',
|
||||
'claude-sonnet-4',
|
||||
'claude-sonnet-4.5',
|
||||
'claude-haiku-4.5',
|
||||
'gemini-3.1-pro-preview',
|
||||
'gemini-3-pro-preview',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-2.5-pro',
|
||||
'grok-code-fast-1',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** Build a Record<providerKey, models[]> for backend lookup */
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
const mockApi = vi.hoisted(() => ({
|
||||
startCopilotLogin: vi.fn(),
|
||||
pollCopilotLogin: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockMessage = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/copilot-auth', () => mockApi)
|
||||
vi.mock('@/utils/clipboard', () => ({ copyToClipboard: vi.fn(async () => true) }))
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (k: string) => k }),
|
||||
}))
|
||||
vi.mock('naive-ui', () => ({
|
||||
NModal: { template: '<div><slot /><slot name="footer" /></div>' },
|
||||
NButton: { template: '<button @click="$emit(\'click\')"><slot /></button>' },
|
||||
NSpin: { template: '<span class="spin" />' },
|
||||
useMessage: () => mockMessage,
|
||||
}))
|
||||
|
||||
import CopilotLoginModal from '@/components/hermes/models/CopilotLoginModal.vue'
|
||||
|
||||
function mountModal() {
|
||||
return mount(CopilotLoginModal)
|
||||
}
|
||||
|
||||
describe('CopilotLoginModal device-flow state machine', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mockApi.startCopilotLogin.mockReset()
|
||||
mockApi.pollCopilotLogin.mockReset()
|
||||
mockMessage.success.mockReset()
|
||||
mockMessage.warning.mockReset()
|
||||
mockMessage.error.mockReset()
|
||||
})
|
||||
|
||||
it('启动后进入 waiting 并显示 user_code', async () => {
|
||||
mockApi.startCopilotLogin.mockResolvedValue({
|
||||
session_id: 'sess-1',
|
||||
user_code: 'ABCD-1234',
|
||||
verification_url: 'https://github.com/login/device',
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
})
|
||||
mockApi.pollCopilotLogin.mockResolvedValue({ status: 'pending', error: null })
|
||||
|
||||
const wrapper = mountModal()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('ABCD-1234')
|
||||
expect(mockApi.startCopilotLogin).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('approved 时 emit success 且消息为 copilotApproved', async () => {
|
||||
mockApi.startCopilotLogin.mockResolvedValue({
|
||||
session_id: 'sess-2',
|
||||
user_code: 'WXYZ-9999',
|
||||
verification_url: 'https://github.com/login/device',
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
})
|
||||
mockApi.pollCopilotLogin.mockResolvedValue({ status: 'approved', error: null })
|
||||
|
||||
const wrapper = mountModal()
|
||||
await flushPromises()
|
||||
|
||||
// 推动一次 poll timer
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockMessage.success).toHaveBeenCalledWith('models.copilotApproved')
|
||||
|
||||
// approved 后 1s 自动关闭
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('success')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('expired 时进入 expired 状态并显示重试按钮', async () => {
|
||||
mockApi.startCopilotLogin.mockResolvedValue({
|
||||
session_id: 'sess-3',
|
||||
user_code: 'EXPI-RED!',
|
||||
verification_url: 'https://github.com/login/device',
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
})
|
||||
mockApi.pollCopilotLogin.mockResolvedValue({ status: 'expired', error: null })
|
||||
|
||||
const wrapper = mountModal()
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('models.copilotExpired')
|
||||
expect(wrapper.emitted('success')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('startCopilotLogin 抛错时显示 error 且不 emit success', async () => {
|
||||
mockApi.startCopilotLogin.mockRejectedValue(new Error('boom'))
|
||||
|
||||
const wrapper = mountModal()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockMessage.error).toHaveBeenCalled()
|
||||
expect(wrapper.emitted('success')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('denied 时进入 error 状态', async () => {
|
||||
mockApi.startCopilotLogin.mockResolvedValue({
|
||||
session_id: 'sess-4',
|
||||
user_code: 'NOPE',
|
||||
verification_url: 'https://github.com/login/device',
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
})
|
||||
mockApi.pollCopilotLogin.mockResolvedValue({ status: 'denied', error: null })
|
||||
|
||||
const wrapper = mountModal()
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('models.copilotDenied')
|
||||
expect(wrapper.emitted('success')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('os', async () => {
|
||||
const actual = await vi.importActual<typeof import('os')>('os')
|
||||
return { ...actual, homedir: () => '/fake/home' }
|
||||
})
|
||||
|
||||
const { mockReadFile, mockWriteFile, mockMkdir, mockSaveEnvValue, mockReadConfigYaml, mockWriteConfigYaml, mockResolveWithSource, mockInvalidate, mockReadAppConfig, mockWriteAppConfig } = vi.hoisted(() => ({
|
||||
mockReadFile: vi.fn(),
|
||||
mockWriteFile: vi.fn().mockResolvedValue(undefined),
|
||||
mockMkdir: vi.fn().mockResolvedValue(undefined),
|
||||
mockSaveEnvValue: vi.fn().mockResolvedValue(undefined),
|
||||
mockReadConfigYaml: vi.fn(),
|
||||
mockWriteConfigYaml: vi.fn().mockResolvedValue(undefined),
|
||||
mockResolveWithSource: vi.fn(),
|
||||
mockInvalidate: vi.fn(),
|
||||
mockReadAppConfig: vi.fn(),
|
||||
mockWriteAppConfig: vi.fn().mockResolvedValue({ copilotEnabled: true }),
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: mockReadFile,
|
||||
writeFile: mockWriteFile,
|
||||
mkdir: mockMkdir,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
||||
saveEnvValue: mockSaveEnvValue,
|
||||
readConfigYaml: mockReadConfigYaml,
|
||||
writeConfigYaml: mockWriteConfigYaml,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/copilot-models', () => ({
|
||||
resolveCopilotOAuthTokenWithSource: mockResolveWithSource,
|
||||
invalidateAllCaches: mockInvalidate,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
getActiveEnvPath: () => '/fake/home/.hermes/.env',
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/app-config', () => ({
|
||||
readAppConfig: mockReadAppConfig,
|
||||
writeAppConfig: mockWriteAppConfig,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/logger', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
|
||||
}))
|
||||
|
||||
import * as ctrl from '../../packages/server/src/controllers/hermes/copilot-auth'
|
||||
|
||||
function makeCtx(): any {
|
||||
return { params: {}, request: { body: {} }, body: undefined, status: 200 }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockReadFile.mockResolvedValue('')
|
||||
mockReadConfigYaml.mockResolvedValue({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.COPILOT_GITHUB_TOKEN
|
||||
})
|
||||
|
||||
describe('copilot-auth controller — checkToken', () => {
|
||||
it('reports has_token=false / source=null / enabled=false when nothing resolves', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
|
||||
mockReadAppConfig.mockResolvedValue({})
|
||||
const ctx = makeCtx()
|
||||
await ctrl.checkToken(ctx)
|
||||
expect(ctx.body).toEqual({ has_token: false, source: null, enabled: false })
|
||||
expect(mockInvalidate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports source and enabled flag', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'env' })
|
||||
mockReadAppConfig.mockResolvedValue({ copilotEnabled: true })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.checkToken(ctx)
|
||||
expect(ctx.body).toEqual({ has_token: true, source: 'env', enabled: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('copilot-auth controller — enable', () => {
|
||||
it('persists copilotEnabled=true and invalidates cache', async () => {
|
||||
const ctx = makeCtx()
|
||||
await ctrl.enable(ctx)
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: true })
|
||||
expect(mockInvalidate).toHaveBeenCalled()
|
||||
expect(ctx.body).toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('copilot-auth controller — disable', () => {
|
||||
it('clears ~/.hermes/.env when token source is env', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'env' })
|
||||
process.env.COPILOT_GITHUB_TOKEN = 'gho_xxx'
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockSaveEnvValue).toHaveBeenCalledWith('COPILOT_GITHUB_TOKEN', '')
|
||||
expect(process.env.COPILOT_GITHUB_TOKEN).toBeUndefined()
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: false })
|
||||
expect(ctx.body).toEqual({ ok: true, cleared_env: true, cleared_default: false })
|
||||
})
|
||||
|
||||
it('does NOT touch .env when token source is gh-cli (preserves gh CLI session)', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'gh-cli' })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockSaveEnvValue).not.toHaveBeenCalled()
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: false })
|
||||
expect(ctx.body).toEqual({ ok: true, cleared_env: false, cleared_default: false })
|
||||
})
|
||||
|
||||
it('does NOT touch .env when token source is apps-json (preserves VS Code Copilot)', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'apps-json' })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockSaveEnvValue).not.toHaveBeenCalled()
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: false })
|
||||
expect(ctx.body).toEqual({ ok: true, cleared_env: false, cleared_default: false })
|
||||
})
|
||||
|
||||
it('still flips enabled=false even when no token is resolvable', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockSaveEnvValue).not.toHaveBeenCalled()
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ copilotEnabled: false })
|
||||
})
|
||||
|
||||
it('clears default model when it belongs to copilot', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
|
||||
mockReadConfigYaml.mockResolvedValue({ model: { default: 'gpt-4o', provider: 'copilot' } })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockWriteConfigYaml).toHaveBeenCalledWith(expect.objectContaining({ model: {} }))
|
||||
expect(ctx.body).toEqual(expect.objectContaining({ cleared_default: true }))
|
||||
})
|
||||
|
||||
it('does NOT touch default model when it belongs to a different provider', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
|
||||
mockReadConfigYaml.mockResolvedValue({ model: { default: 'glm-4', provider: 'zhipu' } })
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(mockWriteConfigYaml).not.toHaveBeenCalled()
|
||||
expect(ctx.body).toEqual(expect.objectContaining({ cleared_default: false }))
|
||||
})
|
||||
|
||||
it('returns 500 and does NOT flip enabled flag when writeConfigYaml fails', async () => {
|
||||
mockResolveWithSource.mockResolvedValue({ token: 'gho_xxx', source: 'env' })
|
||||
mockReadConfigYaml.mockResolvedValue({ model: { default: 'gpt-4o', provider: 'copilot' } })
|
||||
mockWriteConfigYaml.mockRejectedValueOnce(new Error('disk full'))
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
expect(ctx.status).toBe(500)
|
||||
expect(mockSaveEnvValue).not.toHaveBeenCalled()
|
||||
expect(mockWriteAppConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not write process.env on persistToken / disable cleanup is defensive only', async () => {
|
||||
// disable 不依赖 process.env 被写入;只清理之前可能由外部 export 的覆盖。
|
||||
mockResolveWithSource.mockResolvedValue({ token: '', source: null })
|
||||
process.env.COPILOT_GITHUB_TOKEN = 'leftover-from-shell'
|
||||
const ctx = makeCtx()
|
||||
await ctrl.disable(ctx)
|
||||
// source=null → 不动 .env,也不清 process.env(因为不是 web-ui 自己的状态)
|
||||
expect(process.env.COPILOT_GITHUB_TOKEN).toBe('leftover-from-shell')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
startDeviceFlow,
|
||||
pollDeviceFlow,
|
||||
COPILOT_OAUTH_CLIENT_ID,
|
||||
COPILOT_OAUTH_SCOPE,
|
||||
} from '../../packages/server/src/services/hermes/copilot-device-flow'
|
||||
|
||||
function mockJsonResponse(data: any, ok = true, status = 200): any {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => data,
|
||||
text: async () => JSON.stringify(data),
|
||||
}
|
||||
}
|
||||
|
||||
describe('startDeviceFlow', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('POSTs client_id + scope and returns parsed device code', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({
|
||||
device_code: 'DC-1',
|
||||
user_code: 'USER-1234',
|
||||
verification_uri: 'https://github.com/login/device',
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}))
|
||||
const data = await startDeviceFlow(fetchSpy as any)
|
||||
expect(data.device_code).toBe('DC-1')
|
||||
expect(data.user_code).toBe('USER-1234')
|
||||
expect(data.verification_uri).toBe('https://github.com/login/device')
|
||||
expect(data.expires_in).toBe(900)
|
||||
expect(data.interval).toBe(5)
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0]
|
||||
expect(url).toBe('https://github.com/login/device/code')
|
||||
expect(init.method).toBe('POST')
|
||||
const body = String(init.body)
|
||||
expect(body).toContain(`client_id=${encodeURIComponent(COPILOT_OAUTH_CLIENT_ID)}`)
|
||||
expect(body).toContain(`scope=${encodeURIComponent(COPILOT_OAUTH_SCOPE)}`)
|
||||
})
|
||||
|
||||
it('throws on non-2xx status', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: false, status: 503, text: async () => 'unavailable',
|
||||
})
|
||||
await expect(startDeviceFlow(fetchSpy as any)).rejects.toThrow(/503/)
|
||||
})
|
||||
|
||||
it('throws when required fields are missing', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({ device_code: '' }))
|
||||
await expect(startDeviceFlow(fetchSpy as any)).rejects.toThrow(/missing required/)
|
||||
})
|
||||
|
||||
it('falls back to defaults when expires_in / interval are absent', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({
|
||||
device_code: 'DC-2',
|
||||
user_code: 'AAAA',
|
||||
verification_uri: 'https://github.com/login/device',
|
||||
}))
|
||||
const data = await startDeviceFlow(fetchSpy as any)
|
||||
expect(data.expires_in).toBe(900)
|
||||
expect(data.interval).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pollDeviceFlow', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('returns success when access_token is present', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({
|
||||
access_token: 'gho_abc',
|
||||
token_type: 'bearer',
|
||||
scope: 'read:user',
|
||||
}))
|
||||
const r = await pollDeviceFlow('DC-1', fetchSpy as any)
|
||||
expect(r.kind).toBe('success')
|
||||
if (r.kind === 'success') {
|
||||
expect(r.access_token).toBe('gho_abc')
|
||||
expect(r.token_type).toBe('bearer')
|
||||
}
|
||||
})
|
||||
|
||||
it('maps authorization_pending → pending', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({ error: 'authorization_pending' }))
|
||||
const r = await pollDeviceFlow('DC-1', fetchSpy as any)
|
||||
expect(r.kind).toBe('pending')
|
||||
})
|
||||
|
||||
it('maps slow_down → slow_down', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({ error: 'slow_down' }))
|
||||
const r = await pollDeviceFlow('DC-1', fetchSpy as any)
|
||||
expect(r.kind).toBe('slow_down')
|
||||
})
|
||||
|
||||
it('maps access_denied → denied', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({ error: 'access_denied' }))
|
||||
const r = await pollDeviceFlow('DC-1', fetchSpy as any)
|
||||
expect(r.kind).toBe('denied')
|
||||
})
|
||||
|
||||
it('maps expired_token → expired', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({ error: 'expired_token' }))
|
||||
const r = await pollDeviceFlow('DC-1', fetchSpy as any)
|
||||
expect(r.kind).toBe('expired')
|
||||
})
|
||||
|
||||
it('maps unknown server errors → error', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({
|
||||
error: 'unsupported_grant_type',
|
||||
error_description: 'bad grant',
|
||||
}))
|
||||
const r = await pollDeviceFlow('DC-1', fetchSpy as any)
|
||||
expect(r.kind).toBe('error')
|
||||
if (r.kind === 'error') {
|
||||
expect(r.error).toBe('unsupported_grant_type')
|
||||
expect(r.description).toBe('bad grant')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns error on network failure', async () => {
|
||||
const fetchSpy = vi.fn().mockRejectedValue(new Error('boom'))
|
||||
const r = await pollDeviceFlow('DC-1', fetchSpy as any)
|
||||
expect(r.kind).toBe('error')
|
||||
if (r.kind === 'error') expect(r.error).toBe('network')
|
||||
})
|
||||
|
||||
it('POSTs grant_type, client_id, device_code', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(mockJsonResponse({ access_token: 'gho_x' }))
|
||||
await pollDeviceFlow('DEVICE-CODE-XYZ', fetchSpy as any)
|
||||
const [url, init] = fetchSpy.mock.calls[0]
|
||||
expect(url).toBe('https://github.com/login/oauth/access_token')
|
||||
const body = String(init.body)
|
||||
expect(body).toContain(`client_id=${encodeURIComponent(COPILOT_OAUTH_CLIENT_ID)}`)
|
||||
expect(body).toContain('device_code=DEVICE-CODE-XYZ')
|
||||
expect(body).toContain('grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,351 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Mock os.homedir before imports so file path resolution is stable.
|
||||
vi.mock('os', async () => {
|
||||
const actual = await vi.importActual<typeof import('os')>('os')
|
||||
return { ...actual, homedir: () => '/fake/home' }
|
||||
})
|
||||
|
||||
const { mockReadFile, mockExecFile } = vi.hoisted(() => ({
|
||||
mockReadFile: vi.fn(),
|
||||
mockExecFile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({ readFile: mockReadFile }))
|
||||
vi.mock('child_process', () => ({ execFile: mockExecFile }))
|
||||
|
||||
import {
|
||||
resolveCopilotOAuthToken,
|
||||
getCopilotModels,
|
||||
getCopilotModelsDetailed,
|
||||
COPILOT_FALLBACK_MODELS,
|
||||
__resetCopilotModelsCacheForTest,
|
||||
} from '../../packages/server/src/services/hermes/copilot-models'
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env }
|
||||
const ORIGINAL_FETCH = global.fetch
|
||||
|
||||
function clearTokenEnv() {
|
||||
delete process.env.COPILOT_GITHUB_TOKEN
|
||||
delete process.env.GH_TOKEN
|
||||
delete process.env.GITHUB_TOKEN
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetCopilotModelsCacheForTest()
|
||||
vi.clearAllMocks()
|
||||
clearTokenEnv()
|
||||
// Default: apps.json read fails (ENOENT)
|
||||
mockReadFile.mockRejectedValue(new Error('ENOENT'))
|
||||
// Default: gh CLI fails
|
||||
mockExecFile.mockImplementation((_cmd: any, _args: any, _opts: any, cb: any) => {
|
||||
cb(new Error('gh not installed'), { stdout: '', stderr: '' })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV }
|
||||
global.fetch = ORIGINAL_FETCH
|
||||
})
|
||||
|
||||
describe('resolveCopilotOAuthToken', () => {
|
||||
it('优先级:COPILOT_GITHUB_TOKEN > GH_TOKEN > GITHUB_TOKEN', async () => {
|
||||
process.env.COPILOT_GITHUB_TOKEN = 'gho_copilot'
|
||||
process.env.GH_TOKEN = 'gho_gh'
|
||||
process.env.GITHUB_TOKEN = 'gho_github'
|
||||
expect(await resolveCopilotOAuthToken('')).toBe('gho_copilot')
|
||||
|
||||
delete process.env.COPILOT_GITHUB_TOKEN
|
||||
expect(await resolveCopilotOAuthToken('')).toBe('gho_gh')
|
||||
|
||||
delete process.env.GH_TOKEN
|
||||
expect(await resolveCopilotOAuthToken('')).toBe('gho_github')
|
||||
})
|
||||
|
||||
it('跳过 classic PAT (ghp_),回退到下一来源', async () => {
|
||||
process.env.GH_TOKEN = 'ghp_classic_pat'
|
||||
process.env.GITHUB_TOKEN = 'gho_oauth_token'
|
||||
expect(await resolveCopilotOAuthToken('')).toBe('gho_oauth_token')
|
||||
})
|
||||
|
||||
it('从 .env 读取并去掉两端引号', async () => {
|
||||
expect(await resolveCopilotOAuthToken('GH_TOKEN="gho_quoted"\n')).toBe('gho_quoted')
|
||||
expect(await resolveCopilotOAuthToken("GH_TOKEN='gho_single'\n")).toBe('gho_single')
|
||||
expect(await resolveCopilotOAuthToken('GH_TOKEN=gho_plain\n')).toBe('gho_plain')
|
||||
})
|
||||
|
||||
it('忽略 .env 中以 # 开头的注释行', async () => {
|
||||
expect(await resolveCopilotOAuthToken('GH_TOKEN=# comment\n')).toBe('')
|
||||
})
|
||||
|
||||
it('回退到 ~/.config/github-copilot/apps.json 的 oauth_token', async () => {
|
||||
mockReadFile.mockImplementation(async (p: string) => {
|
||||
if (p.includes('apps.json')) {
|
||||
return JSON.stringify({
|
||||
'github.com:abc': { oauth_token: 'gho_from_apps_json', user: 'me' },
|
||||
})
|
||||
}
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
expect(await resolveCopilotOAuthToken('')).toBe('gho_from_apps_json')
|
||||
})
|
||||
|
||||
it('apps.json 中的 ghp_ token 也应跳过', async () => {
|
||||
mockReadFile.mockImplementation(async (p: string) => {
|
||||
if (p.includes('apps.json')) {
|
||||
return JSON.stringify({ 'github.com:a': { oauth_token: 'ghp_pat_in_apps' } })
|
||||
}
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
expect(await resolveCopilotOAuthToken('')).toBe('')
|
||||
})
|
||||
|
||||
it('最后回退到 `gh auth token`', async () => {
|
||||
mockExecFile.mockImplementation((_cmd: any, _args: any, _opts: any, cb: any) => {
|
||||
cb(null, { stdout: 'gho_from_gh_cli\n', stderr: '' })
|
||||
})
|
||||
expect(await resolveCopilotOAuthToken('')).toBe('gho_from_gh_cli')
|
||||
})
|
||||
|
||||
it('所有来源都失败时返回空字符串', async () => {
|
||||
expect(await resolveCopilotOAuthToken('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCopilotModels', () => {
|
||||
function mockFetchSequence(responses: Array<Partial<Response> | Error>) {
|
||||
let i = 0
|
||||
global.fetch = vi.fn(async () => {
|
||||
const r = responses[i++]
|
||||
if (r instanceof Error) throw r
|
||||
return r as Response
|
||||
}) as any
|
||||
}
|
||||
|
||||
it('成功路径:返回 chat type 且 supports /chat/completions 的模型 id', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
mockFetchSequence([
|
||||
{ ok: true, json: async () => ({ token: 'tok_copilot' }) } as any,
|
||||
{
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: 'gpt-5.4', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
{ id: 'claude-opus-4.7', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions', '/v1/messages'] },
|
||||
{ id: 'embedding-1', capabilities: { type: 'embeddings' }, supported_endpoints: ['/embeddings'] },
|
||||
{ id: 'completion-only', capabilities: { type: 'chat' }, supported_endpoints: ['/completions'] },
|
||||
{ id: 'no-endpoints', capabilities: { type: 'chat' } },
|
||||
],
|
||||
}),
|
||||
} as any,
|
||||
])
|
||||
const ids = await getCopilotModels('')
|
||||
expect(ids).toContain('gpt-5.4')
|
||||
expect(ids).toContain('claude-opus-4.7')
|
||||
expect(ids).toContain('no-endpoints') // endpoints 缺省时允许
|
||||
expect(ids).not.toContain('embedding-1')
|
||||
expect(ids).not.toContain('completion-only')
|
||||
})
|
||||
|
||||
it('不再强制 model_picker_enabled —— picker_enabled=false 的模型也返回', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
mockFetchSequence([
|
||||
{ ok: true, json: async () => ({ token: 'tok' }) } as any,
|
||||
{
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: 'a', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'], model_picker_enabled: false },
|
||||
{ id: 'b', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'], model_picker_enabled: true },
|
||||
],
|
||||
}),
|
||||
} as any,
|
||||
])
|
||||
const ids = await getCopilotModels('')
|
||||
expect(ids).toEqual(expect.arrayContaining(['a', 'b']))
|
||||
})
|
||||
|
||||
it('无 token 时返回 fallback 列表', async () => {
|
||||
const ids = await getCopilotModels('')
|
||||
expect(ids).toEqual(COPILOT_FALLBACK_MODELS.map(m => m.id))
|
||||
})
|
||||
|
||||
it('token exchange 失败返回 fallback', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
mockFetchSequence([{ ok: false, status: 401 } as any])
|
||||
const ids = await getCopilotModels('')
|
||||
expect(ids).toEqual(COPILOT_FALLBACK_MODELS.map(m => m.id))
|
||||
})
|
||||
|
||||
it('models endpoint 失败返回 fallback', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
mockFetchSequence([
|
||||
{ ok: true, json: async () => ({ token: 'tok' }) } as any,
|
||||
{ ok: false, status: 503 } as any,
|
||||
])
|
||||
const ids = await getCopilotModels('')
|
||||
expect(ids).toEqual(COPILOT_FALLBACK_MODELS.map(m => m.id))
|
||||
})
|
||||
|
||||
it('网络错误(如超时)返回 fallback', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
mockFetchSequence([new Error('AbortError: timeout')])
|
||||
const ids = await getCopilotModels('')
|
||||
expect(ids).toEqual(COPILOT_FALLBACK_MODELS.map(m => m.id))
|
||||
})
|
||||
|
||||
it('正缓存命中:第二次调用不再发请求', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ token: 'tok' }) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: 'm1', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] }] }),
|
||||
})
|
||||
global.fetch = fetchMock as any
|
||||
const a = await getCopilotModels('')
|
||||
const b = await getCopilotModels('')
|
||||
expect(a).toEqual(['m1'])
|
||||
expect(b).toEqual(['m1'])
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('负缓存:失败后短期内不再重试', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
global.fetch = fetchMock as any
|
||||
const a = await getCopilotModels('')
|
||||
const b = await getCopilotModels('')
|
||||
expect(a).toEqual(COPILOT_FALLBACK_MODELS.map(m => m.id))
|
||||
expect(b).toEqual(COPILOT_FALLBACK_MODELS.map(m => m.id))
|
||||
// 无 token 时根本不会调 fetch
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('并发请求合并:同时调用 N 次只发一组请求', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ token: 'tok' }) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: 'x', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] }] }),
|
||||
})
|
||||
global.fetch = fetchMock as any
|
||||
const [a, b, c] = await Promise.all([
|
||||
getCopilotModels(''),
|
||||
getCopilotModels(''),
|
||||
getCopilotModels(''),
|
||||
])
|
||||
expect(a).toEqual(['x'])
|
||||
expect(b).toEqual(['x'])
|
||||
expect(c).toEqual(['x'])
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCopilotModels noise filter & detailed meta', () => {
|
||||
function mockFetchSequence(responses: Array<Partial<Response> | Error>) {
|
||||
let i = 0
|
||||
global.fetch = vi.fn(async () => {
|
||||
const r = responses[i++]
|
||||
if (r instanceof Error) throw r
|
||||
return r as Response
|
||||
}) as any
|
||||
}
|
||||
|
||||
it('过滤掉噪音 ID(accounts/、text-embedding、rerank 前缀)', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
mockFetchSequence([
|
||||
{ ok: true, json: async () => ({ token: 'tok' }) } as any,
|
||||
{
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: 'gpt-5.4', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
{ id: 'accounts/msft/routers/abc', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
{ id: 'text-embedding-3-small', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
{ id: 'rerank-v1', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
],
|
||||
}),
|
||||
} as any,
|
||||
])
|
||||
const ids = await getCopilotModels('')
|
||||
expect(ids).toEqual(['gpt-5.4'])
|
||||
})
|
||||
|
||||
it('detailed 返回 preview 字段', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
mockFetchSequence([
|
||||
{ ok: true, json: async () => ({ token: 'tok' }) } as any,
|
||||
{
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: 'gemini-3-pro-preview', preview: true, capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
{ id: 'gpt-4o', preview: false, capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
],
|
||||
}),
|
||||
} as any,
|
||||
])
|
||||
const detailed = await getCopilotModelsDetailed('')
|
||||
expect(detailed).toEqual([
|
||||
{ id: 'gemini-3-pro-preview', preview: true, disabled: false },
|
||||
{ id: 'gpt-4o', preview: false, disabled: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('detailed 返回 disabled 字段(policy.state === "disabled")', async () => {
|
||||
process.env.GH_TOKEN = 'gho_token'
|
||||
mockFetchSequence([
|
||||
{ ok: true, json: async () => ({ token: 'tok' }) } as any,
|
||||
{
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: 'gpt-3.5-turbo', policy: { state: 'disabled' }, capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
{ id: 'gpt-4o', policy: { state: 'enabled' }, capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
{ id: 'claude-sonnet-4', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] },
|
||||
],
|
||||
}),
|
||||
} as any,
|
||||
])
|
||||
const detailed = await getCopilotModelsDetailed('')
|
||||
const map = new Map(detailed.map((m) => [m.id, m]))
|
||||
expect(map.get('gpt-3.5-turbo')?.disabled).toBe(true)
|
||||
expect(map.get('gpt-4o')?.disabled).toBe(false)
|
||||
expect(map.get('claude-sonnet-4')?.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('缓存按 oauth token 隔离:切换账号会重新拉取', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
// 账号 A:token exchange + models
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ token: 'tokA' }) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: 'model-a', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] }] }),
|
||||
})
|
||||
// 账号 B:另一组 token exchange + models
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ token: 'tokB' }) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: 'model-b', capabilities: { type: 'chat' }, supported_endpoints: ['/chat/completions'] }] }),
|
||||
})
|
||||
global.fetch = fetchMock as any
|
||||
|
||||
process.env.GH_TOKEN = 'gho_account_A'
|
||||
const a = await getCopilotModels('')
|
||||
expect(a).toEqual(['model-a'])
|
||||
|
||||
// 切换到账号 B,不 reset cache
|
||||
process.env.GH_TOKEN = 'gho_account_B'
|
||||
const b = await getCopilotModels('')
|
||||
expect(b).toEqual(['model-b'])
|
||||
|
||||
// 再切回 A:应该命中 A 的缓存(不再发请求)
|
||||
process.env.GH_TOKEN = 'gho_account_A'
|
||||
const a2 = await getCopilotModels('')
|
||||
expect(a2).toEqual(['model-a'])
|
||||
|
||||
// 总共 4 次请求(A.exchange、A.models、B.exchange、B.models),切回 A 时命中缓存
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user