refactor: remove session model and thinking mode management from providers and related services

This commit is contained in:
Haileyesus
2026-04-07 11:56:28 +03:00
parent 5a1bcb4931
commit ed0a895d75
8 changed files with 21 additions and 226 deletions

View File

@@ -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.
*/ */

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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);

View File

@@ -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);
},
}; };
} }

View File

@@ -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>;
}; };

View File

@@ -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);
},
}; };
/** /**

View File

@@ -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');
});