Files
spoon/packages/backend/convex/aiProviderProfiles.ts
T
2026-06-22 10:37:26 -04:00

274 lines
8.4 KiB
TypeScript

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<Doc<'aiProviderProfiles'>>;
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<Doc<'aiProviderProfiles'>> = {
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 };
},
});