Files
claudecodeui/server/modules/providers/list/claude/claude-models.provider.ts
2026-06-09 20:33:00 +00:00

202 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.8 (1M context)) · $5/$25 per Mtok',
},
{
value: 'fable',
label: 'Fable',
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
},
{
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: 'opus[1m]',
label: 'Opus 4.8 (1M context)',
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 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<ProviderCurrentActiveModel | null> => {
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<ProviderModelsDefinition> {
// 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<ProviderCurrentActiveModel> {
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<ProviderSessionActiveModelChange> {
return writeProviderSessionActiveModelChange('claude', input);
}
}