fix: desktop server CLI spawn and model listing for CLI auth
Browse files- Fix CLI process startup failure: resolveCliArgs now prefers the standalone
dist/cli binary (which has the real runHeadless implementation) over the
OSS sidecar binary (which has a stub print.ts without runHeadless).
Uses CLAUDE_APP_ROOT for real filesystem path resolution inside compiled
sidecar where import.meta.dir is a virtual bunfs path.
Fallback changed from broken bin/claude-haha path to PATH lookup.
- Fix desktop /api/models to recognize CLI auth state: added
fetchCliProviderModels() that reads ~/.claude.json directly and fetches
OpenCode models from models.dev/api.json or OpenRouter models from
openrouter.ai/api/v1/models, with 5-minute caching. This makes OpenCode
and OpenRouter models visible in the desktop UI after CLI /login.
Co-Authored-By: Claude Big Pickle <noreply@anthropic.com>
|
@@ -161,6 +161,94 @@ function normalizeEffortLevel(value: unknown): (typeof EFFORT_LEVELS)[number] {
|
|
| 161 |
: DEFAULT_EFFORT
|
| 162 |
}
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
// βββ Router βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 165 |
|
| 166 |
export async function handleModelsApi(
|
|
@@ -217,6 +305,16 @@ async function handleModelsList(): Promise<Response> {
|
|
| 217 |
provider: { id: activeProvider.id, name: activeProvider.name },
|
| 218 |
})
|
| 219 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
return Response.json({ models: getStandaloneModelList(), provider: null })
|
| 221 |
}
|
| 222 |
|
|
@@ -261,11 +359,16 @@ async function handleCurrentModel(req: Request): Promise<Response> {
|
|
| 261 |
const lookupId = contextTier ? `${currentModelId}:${contextTier}` : currentModelId
|
| 262 |
|
| 263 |
// Build available models for name lookup
|
|
|
|
|
|
|
|
|
|
| 264 |
const availableModels = isOpenAIProviderActive
|
| 265 |
? buildOpenAIModelList()
|
| 266 |
: activeProvider
|
| 267 |
? buildProviderModelList(activeProvider.models)
|
| 268 |
-
:
|
|
|
|
|
|
|
| 269 |
|
| 270 |
const modelEntry = availableModels.find((m) => m.id === lookupId)
|
| 271 |
|| availableModels.find((m) => m.id === currentModelId)
|
|
|
|
| 161 |
: DEFAULT_EFFORT
|
| 162 |
}
|
| 163 |
|
| 164 |
+
// βββ CLI provider model cache (fetched from external APIs) βββββ
|
| 165 |
+
|
| 166 |
+
let cliProviderModelCache: ApiModelInfo[] | null = null
|
| 167 |
+
let cliProviderModelCacheTime = 0
|
| 168 |
+
const CLI_PROVIDER_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
|
| 169 |
+
|
| 170 |
+
async function fetchCliProviderModels(): Promise<ApiModelInfo[]> {
|
| 171 |
+
if (cliProviderModelCache && Date.now() - cliProviderModelCacheTime < CLI_PROVIDER_CACHE_TTL) {
|
| 172 |
+
return cliProviderModelCache
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
try {
|
| 176 |
+
// Read ~/.claude.json directly β avoids importing CLI-side config modules
|
| 177 |
+
const { homedir } = await import('node:os')
|
| 178 |
+
const { readFileSync } = await import('node:fs')
|
| 179 |
+
const { join } = await import('node:path')
|
| 180 |
+
let config: Record<string, unknown> = {}
|
| 181 |
+
try {
|
| 182 |
+
const raw = readFileSync(join(homedir(), '.claude.json'), 'utf8')
|
| 183 |
+
config = JSON.parse(raw)
|
| 184 |
+
} catch {
|
| 185 |
+
return []
|
| 186 |
+
}
|
| 187 |
+
const authProvider = config.authProvider as string | undefined
|
| 188 |
+
|
| 189 |
+
// ββ OpenCode ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 190 |
+
if (authProvider === 'opencode' && config.openCodeApiKey) {
|
| 191 |
+
try {
|
| 192 |
+
const res = await fetch('https://models.dev/api.json')
|
| 193 |
+
if (res.ok) {
|
| 194 |
+
const data = await res.json() as any
|
| 195 |
+
const opencodeModels = data?.opencode?.models || {}
|
| 196 |
+
const models: ApiModelInfo[] = []
|
| 197 |
+
|
| 198 |
+
for (const [modelId, modelCfg] of Object.entries(opencodeModels) as [string, any][]) {
|
| 199 |
+
if (modelCfg.status === 'deprecated') continue
|
| 200 |
+
const isFree = modelCfg.cost?.input === 0 && modelCfg.cost?.output === 0
|
| 201 |
+
models.push({
|
| 202 |
+
id: modelId,
|
| 203 |
+
name: modelCfg.name || modelId,
|
| 204 |
+
description: isFree ? 'Free model' : 'Paid model',
|
| 205 |
+
context: '',
|
| 206 |
+
})
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
if (models.length > 0) {
|
| 210 |
+
cliProviderModelCache = models
|
| 211 |
+
cliProviderModelCacheTime = Date.now()
|
| 212 |
+
return models
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
} catch {
|
| 216 |
+
// fall through
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
// ββ OpenRouter ββββββββββββββββββββββββββββββββββββββββββββ
|
| 221 |
+
if (authProvider === 'openrouter' && config.openRouterApiKey) {
|
| 222 |
+
try {
|
| 223 |
+
const res = await fetch('https://openrouter.ai/api/v1/models', {
|
| 224 |
+
headers: { Authorization: `Bearer ${config.openRouterApiKey}` },
|
| 225 |
+
})
|
| 226 |
+
if (res.ok) {
|
| 227 |
+
const data = await res.json() as any
|
| 228 |
+
const models: ApiModelInfo[] = (data.data || []).map((m: any) => ({
|
| 229 |
+
id: m.id,
|
| 230 |
+
name: m.name || m.id,
|
| 231 |
+
description: m.description || '',
|
| 232 |
+
context: String(m.context_length || ''),
|
| 233 |
+
}))
|
| 234 |
+
|
| 235 |
+
if (models.length > 0) {
|
| 236 |
+
cliProviderModelCache = models
|
| 237 |
+
cliProviderModelCacheTime = Date.now()
|
| 238 |
+
return models
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
} catch {
|
| 242 |
+
// fall through
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
} catch {
|
| 246 |
+
// fall through
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
return []
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
// βββ Router βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 253 |
|
| 254 |
export async function handleModelsApi(
|
|
|
|
| 305 |
provider: { id: activeProvider.id, name: activeProvider.name },
|
| 306 |
})
|
| 307 |
}
|
| 308 |
+
|
| 309 |
+
// No cc-haha provider active β check if CLI has an auth provider configured
|
| 310 |
+
const cliModels = await fetchCliProviderModels()
|
| 311 |
+
if (cliModels.length > 0) {
|
| 312 |
+
return Response.json({
|
| 313 |
+
models: cliModels,
|
| 314 |
+
provider: { id: 'cli', name: 'CLI Provider' },
|
| 315 |
+
})
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
return Response.json({ models: getStandaloneModelList(), provider: null })
|
| 319 |
}
|
| 320 |
|
|
|
|
| 359 |
const lookupId = contextTier ? `${currentModelId}:${contextTier}` : currentModelId
|
| 360 |
|
| 361 |
// Build available models for name lookup
|
| 362 |
+
const cliModelsFallback = !isOpenAIProviderActive && !activeProvider
|
| 363 |
+
? await fetchCliProviderModels()
|
| 364 |
+
: []
|
| 365 |
const availableModels = isOpenAIProviderActive
|
| 366 |
? buildOpenAIModelList()
|
| 367 |
: activeProvider
|
| 368 |
? buildProviderModelList(activeProvider.models)
|
| 369 |
+
: cliModelsFallback.length > 0
|
| 370 |
+
? cliModelsFallback
|
| 371 |
+
: getStandaloneModelList()
|
| 372 |
|
| 373 |
const modelEntry = availableModels.find((m) => m.id === lookupId)
|
| 374 |
|| availableModels.find((m) => m.id === currentModelId)
|
|
@@ -1103,6 +1103,27 @@ export class ConversationService {
|
|
| 1103 |
}
|
| 1104 |
|
| 1105 |
private resolveCliArgs(baseArgs: string[]): string[] {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1106 |
const launcher = resolveClaudeCliLauncher({
|
| 1107 |
cliPath: process.env.CLAUDE_CLI_PATH,
|
| 1108 |
execPath: process.execPath,
|
|
@@ -1118,7 +1139,8 @@ export class ConversationService {
|
|
| 1118 |
...baseArgs,
|
| 1119 |
]
|
| 1120 |
}
|
| 1121 |
-
|
|
|
|
| 1122 |
}
|
| 1123 |
|
| 1124 |
return buildClaudeCliArgs(launcher, baseArgs, process.env.CLAUDE_APP_ROOT)
|
|
|
|
| 1103 |
}
|
| 1104 |
|
| 1105 |
private resolveCliArgs(baseArgs: string[]): string[] {
|
| 1106 |
+
// Prefer the standalone CLI binary (dist/cli) when available. This binary
|
| 1107 |
+
// has the full implementation including runHeadless for SDK mode, unlike
|
| 1108 |
+
// the OSS-build sidecar which uses a stub src/cli/print.ts that doesn't
|
| 1109 |
+
// export runHeadless, causing the subprocess to crash during startup.
|
| 1110 |
+
//
|
| 1111 |
+
// When running inside the compiled sidecar, import.meta.dir resolves to a
|
| 1112 |
+
// virtual bunfs path β use CLAUDE_APP_ROOT (set by the sidecar launcher)
|
| 1113 |
+
// to construct the real filesystem path instead.
|
| 1114 |
+
const appRoot = process.env.CLAUDE_APP_ROOT
|
| 1115 |
+
if (appRoot) {
|
| 1116 |
+
const standaloneCli = path.resolve(appRoot, '../../../../dist/cli')
|
| 1117 |
+
if (fs.existsSync(standaloneCli)) {
|
| 1118 |
+
return [standaloneCli, ...baseArgs]
|
| 1119 |
+
}
|
| 1120 |
+
}
|
| 1121 |
+
// Fallback for direct bun dev mode (outside the sidecar)
|
| 1122 |
+
const standaloneCli = path.resolve(import.meta.dir, '../../../dist/cli')
|
| 1123 |
+
if (fs.existsSync(standaloneCli)) {
|
| 1124 |
+
return [standaloneCli, ...baseArgs]
|
| 1125 |
+
}
|
| 1126 |
+
|
| 1127 |
const launcher = resolveClaudeCliLauncher({
|
| 1128 |
cliPath: process.env.CLAUDE_CLI_PATH,
|
| 1129 |
execPath: process.execPath,
|
|
|
|
| 1139 |
...baseArgs,
|
| 1140 |
]
|
| 1141 |
}
|
| 1142 |
+
// Try claude-haha from PATH (installed via npm/pip)
|
| 1143 |
+
return ['claude-haha', ...baseArgs]
|
| 1144 |
}
|
| 1145 |
|
| 1146 |
return buildClaudeCliArgs(launcher, baseArgs, process.env.CLAUDE_APP_ROOT)
|