2026-04-26 22:51:35 +08:00
|
|
|
|
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 {
|
2026-05-11 23:00:09 +08:00
|
|
|
|
const { stdout } = await execFileAsync('gh', ['auth', 'token'], { timeout: 3000, windowsHide: true })
|
2026-04-26 22:51:35 +08:00
|
|
|
|
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
|