feat: setup unified classes for LLM providers and session processing, add tests for LLM unifier helper functions

This commit is contained in:
Haileyesus
2026-04-06 17:37:58 +03:00
parent 753c58fc1a
commit 6d00c17137
50 changed files with 4689 additions and 1721 deletions

View File

@@ -0,0 +1,267 @@
import { AppError } from '@/shared/utils/app-error.js';
import type {
IProvider,
MutableProviderSession,
ProviderCapabilities,
ProviderExecutionFamily,
ProviderModel,
ProviderSessionEvent,
ProviderSessionSnapshot,
StartSessionInput,
} from '@/modules/llm/providers/provider.interface.js';
import type { LLMProvider } from '@/shared/types/app.js';
type SessionPreference = {
model?: string;
thinkingMode?: string;
};
const MAX_EVENT_BUFFER_SIZE = 2_000;
/**
* Shared provider base for session lifecycle state and capability gating.
*/
export abstract class AbstractProvider implements IProvider {
readonly id: LLMProvider;
readonly family: ProviderExecutionFamily;
readonly capabilities: ProviderCapabilities;
protected readonly sessions = new Map<string, MutableProviderSession>();
protected readonly sessionPreferences = new Map<string, SessionPreference>();
protected constructor(
id: LLMProvider,
family: ProviderExecutionFamily,
capabilities: ProviderCapabilities,
) {
this.id = id;
this.family = family;
this.capabilities = capabilities;
}
abstract listModels(): Promise<ProviderModel[]>;
abstract launchSession(input: StartSessionInput): Promise<ProviderSessionSnapshot>;
abstract resumeSession(
input: StartSessionInput & { sessionId: string },
): Promise<ProviderSessionSnapshot>;
/**
* Returns one in-memory session snapshot when present.
*/
getSession(sessionId: string): ProviderSessionSnapshot | null {
const session = this.sessions.get(sessionId);
if (!session) {
return null;
}
return this.toSnapshot(session);
}
/**
* Returns snapshots of all in-memory sessions.
*/
listSessions(): ProviderSessionSnapshot[] {
return [...this.sessions.values()].map((session) => this.toSnapshot(session));
}
/**
* Waits for a running session to complete and returns the final snapshot.
*/
async waitForSession(sessionId: string): Promise<ProviderSessionSnapshot | null> {
const session = this.sessions.get(sessionId);
if (!session) {
return null;
}
await session.completion;
return this.toSnapshot(session);
}
/**
* Requests a graceful session stop.
*/
async stopSession(sessionId: string): Promise<boolean> {
const session = this.sessions.get(sessionId);
if (!session) {
return false;
}
const stopped = await session.stop();
if (stopped && session.status === 'running') {
this.updateSessionStatus(session, 'stopped');
this.appendEvent(session, {
timestamp: new Date().toISOString(),
channel: 'system',
message: 'Session stop requested.',
});
}
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.
*/
protected createSessionRecord(
sessionId: string,
input: {
model?: string;
thinkingMode?: string;
},
): MutableProviderSession {
const session: MutableProviderSession = {
sessionId,
provider: this.id,
family: this.family,
status: 'running',
startedAt: new Date().toISOString(),
model: input.model,
thinkingMode: input.thinkingMode,
events: [],
completion: Promise.resolve(),
stop: async () => false,
};
this.sessions.set(sessionId, session);
this.rememberSessionPreference(sessionId, {
model: input.model,
thinkingMode: input.thinkingMode,
});
return session;
}
/**
* Appends an event while enforcing the configured ring-buffer size.
*/
protected appendEvent(session: MutableProviderSession, event: ProviderSessionEvent): void {
session.events.push(event);
if (session.events.length > MAX_EVENT_BUFFER_SIZE) {
session.events.splice(0, session.events.length - MAX_EVENT_BUFFER_SIZE);
}
}
/**
* Marks the terminal state for a session.
*/
protected updateSessionStatus(
session: MutableProviderSession,
status: MutableProviderSession['status'],
error?: string,
): void {
session.status = status;
session.endedAt = new Date().toISOString();
session.error = error;
}
/**
* Converts mutable internal session state to an external snapshot.
*/
protected toSnapshot(session: MutableProviderSession): ProviderSessionSnapshot {
return {
sessionId: session.sessionId,
provider: session.provider,
family: session.family,
status: session.status,
startedAt: session.startedAt,
endedAt: session.endedAt,
model: session.model,
thinkingMode: session.thinkingMode,
events: [...session.events],
error: session.error,
};
}
}

View File

@@ -0,0 +1,284 @@
import { randomUUID } from 'node:crypto';
import { once } from 'node:events';
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import spawn from 'cross-spawn';
import { AbstractProvider } from '@/modules/llm/providers/abstract.provider.js';
import type {
MutableProviderSession,
ProviderCapabilities,
ProviderSessionEvent,
ProviderSessionSnapshot,
StartSessionInput,
} from '@/modules/llm/providers/provider.interface.js';
import { createStreamLineAccumulator } from '@/shared/platform/stream.js';
import type { LLMProvider } from '@/shared/types/app.js';
type CreateCliInvocationInput = StartSessionInput & {
sessionId: string;
isResume: boolean;
};
type CliInvocation = {
command: string;
args: string[];
cwd?: string;
env?: Record<string, string | undefined>;
};
const PROCESS_SHUTDOWN_GRACE_PERIOD_MS = 2_000;
/**
* Base class for CLI-driven providers with streamed stdout/stderr parsing.
*/
export abstract class BaseCliProvider extends AbstractProvider {
protected constructor(providerId: LLMProvider, capabilities: ProviderCapabilities) {
super(providerId, 'cli', capabilities);
}
/**
* Starts a new CLI session and begins process output streaming.
*/
async launchSession(input: StartSessionInput): Promise<ProviderSessionSnapshot> {
return this.startSessionInternal({
...input,
sessionId: input.sessionId ?? randomUUID(),
isResume: false,
});
}
/**
* Resumes an existing CLI session and begins process output streaming.
*/
async resumeSession(input: StartSessionInput & { sessionId: string }): Promise<ProviderSessionSnapshot> {
return this.startSessionInternal({
...input,
isResume: true,
});
}
/**
* Implemented by concrete CLI providers to describe command invocation.
*/
protected abstract createCliInvocation(input: CreateCliInvocationInput): CliInvocation;
/**
* Maps one stdout/stderr line into either JSON or plain-text event shapes.
*/
protected mapCliOutputLine(line: string, channel: 'stdout' | 'stderr'): ProviderSessionEvent {
const parsedJson = this.tryParseJson(line);
if (parsedJson !== null) {
return {
timestamp: new Date().toISOString(),
channel: 'json',
data: parsedJson,
};
}
return {
timestamp: new Date().toISOString(),
channel,
message: line,
};
}
/**
* Runs a one-off CLI command and returns full stdout text on success.
*/
protected async runCommandForOutput(command: string, args: string[]): Promise<string> {
const child = spawn(command, args, {
stdio: ['ignore', 'pipe', 'pipe'],
cwd: process.cwd(),
env: process.env,
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString();
});
const closePromise = once(child, 'close');
const errorPromise = once(child, 'error').then(([error]) => {
throw error;
});
await Promise.race([closePromise, errorPromise]);
if ((child.exitCode ?? 1) !== 0) {
const message = stderr.trim() || `Command "${command}" failed with code ${child.exitCode}`;
throw new Error(message);
}
return stdout;
}
/**
* Boots one CLI child process and wires stream handlers to the session buffer.
*/
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, {
model: effectiveModel,
thinkingMode: effectiveThinking,
});
const invocation = this.createCliInvocation({
...input,
model: effectiveModel,
thinkingMode: effectiveThinking,
});
const child = spawn(invocation.command, invocation.args, {
cwd: invocation.cwd ?? input.workspacePath ?? process.cwd(),
env: {
...process.env,
...invocation.env,
},
stdio: ['ignore', 'pipe', 'pipe'],
}) as ChildProcessWithoutNullStreams;
const stop = async (): Promise<boolean> => this.terminateChildProcess(child);
session.stop = stop;
const stdoutAccumulator = createStreamLineAccumulator({ preserveEmptyLines: false });
const stderrAccumulator = createStreamLineAccumulator({ preserveEmptyLines: false });
child.stdout.on('data', (chunk) => {
const lines = stdoutAccumulator.push(chunk);
for (const line of lines) {
const event = this.mapCliOutputLine(line, 'stdout');
this.appendEvent(session, event);
}
});
child.stderr.on('data', (chunk) => {
const lines = stderrAccumulator.push(chunk);
for (const line of lines) {
const event = this.mapCliOutputLine(line, 'stderr');
this.appendEvent(session, event);
}
});
session.completion = this.waitForCliProcess(
session,
child,
stdoutAccumulator,
stderrAccumulator,
);
return this.toSnapshot(session);
}
/**
* Waits for process completion/error and marks final session status.
*/
private async waitForCliProcess(
session: MutableProviderSession,
child: ChildProcessWithoutNullStreams,
stdoutAccumulator: { flush: () => string[] },
stderrAccumulator: { flush: () => string[] },
): Promise<void> {
const closePromise = once(child, 'close') as Promise<[number | null, NodeJS.Signals | null]>;
const errorPromise = once(child, 'error') as Promise<[Error]>;
const raceResult = await Promise.race([
closePromise.then((result) => ({ type: 'close' as const, result })),
errorPromise.then((result) => ({ type: 'error' as const, result })),
]);
const pendingStdout = stdoutAccumulator.flush();
const pendingStderr = stderrAccumulator.flush();
for (const line of pendingStdout) {
this.appendEvent(session, this.mapCliOutputLine(line, 'stdout'));
}
for (const line of pendingStderr) {
this.appendEvent(session, this.mapCliOutputLine(line, 'stderr'));
}
if (raceResult.type === 'error') {
const [error] = raceResult.result;
const message = error.message || 'CLI process failed before start.';
this.updateSessionStatus(session, 'failed', message);
this.appendEvent(session, {
timestamp: new Date().toISOString(),
channel: 'error',
message,
});
return;
}
const [code, signal] = raceResult.result;
if (session.status === 'stopped') {
this.appendEvent(session, {
timestamp: new Date().toISOString(),
channel: 'system',
message: `Session stopped (${signal ?? 'SIGTERM'}).`,
});
return;
}
if (code === 0) {
this.updateSessionStatus(session, 'completed');
return;
}
const message = `CLI command exited with code ${code ?? 'null'}${signal ? ` (signal: ${signal})` : ''}`;
this.updateSessionStatus(session, 'failed', message);
this.appendEvent(session, {
timestamp: new Date().toISOString(),
channel: 'error',
message,
});
}
/**
* Attempts graceful termination first, then force-kills when necessary.
*/
private async terminateChildProcess(child: ChildProcessWithoutNullStreams): Promise<boolean> {
if (child.killed || child.exitCode !== null) {
return true;
}
try {
child.kill('SIGTERM');
await Promise.race([
once(child, 'close'),
new Promise((resolve) => setTimeout(resolve, PROCESS_SHUTDOWN_GRACE_PERIOD_MS)),
]);
if (child.exitCode === null) {
child.kill('SIGKILL');
}
return true;
} catch {
return false;
}
}
/**
* Best-effort JSON parser for stream-json providers.
*/
private tryParseJson(line: string): unknown | null {
const trimmed = line.trim();
if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) {
return null;
}
try {
return JSON.parse(trimmed);
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,147 @@
import { randomUUID } from 'node:crypto';
import { AbstractProvider } from '@/modules/llm/providers/abstract.provider.js';
import type {
MutableProviderSession,
ProviderCapabilities,
ProviderSessionEvent,
ProviderSessionSnapshot,
StartSessionInput,
} from '@/modules/llm/providers/provider.interface.js';
import type { LLMProvider } from '@/shared/types/app.js';
type CreateSdkExecutionInput = StartSessionInput & {
sessionId: string;
isResume: boolean;
};
type SdkExecution = {
stream: AsyncIterable<unknown>;
stop: () => Promise<boolean>;
setModel?: (model: string) => Promise<void>;
setThinkingMode?: (thinkingMode: string) => Promise<void>;
};
/**
* Base class for SDK-driven providers with async stream consumption.
*/
export abstract class BaseSdkProvider extends AbstractProvider {
protected constructor(providerId: LLMProvider, capabilities: ProviderCapabilities) {
super(providerId, 'sdk', capabilities);
}
/**
* Starts a new SDK session and begins event streaming.
*/
async launchSession(input: StartSessionInput): Promise<ProviderSessionSnapshot> {
return this.startSessionInternal({
...input,
sessionId: input.sessionId ?? randomUUID(),
isResume: false,
});
}
/**
* Resumes an existing SDK session and begins event streaming.
*/
async resumeSession(input: StartSessionInput & { sessionId: string }): Promise<ProviderSessionSnapshot> {
return this.startSessionInternal({
...input,
isResume: true,
});
}
/**
* Implemented by concrete SDK providers to create a running execution.
*/
protected abstract createSdkExecution(input: CreateSdkExecutionInput): Promise<SdkExecution>;
/**
* Normalizes raw SDK events to the shared event shape.
*/
protected mapSdkEvent(rawEvent: unknown): ProviderSessionEvent | null {
return {
timestamp: new Date().toISOString(),
channel: 'sdk',
data: rawEvent,
};
}
/**
* Initializes one SDK execution and wires it to the internal session record.
*/
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, {
model: effectiveModel,
thinkingMode: effectiveThinking,
});
let execution: SdkExecution;
try {
execution = await this.createSdkExecution({
...input,
model: effectiveModel,
thinkingMode: effectiveThinking,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to start SDK session';
this.updateSessionStatus(session, 'failed', message);
this.appendEvent(session, {
timestamp: new Date().toISOString(),
channel: 'error',
message,
});
throw error;
}
session.stop = execution.stop;
session.setModel = execution.setModel;
session.setThinkingMode = execution.setThinkingMode;
session.completion = this.consumeStream(session, execution.stream);
return this.toSnapshot(session);
}
/**
* Drains SDK events until completion/error and updates final status.
*/
private async consumeStream(
session: MutableProviderSession,
stream: AsyncIterable<unknown>,
): Promise<void> {
try {
for await (const sdkEvent of stream) {
const normalized = this.mapSdkEvent(sdkEvent);
if (normalized) {
this.appendEvent(session, normalized);
}
}
if (session.status === 'running') {
this.updateSessionStatus(session, 'completed');
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown SDK execution failure';
if (session.status === 'stopped') {
this.appendEvent(session, {
timestamp: new Date().toISOString(),
channel: 'system',
message: 'Session stopped.',
});
return;
}
this.updateSessionStatus(session, 'failed', message);
this.appendEvent(session, {
timestamp: new Date().toISOString(),
channel: 'error',
message,
});
}
}
}

View File

@@ -0,0 +1,182 @@
import {
query,
type CanUseTool,
type ModelInfo,
type Options,
} from '@anthropic-ai/claude-agent-sdk';
import { BaseSdkProvider } from '@/modules/llm/providers/base-sdk.provider.js';
import type {
ProviderModel,
ProviderSessionEvent,
RuntimePermissionMode,
StartSessionInput,
} from '@/modules/llm/providers/provider.interface.js';
type ClaudeExecutionInput = StartSessionInput & {
sessionId: string;
isResume: boolean;
};
const CLAUDE_THINKING_LEVELS = new Set(['low', 'medium', 'high', 'max']);
/**
* Claude SDK provider implementation.
*/
export class ClaudeProvider extends BaseSdkProvider {
constructor() {
super('claude', {
supportsRuntimePermissionRequests: true,
supportsThinkingModeControl: true,
supportsModelSwitching: true,
supportsSessionResume: true,
supportsSessionStop: true,
});
}
/**
* Retrieves available Claude models from the SDK.
*/
async listModels(): Promise<ProviderModel[]> {
const probe = query({
prompt: 'model_probe',
options: {
permissionMode: 'plan',
},
});
try {
const models = await probe.supportedModels();
return models.map((model) => this.mapModelInfo(model));
} finally {
probe.close();
}
}
/**
* Creates a Claude SDK query execution for start/resume flows.
*/
protected async createSdkExecution(input: ClaudeExecutionInput): Promise<{
stream: AsyncIterable<unknown>;
stop: () => Promise<boolean>;
setModel: (model: string) => Promise<void>;
}> {
const options: Options = {
cwd: input.workspacePath,
model: input.model,
effort: this.resolveClaudeEffort(input.thinkingMode),
canUseTool: this.resolvePermissionHandler(input.runtimePermissionMode),
};
if (input.isResume) {
options.resume = input.sessionId;
} else {
options.sessionId = input.sessionId;
}
const queryInstance = query({
prompt: input.prompt,
options,
});
return {
stream: queryInstance,
stop: async () => {
await queryInstance.interrupt();
return true;
},
setModel: async (model: string) => {
await queryInstance.setModel(model);
},
};
}
/**
* Produces compact event metadata for frontend stream rendering.
*/
protected mapSdkEvent(rawEvent: unknown): ProviderSessionEvent | null {
if (typeof rawEvent !== 'object' || rawEvent === null) {
return {
timestamp: new Date().toISOString(),
channel: 'sdk',
message: String(rawEvent),
};
}
const messageType = this.getStringProperty(rawEvent, 'type');
const messageSubtype = this.getStringProperty(rawEvent, 'subtype');
const message = [messageType, messageSubtype].filter(Boolean).join(':') || 'claude_event';
return {
timestamp: new Date().toISOString(),
channel: 'sdk',
message,
data: rawEvent,
};
}
/**
* Normalizes Claude model metadata to the shared model shape.
*/
private mapModelInfo(model: ModelInfo): ProviderModel {
return {
value: model.value,
displayName: model.displayName,
description: model.description,
supportsThinkingModes: Boolean(model.supportsEffort),
supportedThinkingModes: model.supportedEffortLevels,
};
}
/**
* Maps requested thinking mode to Claude effort levels.
*/
private resolveClaudeEffort(thinkingMode?: string): Options['effort'] {
if (!thinkingMode) {
return 'high';
}
const normalized = thinkingMode.trim().toLowerCase();
if (CLAUDE_THINKING_LEVELS.has(normalized)) {
return normalized as Options['effort'];
}
return 'high';
}
/**
* Builds a runtime permission callback when explicit allow/deny is requested.
*/
private resolvePermissionHandler(mode?: RuntimePermissionMode): CanUseTool | undefined {
if (!mode || mode === 'ask') {
return undefined;
}
if (mode === 'allow') {
return async () => ({ behavior: 'allow' });
}
return async () => ({
behavior: 'deny',
message: 'Permission denied by runtime permission mode.',
interrupt: false,
});
}
/**
* Reads one optional string property from an unknown event object.
*/
private getStringProperty(value: unknown, key: string): string | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const record = value as Record<string, unknown>;
const rawValue = record[key];
if (typeof rawValue !== 'string') {
return undefined;
}
return rawValue;
}
}

View File

@@ -0,0 +1,171 @@
import os from 'node:os';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { BaseSdkProvider } from '@/modules/llm/providers/base-sdk.provider.js';
import type { ProviderModel, ProviderSessionEvent, StartSessionInput } from '@/modules/llm/providers/provider.interface.js';
import { AppError } from '@/shared/utils/app-error.js';
type CodexExecutionInput = StartSessionInput & {
sessionId: string;
isResume: boolean;
};
type CodexModelCacheEntry = {
slug?: string;
display_name?: string;
description?: string;
supported_reasoning_levels?: Array<{
effort?: string;
description?: string;
}>;
priority?: number;
};
type CodexSdkClient = {
startThread: (options?: Record<string, unknown>) => CodexThread;
resumeThread: (sessionId: string, options?: Record<string, unknown>) => CodexThread;
};
type CodexThread = {
runStreamed: (
prompt: string,
options?: {
signal?: AbortSignal;
},
) => Promise<{
events: AsyncIterable<unknown>;
}>;
};
type CodexSdkModule = {
Codex: new () => CodexSdkClient;
};
/**
* Codex SDK provider implementation.
*/
export class CodexProvider extends BaseSdkProvider {
constructor() {
super('codex', {
supportsRuntimePermissionRequests: false,
supportsThinkingModeControl: true,
supportsModelSwitching: true,
supportsSessionResume: true,
supportsSessionStop: true,
});
}
/**
* Reads codex models from ~/.codex/models_cache.json.
*/
async listModels(): Promise<ProviderModel[]> {
const modelCachePath = path.join(os.homedir(), '.codex', 'models_cache.json');
let content: string;
try {
content = await readFile(modelCachePath, 'utf8');
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (code === 'ENOENT') {
throw new AppError('Codex model cache was not found. Expected ~/.codex/models_cache.json.', {
code: 'CODEX_MODEL_CACHE_NOT_FOUND',
statusCode: 404,
});
}
throw error;
}
const parsed = JSON.parse(content) as { models?: CodexModelCacheEntry[] };
const models = parsed.models ?? [];
return models
.filter((entry) => Boolean(entry.slug))
.map((entry) => ({
value: entry.slug as string,
displayName: entry.display_name ?? entry.slug ?? 'unknown',
description: entry.description,
default: entry.priority === 1,
supportsThinkingModes: Boolean(entry.supported_reasoning_levels?.length),
supportedThinkingModes: entry.supported_reasoning_levels
?.map((level) => level.effort)
.filter((effort): effort is string => typeof effort === 'string'),
}));
}
/**
* Creates a Codex thread execution and wires abort support.
*/
protected async createSdkExecution(input: CodexExecutionInput): Promise<{
stream: AsyncIterable<unknown>;
stop: () => Promise<boolean>;
}> {
const sdkModule = await this.loadCodexSdkModule();
const client = new sdkModule.Codex();
const threadOptions: Record<string, unknown> = {
model: input.model,
workingDirectory: input.workspacePath,
modelReasoningEffort: input.thinkingMode,
};
const thread = input.isResume
? client.resumeThread(input.sessionId, threadOptions)
: client.startThread(threadOptions);
const abortController = new AbortController();
const streamedTurn = await thread.runStreamed(input.prompt, {
signal: abortController.signal,
});
return {
stream: streamedTurn.events,
stop: async () => {
abortController.abort('Session stop requested');
return true;
},
};
}
/**
* Normalizes Codex stream events into the shared event shape.
*/
protected mapSdkEvent(rawEvent: unknown): ProviderSessionEvent | null {
if (typeof rawEvent !== 'object' || rawEvent === null) {
return {
timestamp: new Date().toISOString(),
channel: 'sdk',
message: String(rawEvent),
};
}
const record = rawEvent as Record<string, unknown>;
const message = typeof record.type === 'string' ? record.type : 'codex_event';
return {
timestamp: new Date().toISOString(),
channel: 'sdk',
message,
data: rawEvent,
};
}
/**
* Dynamically imports the Codex SDK to support environments where it is optional.
*/
private async loadCodexSdkModule(): Promise<CodexSdkModule> {
try {
const sdkModule = (await import('@openai/codex-sdk')) as unknown as CodexSdkModule;
if (!sdkModule?.Codex) {
throw new Error('Codex SDK did not export "Codex".');
}
return sdkModule;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to import Codex SDK';
throw new AppError(`Codex SDK is unavailable: ${message}`, {
code: 'CODEX_SDK_UNAVAILABLE',
statusCode: 503,
});
}
}
}

View File

@@ -0,0 +1,123 @@
import { BaseCliProvider } from '@/modules/llm/providers/base-cli.provider.js';
import type { ProviderModel, StartSessionInput } from '@/modules/llm/providers/provider.interface.js';
type CursorExecutionInput = StartSessionInput & {
sessionId: string;
isResume: boolean;
};
const ANSI_REGEX =
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping.
/\u001b\[[0-9;]*m/g;
/**
* Cursor CLI provider implementation.
*/
export class CursorProvider extends BaseCliProvider {
constructor() {
super('cursor', {
supportsRuntimePermissionRequests: false,
supportsThinkingModeControl: false,
supportsModelSwitching: true,
supportsSessionResume: true,
supportsSessionStop: true,
});
}
/**
* Lists cursor models by parsing `cursor-agent --list-models`.
*/
async listModels(): Promise<ProviderModel[]> {
const output = await this.runCommandForOutput('cursor-agent', ['--list-models']);
return this.parseModelsOutput(output);
}
/**
* Creates the command invocation for cursor start/resume flows.
*/
protected createCliInvocation(input: CursorExecutionInput): {
command: string;
args: string[];
cwd?: string;
} {
const args = ['--print', '--trust', '--output-format', 'stream-json'];
if (input.allowYolo) {
args.push('--yolo');
}
if (input.model) {
args.push('--model', input.model);
}
if (input.isResume) {
args.push('--resume', input.sessionId);
}
args.push(input.prompt);
return {
command: 'cursor-agent',
args,
cwd: input.workspacePath,
};
}
/**
* Parses full model-list output into normalized model entries.
*/
private parseModelsOutput(output: string): ProviderModel[] {
const models: ProviderModel[] = [];
const lines = output.replace(ANSI_REGEX, '').split(/\r?\n/);
for (const line of lines) {
const parsed = this.parseModelLine(line);
if (!parsed) {
continue;
}
models.push(parsed);
}
return models;
}
/**
* Parses one cursor model line.
*/
private parseModelLine(line: string): ProviderModel | null {
const trimmed = line.trim();
if (
!trimmed ||
trimmed === 'Available models' ||
trimmed.startsWith('Loading models') ||
trimmed.startsWith('Tip:')
) {
return null;
}
const match = trimmed.match(/^(.+?)\s+-\s+(.+)$/);
if (!match) {
return null;
}
const value = match[1].trim();
const descriptionRaw = match[2].trim();
const current = /\(current\)/i.test(descriptionRaw);
const defaultModel = /\(default\)/i.test(descriptionRaw);
const description = descriptionRaw
.replace(/\s*\((current|default)\)/gi, '')
.replace(/\s{2,}/g, ' ')
.trim();
return {
value,
displayName: value,
description,
current,
default: defaultModel,
supportsThinkingModes: false,
supportedThinkingModes: [],
};
}
}

View File

@@ -0,0 +1,66 @@
import { BaseCliProvider } from '@/modules/llm/providers/base-cli.provider.js';
import type { ProviderModel, StartSessionInput } from '@/modules/llm/providers/provider.interface.js';
type GeminiExecutionInput = StartSessionInput & {
sessionId: string;
isResume: boolean;
};
const GEMINI_MODELS: ProviderModel[] = [
{ value: 'gemini-3.1-pro-preview', displayName: 'Gemini 3.1 Pro Preview' },
{ value: 'gemini-3-pro-preview', displayName: 'Gemini 3 Pro Preview' },
{ value: 'gemini-3-flash-preview', displayName: 'Gemini 3 Flash Preview' },
{ value: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-pro', displayName: 'Gemini 2.5 Pro' },
{ value: 'gemini-2.0-flash-lite', displayName: 'Gemini 2.0 Flash Lite' },
{ value: 'gemini-2.0-flash', displayName: 'Gemini 2.0 Flash' },
{ value: 'gemini-2.0-pro-exp', displayName: 'Gemini 2.0 Pro Experimental' },
{ value: 'gemini-2.0-flash-thinking-exp', displayName: 'Gemini 2.0 Flash Thinking' },
];
/**
* Gemini CLI provider implementation.
*/
export class GeminiProvider extends BaseCliProvider {
constructor() {
super('gemini', {
supportsRuntimePermissionRequests: false,
supportsThinkingModeControl: false,
supportsModelSwitching: true,
supportsSessionResume: true,
supportsSessionStop: true,
});
}
/**
* Returns curated Gemini model options from the refactor doc.
*/
async listModels(): Promise<ProviderModel[]> {
return GEMINI_MODELS;
}
/**
* Creates the command invocation for gemini start/resume flows.
*/
protected createCliInvocation(input: GeminiExecutionInput): {
command: string;
args: string[];
cwd?: string;
} {
const args = ['--prompt', input.prompt, '--output-format', 'stream-json'];
if (input.model) {
args.push('--model', input.model);
}
if (input.isResume) {
args.push('--resume', input.sessionId);
}
return {
command: 'gemini',
args,
cwd: input.workspacePath,
};
}
}

View File

@@ -0,0 +1,103 @@
import type { LLMProvider } from '@/shared/types/app.js';
export type ProviderExecutionFamily = 'sdk' | 'cli';
export type ProviderSessionStatus = 'running' | 'completed' | 'failed' | 'stopped';
export type RuntimePermissionMode = 'ask' | 'allow' | 'deny';
/**
* Advertises optional provider behaviors so route/service code can gate features.
*/
export type ProviderCapabilities = {
supportsRuntimePermissionRequests: boolean;
supportsThinkingModeControl: boolean;
supportsModelSwitching: boolean;
supportsSessionResume: boolean;
supportsSessionStop: boolean;
};
/**
* Provider model descriptor normalized for frontend consumption.
*/
export type ProviderModel = {
value: string;
displayName: string;
description?: string;
default?: boolean;
current?: boolean;
supportsThinkingModes?: boolean;
supportedThinkingModes?: string[];
};
/**
* Unified in-memory event emitted while a provider session runs.
*/
export type ProviderSessionEvent = {
timestamp: string;
channel: 'sdk' | 'stdout' | 'stderr' | 'json' | 'system' | 'error';
message?: string;
data?: unknown;
};
/**
* Common launch/resume payload consumed by all providers.
*/
export type StartSessionInput = {
prompt: string;
workspacePath?: string;
sessionId?: string;
model?: string;
thinkingMode?: string;
runtimePermissionMode?: RuntimePermissionMode;
allowYolo?: boolean;
};
/**
* Snapshot shape exposed externally for a provider session.
*/
export type ProviderSessionSnapshot = {
sessionId: string;
provider: LLMProvider;
family: ProviderExecutionFamily;
status: ProviderSessionStatus;
startedAt: string;
endedAt?: string;
model?: string;
thinkingMode?: string;
events: ProviderSessionEvent[];
error?: string;
};
/**
* Provider contract that both SDK and CLI families implement.
*/
export interface IProvider {
readonly id: LLMProvider;
readonly family: ProviderExecutionFamily;
readonly capabilities: ProviderCapabilities;
listModels(): Promise<ProviderModel[]>;
launchSession(input: StartSessionInput): Promise<ProviderSessionSnapshot>;
resumeSession(input: StartSessionInput & { sessionId: string }): Promise<ProviderSessionSnapshot>;
stopSession(sessionId: string): Promise<boolean>;
setSessionModel(sessionId: string, model: string): Promise<void>;
setSessionThinkingMode(sessionId: string, thinkingMode: string): Promise<void>;
getSession(sessionId: string): ProviderSessionSnapshot | null;
listSessions(): ProviderSessionSnapshot[];
waitForSession(sessionId: string): Promise<ProviderSessionSnapshot | null>;
}
/**
* Internal mutable session state used by provider base classes.
*/
export type MutableProviderSession = Omit<ProviderSessionSnapshot, 'events'> & {
events: ProviderSessionEvent[];
completion: Promise<void>;
stop: () => Promise<boolean>;
setModel?: (model: string) => Promise<void>;
setThinkingMode?: (thinkingMode: string) => Promise<void>;
};