diff --git a/src/main.tsx b/src/main.tsx index 02a7ed743..b80aefaab 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5960,9 +5960,10 @@ async function run(): Promise { 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 { 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 diff --git a/src/server/routes/provider.ts b/src/server/routes/provider.ts index 838ca925a..e1543441d 100644 --- a/src/server/routes/provider.ts +++ b/src/server/routes/provider.ts @@ -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 = {} + 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) - 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).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).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 -} diff --git a/src/server/routes/session.ts b/src/server/routes/session.ts index 62a5134e3..7a052b9c5 100644 --- a/src/server/routes/session.ts +++ b/src/server/routes/session.ts @@ -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, diff --git a/src/server/sessionHandle.ts b/src/server/sessionHandle.ts index 23ac38a3e..11caf5960 100644 --- a/src/server/sessionHandle.ts +++ b/src/server/sessionHandle.ts @@ -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 { + 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; 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 { - 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 - 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>)[0] + this._model = first?.value ?? first?.name } this.initResolve(initData) this.initResolve = null diff --git a/src/server/sessionManager.ts b/src/server/sessionManager.ts index 1e48718fc..fb7a9abf5 100644 --- a/src/server/sessionManager.ts +++ b/src/server/sessionManager.ts @@ -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 | null = null private indexDirty = false + private _cachedInitData: InitData | null = null + private _initDataReady: Promise | 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 { + 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 { const handle = this.sessions.get(id) if (!handle) return false diff --git a/src/setup.ts b/src/setup.ts index 030be6037..25c94c928 100644 --- a/src/setup.ts +++ b/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.