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
@@ -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 里的模型(用户决定全量展示,由用户自行判断订阅是否覆盖)。
// 额外去掉噪音 IDembedding/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