import { readFile } from 'node:fs/promises'; import { query, type ModelInfo, type Options } from '@anthropic-ai/claude-agent-sdk'; import { sessionsDb } from '@/modules/database/index.js'; import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js'; import type { IProviderModels } from '@/shared/interfaces.js'; import type { ProviderChangeActiveModelInput, ProviderCurrentActiveModel, ProviderModelOption, ProviderModelsDefinition, ProviderSessionActiveModelChange, } from '@/shared/types.js'; import { buildDefaultProviderCurrentActiveModel, writeProviderSessionActiveModelChange, } from '@/shared/utils.js'; export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { OPTIONS: [ { value: 'default', label: 'Default (recommended)' }, { value: 'sonnet[1m]', label: 'Sonnet (1M context)' }, { value: 'opus', label: 'Opus' }, { value: 'opus[1m]', label: 'Opus (1M context)' }, { value: 'haiku', label: 'Haiku' }, { value: 'sonnet', label: 'sonnet' }, ], DEFAULT: 'default', }; type ClaudeModelQueryOptions = Pick; type ClaudeInitEvent = { sessionId?: string; session_id?: string; type?: string; subtype?: string; model?: string; message?: { content?: unknown; model?: string; }; }; const ANSI_PATTERN = new RegExp( '[\\u001B\\u009B][[\\]()#;?]*(?:' + '(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]' + '|(?:[\\dA-PR-TZcf-ntqry=><~]))', 'g', ); const buildClaudeQueryOptions = (): ClaudeModelQueryOptions => ({ env: { ...process.env }, pathToClaudeCodeExecutable: resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH), permissionMode: 'default', }); const mapClaudeModel = (model: ModelInfo): ProviderModelOption => ({ value: model.value, label: model.displayName || model.value, description: model.description || undefined, }); const buildClaudeModelsDefinition = (models: ModelInfo[]): ProviderModelsDefinition => { const options: ProviderModelOption[] = []; const seenValues = new Set(); for (const model of models) { const mappedModel = mapClaudeModel(model); if (seenValues.has(mappedModel.value)) { continue; } seenValues.add(mappedModel.value); options.push(mappedModel); } if (options.length === 0) { return CLAUDE_FALLBACK_MODELS; } const defaultValue = options.find((option) => option.value === 'default')?.value ?? options[0]?.value ?? CLAUDE_FALLBACK_MODELS.DEFAULT; return { OPTIONS: options, DEFAULT: defaultValue, }; }; const extractClaudeEventModel = (event: ClaudeInitEvent, sessionId: string): string | null => { const eventSessionId = event.sessionId ?? event.session_id; if (eventSessionId && eventSessionId !== sessionId) { return null; } const contentModel = extractClaudeModelFromMessageContent(event.message?.content); if (contentModel) { return contentModel; } const directModel = event.model?.trim(); if (directModel) { return directModel; } const messageModel = event.message?.model?.trim(); return messageModel || null; }; const stripAnsi = (value: string): string => value.replace(ANSI_PATTERN, ''); const extractTaggedContent = (content: string, tagName: string): string | null => { const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const match = new RegExp(`<${escapedTagName}>([\\s\\S]*?)<\\/${escapedTagName}>`).exec(content); return match ? match[1] : null; }; const extractClaudeModelFromTextContent = (content: string): string | null => { const localCommandStdout = extractTaggedContent(content, 'local-command-stdout'); if (localCommandStdout !== null) { const cleanedStdout = stripAnsi(localCommandStdout).replace(/\s+/g, ' ').trim(); const changedModel = /(?:set|changed|switched)\s+model\s+to\s+(.+?)\.?$/i.exec(cleanedStdout); if (changedModel?.[1]?.trim()) { return changedModel[1].trim(); } } const modelTag = extractTaggedContent(content, 'model')?.trim(); return modelTag || null; }; const extractClaudeModelFromMessageContent = (content: unknown): string | null => { if (typeof content === 'string') { return extractClaudeModelFromTextContent(content); } if (!Array.isArray(content)) { return null; } for (const part of content) { if (!part || typeof part !== 'object' || !('text' in part) || typeof part.text !== 'string') { continue; } const model = extractClaudeModelFromTextContent(part.text); if (model) { return model; } } return null; }; const readClaudeSessionModelFromJsonl = async ( sessionId: string, jsonlPath: string, ): Promise => { const content = await readFile(jsonlPath, 'utf8'); const lines = content .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); for (let index = lines.length - 1; index >= 0; index -= 1) { try { const event = JSON.parse(lines[index]) as ClaudeInitEvent; const model = extractClaudeEventModel(event, sessionId); if (model) { return { model }; } } catch { // Skip malformed JSONL lines that can happen during concurrent writes. } } return null; }; export class ClaudeProviderModels implements IProviderModels { async getSupportedModels(): Promise { let queryInstance: ReturnType | null = null; try { // The SDK exposes its runtime model catalog on the initialized query // instance, so we create a lightweight query and immediately close it // after reading the control-plane metadata. queryInstance = query({ prompt: 'Get supported models', options: buildClaudeQueryOptions(), }); const supportedModels = await queryInstance.supportedModels(); return buildClaudeModelsDefinition(supportedModels); } catch { return CLAUDE_FALLBACK_MODELS; } finally { queryInstance?.close(); } } async getCurrentActiveModel(sessionId?: string): Promise { if (!sessionId?.trim()) { return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels()); } try { const jsonlPath = sessionsDb.getSessionById(sessionId)?.jsonl_path; const activeModel = jsonlPath ? await readClaudeSessionModelFromJsonl(sessionId, jsonlPath) : null; if (activeModel?.model) { return activeModel; } } catch { // Fall through to the provider default when the session-backed lookup fails. } return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels()); } async changeActiveModel( input: ProviderChangeActiveModelInput, ): Promise { return writeProviderSessionActiveModelChange('claude', input); } }