mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-10 07:35:56 +08:00
202 lines
6.0 KiB
TypeScript
202 lines
6.0 KiB
TypeScript
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);
|
||
}
|
||
}
|