mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-28 23:15:33 +08:00
refactor: remove session model and thinking mode management from providers and related services
This commit is contained in:
@@ -305,56 +305,6 @@ router.post(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
router.patch(
|
|
||||||
'/providers/:provider/sessions/:sessionId/model',
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const provider = parseProvider(req.params.provider);
|
|
||||||
const sessionId = readPathParam(req.params.sessionId, 'sessionId');
|
|
||||||
const model = typeof req.body?.model === 'string' ? req.body.model.trim() : '';
|
|
||||||
if (!model) {
|
|
||||||
throw new AppError('model is required.', {
|
|
||||||
code: 'MODEL_REQUIRED',
|
|
||||||
statusCode: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await llmService.setSessionModel(provider, sessionId, model);
|
|
||||||
res.json(
|
|
||||||
createApiSuccessResponse({
|
|
||||||
provider,
|
|
||||||
sessionId,
|
|
||||||
model,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
router.patch(
|
|
||||||
'/providers/:provider/sessions/:sessionId/thinking',
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const provider = parseProvider(req.params.provider);
|
|
||||||
const sessionId = readPathParam(req.params.sessionId, 'sessionId');
|
|
||||||
const thinkingMode =
|
|
||||||
typeof req.body?.thinkingMode === 'string' ? req.body.thinkingMode.trim() : '';
|
|
||||||
|
|
||||||
if (!thinkingMode) {
|
|
||||||
throw new AppError('thinkingMode is required.', {
|
|
||||||
code: 'THINKING_MODE_REQUIRED',
|
|
||||||
statusCode: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await llmService.setSessionThinkingMode(provider, sessionId, thinkingMode);
|
|
||||||
res.json(
|
|
||||||
createApiSuccessResponse({
|
|
||||||
provider,
|
|
||||||
sessionId,
|
|
||||||
thinkingMode,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads one or more images into `.cloudcli/assets` so providers can reuse file paths.
|
* Uploads one or more images into `.cloudcli/assets` so providers can reuse file paths.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { AppError } from '@/shared/utils/app-error.js';
|
|
||||||
import type {
|
import type {
|
||||||
IProvider,
|
IProvider,
|
||||||
MutableProviderSession,
|
MutableProviderSession,
|
||||||
@@ -11,11 +10,6 @@ import type {
|
|||||||
} from '@/modules/llm/providers/provider.interface.js';
|
} from '@/modules/llm/providers/provider.interface.js';
|
||||||
import type { LLMProvider } from '@/shared/types/app.js';
|
import type { LLMProvider } from '@/shared/types/app.js';
|
||||||
|
|
||||||
type SessionPreference = {
|
|
||||||
model?: string;
|
|
||||||
thinkingMode?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAX_EVENT_BUFFER_SIZE = 2_000;
|
const MAX_EVENT_BUFFER_SIZE = 2_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +21,6 @@ export abstract class AbstractProvider implements IProvider {
|
|||||||
readonly capabilities: ProviderCapabilities;
|
readonly capabilities: ProviderCapabilities;
|
||||||
|
|
||||||
protected readonly sessions = new Map<string, MutableProviderSession>();
|
protected readonly sessions = new Map<string, MutableProviderSession>();
|
||||||
protected readonly sessionPreferences = new Map<string, SessionPreference>();
|
|
||||||
|
|
||||||
protected constructor(
|
protected constructor(
|
||||||
id: LLMProvider,
|
id: LLMProvider,
|
||||||
@@ -90,98 +83,6 @@ export abstract class AbstractProvider implements IProvider {
|
|||||||
return stopped;
|
return stopped;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates/supports model switching and updates both live and persisted state.
|
|
||||||
*/
|
|
||||||
async setSessionModel(sessionId: string, model: string): Promise<void> {
|
|
||||||
if (!this.capabilities.supportsModelSwitching) {
|
|
||||||
throw new AppError(`Provider "${this.id}" does not support model switching.`, {
|
|
||||||
code: 'MODEL_SWITCH_NOT_SUPPORTED',
|
|
||||||
statusCode: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedModel = model.trim();
|
|
||||||
if (!trimmedModel) {
|
|
||||||
throw new AppError('Model cannot be empty.', {
|
|
||||||
code: 'INVALID_MODEL',
|
|
||||||
statusCode: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = this.sessions.get(sessionId);
|
|
||||||
if (session?.setModel) {
|
|
||||||
await session.setModel(trimmedModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPreference = this.sessionPreferences.get(sessionId) ?? {};
|
|
||||||
this.sessionPreferences.set(sessionId, { ...currentPreference, model: trimmedModel });
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
session.model = trimmedModel;
|
|
||||||
this.appendEvent(session, {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
channel: 'system',
|
|
||||||
message: `Model updated to "${trimmedModel}".`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates/supports thinking mode updates and applies them to live/persisted state.
|
|
||||||
*/
|
|
||||||
async setSessionThinkingMode(sessionId: string, thinkingMode: string): Promise<void> {
|
|
||||||
if (!this.capabilities.supportsThinkingModeControl) {
|
|
||||||
throw new AppError(`Provider "${this.id}" does not support thinking mode control.`, {
|
|
||||||
code: 'THINKING_MODE_NOT_SUPPORTED',
|
|
||||||
statusCode: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedMode = thinkingMode.trim();
|
|
||||||
if (!trimmedMode) {
|
|
||||||
throw new AppError('Thinking mode cannot be empty.', {
|
|
||||||
code: 'INVALID_THINKING_MODE',
|
|
||||||
statusCode: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = this.sessions.get(sessionId);
|
|
||||||
if (session?.setThinkingMode) {
|
|
||||||
await session.setThinkingMode(trimmedMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPreference = this.sessionPreferences.get(sessionId) ?? {};
|
|
||||||
this.sessionPreferences.set(sessionId, { ...currentPreference, thinkingMode: trimmedMode });
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
session.thinkingMode = trimmedMode;
|
|
||||||
this.appendEvent(session, {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
channel: 'system',
|
|
||||||
message: `Thinking mode updated to "${trimmedMode}".`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads saved preferences for resumed sessions.
|
|
||||||
*/
|
|
||||||
protected getSessionPreference(sessionId: string): SessionPreference {
|
|
||||||
return this.sessionPreferences.get(sessionId) ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores session preferences for subsequent resume/start operations.
|
|
||||||
*/
|
|
||||||
protected rememberSessionPreference(sessionId: string, preference: SessionPreference): void {
|
|
||||||
const currentPreference = this.sessionPreferences.get(sessionId) ?? {};
|
|
||||||
this.sessionPreferences.set(sessionId, {
|
|
||||||
...currentPreference,
|
|
||||||
...preference,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates mutable internal session state and registers it in memory.
|
* Creates mutable internal session state and registers it in memory.
|
||||||
*/
|
*/
|
||||||
@@ -206,10 +107,6 @@ export abstract class AbstractProvider implements IProvider {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.sessions.set(sessionId, session);
|
this.sessions.set(sessionId, session);
|
||||||
this.rememberSessionPreference(sessionId, {
|
|
||||||
model: input.model,
|
|
||||||
thinkingMode: input.thinkingMode,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.appendEvent(session, {
|
this.appendEvent(session, {
|
||||||
timestamp: session.startedAt,
|
timestamp: session.startedAt,
|
||||||
|
|||||||
@@ -133,20 +133,12 @@ export abstract class BaseCliProvider extends AbstractProvider {
|
|||||||
* Boots one CLI child process and wires stream handlers to the session buffer.
|
* Boots one CLI child process and wires stream handlers to the session buffer.
|
||||||
*/
|
*/
|
||||||
private async startSessionInternal(input: CreateCliInvocationInput): Promise<ProviderSessionSnapshot> {
|
private async startSessionInternal(input: CreateCliInvocationInput): Promise<ProviderSessionSnapshot> {
|
||||||
const preferred = this.getSessionPreference(input.sessionId);
|
|
||||||
const effectiveModel = input.model ?? preferred.model;
|
|
||||||
const effectiveThinking = input.thinkingMode ?? preferred.thinkingMode;
|
|
||||||
|
|
||||||
const session = this.createSessionRecord(input.sessionId, {
|
const session = this.createSessionRecord(input.sessionId, {
|
||||||
model: effectiveModel,
|
model: input.model,
|
||||||
thinkingMode: effectiveThinking,
|
thinkingMode: input.thinkingMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const invocation = this.createCliInvocation({
|
const invocation = this.createCliInvocation(input);
|
||||||
...input,
|
|
||||||
model: effectiveModel,
|
|
||||||
thinkingMode: effectiveThinking,
|
|
||||||
});
|
|
||||||
|
|
||||||
const child = spawn(invocation.command, invocation.args, {
|
const child = spawn(invocation.command, invocation.args, {
|
||||||
cwd: invocation.cwd ?? input.workspacePath ?? process.cwd(),
|
cwd: invocation.cwd ?? input.workspacePath ?? process.cwd(),
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ type CreateSdkExecutionInput = StartSessionInput & {
|
|||||||
type SdkExecution = {
|
type SdkExecution = {
|
||||||
stream: AsyncIterable<unknown>;
|
stream: AsyncIterable<unknown>;
|
||||||
stop: () => Promise<boolean>;
|
stop: () => Promise<boolean>;
|
||||||
setModel?: (model: string) => Promise<void>;
|
|
||||||
setThinkingMode?: (thinkingMode: string) => Promise<void>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,21 +70,15 @@ export abstract class BaseSdkProvider extends AbstractProvider {
|
|||||||
* Initializes one SDK execution and wires it to the internal session record.
|
* Initializes one SDK execution and wires it to the internal session record.
|
||||||
*/
|
*/
|
||||||
private async startSessionInternal(input: CreateSdkExecutionInput): Promise<ProviderSessionSnapshot> {
|
private async startSessionInternal(input: CreateSdkExecutionInput): Promise<ProviderSessionSnapshot> {
|
||||||
const preferred = this.getSessionPreference(input.sessionId);
|
|
||||||
const effectiveModel = input.model ?? preferred.model;
|
|
||||||
const effectiveThinking = input.thinkingMode ?? preferred.thinkingMode;
|
|
||||||
|
|
||||||
const session = this.createSessionRecord(input.sessionId, {
|
const session = this.createSessionRecord(input.sessionId, {
|
||||||
model: effectiveModel,
|
model: input.model,
|
||||||
thinkingMode: effectiveThinking,
|
thinkingMode: input.thinkingMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
let execution: SdkExecution;
|
let execution: SdkExecution;
|
||||||
try {
|
try {
|
||||||
execution = await this.createSdkExecution({
|
execution = await this.createSdkExecution({
|
||||||
...input,
|
...input,
|
||||||
model: effectiveModel,
|
|
||||||
thinkingMode: effectiveThinking,
|
|
||||||
emitEvent: (event) => {
|
emitEvent: (event) => {
|
||||||
this.appendEvent(session, event);
|
this.appendEvent(session, event);
|
||||||
},
|
},
|
||||||
@@ -103,8 +95,6 @@ export abstract class BaseSdkProvider extends AbstractProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
session.stop = execution.stop;
|
session.stop = execution.stop;
|
||||||
session.setModel = execution.setModel;
|
|
||||||
session.setThinkingMode = execution.setThinkingMode;
|
|
||||||
|
|
||||||
session.completion = this.consumeStream(session, execution.stream);
|
session.completion = this.consumeStream(session, execution.stream);
|
||||||
return this.toSnapshot(session);
|
return this.toSnapshot(session);
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ export class ClaudeProvider extends BaseSdkProvider {
|
|||||||
protected async createSdkExecution(input: ClaudeExecutionInput): Promise<{
|
protected async createSdkExecution(input: ClaudeExecutionInput): Promise<{
|
||||||
stream: AsyncIterable<unknown>;
|
stream: AsyncIterable<unknown>;
|
||||||
stop: () => Promise<boolean>;
|
stop: () => Promise<boolean>;
|
||||||
setModel: (model: string) => Promise<void>;
|
|
||||||
}> {
|
}> {
|
||||||
const options: Options = {
|
const options: Options = {
|
||||||
cwd: input.workspacePath,
|
cwd: input.workspacePath,
|
||||||
@@ -131,9 +130,6 @@ export class ClaudeProvider extends BaseSdkProvider {
|
|||||||
await queryInstance.interrupt();
|
await queryInstance.interrupt();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
setModel: async (model: string) => {
|
|
||||||
await queryInstance.setModel(model);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,8 +84,6 @@ export interface IProvider {
|
|||||||
resumeSession(input: StartSessionInput & { sessionId: string }): Promise<ProviderSessionSnapshot>;
|
resumeSession(input: StartSessionInput & { sessionId: string }): Promise<ProviderSessionSnapshot>;
|
||||||
|
|
||||||
stopSession(sessionId: string): Promise<boolean>;
|
stopSession(sessionId: string): Promise<boolean>;
|
||||||
setSessionModel(sessionId: string, model: string): Promise<void>;
|
|
||||||
setSessionThinkingMode(sessionId: string, thinkingMode: string): Promise<void>;
|
|
||||||
|
|
||||||
getSession(sessionId: string): ProviderSessionSnapshot | null;
|
getSession(sessionId: string): ProviderSessionSnapshot | null;
|
||||||
listSessions(): ProviderSessionSnapshot[];
|
listSessions(): ProviderSessionSnapshot[];
|
||||||
@@ -98,6 +96,4 @@ export type MutableProviderSession = Omit<ProviderSessionSnapshot, 'events'> & {
|
|||||||
events: ProviderSessionEvent[];
|
events: ProviderSessionEvent[];
|
||||||
completion: Promise<void>;
|
completion: Promise<void>;
|
||||||
stop: () => Promise<boolean>;
|
stop: () => Promise<boolean>;
|
||||||
setModel?: (model: string) => Promise<void>;
|
|
||||||
setThinkingMode?: (thinkingMode: string) => Promise<void>;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -127,20 +127,6 @@ export const llmService = {
|
|||||||
const provider = llmProviderRegistry.resolveProvider(providerName);
|
const provider = llmProviderRegistry.resolveProvider(providerName);
|
||||||
return provider.stopSession(sessionId);
|
return provider.stopSession(sessionId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async setSessionModel(providerName: string, sessionId: string, model: string): Promise<void> {
|
|
||||||
const provider = llmProviderRegistry.resolveProvider(providerName);
|
|
||||||
await provider.setSessionModel(sessionId, model);
|
|
||||||
},
|
|
||||||
|
|
||||||
async setSessionThinkingMode(
|
|
||||||
providerName: string,
|
|
||||||
sessionId: string,
|
|
||||||
thinkingMode: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const provider = llmProviderRegistry.resolveProvider(providerName);
|
|
||||||
await provider.setSessionThinkingMode(sessionId, thinkingMode);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -220,15 +220,15 @@ test('codex provider reads models_cache.json and maps model metadata', async ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// This test covers persisted session-level model/thinking preferences flowing into Codex thread options.
|
// This test covers explicit start/resume payload control for model/thinking without implicit persistence.
|
||||||
test('codex provider applies saved model/thinking preferences on subsequent launch', async () => {
|
test('codex provider does not persist model/thinking between launches', async () => {
|
||||||
const provider = new CodexProvider() as any;
|
const provider = new CodexProvider() as any;
|
||||||
let threadOptions: Record<string, unknown> | null = null;
|
const threadOptionsHistory: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
provider.loadCodexSdkModule = async () => ({
|
provider.loadCodexSdkModule = async () => ({
|
||||||
Codex: class {
|
Codex: class {
|
||||||
startThread(options?: Record<string, unknown>) {
|
startThread(options?: Record<string, unknown>) {
|
||||||
threadOptions = options ?? null;
|
threadOptionsHistory.push(options ?? {});
|
||||||
return {
|
return {
|
||||||
async runStreamed() {
|
async runStreamed() {
|
||||||
return { events: asyncEvents([]) };
|
return { events: asyncEvents([]) };
|
||||||
@@ -246,17 +246,23 @@ test('codex provider applies saved model/thinking preferences on subsequent laun
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await provider.setSessionModel('codex-pref-1', 'gpt-5.4');
|
await provider.launchSession({
|
||||||
await provider.setSessionThinkingMode('codex-pref-1', 'xhigh');
|
prompt: 'explicit launch options',
|
||||||
|
sessionId: 'codex-pref-1',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
thinkingMode: 'xhigh',
|
||||||
|
});
|
||||||
|
|
||||||
await provider.launchSession({
|
await provider.launchSession({
|
||||||
prompt: 'use stored preferences',
|
prompt: 'follow-up launch without options',
|
||||||
sessionId: 'codex-pref-1',
|
sessionId: 'codex-pref-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.ok(threadOptions);
|
assert.equal(threadOptionsHistory.length, 2);
|
||||||
assert.equal((threadOptions as { model?: string }).model, 'gpt-5.4');
|
assert.equal((threadOptionsHistory[0] as { model?: string }).model, 'gpt-5.4');
|
||||||
assert.equal((threadOptions as { modelReasoningEffort?: string }).modelReasoningEffort, 'xhigh');
|
assert.equal((threadOptionsHistory[0] as { modelReasoningEffort?: string }).modelReasoningEffort, 'xhigh');
|
||||||
|
assert.equal((threadOptionsHistory[1] as { model?: string }).model, undefined);
|
||||||
|
assert.equal((threadOptionsHistory[1] as { modelReasoningEffort?: string }).modelReasoningEffort, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
// This test covers Claude thinking-level mapping, runtime permission handlers, and model/event normalization.
|
// This test covers Claude thinking-level mapping, runtime permission handlers, and model/event normalization.
|
||||||
@@ -318,21 +324,3 @@ test('llmService rejects unsupported runtime permission and thinking mode combin
|
|||||||
error.statusCode === 400,
|
error.statusCode === 400,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// This test covers model/thinking capability gates on providers before any external process/SDK usage.
|
|
||||||
test('providers enforce capability gates for model/thinking updates', async () => {
|
|
||||||
const claudeProvider = new ClaudeProvider();
|
|
||||||
const cursorProvider = new CursorProvider();
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
cursorProvider.setSessionThinkingMode('cursor-session', 'high'),
|
|
||||||
(error: unknown) =>
|
|
||||||
error instanceof AppError &&
|
|
||||||
error.code === 'THINKING_MODE_NOT_SUPPORTED' &&
|
|
||||||
error.statusCode === 400,
|
|
||||||
);
|
|
||||||
|
|
||||||
await claudeProvider.setSessionModel('claude-session', 'sonnet');
|
|
||||||
const preference = (claudeProvider as any).getSessionPreference('claude-session');
|
|
||||||
assert.equal(preference.model, 'sonnet');
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user