✨ feat(vscode): 新增会话 transcript 推断 profilePath 纯模块
This commit is contained in:
@@ -0,0 +1,195 @@
|
|||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import * as os from 'node:os'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
collectProfileModelIds,
|
||||||
|
extractLastAssistantModel,
|
||||||
|
inferProfilePathForSession,
|
||||||
|
matchProfilePathForModel,
|
||||||
|
normalizeModelId,
|
||||||
|
} from './sessionProfileInference'
|
||||||
|
|
||||||
|
const tempDirs: string[] = []
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (tempDirs.length > 0) {
|
||||||
|
const dir = tempDirs.pop()
|
||||||
|
if (dir)
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function tempDir(prefix: string): string {
|
||||||
|
const dir = mkdtempSync(path.join(os.tmpdir(), prefix))
|
||||||
|
tempDirs.push(dir)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('normalizeModelId', () => {
|
||||||
|
it('小写并去掉 -build 后缀', () => {
|
||||||
|
expect(normalizeModelId('Grok-4.5-build')).toBe('grok-4.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('去掉方括号后缀如 [1m]', () => {
|
||||||
|
expect(normalizeModelId('grok-4.5[1m]')).toBe('grok-4.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trim + 大小写 + 方括号 + -build 组合', () => {
|
||||||
|
expect(normalizeModelId(' Grok-4.5-build[1m] ')).toBe('grok-4.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不剥离版本号本身', () => {
|
||||||
|
expect(normalizeModelId('claude-opus-4-20250514')).toBe('claude-opus-4-20250514')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不剥离 -preview', () => {
|
||||||
|
expect(normalizeModelId('model-preview')).toBe('model-preview')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('折叠连续空白', () => {
|
||||||
|
expect(normalizeModelId('grok 4.5')).toBe('grok 4.5')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('extractLastAssistantModel', () => {
|
||||||
|
it('多条 assistant 取最后一条 message.model', () => {
|
||||||
|
const text = [
|
||||||
|
JSON.stringify({ type: 'assistant', message: { model: 'old-model' } }),
|
||||||
|
JSON.stringify({ type: 'user', message: 'hi' }),
|
||||||
|
JSON.stringify({ type: 'assistant', message: { model: 'grok-4.5-build' } }),
|
||||||
|
].join('\n')
|
||||||
|
expect(extractLastAssistantModel(text)).toBe('grok-4.5-build')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无 assistant model 返回 null', () => {
|
||||||
|
const text = [
|
||||||
|
JSON.stringify({ type: 'user', message: 'hi' }),
|
||||||
|
JSON.stringify({ type: 'assistant', message: { content: 'yo' } }),
|
||||||
|
].join('\n')
|
||||||
|
expect(extractLastAssistantModel(text)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('message.model 为空字符串忽略', () => {
|
||||||
|
const text = JSON.stringify({ type: 'assistant', message: { model: ' ' } })
|
||||||
|
expect(extractLastAssistantModel(text)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非法 json 行跳过', () => {
|
||||||
|
const text = [
|
||||||
|
'not-json',
|
||||||
|
JSON.stringify({ type: 'assistant', message: { model: 'ok-model' } }),
|
||||||
|
'{broken',
|
||||||
|
].join('\n')
|
||||||
|
expect(extractLastAssistantModel(text)).toBe('ok-model')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('collectProfileModelIds', () => {
|
||||||
|
it('从 env 收集已知 model 字段的非空字符串', () => {
|
||||||
|
const ids = collectProfileModelIds({
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_MODEL: 'grok-4.5[1m]',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'opus-x',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: '',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: ' ',
|
||||||
|
ANTHROPIC_SMALL_FAST_MODEL: 'haiku-fast',
|
||||||
|
OTHER: 'ignore',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(ids).toEqual(['grok-4.5[1m]', 'opus-x', 'haiku-fast'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无 env 或非法结构返回空数组', () => {
|
||||||
|
expect(collectProfileModelIds(null)).toEqual([])
|
||||||
|
expect(collectProfileModelIds({})).toEqual([])
|
||||||
|
expect(collectProfileModelIds({ env: 'bad' })).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('matchProfilePathForModel', () => {
|
||||||
|
const grok = '/profiles/grok-4.5.json'
|
||||||
|
const official = '/profiles/offical.json'
|
||||||
|
const other = '/profiles/other.json'
|
||||||
|
|
||||||
|
it('唯一 normalize 命中返回 path', () => {
|
||||||
|
const path_ = matchProfilePathForModel('grok-4.5-build', [
|
||||||
|
{ path: official, modelIds: [] },
|
||||||
|
{ path: grok, modelIds: ['grok-4.5[1m]'] },
|
||||||
|
])
|
||||||
|
expect(path_).toBe(grok)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('零命中返回 null', () => {
|
||||||
|
expect(matchProfilePathForModel('claude-opus-4', [
|
||||||
|
{ path: grok, modelIds: ['grok-4.5[1m]'] },
|
||||||
|
{ path: official, modelIds: [] },
|
||||||
|
])).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多个不同 path 命中返回 null(歧义)', () => {
|
||||||
|
expect(matchProfilePathForModel('shared-model', [
|
||||||
|
{ path: grok, modelIds: ['shared-model'] },
|
||||||
|
{ path: other, modelIds: ['SHARED-MODEL'] },
|
||||||
|
])).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('同一 path 多 modelIds 命中仍唯一', () => {
|
||||||
|
expect(matchProfilePathForModel('m1', [
|
||||||
|
{ path: grok, modelIds: ['m1', 'm1-alt'] },
|
||||||
|
])).toBe(grok)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('inferProfilePathForSession', () => {
|
||||||
|
it('jsonl grok model 唯一匹配 grok profile', async () => {
|
||||||
|
const projectsDir = tempDir('infer-proj-')
|
||||||
|
const profilesDir = tempDir('infer-prof-')
|
||||||
|
const sessionId = 'sess-grok'
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
path.join(projectsDir, `${sessionId}.jsonl`),
|
||||||
|
[
|
||||||
|
JSON.stringify({ type: 'user', message: 'hi' }),
|
||||||
|
JSON.stringify({ type: 'assistant', message: { model: 'grok-4.5-build' } }),
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const grokPath = path.join(profilesDir, 'grok-4.5.json')
|
||||||
|
writeFileSync(grokPath, JSON.stringify({
|
||||||
|
env: { ANTHROPIC_MODEL: 'grok-4.5[1m]' },
|
||||||
|
}))
|
||||||
|
writeFileSync(path.join(profilesDir, 'offical.json'), JSON.stringify({
|
||||||
|
env: {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const inferred = await inferProfilePathForSession(sessionId, projectsDir, profilesDir)
|
||||||
|
expect(inferred).toBe(grokPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('会话文件缺失返回 null', async () => {
|
||||||
|
const projectsDir = tempDir('infer-miss-')
|
||||||
|
const profilesDir = tempDir('infer-miss-p-')
|
||||||
|
mkdirSync(profilesDir, { recursive: true })
|
||||||
|
expect(await inferProfilePathForSession('no-such', projectsDir, profilesDir)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无唯一匹配返回 null', async () => {
|
||||||
|
const projectsDir = tempDir('infer-amb-')
|
||||||
|
const profilesDir = tempDir('infer-amb-p-')
|
||||||
|
const sessionId = 'sess-x'
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
path.join(projectsDir, `${sessionId}.jsonl`),
|
||||||
|
JSON.stringify({ type: 'assistant', message: { model: 'shared' } }),
|
||||||
|
)
|
||||||
|
writeFileSync(path.join(profilesDir, 'a.json'), JSON.stringify({
|
||||||
|
env: { ANTHROPIC_MODEL: 'shared' },
|
||||||
|
}))
|
||||||
|
writeFileSync(path.join(profilesDir, 'b.json'), JSON.stringify({
|
||||||
|
env: { ANTHROPIC_MODEL: 'shared' },
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(await inferProfilePathForSession(sessionId, projectsDir, profilesDir)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { readdir, readFile } from 'node:fs/promises'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
import { getProfilesDir } from '../cc/profiles'
|
||||||
|
|
||||||
|
/** 归一化 model id:小写、去 [1m]、去 -build;保留主版本号 */
|
||||||
|
export function normalizeModelId(raw: string): string {
|
||||||
|
let s = raw.trim().toLowerCase()
|
||||||
|
s = s.replace(/\[[^\]]*\]/g, '')
|
||||||
|
s = s.replace(/-build$/, '')
|
||||||
|
s = s.replace(/\s+/g, ' ').trim()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 JSONL 全文取「最后一个」assistant.message.model;无则 null */
|
||||||
|
export function extractLastAssistantModel(jsonlText: string): string | null {
|
||||||
|
let last: string | null = null
|
||||||
|
const lines = jsonlText.split(/\r?\n/)
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed)
|
||||||
|
continue
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object')
|
||||||
|
continue
|
||||||
|
const rec = parsed as { type?: unknown, message?: unknown }
|
||||||
|
if (rec.type !== 'assistant')
|
||||||
|
continue
|
||||||
|
if (!rec.message || typeof rec.message !== 'object')
|
||||||
|
continue
|
||||||
|
const model = (rec.message as { model?: unknown }).model
|
||||||
|
if (typeof model !== 'string')
|
||||||
|
continue
|
||||||
|
const value = model.trim()
|
||||||
|
if (!value)
|
||||||
|
continue
|
||||||
|
last = value
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILE_MODEL_ENV_KEYS = [
|
||||||
|
'ANTHROPIC_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||||
|
'ANTHROPIC_SMALL_FAST_MODEL',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读 profile JSON 的 env 字段,收集可匹配的 model 字符串。
|
||||||
|
* 仅字符串且非空才纳入。
|
||||||
|
*/
|
||||||
|
export function collectProfileModelIds(profileJson: unknown): string[] {
|
||||||
|
if (!profileJson || typeof profileJson !== 'object')
|
||||||
|
return []
|
||||||
|
const env = (profileJson as { env?: unknown }).env
|
||||||
|
if (!env || typeof env !== 'object')
|
||||||
|
return []
|
||||||
|
const rec = env as Record<string, unknown>
|
||||||
|
const ids: string[] = []
|
||||||
|
for (const key of PROFILE_MODEL_ENV_KEYS) {
|
||||||
|
const value = rec[key]
|
||||||
|
if (typeof value !== 'string')
|
||||||
|
continue
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed)
|
||||||
|
continue
|
||||||
|
ids.push(value)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 给定 transcript model + profiles 列表,normalize 后精确匹配:
|
||||||
|
* 唯一 path → 返回该 path;0 或多个 → null
|
||||||
|
*/
|
||||||
|
export function matchProfilePathForModel(
|
||||||
|
transcriptModel: string,
|
||||||
|
profiles: Array<{ path: string, modelIds: string[] }>,
|
||||||
|
): string | null {
|
||||||
|
const target = normalizeModelId(transcriptModel)
|
||||||
|
if (!target)
|
||||||
|
return null
|
||||||
|
|
||||||
|
const matched = new Set<string>()
|
||||||
|
for (const profile of profiles) {
|
||||||
|
for (const id of profile.modelIds) {
|
||||||
|
if (normalizeModelId(id) === target) {
|
||||||
|
matched.add(profile.path)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched.size !== 1)
|
||||||
|
return null
|
||||||
|
return matched.values().next().value ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读 session JSONL + 扫描 profilesDir 下 *.json,
|
||||||
|
* 返回唯一匹配的 profile 绝对路径,否则 null。
|
||||||
|
* IO 失败返回 null。
|
||||||
|
*/
|
||||||
|
export async function inferProfilePathForSession(
|
||||||
|
sessionId: string,
|
||||||
|
projectsDir: string,
|
||||||
|
profilesDir?: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const sessionPath = path.join(projectsDir, `${sessionId}.jsonl`)
|
||||||
|
const jsonlText = await readFile(sessionPath, 'utf8')
|
||||||
|
const transcriptModel = extractLastAssistantModel(jsonlText)
|
||||||
|
if (!transcriptModel)
|
||||||
|
return null
|
||||||
|
|
||||||
|
const dir = profilesDir ?? getProfilesDir()
|
||||||
|
const entries = await readdir(dir)
|
||||||
|
const profiles: Array<{ path: string, modelIds: string[] }> = []
|
||||||
|
for (const name of entries) {
|
||||||
|
if (!name.endsWith('.json'))
|
||||||
|
continue
|
||||||
|
const profilePath = path.join(dir, name)
|
||||||
|
try {
|
||||||
|
const raw = await readFile(profilePath, 'utf8')
|
||||||
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
profiles.push({
|
||||||
|
path: profilePath,
|
||||||
|
modelIds: collectProfileModelIds(parsed),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// 跳过坏 profile,继续扫描其余
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchProfilePathForModel(transcriptModel, profiles)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user