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