import { ConvexError, v } from 'convex/values'; import type { Doc, Id } from './_generated/dataModel'; import type { MutationCtx } from './_generated/server'; import { internalMutation, mutation, query } from './_generated/server'; import { getRequiredUserId, optionalText } from './model'; type AiProviderProfileWithDefault = Doc<'aiProviderProfiles'> & { isDefault?: boolean; }; const provider = v.union( v.literal('openai'), v.literal('anthropic'), v.literal('google'), v.literal('openrouter'), v.literal('requesty'), v.literal('litellm'), v.literal('cloudflare_ai_gateway'), v.literal('custom_openai_compatible'), v.literal('opencode_openai_login'), ); const authType = v.union( v.literal('api_key'), v.literal('opencode_auth_json'), v.literal('none'), ); const reasoningEffort = v.union( v.literal('none'), v.literal('minimal'), v.literal('low'), v.literal('medium'), v.literal('high'), v.literal('xhigh'), ); const isConfigured = (profile: Doc<'aiProviderProfiles'>) => profile.authType === 'none' || Boolean(profile.encryptedSecret); const defaultPatch = (isDefault: boolean) => ({ isDefault }) as Partial>; const publicProfile = ( profile: AiProviderProfileWithDefault, defaultProfileId?: Id<'aiProviderProfiles'>, ) => ({ _id: profile._id, _creationTime: profile._creationTime, name: profile.name, provider: profile.provider, authType: profile.authType, secretPreview: profile.secretPreview, baseUrl: profile.baseUrl, defaultModel: profile.defaultModel, modelOptions: profile.modelOptions, reasoningEffort: profile.reasoningEffort, enabled: profile.enabled, configured: isConfigured(profile), isDefault: profile._id === defaultProfileId, createdAt: profile.createdAt, updatedAt: profile.updatedAt, }); const requireOwnedProfile = async ( ctx: MutationCtx, profileId: Id<'aiProviderProfiles'>, ownerId: Id<'users'>, ) => { const profile = await ctx.db.get(profileId); if (profile?.ownerId !== ownerId) { throw new ConvexError('AI provider profile not found.'); } return profile; }; export const listMine = query({ args: {}, handler: async (ctx) => { const ownerId = await getRequiredUserId(ctx); const profiles = await ctx.db .query('aiProviderProfiles') .withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) .order('desc') .collect(); const configuredProfiles = profiles.filter( (profile) => profile.enabled && isConfigured(profile), ); const explicitDefault = configuredProfiles.find( (profile) => (profile as AiProviderProfileWithDefault).isDefault, ); const defaultProfileId = explicitDefault?._id ?? (configuredProfiles.length === 1 ? configuredProfiles[0]?._id : undefined); return profiles.map((profile) => publicProfile(profile, defaultProfileId)); }, }); export const get = query({ args: { profileId: v.id('aiProviderProfiles') }, handler: async (ctx, { profileId }) => { const ownerId = await getRequiredUserId(ctx); const profile = await ctx.db.get(profileId); if (profile?.ownerId !== ownerId) { throw new ConvexError('AI provider profile not found.'); } const profiles = await ctx.db .query('aiProviderProfiles') .withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) .collect(); const configuredProfiles = profiles.filter( (item) => item.enabled && isConfigured(item), ); const explicitDefault = configuredProfiles.find( (item) => (item as AiProviderProfileWithDefault).isDefault, ); const defaultProfileId = explicitDefault?._id ?? (configuredProfiles.length === 1 ? configuredProfiles[0]?._id : undefined); return publicProfile(profile, defaultProfileId); }, }); export const upsertEncryptedInternal = internalMutation({ args: { ownerId: v.id('users'), profileId: v.optional(v.id('aiProviderProfiles')), name: v.string(), provider, authType, encryptedSecret: v.optional(v.string()), secretPreview: v.optional(v.string()), baseUrl: v.optional(v.string()), defaultModel: v.string(), modelOptions: v.optional(v.array(v.string())), reasoningEffort, enabled: v.boolean(), }, handler: async (ctx, args) => { const now = Date.now(); const patch: Partial> = { name: args.name.trim() || 'AI provider', provider: args.provider, authType: args.authType, baseUrl: optionalText(args.baseUrl), defaultModel: args.defaultModel.trim() || 'gpt-5.5', modelOptions: args.modelOptions ?.map((model) => model.trim()) .filter(Boolean), reasoningEffort: args.reasoningEffort, enabled: args.enabled, updatedAt: now, }; if (args.encryptedSecret !== undefined) { patch.encryptedSecret = args.encryptedSecret; patch.secretPreview = args.secretPreview; } if (args.profileId) { await requireOwnedProfile(ctx, args.profileId, args.ownerId); await ctx.db.patch(args.profileId, patch); return args.profileId; } const existingProfiles = await ctx.db .query('aiProviderProfiles') .withIndex('by_owner', (q) => q.eq('ownerId', args.ownerId)) .collect(); const shouldBecomeDefault = args.enabled && (args.authType === 'none' || Boolean(args.encryptedSecret)) && existingProfiles.filter( (profile) => profile.enabled && isConfigured(profile), ).length === 0; const profileId = await ctx.db.insert('aiProviderProfiles', { ownerId: args.ownerId, name: patch.name ?? 'AI provider', provider: args.provider, authType: args.authType, encryptedSecret: args.encryptedSecret, secretPreview: args.secretPreview, baseUrl: optionalText(args.baseUrl), defaultModel: patch.defaultModel ?? 'gpt-5.5', modelOptions: patch.modelOptions, reasoningEffort: args.reasoningEffort, enabled: args.enabled, createdAt: now, updatedAt: now, }); if (shouldBecomeDefault) { await ctx.db.patch(profileId, defaultPatch(true)); } return profileId; }, }); export const updateMetadata = mutation({ args: { profileId: v.id('aiProviderProfiles'), name: v.string(), baseUrl: v.optional(v.string()), defaultModel: v.string(), modelOptions: v.optional(v.array(v.string())), reasoningEffort, enabled: v.boolean(), }, handler: async (ctx, args) => { const ownerId = await getRequiredUserId(ctx); await requireOwnedProfile(ctx, args.profileId, ownerId); await ctx.db.patch(args.profileId, { name: args.name.trim() || 'AI provider', baseUrl: optionalText(args.baseUrl), defaultModel: args.defaultModel.trim() || 'gpt-5.5', modelOptions: args.modelOptions ?.map((model) => model.trim()) .filter(Boolean), reasoningEffort: args.reasoningEffort, enabled: args.enabled, updatedAt: Date.now(), }); return { success: true }; }, }); export const remove = mutation({ args: { profileId: v.id('aiProviderProfiles') }, handler: async (ctx, { profileId }) => { const ownerId = await getRequiredUserId(ctx); const profile = (await requireOwnedProfile( ctx, profileId, ownerId, )) as AiProviderProfileWithDefault; await ctx.db.delete(profileId); if (profile.isDefault) { const remaining = await ctx.db .query('aiProviderProfiles') .withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) .collect(); const nextDefault = remaining.find( (item) => item.enabled && isConfigured(item), ); if (nextDefault) { await ctx.db.patch(nextDefault._id, defaultPatch(true)); } } return { success: true }; }, }); export const setDefault = mutation({ args: { profileId: v.id('aiProviderProfiles') }, handler: async (ctx, { profileId }) => { const ownerId = await getRequiredUserId(ctx); const target = await requireOwnedProfile(ctx, profileId, ownerId); if (!target.enabled || !isConfigured(target)) { throw new ConvexError('Default provider must be enabled and configured.'); } const profiles = await ctx.db .query('aiProviderProfiles') .withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) .collect(); await Promise.all( profiles.map((profile) => ctx.db.patch(profile._id, defaultPatch(profile._id === profileId)), ), ); return { success: true }; }, });