mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-17 09:30:05 +00:00
feat: setup unified classes for LLM providers and session processing, add tests for LLM unifier helper functions
This commit is contained in:
267
server/src/modules/llm/providers/abstract.provider.ts
Normal file
267
server/src/modules/llm/providers/abstract.provider.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
284
server/src/modules/llm/providers/base-cli.provider.ts
Normal file
284
server/src/modules/llm/providers/base-cli.provider.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
147
server/src/modules/llm/providers/base-sdk.provider.ts
Normal file
147
server/src/modules/llm/providers/base-sdk.provider.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
182
server/src/modules/llm/providers/claude.provider.ts
Normal file
182
server/src/modules/llm/providers/claude.provider.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
171
server/src/modules/llm/providers/codex.provider.ts
Normal file
171
server/src/modules/llm/providers/codex.provider.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
123
server/src/modules/llm/providers/cursor.provider.ts
Normal file
123
server/src/modules/llm/providers/cursor.provider.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
66
server/src/modules/llm/providers/gemini.provider.ts
Normal file
66
server/src/modules/llm/providers/gemini.provider.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
103
server/src/modules/llm/providers/provider.interface.ts
Normal file
103
server/src/modules/llm/providers/provider.interface.ts
Normal 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>;
|
||||
};
|
||||
Reference in New Issue
Block a user