Move to threads based system.
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
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 };
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user