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

View File

@ -2,64 +2,91 @@ import { Hono } from 'hono'
import type { SessionManager } from '../sessionManager.js' import type { SessionManager } from '../sessionManager.js'
import type { InitData } from '../sessionHandle.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 { export function createProviderRoutes(sessionManager: SessionManager): Hono {
return new Hono() return new Hono()
.get('/provider', c => { .get('/provider', async c => {
const initData = getFirstInitData(sessionManager) let initData = sessionManager.getCachedInitData()
const models = initData?.models ?? {} if (!initData) {
const modelValues = Object.values(models as Record<string, string>) await sessionManager.waitForInitData()
const defaultModel = modelValues[0] ?? '' 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,
}),
),
},
],
})
})
.get('/provider/capabilities', c => {
const initData = getFirstInitData(sessionManager)
const models = initData?.models ?? {}
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,
}),
),
},
],
})
})
} }
function getFirstInitData(sessionManager: SessionManager): InitData | null { return c.json(toCapabilities(initData))
for (const handle of sessionManager.getAllSessions()) { })
if (handle.initData) return handle.initData .get('/provider/capabilities', async c => {
let initData = sessionManager.getCachedInitData()
if (!initData) {
await sessionManager.waitForInitData()
initData = sessionManager.getCachedInitData()
} }
return null
return c.json(toCapabilities(initData))
})
} }

View File

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

View File

@ -61,6 +61,52 @@ export type SessionHandleOptions = {
verbose?: boolean 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 { export class SessionHandle {
readonly sessionId: string readonly sessionId: string
readonly cwd: string readonly cwd: string
@ -138,8 +184,7 @@ export class SessionHandle {
} }
async start(): Promise<void> { async start(): Promise<void> {
const args = [ const printArgs = [
...this.opts.scriptArgs,
'--print', '--print',
'--input-format', '--input-format',
'stream-json', 'stream-json',
@ -154,7 +199,7 @@ export class SessionHandle {
...(this.opts.resumeSessionId ...(this.opts.resumeSessionId
? ['--resume', this.opts.resumeSessionId] ? ['--resume', this.opts.resumeSessionId]
: []), : []),
...(this.opts.verbose ? ['--verbose'] : []), '--verbose',
] ]
const env: NodeJS.ProcessEnv = { const env: NodeJS.ProcessEnv = {
@ -166,7 +211,12 @@ export class SessionHandle {
env.CLAUDE_CODE_SYSTEM_PROMPT = this.opts.systemPrompt 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, cwd: this.opts.cwd,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
env, env,
@ -293,10 +343,9 @@ export class SessionHandle {
if (response?.subtype === 'success' && response?.request_id === this.initRequestId) { if (response?.subtype === 'success' && response?.request_id === this.initRequestId) {
const initData = (response.response ?? {}) as InitData const initData = (response.response ?? {}) as InitData
this._initData = initData this._initData = initData
if (initData.models) { if (Array.isArray(initData.models) && initData.models.length > 0 && !this._model) {
const models = initData.models as Record<string, string> const first = (initData.models as Array<Record<string, string>>)[0]
const vals = Object.values(models) this._model = first?.value ?? first?.name
if (vals.length > 0 && !this._model) this._model = vals[0]
} }
this.initResolve(initData) this.initResolve(initData)
this.initResolve = null this.initResolve = null

View File

@ -4,7 +4,7 @@ import { join } from 'path'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js' import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
import { logError } from '../utils/log.js' import { logError } from '../utils/log.js'
import type { EventBus } from './eventBus.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' import type { SessionIndex, SessionIndexEntry, SessionState } from './types.js'
const INDEX_FILE = 'server-sessions.json' const INDEX_FILE = 'server-sessions.json'
@ -17,6 +17,10 @@ export class SessionManager {
private defaultWorkspace?: string private defaultWorkspace?: string
private idleCheckInterval: ReturnType<typeof setInterval> | null = null private idleCheckInterval: ReturnType<typeof setInterval> | null = null
private indexDirty = false 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: { constructor(opts: {
eventBus: EventBus eventBus: EventBus
@ -182,9 +186,61 @@ export class SessionManager {
throw err throw err
} }
if (handle.initData) {
this._cachedInitData = handle.initData
}
return handle 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> { async deleteSession(id: string): Promise<boolean> {
const handle = this.sessions.get(id) const handle = this.sessions.get(id)
if (!handle) return false if (!handle) return false

View File

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