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:
ww
2026-04-26 22:51:35 +08:00
committed by GitHub
parent b07a8fc76f
commit 610f3eb9d0
30 changed files with 2264 additions and 16 deletions
+133
View File
@@ -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')
})
})
+139
View File
@@ -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')
})
})
+351
View File
@@ -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('过滤掉噪音 IDaccounts/、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()
// 账号 Atoken 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)
})
})