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:
parent
954c218085
commit
d5cfe0aff0
17
src/main.tsx
17
src/main.tsx
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
88
src/setup.ts
88
src/setup.ts
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user