Advertisement

← vibesdk

worker/database/services/ModelConfigService.ts

11.6 KB Download file
/**
 * Model Configuration Service
 * Handles CRUD operations for user model configurations with constraint enforcement
 */

import { BaseService } from './BaseService';
import { UserModelConfig, NewUserModelConfig, userModelConfigs } from '../schema';
import { eq, and } from 'drizzle-orm';
import { AgentActionKey, ModelConfig } from '../../agents/inferutils/config.types';
import { AGENT_CONFIG, AGENT_CONSTRAINTS } from '../../agents/inferutils/config';
import type { ReasoningEffort } from '../../agents/inferutils/config.types';
import { generateId } from '../../utils/idGenerator';
import type { UserModelConfigWithMetadata } from '../types';
import { validateAgentConstraints } from 'worker/api/controllers/modelConfig/constraintHelper';
import { toAIModel } from '../../agents/inferutils/config.types';

type ConstraintStrategy = 'throw' | 'fallback';

export class ModelConfigService extends BaseService {
	private castToReasoningEffort(value: string | null): ReasoningEffort | undefined {
		if (!value) return undefined;
		return value as ReasoningEffort;
	}

	private validateModel(
		agentActionName: AgentActionKey,
		modelName: string | undefined,
		modelType: 'primary' | 'fallback',
		strategy: ConstraintStrategy
	): boolean {
		if (!modelName) return true;

		const constraintCheck = validateAgentConstraints(agentActionName, modelName);

		if (constraintCheck.constraintEnabled && !constraintCheck.valid) {
			const errorMsg = `${modelType === 'fallback' ? 'Fallback model' : 'Model'} '${modelName}' is not allowed for '${agentActionName}'. ` +
				`Allowed models: ${constraintCheck.allowedModels?.join(', ')}`;

			if (strategy === 'throw') {
				throw new Error(errorMsg);
			} else {
				this.logger.warn(`${errorMsg} - falling back to default`);
				return false;
			}
		}

		return true;
	}

	/**
	 * Core merging logic: converts DB record to merged config.
	 * Single source of truth for merge semantics.
	 */
	private mergeWithDefaults(
		userConfig: UserModelConfig | null,
		agentActionName: AgentActionKey
	): UserModelConfigWithMetadata {
		const defaultConfig = AGENT_CONFIG[agentActionName];

		if (!userConfig) {
			return {
				...defaultConfig,
				isUserOverride: false
			};
		}

		// Merge user config with defaults (user takes precedence, null values use defaults)
		// Validate database values before using them
		return {
			name: toAIModel(userConfig.modelName) ?? defaultConfig.name,
			max_tokens: userConfig.maxTokens ?? defaultConfig.max_tokens,
			temperature: userConfig.temperature !== null ? userConfig.temperature : defaultConfig.temperature,
			reasoning_effort: this.castToReasoningEffort(userConfig.reasoningEffort) ?? defaultConfig.reasoning_effort,
			fallbackModel: toAIModel(userConfig.fallbackModel) ?? defaultConfig.fallbackModel,
			isUserOverride: true,
			userConfigId: userConfig.id
		};
	}

	/**
	 * Applies constraint validation to merged config.
	 * Returns fallback config if constraints violated
	 */
	private applyConstraintsWithFallback(
		mergedConfig: UserModelConfigWithMetadata,
		agentActionName: AgentActionKey
	): UserModelConfigWithMetadata {
		const defaultConfig = AGENT_CONFIG[agentActionName];

		// Already a default config - no validation needed
		if (!mergedConfig.isUserOverride) {
			return mergedConfig;
		}

		// Validate primary model - fall back to full default if invalid
		if (!this.validateModel(agentActionName, mergedConfig.name, 'primary', 'fallback')) {
			return {
				...defaultConfig,
				isUserOverride: false
			};
		}

		// Validate fallback model - use default fallback only if invalid
		if (!this.validateModel(agentActionName, mergedConfig.fallbackModel, 'fallback', 'fallback')) {
			return {
				...mergedConfig,
				fallbackModel: defaultConfig.fallbackModel
			};
		}

		return mergedConfig;
	}

	/**
	 * Get all model configurations for a user (merged with defaults, constraint-enforced)
	 */
	async getUserModelConfigs(userId: string): Promise<Record<AgentActionKey, UserModelConfigWithMetadata>> {
		const userConfigs = await this.database
			.select()
			.from(userModelConfigs)
			.where(and(
				eq(userModelConfigs.userId, userId),
				eq(userModelConfigs.isActive, true)
			));

		const result: Record<string, UserModelConfigWithMetadata> = {};

		// Process all agent actions
		for (const actionKey of Object.keys(AGENT_CONFIG)) {
			const userConfig = userConfigs.find((uc: UserModelConfig) => uc.agentActionName === actionKey) ?? null;
			const mergedConfig = this.mergeWithDefaults(userConfig, actionKey as AgentActionKey);
			result[actionKey] = this.applyConstraintsWithFallback(mergedConfig, actionKey as AgentActionKey);
		}

		return result as Record<AgentActionKey, UserModelConfigWithMetadata>;
	}

	/**
	 * Get a specific model configuration for a user (merged with defaults, constraint-enforced)
	 */
	async getUserModelConfig(userId: string, agentActionName: AgentActionKey): Promise<UserModelConfigWithMetadata> {
		const userConfig = await this.database
			.select()
			.from(userModelConfigs)
			.where(and(
				eq(userModelConfigs.userId, userId),
				eq(userModelConfigs.agentActionName, agentActionName),
				eq(userModelConfigs.isActive, true)
			))
			.limit(1);

		const mergedConfig = this.mergeWithDefaults(userConfig[0] ?? null, agentActionName);
		return this.applyConstraintsWithFallback(mergedConfig, agentActionName);
	}

	/**
	 * Get raw user model configuration without merging with defaults.
	 * Returns null if user has no custom config OR if config violates constraints.
	 */
	async getRawUserModelConfig(userId: string, agentActionName: AgentActionKey): Promise<ModelConfig | null> {
		const userConfig = await this.database
			.select()
			.from(userModelConfigs)
			.where(and(
				eq(userModelConfigs.userId, userId),
				eq(userModelConfigs.agentActionName, agentActionName),
				eq(userModelConfigs.isActive, true)
			))
			.limit(1);

		if (userConfig.length === 0) {
			return null;
		}

		const config = userConfig[0];

		// Check if user has actual overrides (any non-null value)
		const hasOverrides = config.modelName || config.maxTokens ||
			config.temperature !== null || config.reasoningEffort ||
			config.fallbackModel;

		if (!hasOverrides) {
			return null;
		}

		// Merge with defaults
		const mergedConfig = this.mergeWithDefaults(config, agentActionName);

		// Validate primary model - return null if violated (triggers AGENT_CONFIG fallback)
		if (!this.validateModel(agentActionName, mergedConfig.name, 'primary', 'fallback')) {
			return null;
		}

		// Validate fallback model - use default fallback if invalid
		const defaultConfig = AGENT_CONFIG[agentActionName];
		const validFallback = this.validateModel(agentActionName, mergedConfig.fallbackModel, 'fallback', 'fallback')
			? mergedConfig.fallbackModel
			: defaultConfig.fallbackModel;

		return {
			name: mergedConfig.name,
			max_tokens: mergedConfig.max_tokens,
			temperature: mergedConfig.temperature,
			reasoning_effort: mergedConfig.reasoning_effort,
			fallbackModel: validFallback
		};
	}

    /**
     * Get current model configurations (defaults + user overrides)
     * Used by WebSocket to provide configuration info to frontend
     */
    async getModelConfigsInfo(userId: string) {
        if (!userId) {
            throw new Error('No user session available for model configurations');
        }

        try {
            // Get all user configs
            const userConfigsRecord = await this.getUserModelConfigs(userId);
            
            // Transform to match frontend interface with constraint info
            const agents = Object.entries(AGENT_CONFIG).map(([key, config]) => {
                const constraint = AGENT_CONSTRAINTS.get(key as AgentActionKey);
                return {
                    key,
                    name: config.name,
                    description: config.description,
                    constraint: constraint ? {
                        enabled: constraint.enabled,
                        allowedModels: Array.from(constraint.allowedModels)
                    } : undefined
                };
            });

            const userModelConfigs: Record<string, ModelConfig> = {}
            const defaultConfigs: Record<string, ModelConfig> = {};
            for (const [actionKey, mergedConfig] of Object.entries(userConfigsRecord)) {
                if (mergedConfig.isUserOverride) {
                    const { isUserOverride, userConfigId, ...modelConfig } = mergedConfig;
                    userModelConfigs[actionKey] = modelConfig;
                }
                const defaultConfig = AGENT_CONFIG[actionKey as AgentActionKey];
                if (defaultConfig) {
                    defaultConfigs[actionKey] = defaultConfig;
                }
            }

            return {
                agents,
                userConfigs: userModelConfigs,
                defaultConfigs
            };
        } catch (error) {
            console.error('Error fetching model configs info:', error);
            throw error;
        }
    }

	/**
	 * Update or create a user model configuration.
	 * Validates constraints - throws on violation.
	 */
	async upsertUserModelConfig(
		userId: string,
		agentActionName: AgentActionKey,
		config: Partial<ModelConfig>
	): Promise<UserModelConfig> {
		// Validate constraints (throws if invalid)
		this.validateModel(agentActionName, config.name, 'primary', 'throw');
		this.validateModel(agentActionName, config.fallbackModel, 'fallback', 'throw');

		const existingConfig = await this.database
			.select()
			.from(userModelConfigs)
			.where(and(
				eq(userModelConfigs.userId, userId),
				eq(userModelConfigs.agentActionName, agentActionName)
			))
			.limit(1);

		const configData: Partial<NewUserModelConfig> = {
			userId,
			agentActionName,
			modelName: config.name ?? null,
			maxTokens: config.max_tokens ?? null,
			temperature: config.temperature !== undefined ? config.temperature : null,
			reasoningEffort: (config.reasoning_effort && config.reasoning_effort !== 'minimal') ? config.reasoning_effort : null,
			fallbackModel: config.fallbackModel ?? null,
			isActive: true,
			updatedAt: new Date()
		};

		if (existingConfig.length > 0) {
			// Update existing config
			const updated = await this.database
				.update(userModelConfigs)
				.set(configData)
				.where(eq(userModelConfigs.id, existingConfig[0].id))
				.returning();

			return updated[0];
		} else {
			// Create new config
			const newConfig: NewUserModelConfig = {
				id: generateId(),
				...configData,
				createdAt: new Date()
			} as NewUserModelConfig;

			const created = await this.database
				.insert(userModelConfigs)
				.values(newConfig)
				.returning();

			return created[0];
		}
	}

	/**
	 * Delete/reset a user model configuration (revert to default)
	 */
	async deleteUserModelConfig(userId: string, agentActionName: AgentActionKey): Promise<boolean> {
		const result = await this.database
			.delete(userModelConfigs)
			.where(and(
				eq(userModelConfigs.userId, userId),
				eq(userModelConfigs.agentActionName, agentActionName)
			));

		return (result.meta?.changes || 0) > 0;
	}

	/**
	 * Get default configurations (from AGENT_CONFIG)
	 */
	getDefaultConfigs(): Record<AgentActionKey, ModelConfig> {
		return AGENT_CONFIG;
	}

	/**
	 * Reset all user configurations to defaults
	 */
	async resetAllUserConfigs(userId: string): Promise<number> {
		const result = await this.database
			.delete(userModelConfigs)
			.where(eq(userModelConfigs.userId, userId));

		return result.meta?.changes || 0;
	}
}