feat: ditch triplit, move to postgresql + drizzle orm
This commit is contained in:
@@ -1,48 +1,44 @@
|
||||
import { type Entity } from '@triplit/client';
|
||||
import * as z from 'zod';
|
||||
import { SupportedModalities } from '~/types/model';
|
||||
import { Providers } from '~/types/model';
|
||||
import { httpClient } from '~~/server/lib/triplit';
|
||||
import { GatewayFetchError, getProviderDetails } from '~~/server/utils/ai-provider';
|
||||
import { getModelsDevData } from '~~/server/utils/models-dev';
|
||||
import { schema } from '~~/triplit/schema';
|
||||
import { Err, Ok, type Result } from '~~/types/result';
|
||||
import { db } from '~~/server/lib/db';
|
||||
import { type Provider } from '~/composables/useModels';
|
||||
import { models } from '~~/drizzle/schema';
|
||||
import { and, eq, notInArray } from 'drizzle-orm';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
const userId = event.context.user!.id;
|
||||
const userId = event.context.user!.id as string;
|
||||
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
providerApiKey: z.string().optional(),
|
||||
})
|
||||
.safeParse(body),
|
||||
z.object({ providerApiKey: z.string().optional() }).safeParse(body)
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
});
|
||||
throw createError({ statusCode: 400, message: result.error.issues[0]!.message });
|
||||
}
|
||||
|
||||
const providerId = getRouterParam(event, 'providerId')
|
||||
const providerId = getRouterParam(event, 'providerId');
|
||||
if (!providerId) throw createError({ statusCode: 400, message: 'Invalid provider' });
|
||||
|
||||
const provider = await httpClient.fetchOne(httpClient.query('providers').Where('id', '=', providerId));
|
||||
if (provider === null || provider.userId !== userId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Invalid provider',
|
||||
});
|
||||
}
|
||||
const provider = await db.query.providers.findFirst({
|
||||
where: {
|
||||
id: providerId,
|
||||
userId,
|
||||
},
|
||||
with: { models: true }
|
||||
});
|
||||
|
||||
console.log("apiKey", result.data.providerApiKey);
|
||||
if (!provider) throw createError({ statusCode: 404, message: 'Provider not found' });
|
||||
|
||||
const [providerModelsRes, modelsDevRes] = await Promise.all([
|
||||
fetchProviderModels(provider, result.data.providerApiKey),
|
||||
getModelsDevData(),
|
||||
]);
|
||||
|
||||
if (!providerModelsRes.ok) {
|
||||
switch (providerModelsRes.error) {
|
||||
case ProviderFetchError.NoProviderApiKey: {
|
||||
@@ -60,17 +56,75 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
console.log("providerModelsRes.data", providerModelsRes.data);
|
||||
|
||||
const normalizedModels = await normalizeResponse(
|
||||
providerModelsRes.data.data,
|
||||
provider.type,
|
||||
provider.type as typeof Providers[number],
|
||||
providerModelsRes.data.baseURL,
|
||||
modelsDevRes
|
||||
);
|
||||
|
||||
const existingModels = provider.models || [];
|
||||
const apiModelExternalIds = normalizedModels.map((m: any) => m.id);
|
||||
|
||||
console.log({ existingModels, apiModelExternalIds, normalizedModels });
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (existingModels.length > 0) {
|
||||
await tx.delete(models)
|
||||
.where(
|
||||
and(
|
||||
eq(models.providerId, providerId),
|
||||
eq(models.isCustom, false),
|
||||
notInArray(models.externalId, apiModelExternalIds)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const model of normalizedModels) {
|
||||
const existing = existingModels.find(m => m.externalId === model.id);
|
||||
|
||||
if (existing) {
|
||||
await tx.update(models)
|
||||
.set({
|
||||
name: model.name || existing.name,
|
||||
cost: model.cost,
|
||||
inputModalities: model.attributes.inputModalities,
|
||||
outputModalities: model.attributes.outputModalities,
|
||||
capabilities: model.attributes.capabilities,
|
||||
contextWindow: model.attributes.contextWindow,
|
||||
releasedAt: model.releasedAt ? new Date(model.releasedAt) : existing.releasedAt,
|
||||
})
|
||||
.where(eq(models.id, existing.id));
|
||||
} else {
|
||||
await tx.insert(models).values({
|
||||
userId,
|
||||
providerId,
|
||||
externalId: model.id,
|
||||
name: model.name || model.id,
|
||||
cost: model.cost || {},
|
||||
inputModalities: model.attributes.inputModalities,
|
||||
outputModalities: model.attributes.outputModalities,
|
||||
capabilities: model.attributes.capabilities,
|
||||
contextWindow: model.attributes.contextWindow,
|
||||
isCustom: false,
|
||||
enabled: false,
|
||||
releasedAt: model.releasedAt ? new Date(model.releasedAt) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const updatedModels = await db.query.models.findMany({
|
||||
where: {
|
||||
providerId,
|
||||
},
|
||||
orderBy: {
|
||||
releasedAt: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
models: normalizedModels,
|
||||
models: updatedModels
|
||||
};
|
||||
});
|
||||
|
||||
@@ -79,22 +133,14 @@ enum ProviderFetchError {
|
||||
NoProviderBaseUrl,
|
||||
}
|
||||
|
||||
const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>, providerApiKey: string | undefined): Promise<Result<{ data: Record<string, any>, baseURL: string }, ProviderFetchError>> => {
|
||||
const fetchProviderModels = async (provider: Provider, providerApiKey: string | undefined): Promise<Result<{ data: Record<string, any>, baseURL: string }, ProviderFetchError>> => {
|
||||
const providerDetails = await getProviderDetails(provider, providerApiKey);
|
||||
if (!providerDetails.ok) {
|
||||
switch (providerDetails.error) {
|
||||
case GatewayFetchError.NoProviderApiKey: {
|
||||
// throw createError({
|
||||
// statusCode: 400,
|
||||
// message: `${provider.type} provider requires an API key`,
|
||||
// });
|
||||
return Err(ProviderFetchError.NoProviderApiKey);
|
||||
}
|
||||
case GatewayFetchError.NoProviderBaseUrl: {
|
||||
// throw createError({
|
||||
// statusCode: 400,
|
||||
// message: 'Invalid provider URL',
|
||||
// });
|
||||
return Err(ProviderFetchError.NoProviderBaseUrl);
|
||||
}
|
||||
}
|
||||
@@ -107,7 +153,7 @@ const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>,
|
||||
// longcat doesn't have a model list endpoint so we just hardcode them here,
|
||||
// sry. I talked to Meituan and this is what they said:
|
||||
// 后续如果我们新增了这样的接口会及时同步您。
|
||||
// en (approx): If we add an interface like this in the future, we will promptly update you
|
||||
// en (approx): If we add an interface like this in the future, we will keep you in sync
|
||||
return Ok({
|
||||
data: {
|
||||
models: [
|
||||
@@ -122,8 +168,8 @@ const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>,
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "LongCat-Flash-Thinking",
|
||||
name: "LongCat Flash Thinking",
|
||||
id: "LongCat-Flash-Thinking-2601",
|
||||
name: "LongCat Flash Thinking (2601)",
|
||||
attributes: {
|
||||
inputModalities: ['text'],
|
||||
outputModalities: ['text'],
|
||||
@@ -132,12 +178,12 @@ const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>,
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "LongCat-Flash-Thinking-2601",
|
||||
name: "LongCat Flash Thinking (2601)",
|
||||
id: "LongCat-Flash-Omni-2603",
|
||||
name: "LongCat Flash Omni",
|
||||
attributes: {
|
||||
inputModalities: ['text'],
|
||||
outputModalities: ['text'],
|
||||
capabilities: ['reasoning', 'tools'],
|
||||
inputModalities: ['text', 'image', 'audio', 'video'],
|
||||
outputModalities: ['text', 'audio'],
|
||||
capabilities: ['tools'],
|
||||
contextWindow: 256_000,
|
||||
}
|
||||
},
|
||||
@@ -155,6 +201,24 @@ const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>,
|
||||
},
|
||||
baseURL
|
||||
});
|
||||
case 'inception':
|
||||
return Ok({
|
||||
data: {
|
||||
models: [
|
||||
{
|
||||
id: "mercury-2",
|
||||
name: "Mercury 2",
|
||||
attributes: {
|
||||
inputModalities: ['text'],
|
||||
outputModalities: ['text'],
|
||||
capabilities: ['tools', 'reasoning'],
|
||||
contextWindow: 128_000,
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
baseURL
|
||||
});
|
||||
}
|
||||
|
||||
let res;
|
||||
@@ -189,7 +253,6 @@ function mergeSets(setA: Set<string>, setB: Set<string>): string[] {
|
||||
|
||||
const getModelData = (modelId: string, providerId: string, modelsDevData: any) => {
|
||||
const modelData = modelsDevData[providerId]?.models[modelId];
|
||||
console.log("modelData", modelData);
|
||||
|
||||
if (modelData === undefined) return {
|
||||
cost: {},
|
||||
@@ -200,13 +263,13 @@ const getModelData = (modelId: string, providerId: string, modelsDevData: any) =
|
||||
}
|
||||
};
|
||||
|
||||
const capabilities = new Set<string>();
|
||||
const capabilities = new Array<string>();
|
||||
if (modelData.reasoning) {
|
||||
capabilities.add('reasoning');
|
||||
capabilities.push('reasoning');
|
||||
}
|
||||
|
||||
if (modelData.tool_call) {
|
||||
capabilities.add('tools');
|
||||
capabilities.push('tools');
|
||||
}
|
||||
|
||||
let inputModalities = modelData.modalities.input.filter((m: string) => (SupportedModalities as Readonly<string[]>).includes(m));
|
||||
@@ -236,7 +299,7 @@ const getModelData = (modelId: string, providerId: string, modelsDevData: any) =
|
||||
attributes: {
|
||||
inputModalities,
|
||||
outputModalities,
|
||||
capabilities: modelData.capabilities || [],
|
||||
capabilities: capabilities || [],
|
||||
contextWindow,
|
||||
supported_parameters: modelData.supportedParameters,
|
||||
},
|
||||
@@ -245,15 +308,6 @@ const getModelData = (modelId: string, providerId: string, modelsDevData: any) =
|
||||
}
|
||||
}
|
||||
|
||||
// const formatBig = (bigValue: Big) => {
|
||||
// let str = bigValue.toString();
|
||||
|
||||
// if (!str.includes('.')) return str + '.00';
|
||||
// if (str.split('.')[1]!.length === 1) return str + '0';
|
||||
|
||||
// return str;
|
||||
// };
|
||||
|
||||
// this function multiplies a string in the format of 'D.DD' by 1_000_000
|
||||
// it does this by finding the first digit that is not a zero, and then
|
||||
// left shifting it in decimal by 3 places
|
||||
@@ -320,7 +374,7 @@ const formatMoney = (value: string) => {
|
||||
return `${integerPart}.${fractionalPart}`;
|
||||
}
|
||||
|
||||
const normalizeResponse = async (response: Record<string, any>, provider: typeof Providers[number], baseUrl: string, modelsDevData: any) => {
|
||||
const normalizeResponse = async (response: Record<string, any>, provider: typeof Providers[number], baseUrl: string, modelsDevData: any): Promise<any[]> => {
|
||||
switch (provider) {
|
||||
case 'cerebras': {
|
||||
console.log("response.data", response.data);
|
||||
@@ -397,14 +451,6 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
|
||||
name: model.name as string,
|
||||
cost: pricing,
|
||||
attributes: {
|
||||
// inputModalities: mergeSets(
|
||||
// new Set(modelData.inputModalities || []),
|
||||
// new Set(model.architecture.input_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality))),
|
||||
// ),
|
||||
// outputModalities: mergeSets(
|
||||
// new Set(modelData.outputModalities || []),
|
||||
// new Set(model.architecture.output_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality))),
|
||||
// ),
|
||||
inputModalities: model.architecture.input_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality)),
|
||||
outputModalities: model.architecture.output_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality)),
|
||||
capabilities: Array.from(capabilities),
|
||||
@@ -417,6 +463,33 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
|
||||
|
||||
return models;
|
||||
}
|
||||
case 'closedrouter': {
|
||||
return response.data.map((model: any) => {
|
||||
const capabilities = new Array<string>();
|
||||
|
||||
if (model.capabilities) {
|
||||
if (model.capabilities.reasoning) {
|
||||
capabilities.push('reasoning');
|
||||
}
|
||||
|
||||
if (model.capabilities.tool_call) {
|
||||
capabilities.push('tools');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
attributes: {
|
||||
inputModalities: model.modalities?.input || ['text'],
|
||||
outputModalities: model.modalities?.output || ['text'],
|
||||
capabilities: capabilities,
|
||||
contextWindow: model.context_window,
|
||||
supported_parameters: [],
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
case 'ollama': {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -503,12 +576,21 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
|
||||
|
||||
return Array.from(models.values());
|
||||
}
|
||||
case 'vllm': {
|
||||
return response.data.map((model: any) => {
|
||||
return {
|
||||
id: model.id,
|
||||
attributes: {
|
||||
inputModalities: ['text'],
|
||||
outputModalities: ['text'],
|
||||
contextWindow: model.max_model_len,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
case 'google': {
|
||||
return response.models.map((model: any) => ({ ...getModelData(model.name.replace('models/', ''), provider, modelsDevData), id: model.name.replace('models/', ''), name: model.displayName }));
|
||||
}
|
||||
case 'longcat': {
|
||||
return response.models;
|
||||
}
|
||||
case 'cohere': {
|
||||
const models = [];
|
||||
for (const model of response.models) {
|
||||
@@ -551,5 +633,55 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
|
||||
|
||||
return models;
|
||||
}
|
||||
case 'mistral': {
|
||||
const models = [];
|
||||
for (const model of response.data) {
|
||||
const inputModalities = new Set<string>();
|
||||
const capabilities = new Set<string>();
|
||||
|
||||
for (const capability of Object.keys(model.capabilities)) {
|
||||
if (model.capabilities[capability] === false) continue;
|
||||
|
||||
switch (capability) {
|
||||
case 'function_calling': {
|
||||
capabilities.add('tools');
|
||||
} break;
|
||||
case 'completion_chat': {
|
||||
inputModalities.add('text');
|
||||
capabilities.add('completion');
|
||||
} break;
|
||||
case 'vision': {
|
||||
inputModalities.add('image');
|
||||
} break;
|
||||
}
|
||||
}
|
||||
|
||||
const modelData = getModelData(model.id, provider, modelsDevData);
|
||||
|
||||
models.push({
|
||||
...modelData,
|
||||
id: model.id,
|
||||
name: modelData.name || model.name || model.id,
|
||||
attributes: {
|
||||
...modelData.attributes,
|
||||
inputModalities: mergeSets(
|
||||
inputModalities,
|
||||
new Set(modelData.attributes?.inputModalities || [])
|
||||
),
|
||||
capabilities: mergeSets(
|
||||
capabilities,
|
||||
new Set(modelData.attributes?.capabilities || [])
|
||||
),
|
||||
contextWindow: model.max_context_length || modelData.attributes?.contextWindow,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
case 'inception':
|
||||
case 'longcat': {
|
||||
return response.models;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user