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,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
|
||||
Reference in New Issue
Block a user