fix: 修复 serve 模式模型接口返回空数据

多层问题修复:
- setup.ts: CoStrict 模型预取从 fire-and-forget 改为 await,确保缓存就绪
- sessionHandle.ts: 修复子进程 spawn 参数丢失 -d/--feature 的问题,
  修复 models 数组被当 Record 处理的类型错误,始终传 --verbose
- sessionManager.ts: 新增 probe session 预热机制和 initData 缓存,
  provider 路由不再依赖活跃 session
- provider.ts: 输出格式匹配消费端 ProviderCapabilitiesResponse 类型,
  models 从数组转为 Record<string, ModelInfo>
- session.ts: 修复 scriptArgs 计算逻辑,避免传入 serve 子命令
This commit is contained in:
DoSun 2026-05-07 20:08:27 +08:00
parent 954c218085
commit d5cfe0aff0
6 changed files with 250 additions and 118 deletions

View File

@ -5960,9 +5960,10 @@ async function run(): Promise<CommanderCommand> {
await import("./server/sessionManager.js");
const { printBanner } =
await import("./server/serverBanner.js");
const { createServerLogger } =
await import("./server/serverLog.js");
const { getScriptArgsForChild, saveChildSpawnPrefix } =
await import("./server/sessionHandle.js");
await saveChildSpawnPrefix();
const eventBus = new EventBus();
const config = {
port: parseInt(opts.port, 10),
@ -5981,23 +5982,29 @@ async function run(): Promise<CommanderCommand> {
workspace: config.workspace,
});
await sessionManager.init();
const logger = createServerLogger();
const server = startServer(config, sessionManager);
const actualPort = server.port ?? config.port;
printBanner(config, undefined, actualPort);
sessionManager.startProbeSession({
cwd: config.workspace || process.cwd(),
execPath: process.execPath,
scriptArgs: getScriptArgsForChild(),
});
let shuttingDown = false;
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
sessionManager.killProbe();
server.stop(true);
eventBus.destroy();
await sessionManager.destroyAll();
process.exit(0);
};
process.once("SIGINT", () => void shutdown());
process.once("SIGTERM", () => void shutdown());
process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
};
program

View File

@ -2,64 +2,91 @@ import { Hono } from 'hono'
import type { SessionManager } from '../sessionManager.js'
import type { InitData } from '../sessionHandle.js'
type ModelInfo = {
value: string
displayName: string
description: string
supportsEffort?: boolean
supportedEffortLevels?: string[]
supportsAdaptiveThinking?: boolean
supportsFastMode?: boolean
supportsAutoMode?: boolean
}
function getModels(initData: InitData | null): ModelInfo[] {
const raw = initData?.models
if (Array.isArray(raw)) return raw as ModelInfo[]
return []
}
function getProviderId(initData: InitData | null): string {
return initData?.account?.apiProvider ?? 'anthropic'
}
function getProviderName(initData: InitData | null): string {
const p = initData?.account?.apiProvider
if (p === 'firstParty') return 'Anthropic'
if (p === 'costrict') return 'CoStrict'
return p ?? 'Anthropic'
}
function toCapabilities(initData: InitData | null) {
const models = getModels(initData)
const providerId = getProviderId(initData)
const providerName = getProviderName(initData)
const defaultModel = models[0]?.value ?? ''
const modelsRecord: Record<string, unknown> = {}
for (const m of models) {
modelsRecord[m.value] = {
id: m.value,
name: m.displayName,
limit: {
context: 200000,
output: 8192,
},
capabilities: {
temperature: false,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
status: 'active' as const,
}
}
return {
connected: [{
id: providerId,
name: providerName,
source: 'config' as const,
default_model: defaultModel,
models: modelsRecord,
}],
}
}
export function createProviderRoutes(sessionManager: SessionManager): Hono {
return new Hono()
.get('/provider', c => {
const initData = getFirstInitData(sessionManager)
const models = initData?.models ?? {}
const modelValues = Object.values(models as Record<string, string>)
const defaultModel = modelValues[0] ?? ''
.get('/provider', async c => {
let initData = sessionManager.getCachedInitData()
if (!initData) {
await sessionManager.waitForInitData()
initData = sessionManager.getCachedInitData()
}
return c.json({
connected: initData?.account?.apiProvider
? [initData.account.apiProvider]
: ['anthropic'],
default_model: defaultModel,
providers: [
{
id: initData?.account?.apiProvider ?? 'anthropic',
name:
initData?.account?.apiProvider === 'firstParty'
? 'Anthropic'
: initData?.account?.apiProvider ?? 'Anthropic',
connected: true,
models: Object.entries(models as Record<string, string>).map(
([key, id]) => ({
id,
name: key,
}),
),
},
],
})
return c.json(toCapabilities(initData))
})
.get('/provider/capabilities', c => {
const initData = getFirstInitData(sessionManager)
const models = initData?.models ?? {}
.get('/provider/capabilities', async c => {
let initData = sessionManager.getCachedInitData()
if (!initData) {
await sessionManager.waitForInitData()
initData = sessionManager.getCachedInitData()
}
return c.json({
connected: [
{
provider_id: initData?.account?.apiProvider ?? 'anthropic',
provider_name:
initData?.account?.apiProvider === 'firstParty'
? 'Anthropic'
: initData?.account?.apiProvider ?? 'Anthropic',
models: Object.entries(models as Record<string, string>).map(
([key, id]) => ({
model_id: id,
model_name: key,
}),
),
},
],
})
return c.json(toCapabilities(initData))
})
}
function getFirstInitData(sessionManager: SessionManager): InitData | null {
for (const handle of sessionManager.getAllSessions()) {
if (handle.initData) return handle.initData
}
return null
}

View File

@ -1,6 +1,7 @@
import { Hono } from 'hono'
import { streamSSE } from 'hono/streaming'
import type { SessionManager } from '../sessionManager.js'
import { getScriptArgsForChild } from '../sessionHandle.js'
import {
badRequest,
notFound,
@ -125,7 +126,7 @@ export function createSessionRoutes(
systemPrompt: body.system_prompt,
resumeSessionId: body.resume_session_id,
execPath: process.execPath,
scriptArgs: process.argv[1] ? [process.argv[1]] : [],
scriptArgs: getScriptArgsForChild(),
})
const initData = handle.initData
@ -138,7 +139,7 @@ export function createSessionRoutes(
created_at: handle.getInfo().created_at,
commands: initData?.commands ?? [],
agents: initData?.agents ?? [],
models: initData?.models ?? {},
models: initData?.models ?? [],
account: initData?.account ?? {},
},
201,

View File

@ -61,6 +61,52 @@ export type SessionHandleOptions = {
verbose?: boolean
}
export function getScriptArgsForChild(): string[] {
const argv1 = process.argv[1]
if (!argv1) return []
if (argv1.endsWith('.ts') || argv1.endsWith('.tsx') || argv1.includes('/') || argv1.includes('\\')) {
return [argv1]
}
return []
}
export function getChildSpawnArgs(): { execPath: string; scriptArgs: string[] } {
const execPath = process.execPath
const scriptArgs = getScriptArgsForChild()
return { execPath, scriptArgs }
}
export async function saveChildSpawnPrefix(): Promise<void> {
if (process.env._CSC_CHILD_SPAWN_PREFIX) return
const { execPath, scriptArgs } = getChildSpawnArgs()
let defineArgs: string[] = []
let featureArgs: string[] = []
try {
const definesMod = await import('../../scripts/defines.js') as { getMacroDefines: () => Record<string, string>; DEFAULT_BUILD_FEATURES: readonly string[] }
const defines = definesMod.getMacroDefines()
defineArgs = Object.entries(defines).flatMap(([k, v]) => ['-d', `${k}:${v}`])
const features = definesMod.DEFAULT_BUILD_FEATURES
featureArgs = features.flatMap((f: string) => ['--feature', f])
} catch {}
const envFeatures = Object.entries(process.env)
.filter(([k]) => k.startsWith('FEATURE_') && k.slice(8))
.map(([k]) => ['--feature', k.slice(8)] as [string, string])
.flat()
const allFeatureArgs = [...featureArgs, ...envFeatures]
const prefix = JSON.stringify({ execPath, scriptArgs, defineArgs, featureArgs: allFeatureArgs })
process.env._CSC_CHILD_SPAWN_PREFIX = prefix
}
function loadChildSpawnPrefix(): { execPath: string; scriptArgs: string[]; defineArgs?: string[]; featureArgs?: string[] } | null {
const raw = process.env._CSC_CHILD_SPAWN_PREFIX
if (!raw) return null
try {
return JSON.parse(raw) as { execPath: string; scriptArgs: string[]; defineArgs?: string[]; featureArgs?: string[] }
} catch {
return null
}
}
export class SessionHandle {
readonly sessionId: string
readonly cwd: string
@ -138,8 +184,7 @@ export class SessionHandle {
}
async start(): Promise<void> {
const args = [
...this.opts.scriptArgs,
const printArgs = [
'--print',
'--input-format',
'stream-json',
@ -154,7 +199,7 @@ export class SessionHandle {
...(this.opts.resumeSessionId
? ['--resume', this.opts.resumeSessionId]
: []),
...(this.opts.verbose ? ['--verbose'] : []),
'--verbose',
]
const env: NodeJS.ProcessEnv = {
@ -166,7 +211,12 @@ export class SessionHandle {
env.CLAUDE_CODE_SYSTEM_PROMPT = this.opts.systemPrompt
}
this.child = spawn(this.opts.execPath, args, {
const saved = loadChildSpawnPrefix()
const defineArgs = saved?.defineArgs ?? []
const featureArgs = saved?.featureArgs ?? []
const spawnArgs = [...defineArgs, ...featureArgs, ...this.opts.scriptArgs, ...printArgs]
this.child = spawn(this.opts.execPath, spawnArgs, {
cwd: this.opts.cwd,
stdio: ['pipe', 'pipe', 'pipe'],
env,
@ -293,10 +343,9 @@ export class SessionHandle {
if (response?.subtype === 'success' && response?.request_id === this.initRequestId) {
const initData = (response.response ?? {}) as InitData
this._initData = initData
if (initData.models) {
const models = initData.models as Record<string, string>
const vals = Object.values(models)
if (vals.length > 0 && !this._model) this._model = vals[0]
if (Array.isArray(initData.models) && initData.models.length > 0 && !this._model) {
const first = (initData.models as Array<Record<string, string>>)[0]
this._model = first?.value ?? first?.name
}
this.initResolve(initData)
this.initResolve = null

View File

@ -4,7 +4,7 @@ import { join } from 'path'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
import { logError } from '../utils/log.js'
import type { EventBus } from './eventBus.js'
import { SessionHandle } from './sessionHandle.js'
import { SessionHandle, type InitData } from './sessionHandle.js'
import type { SessionIndex, SessionIndexEntry, SessionState } from './types.js'
const INDEX_FILE = 'server-sessions.json'
@ -17,6 +17,10 @@ export class SessionManager {
private defaultWorkspace?: string
private idleCheckInterval: ReturnType<typeof setInterval> | null = null
private indexDirty = false
private _cachedInitData: InitData | null = null
private _initDataReady: Promise<void> | null = null
private _resolveInitDataReady: (() => void) | null = null
private _probeHandle: SessionHandle | null = null
constructor(opts: {
eventBus: EventBus
@ -182,9 +186,61 @@ export class SessionManager {
throw err
}
if (handle.initData) {
this._cachedInitData = handle.initData
}
return handle
}
getCachedInitData(): InitData | null {
if (this._cachedInitData) return this._cachedInitData
for (const handle of this.sessions) {
if (handle[1].initData) return handle[1].initData
}
return null
}
waitForInitData(timeoutMs = 30000): Promise<void> {
if (this._cachedInitData) return Promise.resolve()
return this._initDataReady ?? Promise.resolve()
}
startProbeSession(opts: {
cwd?: string
execPath: string
scriptArgs: string[]
}): void {
this._initDataReady = new Promise(resolve => {
this._resolveInitDataReady = resolve
})
void (async () => {
try {
const probe = await this.createSession({
cwd: opts.cwd,
execPath: opts.execPath,
scriptArgs: opts.scriptArgs,
})
this._probeHandle = probe
this._probeHandle = null
await this.deleteSession(probe.sessionId)
} catch (err) {
this._probeHandle?.forceKill()
this._probeHandle = null
}
this._resolveInitDataReady?.()
})()
}
killProbe(): void {
if (this._probeHandle) {
this._probeHandle.forceKill()
this._probeHandle = null
}
this._resolveInitDataReady?.()
}
async deleteSession(id: string): Promise<boolean> {
const handle = this.sessions.get(id)
if (!handle) return false

View File

@ -377,64 +377,56 @@ export async function setup(
profileCheckpoint('setup_after_prefetch')
// CoStrict provider: 启动时恢复凭证并预取模型列表
// 必须同步 await否则 serve/SDK 模式下 getModelOptions() 读取缓存时还为空
if (getAPIProvider() === 'costrict') {
void (async () => {
try {
const { loadCoStrictCredentials, saveCoStrictCredentials } = await import('./costrict/provider/credentials.js')
const { isCoStrictTokenValid, refreshCoStrictToken, extractExpiryFromJWT } = await import('./costrict/provider/token.js')
const { fetchCoStrictModels } = await import('./costrict/provider/models.js')
const { getCoStrictBaseURL } = await import('./costrict/provider/auth.js')
try {
const { loadCoStrictCredentials, saveCoStrictCredentials } = await import('./costrict/provider/credentials.js')
const { isCoStrictTokenValid, refreshCoStrictToken, extractExpiryFromJWT } = await import('./costrict/provider/token.js')
const { fetchCoStrictModels } = await import('./costrict/provider/models.js')
const { getCoStrictBaseURL } = await import('./costrict/provider/auth.js')
const creds = await loadCoStrictCredentials()
if (!creds) {
// 没有凭证,清除 modelType 让下次启动回到登录界面
const { updateSettingsForSource } = await import('./utils/settings/settings.js')
updateSettingsForSource('userSettings', { modelType: undefined } as any)
return
}
// 验证 / 刷新 token注意 refreshCoStrictToken 接收 RefreshTokenParams字段名与 CoStrictCredentials 不同)
const creds = await loadCoStrictCredentials()
if (!creds) {
const { updateSettingsForSource } = await import('./utils/settings/settings.js')
updateSettingsForSource('userSettings', { modelType: undefined } as any)
} else {
let activeCreds = creds
if (!isCoStrictTokenValid(creds)) {
if (creds.refresh_token) {
try {
const refreshed = await refreshCoStrictToken({
baseUrl: getCoStrictBaseURL(creds.base_url),
refreshToken: creds.refresh_token,
state: creds.state,
})
activeCreds = {
...creds,
access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token,
expiry_date: extractExpiryFromJWT(refreshed.access_token),
updated_at: new Date().toISOString(),
}
await saveCoStrictCredentials(activeCreds)
} catch {
// 刷新失败,清除 modelType 提示重新登录
const { updateSettingsForSource } = await import('./utils/settings/settings.js')
updateSettingsForSource('userSettings', { modelType: undefined } as any)
return
let tokenReady = isCoStrictTokenValid(creds)
if (!tokenReady && creds.refresh_token) {
try {
const refreshed = await refreshCoStrictToken({
baseUrl: getCoStrictBaseURL(creds.base_url),
refreshToken: creds.refresh_token,
state: creds.state,
})
activeCreds = {
...creds,
access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token,
expiry_date: extractExpiryFromJWT(refreshed.access_token),
updated_at: new Date().toISOString(),
}
} else {
// 无 refresh_token清除 modelType
await saveCoStrictCredentials(activeCreds)
tokenReady = true
} catch {
const { updateSettingsForSource } = await import('./utils/settings/settings.js')
updateSettingsForSource('userSettings', { modelType: undefined } as any)
return
}
} else if (!tokenReady) {
const { updateSettingsForSource } = await import('./utils/settings/settings.js')
updateSettingsForSource('userSettings', { modelType: undefined } as any)
}
// 预取模型列表,填充同步缓存
const baseUrl = getCoStrictBaseURL(activeCreds.base_url)
const models = await fetchCoStrictModels(baseUrl, activeCreds.access_token)
// 模型列表已预取并缓存,不再自动设置 COSTRICT_MODEL 环境变量
// resolveCoStrictModel() 会直接透传用户配置的模型名
process.env.CLAUDE_CODE_USE_COSTRICT = '1'
} catch {
// 初始化失败不阻断启动
if (tokenReady && activeCreds.access_token) {
const baseUrl = getCoStrictBaseURL(activeCreds.base_url)
await fetchCoStrictModels(baseUrl, activeCreds.access_token)
process.env.CLAUDE_CODE_USE_COSTRICT = '1'
}
}
})()
} catch {
// 初始化失败不阻断启动
}
}
// Pre-fetch data for Logo v2 - await to ensure it's ready before logo renders.