import { readFile } from 'node:fs/promises'; import { sessionsDb } from '@/modules/database/index.js'; import type { IProviderModels } from '@/shared/interfaces.js'; import type { ProviderChangeActiveModelInput, ProviderCurrentActiveModel, 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)', description: 'Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok', }, { value: 'sonnet', label: 'Sonnet', description: 'Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok', }, { value: 'sonnet[1m]', label: 'Sonnet (1M context)', description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok', }, { value: 'haiku', label: 'Haiku', description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok', }, ], DEFAULT: 'default', }; 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 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 { // claude creates a new jsonl file as a separate session for this request. // As a result, it lists the workspace where this is invoked when it shouldn't. // // Disabled for now: // const queryInstance = query({ // prompt: 'Get supported models', // options: buildClaudeQueryOptions(), // }); // const supportedModels = await queryInstance.supportedModels(); // queryInstance.close(); // return buildClaudeModelsDefinition(supportedModels); return CLAUDE_FALLBACK_MODELS; } 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); } }