Files
Hermes-ui/packages/server/src/services/hermes/copilot-models.ts
T
ekko f78fb2da90 fix: add windowsHide to child_process calls to prevent terminal popup on Windows (#637)
- copilot-models.ts: add windowsHide to `gh auth token` call
- file-provider.ts: add shared execOpts with windowsHide for all
  docker/ssh/singularity calls

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 23:00:09 +08:00

271 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, windowsHide: true })
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