mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-28 23:15:33 +08:00
Model selection was acting like a provider-level preference. That made resumed sessions drift back to a default or request-time model. Users expect /models changes made inside a conversation to affect that session. Store explicit session choices in app-owned ~/.cloudcli state. This avoids editing provider transcripts or native provider config. Resolve the effective model before launching each provider runtime. Claude, Cursor, Codex, Gemini, and OpenCode now honor stored resume choices. Expose a backend active-model change endpoint for existing sessions. The models modal can now distinguish default changes from session overrides. It also shows when a selected model will apply on the next response. For Claude, stop probing active model state by resuming with a dummy prompt. Read the indexed JSONL transcript from the end instead. This preserves provider history while honoring /model stdout or model fields. Add service tests for adapter delegation and resume-model precedence. The tests keep cache state, override state, and requested fallback separate.
231 lines
6.8 KiB
TypeScript
231 lines
6.8 KiB
TypeScript
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<Options, 'env' | 'pathToClaudeCodeExecutable' | 'permissionMode'>;
|
|
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<string>();
|
|
|
|
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<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> {
|
|
let queryInstance: ReturnType<typeof query> | 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<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);
|
|
}
|
|
}
|