@chimerai/cli 0.2.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +293 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +317 -0
- package/dist/commands/add.d.ts +11 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/add.js +2126 -0
- package/dist/commands/create.d.ts +12 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/create.js +1703 -0
- package/dist/commands/deploy.d.ts +11 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +219 -0
- package/dist/commands/dev.d.ts +17 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +206 -0
- package/dist/commands/doctor.d.ts +11 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +728 -0
- package/dist/commands/generate.d.ts +19 -0
- package/dist/commands/generate.d.ts.map +1 -0
- package/dist/commands/generate.js +429 -0
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +269 -0
- package/dist/commands/list.d.ts +12 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +328 -0
- package/dist/commands/migrate.d.ts +14 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +197 -0
- package/dist/commands/plugin.d.ts +10 -0
- package/dist/commands/plugin.d.ts.map +1 -0
- package/dist/commands/plugin.js +239 -0
- package/dist/commands/remove.d.ts +11 -0
- package/dist/commands/remove.d.ts.map +1 -0
- package/dist/commands/remove.js +472 -0
- package/dist/commands/secret.d.ts +12 -0
- package/dist/commands/secret.d.ts.map +1 -0
- package/dist/commands/secret.js +102 -0
- package/dist/commands/setup.d.ts +9 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/setup.js +788 -0
- package/dist/commands/update.d.ts +14 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +211 -0
- package/dist/commands/use.d.ts +9 -0
- package/dist/commands/use.d.ts.map +1 -0
- package/dist/commands/use.js +51 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +45 -0
- package/dist/license.d.ts +55 -0
- package/dist/license.d.ts.map +1 -0
- package/dist/license.js +258 -0
- package/dist/scanner.d.ts +31 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +113 -0
- package/dist/schema-manager.d.ts +26 -0
- package/dist/schema-manager.d.ts.map +1 -0
- package/dist/schema-manager.js +132 -0
- package/dist/templates/admin.d.ts +49 -0
- package/dist/templates/admin.d.ts.map +1 -0
- package/dist/templates/admin.js +1358 -0
- package/dist/templates/ai-routes.d.ts +17 -0
- package/dist/templates/ai-routes.d.ts.map +1 -0
- package/dist/templates/ai-routes.js +1130 -0
- package/dist/templates/ai-service-tools.d.ts +22 -0
- package/dist/templates/ai-service-tools.d.ts.map +1 -0
- package/dist/templates/ai-service-tools.js +1424 -0
- package/dist/templates/ai-service.d.ts +66 -0
- package/dist/templates/ai-service.d.ts.map +1 -0
- package/dist/templates/ai-service.js +2202 -0
- package/dist/templates/api-routes.d.ts +108 -0
- package/dist/templates/api-routes.d.ts.map +1 -0
- package/dist/templates/api-routes.js +1219 -0
- package/dist/templates/auth.d.ts +48 -0
- package/dist/templates/auth.d.ts.map +1 -0
- package/dist/templates/auth.js +381 -0
- package/dist/templates/billing.d.ts +44 -0
- package/dist/templates/billing.d.ts.map +1 -0
- package/dist/templates/billing.js +551 -0
- package/dist/templates/chat.d.ts +63 -0
- package/dist/templates/chat.d.ts.map +1 -0
- package/dist/templates/chat.js +1979 -0
- package/dist/templates/components.d.ts +22 -0
- package/dist/templates/components.d.ts.map +1 -0
- package/dist/templates/components.js +672 -0
- package/dist/templates/config.d.ts +6 -0
- package/dist/templates/config.d.ts.map +1 -0
- package/dist/templates/config.js +86 -0
- package/dist/templates/docker.d.ts +25 -0
- package/dist/templates/docker.d.ts.map +1 -0
- package/dist/templates/docker.js +165 -0
- package/dist/templates/gdpr.d.ts +16 -0
- package/dist/templates/gdpr.d.ts.map +1 -0
- package/dist/templates/gdpr.js +259 -0
- package/dist/templates/index.d.ts +77 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +339 -0
- package/dist/templates/layout.d.ts +67 -0
- package/dist/templates/layout.d.ts.map +1 -0
- package/dist/templates/layout.js +670 -0
- package/dist/templates/mfa.d.ts +23 -0
- package/dist/templates/mfa.d.ts.map +1 -0
- package/dist/templates/mfa.js +353 -0
- package/dist/templates/middleware.d.ts +12 -0
- package/dist/templates/middleware.d.ts.map +1 -0
- package/dist/templates/middleware.js +116 -0
- package/dist/templates/prisma.d.ts +35 -0
- package/dist/templates/prisma.d.ts.map +1 -0
- package/dist/templates/prisma.js +724 -0
- package/dist/templates/provider-routes.d.ts +21 -0
- package/dist/templates/provider-routes.d.ts.map +1 -0
- package/dist/templates/provider-routes.js +1203 -0
- package/dist/templates/rag.d.ts +48 -0
- package/dist/templates/rag.d.ts.map +1 -0
- package/dist/templates/rag.js +532 -0
- package/dist/templates/widget.d.ts +64 -0
- package/dist/templates/widget.d.ts.map +1 -0
- package/dist/templates/widget.js +1360 -0
- package/dist/utils/provider-db.d.ts +63 -0
- package/dist/utils/provider-db.d.ts.map +1 -0
- package/dist/utils/provider-db.js +300 -0
- package/dist/utils.d.ts +78 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +330 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Provider Route Templates
|
|
4
|
+
*
|
|
5
|
+
* Generates the complete Provider API infrastructure:
|
|
6
|
+
* - CRUD routes (/api/providers)
|
|
7
|
+
* - Single provider routes (/api/providers/[id])
|
|
8
|
+
* - Provider test route (/api/providers/[id]/test)
|
|
9
|
+
* - Internal API routes (/api/internal/providers/*)
|
|
10
|
+
* - Notify-provider-change utility
|
|
11
|
+
*
|
|
12
|
+
* These are CORE infrastructure — always generated, not feature-conditional.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.generateProviderCrudRoute = generateProviderCrudRoute;
|
|
16
|
+
exports.generateProviderIdRoute = generateProviderIdRoute;
|
|
17
|
+
exports.generateProviderTestRoute = generateProviderTestRoute;
|
|
18
|
+
exports.generateProviderSyncRoute = generateProviderSyncRoute;
|
|
19
|
+
exports.generateInternalProvidersRoute = generateInternalProvidersRoute;
|
|
20
|
+
exports.generateInternalProviderIdRoute = generateInternalProviderIdRoute;
|
|
21
|
+
exports.generateInternalProviderUsageRoute = generateInternalProviderUsageRoute;
|
|
22
|
+
exports.generateNotifyProviderChangeLib = generateNotifyProviderChangeLib;
|
|
23
|
+
// ─────────────────────────────────────────────────────────
|
|
24
|
+
// Provider CRUD Route — /api/providers/route.ts
|
|
25
|
+
// ─────────────────────────────────────────────────────────
|
|
26
|
+
function generateProviderCrudRoute() {
|
|
27
|
+
return `// @chimerai component=ProviderCrudRoute version=1.0
|
|
28
|
+
/**
|
|
29
|
+
* Provider CRUD API Route
|
|
30
|
+
* GET /api/providers — List all providers
|
|
31
|
+
* POST /api/providers — Create new provider
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
35
|
+
import { getServerSession } from 'next-auth';
|
|
36
|
+
import { authOptions } from '@/lib/auth';
|
|
37
|
+
import { prisma } from '@/lib/prisma';
|
|
38
|
+
import { encrypt } from '@/lib/encryption';
|
|
39
|
+
import { notifyProviderChange } from '@/lib/notify-provider-change';
|
|
40
|
+
import { logAuditAction } from '@/lib/audit';
|
|
41
|
+
|
|
42
|
+
/** SQLite stores Json/String[] as plain String — serialize/deserialize at runtime */
|
|
43
|
+
const _isSqlite = (process.env.DATABASE_URL || '').startsWith('file:');
|
|
44
|
+
function _toDb(v: any): any { return _isSqlite && v != null && typeof v !== 'string' ? JSON.stringify(v) : v; }
|
|
45
|
+
function _fromDb(v: any): any { if (!_isSqlite || typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return v; } }
|
|
46
|
+
|
|
47
|
+
/** Provider types that work without an API key (e.g. local services) */
|
|
48
|
+
const KEYLESS_PROVIDERS = ['ollama'];
|
|
49
|
+
|
|
50
|
+
function getDefaultBaseUrl(type: string): string | null {
|
|
51
|
+
switch (type) {
|
|
52
|
+
case 'openai': return 'https://api.openai.com/v1';
|
|
53
|
+
case 'anthropic': return 'https://api.anthropic.com/v1';
|
|
54
|
+
case 'ollama': return 'http://localhost:11434';
|
|
55
|
+
case 'groq': return 'https://api.groq.com/openai/v1';
|
|
56
|
+
case 'google': return 'https://generativelanguage.googleapis.com/v1beta';
|
|
57
|
+
default: return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function GET(request: NextRequest) {
|
|
62
|
+
try {
|
|
63
|
+
const session = await getServerSession(authOptions);
|
|
64
|
+
if (!session?.user) {
|
|
65
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { searchParams } = new URL(request.url);
|
|
69
|
+
const type = searchParams.get('type');
|
|
70
|
+
const status = searchParams.get('status');
|
|
71
|
+
|
|
72
|
+
const providers = await prisma.provider.findMany({
|
|
73
|
+
where: {
|
|
74
|
+
...(type && { type }),
|
|
75
|
+
...(status && { status }),
|
|
76
|
+
},
|
|
77
|
+
include: {
|
|
78
|
+
models: {
|
|
79
|
+
where: { isAvailable: true, isDeprecated: false },
|
|
80
|
+
select: {
|
|
81
|
+
id: true,
|
|
82
|
+
modelId: true,
|
|
83
|
+
name: true,
|
|
84
|
+
capabilities: true,
|
|
85
|
+
contextWindow: true,
|
|
86
|
+
inputCost: true,
|
|
87
|
+
outputCost: true,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
health: true,
|
|
91
|
+
},
|
|
92
|
+
orderBy: [{ isDefault: 'desc' }, { priority: 'asc' }, { name: 'asc' }],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Don't expose encrypted API keys in list view
|
|
96
|
+
// Deserialize JSON-string fields (SQLite stores Json/String[] as plain String)
|
|
97
|
+
const sanitized = providers.map((p: any) => ({
|
|
98
|
+
...p,
|
|
99
|
+
apiKey: p.apiKey ? '***encrypted***' : null,
|
|
100
|
+
config: _fromDb(p.config),
|
|
101
|
+
tags: _fromDb(p.tags),
|
|
102
|
+
models: p.models?.map((m: any) => ({ ...m, capabilities: _fromDb(m.capabilities) })),
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
return NextResponse.json(sanitized);
|
|
106
|
+
} catch (error: any) {
|
|
107
|
+
console.error('Error fetching providers:', error);
|
|
108
|
+
return NextResponse.json(
|
|
109
|
+
{ error: 'Failed to fetch providers', details: error.message },
|
|
110
|
+
{ status: 500 }
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function POST(request: NextRequest) {
|
|
116
|
+
try {
|
|
117
|
+
const session = await getServerSession(authOptions);
|
|
118
|
+
if (!session?.user) {
|
|
119
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const body = await request.json();
|
|
123
|
+
|
|
124
|
+
// Validate API key based on provider type
|
|
125
|
+
const requiresKey = !KEYLESS_PROVIDERS.includes(body.type);
|
|
126
|
+
if (requiresKey && !body.config?.apiKey) {
|
|
127
|
+
return NextResponse.json(
|
|
128
|
+
{ error: 'API key is required for this provider type' },
|
|
129
|
+
{ status: 400 }
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Key verschluesseln wenn vorhanden, sonst null (nicht leerer String!)
|
|
134
|
+
const encryptedKey = body.config?.apiKey ? encrypt(body.config.apiKey) : null;
|
|
135
|
+
|
|
136
|
+
// Resolve baseUrl
|
|
137
|
+
const resolvedBaseUrl = body.config?.baseUrl || getDefaultBaseUrl(body.type);
|
|
138
|
+
|
|
139
|
+
// If setting as default, unset other defaults of same type
|
|
140
|
+
if (body.isDefault) {
|
|
141
|
+
await prisma.provider.updateMany({
|
|
142
|
+
where: { type: body.type, isDefault: true },
|
|
143
|
+
data: { isDefault: false },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const provider = await prisma.provider.create({
|
|
148
|
+
data: {
|
|
149
|
+
name: body.name,
|
|
150
|
+
type: body.type,
|
|
151
|
+
description: body.description,
|
|
152
|
+
baseUrl: resolvedBaseUrl,
|
|
153
|
+
apiKey: encryptedKey,
|
|
154
|
+
config: _toDb({ ...body.config, apiKey: undefined }),
|
|
155
|
+
status: 'active',
|
|
156
|
+
isDefault: body.isDefault || false,
|
|
157
|
+
priority: body.priority || 0,
|
|
158
|
+
tags: _toDb(body.tags || []),
|
|
159
|
+
createdBy: session.user.id,
|
|
160
|
+
},
|
|
161
|
+
include: { models: true, health: true },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Notify AI service to invalidate cache
|
|
165
|
+
notifyProviderChange().catch(() => {});
|
|
166
|
+
|
|
167
|
+
// Audit log
|
|
168
|
+
await logAuditAction({
|
|
169
|
+
action: 'provider.create',
|
|
170
|
+
userId: session.user.id,
|
|
171
|
+
targetType: 'provider',
|
|
172
|
+
targetId: provider.id,
|
|
173
|
+
metadata: { name: provider.name, type: provider.type },
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return NextResponse.json(
|
|
177
|
+
{ ...provider, apiKey: '***encrypted***' },
|
|
178
|
+
{ status: 201 }
|
|
179
|
+
);
|
|
180
|
+
} catch (error: any) {
|
|
181
|
+
console.error('Error creating provider:', error);
|
|
182
|
+
return NextResponse.json(
|
|
183
|
+
{ error: 'Failed to create provider', details: error.message },
|
|
184
|
+
{ status: 500 }
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
`;
|
|
189
|
+
}
|
|
190
|
+
// ─────────────────────────────────────────────────────────
|
|
191
|
+
// Provider [id] Route — /api/providers/[id]/route.ts
|
|
192
|
+
// ─────────────────────────────────────────────────────────
|
|
193
|
+
function generateProviderIdRoute() {
|
|
194
|
+
return `// @chimerai component=ProviderIdRoute version=1.0
|
|
195
|
+
/**
|
|
196
|
+
* Single Provider API Route
|
|
197
|
+
* GET /api/providers/[id] — Get provider details
|
|
198
|
+
* PUT /api/providers/[id] — Full update
|
|
199
|
+
* PATCH /api/providers/[id] — Partial update
|
|
200
|
+
* DELETE /api/providers/[id] — Delete provider
|
|
201
|
+
*/
|
|
202
|
+
|
|
203
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
204
|
+
import { getServerSession } from 'next-auth';
|
|
205
|
+
import { authOptions } from '@/lib/auth';
|
|
206
|
+
import { prisma } from '@/lib/prisma';
|
|
207
|
+
import { encrypt } from '@/lib/encryption';
|
|
208
|
+
import { notifyProviderChange } from '@/lib/notify-provider-change';
|
|
209
|
+
import { logAuditAction } from '@/lib/audit';
|
|
210
|
+
|
|
211
|
+
/** SQLite stores Json/String[] as plain String — serialize/deserialize at runtime */
|
|
212
|
+
const _isSqlite = (process.env.DATABASE_URL || '').startsWith('file:');
|
|
213
|
+
function _toDb(v: any): any { return _isSqlite && v != null && typeof v !== 'string' ? JSON.stringify(v) : v; }
|
|
214
|
+
function _fromDb(v: any): any { if (!_isSqlite || typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return v; } }
|
|
215
|
+
|
|
216
|
+
function getDefaultBaseUrl(type: string): string | null {
|
|
217
|
+
switch (type) {
|
|
218
|
+
case 'openai': return 'https://api.openai.com/v1';
|
|
219
|
+
case 'anthropic': return 'https://api.anthropic.com/v1';
|
|
220
|
+
case 'ollama': return 'http://localhost:11434';
|
|
221
|
+
case 'groq': return 'https://api.groq.com/openai/v1';
|
|
222
|
+
case 'google': return 'https://generativelanguage.googleapis.com/v1beta';
|
|
223
|
+
default: return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function GET(
|
|
228
|
+
request: NextRequest,
|
|
229
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
230
|
+
) {
|
|
231
|
+
const { id } = await params;
|
|
232
|
+
try {
|
|
233
|
+
const session = await getServerSession(authOptions);
|
|
234
|
+
if (!session?.user) {
|
|
235
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const provider = await prisma.provider.findUnique({
|
|
239
|
+
where: { id: id },
|
|
240
|
+
include: {
|
|
241
|
+
models: { orderBy: { name: 'asc' } },
|
|
242
|
+
health: true,
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (!provider) {
|
|
247
|
+
return NextResponse.json({ error: 'Provider not found' }, { status: 404 });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return NextResponse.json({
|
|
251
|
+
...provider,
|
|
252
|
+
apiKey: provider.apiKey ? provider.apiKey.substring(0, 8) + '...' : null,
|
|
253
|
+
config: _fromDb(provider.config),
|
|
254
|
+
tags: _fromDb(provider.tags),
|
|
255
|
+
models: provider.models?.map((m: any) => ({ ...m, capabilities: _fromDb(m.capabilities) })),
|
|
256
|
+
});
|
|
257
|
+
} catch (error: any) {
|
|
258
|
+
console.error('Error fetching provider:', error);
|
|
259
|
+
return NextResponse.json(
|
|
260
|
+
{ error: 'Failed to fetch provider', details: error.message },
|
|
261
|
+
{ status: 500 }
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function PUT(
|
|
267
|
+
request: NextRequest,
|
|
268
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
269
|
+
) {
|
|
270
|
+
const { id } = await params;
|
|
271
|
+
try {
|
|
272
|
+
const session = await getServerSession(authOptions);
|
|
273
|
+
if (!session?.user) {
|
|
274
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const body = await request.json();
|
|
278
|
+
|
|
279
|
+
// If setting as default, unset other defaults
|
|
280
|
+
if (body.isDefault) {
|
|
281
|
+
const existing = await prisma.provider.findUnique({ where: { id: id } });
|
|
282
|
+
if (existing) {
|
|
283
|
+
await prisma.provider.updateMany({
|
|
284
|
+
where: { type: existing.type, isDefault: true, NOT: { id: id } },
|
|
285
|
+
data: { isDefault: false },
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Handle API key update
|
|
291
|
+
const updateData: any = { ...body };
|
|
292
|
+
if (body.config?.apiKey) {
|
|
293
|
+
updateData.apiKey = encrypt(body.config.apiKey);
|
|
294
|
+
updateData.config = _toDb({ ...body.config, apiKey: undefined });
|
|
295
|
+
} else if (body.config) {
|
|
296
|
+
updateData.config = _toDb({ ...body.config, apiKey: undefined });
|
|
297
|
+
}
|
|
298
|
+
if ('tags' in body) updateData.tags = _toDb(body.tags);
|
|
299
|
+
|
|
300
|
+
const provider = await prisma.provider.update({
|
|
301
|
+
where: { id: id },
|
|
302
|
+
data: updateData,
|
|
303
|
+
include: { models: true, health: true },
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
notifyProviderChange().catch(() => {});
|
|
307
|
+
|
|
308
|
+
// Audit log
|
|
309
|
+
await logAuditAction({
|
|
310
|
+
action: 'provider.update',
|
|
311
|
+
userId: session.user.id,
|
|
312
|
+
targetType: 'provider',
|
|
313
|
+
targetId: id,
|
|
314
|
+
metadata: { name: provider.name, type: provider.type },
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return NextResponse.json({ ...provider, apiKey: '***encrypted***' });
|
|
318
|
+
} catch (error: any) {
|
|
319
|
+
console.error('Error updating provider:', error);
|
|
320
|
+
return NextResponse.json(
|
|
321
|
+
{ error: 'Failed to update provider', details: error.message },
|
|
322
|
+
{ status: 500 }
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function PATCH(
|
|
328
|
+
request: NextRequest,
|
|
329
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
330
|
+
) {
|
|
331
|
+
const { id } = await params;
|
|
332
|
+
try {
|
|
333
|
+
const session = await getServerSession(authOptions);
|
|
334
|
+
if (!session?.user) {
|
|
335
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const body = await request.json();
|
|
339
|
+
const updateData: any = {};
|
|
340
|
+
|
|
341
|
+
// Handle simple field updates
|
|
342
|
+
if ('status' in body) updateData.status = body.status;
|
|
343
|
+
if ('isDefault' in body) updateData.isDefault = body.isDefault;
|
|
344
|
+
if ('priority' in body) updateData.priority = body.priority;
|
|
345
|
+
if ('tags' in body) updateData.tags = _toDb(body.tags);
|
|
346
|
+
if ('name' in body) updateData.name = body.name;
|
|
347
|
+
if ('type' in body) updateData.type = body.type;
|
|
348
|
+
if ('description' in body) updateData.description = body.description;
|
|
349
|
+
|
|
350
|
+
// Handle baseUrl
|
|
351
|
+
if ('baseUrl' in body) {
|
|
352
|
+
updateData.baseUrl = body.baseUrl;
|
|
353
|
+
} else if (body.config?.baseUrl !== undefined) {
|
|
354
|
+
updateData.baseUrl = body.config.baseUrl || getDefaultBaseUrl(body.type || '');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Handle API key update (encrypted)
|
|
358
|
+
if (body.config?.apiKey) {
|
|
359
|
+
updateData.apiKey = encrypt(body.config.apiKey);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (body.config) {
|
|
363
|
+
updateData.config = _toDb({ ...body.config, apiKey: undefined });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// If setting as default, unset other defaults
|
|
367
|
+
if (body.isDefault) {
|
|
368
|
+
const existing = await prisma.provider.findUnique({ where: { id: id } });
|
|
369
|
+
if (existing) {
|
|
370
|
+
await prisma.provider.updateMany({
|
|
371
|
+
where: { type: existing.type, isDefault: true, NOT: { id: id } },
|
|
372
|
+
data: { isDefault: false },
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const provider = await prisma.provider.update({
|
|
378
|
+
where: { id: id },
|
|
379
|
+
data: updateData,
|
|
380
|
+
include: { models: true, health: true },
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
notifyProviderChange().catch(() => {});
|
|
384
|
+
|
|
385
|
+
// Audit log
|
|
386
|
+
await logAuditAction({
|
|
387
|
+
action: 'provider.update',
|
|
388
|
+
userId: session.user.id,
|
|
389
|
+
targetType: 'provider',
|
|
390
|
+
targetId: id,
|
|
391
|
+
metadata: { name: provider.name, type: provider.type },
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
return NextResponse.json({
|
|
395
|
+
...provider,
|
|
396
|
+
apiKey: provider.apiKey ? '***encrypted***' : null,
|
|
397
|
+
});
|
|
398
|
+
} catch (error: any) {
|
|
399
|
+
console.error('Error patching provider:', error);
|
|
400
|
+
return NextResponse.json(
|
|
401
|
+
{ error: 'Failed to update provider', details: error.message },
|
|
402
|
+
{ status: 500 }
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export async function DELETE(
|
|
408
|
+
request: NextRequest,
|
|
409
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
410
|
+
) {
|
|
411
|
+
const { id } = await params;
|
|
412
|
+
try {
|
|
413
|
+
const session = await getServerSession(authOptions);
|
|
414
|
+
if (!session?.user) {
|
|
415
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
await prisma.provider.delete({ where: { id: id } });
|
|
419
|
+
notifyProviderChange().catch(() => {});
|
|
420
|
+
|
|
421
|
+
// Audit log
|
|
422
|
+
await logAuditAction({
|
|
423
|
+
action: 'provider.delete',
|
|
424
|
+
userId: session.user.id,
|
|
425
|
+
targetType: 'provider',
|
|
426
|
+
targetId: id,
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
return NextResponse.json({ success: true });
|
|
430
|
+
} catch (error: any) {
|
|
431
|
+
console.error('Error deleting provider:', error);
|
|
432
|
+
return NextResponse.json(
|
|
433
|
+
{ error: 'Failed to delete provider', details: error.message },
|
|
434
|
+
{ status: 500 }
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
`;
|
|
439
|
+
}
|
|
440
|
+
// ─────────────────────────────────────────────────────────
|
|
441
|
+
// Provider Test Route — /api/providers/[id]/test/route.ts
|
|
442
|
+
// ─────────────────────────────────────────────────────────
|
|
443
|
+
function generateProviderTestRoute() {
|
|
444
|
+
return `// @chimerai component=ProviderTestRoute version=1.0
|
|
445
|
+
/**
|
|
446
|
+
* Provider Test API Route
|
|
447
|
+
* POST /api/providers/[id]/test — Test provider connection
|
|
448
|
+
*/
|
|
449
|
+
|
|
450
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
451
|
+
import { getServerSession } from 'next-auth';
|
|
452
|
+
import { authOptions } from '@/lib/auth';
|
|
453
|
+
import { prisma } from '@/lib/prisma';
|
|
454
|
+
import { decrypt } from '@/lib/encryption';
|
|
455
|
+
|
|
456
|
+
/** Provider types that work without an API key (e.g. local services) */
|
|
457
|
+
const KEYLESS_PROVIDERS = ['ollama'];
|
|
458
|
+
|
|
459
|
+
function getDefaultBaseUrl(type: string): string {
|
|
460
|
+
switch (type) {
|
|
461
|
+
case 'openai': return 'https://api.openai.com/v1';
|
|
462
|
+
case 'anthropic': return 'https://api.anthropic.com/v1';
|
|
463
|
+
case 'ollama': return 'http://localhost:11434';
|
|
464
|
+
case 'groq': return 'https://api.groq.com/openai/v1';
|
|
465
|
+
case 'google': return 'https://generativelanguage.googleapis.com/v1beta';
|
|
466
|
+
default: return 'https://api.openai.com/v1';
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export async function POST(
|
|
471
|
+
request: NextRequest,
|
|
472
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
473
|
+
) {
|
|
474
|
+
const { id } = await params;
|
|
475
|
+
try {
|
|
476
|
+
const session = await getServerSession(authOptions);
|
|
477
|
+
if (!session?.user) {
|
|
478
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const provider = await prisma.provider.findUnique({
|
|
482
|
+
where: { id: id },
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
if (!provider) {
|
|
486
|
+
return NextResponse.json({ error: 'Provider not found' }, { status: 404 });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const baseUrl = provider.baseUrl || getDefaultBaseUrl(provider.type);
|
|
490
|
+
const startTime = Date.now();
|
|
491
|
+
let result: any = { success: false, responseTime: 0, details: {} };
|
|
492
|
+
|
|
493
|
+
// Decrypt API key — keyless providers (e.g. Ollama) skip this
|
|
494
|
+
const requiresKey = !KEYLESS_PROVIDERS.includes(provider.type);
|
|
495
|
+
let apiKey: string | null = null;
|
|
496
|
+
|
|
497
|
+
if (provider.apiKey) {
|
|
498
|
+
try {
|
|
499
|
+
apiKey = decrypt(provider.apiKey);
|
|
500
|
+
} catch (decryptError: any) {
|
|
501
|
+
return NextResponse.json({
|
|
502
|
+
success: false,
|
|
503
|
+
responseTime: 0,
|
|
504
|
+
errorMessage: 'Failed to decrypt API key. Please re-enter the API key for this provider.',
|
|
505
|
+
details: {},
|
|
506
|
+
}, { status: 500 });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (requiresKey && !apiKey) {
|
|
511
|
+
return NextResponse.json({
|
|
512
|
+
success: false,
|
|
513
|
+
responseTime: 0,
|
|
514
|
+
errorMessage: 'No API key configured. Please edit the provider and add an API key.',
|
|
515
|
+
details: {},
|
|
516
|
+
}, { status: 400 });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// At this point apiKey is either a valid string or null (for keyless providers)
|
|
520
|
+
const resolvedKey = apiKey ?? '';
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
|
|
524
|
+
switch (provider.type) {
|
|
525
|
+
case 'openai': {
|
|
526
|
+
const resp = await fetch(\`\${baseUrl}/models\`, {
|
|
527
|
+
headers: { Authorization: \`Bearer \${resolvedKey}\` },
|
|
528
|
+
signal: AbortSignal.timeout(10000),
|
|
529
|
+
});
|
|
530
|
+
const responseTime = Date.now() - startTime;
|
|
531
|
+
if (resp.ok) {
|
|
532
|
+
const data = await resp.json();
|
|
533
|
+
result = { success: true, responseTime, details: { chatTest: true, modelsFound: data.data?.length || 0 } };
|
|
534
|
+
} else {
|
|
535
|
+
throw new Error(\`API returned \${resp.status}: \${resp.statusText}\`);
|
|
536
|
+
}
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
case 'anthropic': {
|
|
541
|
+
const resp = await fetch(\`\${baseUrl}/messages\`, {
|
|
542
|
+
method: 'POST',
|
|
543
|
+
headers: {
|
|
544
|
+
'x-api-key': resolvedKey,
|
|
545
|
+
'anthropic-version': '2023-06-01',
|
|
546
|
+
'Content-Type': 'application/json',
|
|
547
|
+
},
|
|
548
|
+
body: JSON.stringify({
|
|
549
|
+
model: 'claude-3-haiku-20240307',
|
|
550
|
+
max_tokens: 10,
|
|
551
|
+
messages: [{ role: 'user', content: 'Hi' }],
|
|
552
|
+
}),
|
|
553
|
+
signal: AbortSignal.timeout(10000),
|
|
554
|
+
});
|
|
555
|
+
const responseTime = Date.now() - startTime;
|
|
556
|
+
if (resp.ok) {
|
|
557
|
+
result = { success: true, responseTime, details: { chatTest: true } };
|
|
558
|
+
} else {
|
|
559
|
+
throw new Error(\`API returned \${resp.status}: \${resp.statusText}\`);
|
|
560
|
+
}
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
case 'ollama': {
|
|
565
|
+
const resp = await fetch(\`\${baseUrl}/api/tags\`, {
|
|
566
|
+
signal: AbortSignal.timeout(10000),
|
|
567
|
+
});
|
|
568
|
+
const responseTime = Date.now() - startTime;
|
|
569
|
+
if (resp.ok) {
|
|
570
|
+
const data = await resp.json();
|
|
571
|
+
result = { success: true, responseTime, details: { chatTest: true, modelsFound: data.models?.length || 0 } };
|
|
572
|
+
} else {
|
|
573
|
+
throw new Error(\`API returned \${resp.status}: \${resp.statusText}\`);
|
|
574
|
+
}
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
default: {
|
|
579
|
+
const resp = await fetch(\`\${baseUrl}/models\`, {
|
|
580
|
+
headers: { Authorization: \`Bearer \${resolvedKey}\` },
|
|
581
|
+
signal: AbortSignal.timeout(10000),
|
|
582
|
+
});
|
|
583
|
+
const responseTime = Date.now() - startTime;
|
|
584
|
+
result = { success: resp.ok, responseTime, details: { chatTest: resp.ok } };
|
|
585
|
+
if (!resp.ok) throw new Error(\`API returned \${resp.status}: \${resp.statusText}\`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Update status + health
|
|
590
|
+
await prisma.provider.update({
|
|
591
|
+
where: { id: id },
|
|
592
|
+
data: { status: 'active' },
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
await prisma.providerHealth.upsert({
|
|
596
|
+
where: { providerId: id },
|
|
597
|
+
create: {
|
|
598
|
+
providerId: id, status: 'healthy',
|
|
599
|
+
responseTime: result.responseTime, lastCheck: new Date(),
|
|
600
|
+
modelsAvailable: result.details.modelsFound || 0,
|
|
601
|
+
chatAvailable: result.details.chatTest || false,
|
|
602
|
+
apiKeyValid: true,
|
|
603
|
+
},
|
|
604
|
+
update: {
|
|
605
|
+
status: 'healthy', responseTime: result.responseTime,
|
|
606
|
+
lastCheck: new Date(), modelsAvailable: result.details.modelsFound || 0,
|
|
607
|
+
chatAvailable: result.details.chatTest || false,
|
|
608
|
+
apiKeyValid: true, errorMessage: null,
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
return NextResponse.json(result);
|
|
613
|
+
} catch (error: any) {
|
|
614
|
+
const responseTime = Date.now() - startTime;
|
|
615
|
+
result = { success: false, responseTime, errorMessage: error.message, details: {} };
|
|
616
|
+
|
|
617
|
+
await prisma.provider.update({
|
|
618
|
+
where: { id: id },
|
|
619
|
+
data: { status: 'error' },
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
await prisma.providerHealth.upsert({
|
|
623
|
+
where: { providerId: id },
|
|
624
|
+
create: {
|
|
625
|
+
providerId: id, status: 'unhealthy', responseTime,
|
|
626
|
+
lastCheck: new Date(), errorMessage: error.message,
|
|
627
|
+
modelsAvailable: 0, chatAvailable: false, apiKeyValid: false,
|
|
628
|
+
},
|
|
629
|
+
update: {
|
|
630
|
+
status: 'unhealthy', responseTime, lastCheck: new Date(),
|
|
631
|
+
errorMessage: error.message, chatAvailable: false, apiKeyValid: false,
|
|
632
|
+
},
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
return NextResponse.json(result, { status: 500 });
|
|
636
|
+
}
|
|
637
|
+
} catch (error: any) {
|
|
638
|
+
console.error('Error testing provider:', error);
|
|
639
|
+
return NextResponse.json(
|
|
640
|
+
{ error: 'Failed to test provider', details: error.message },
|
|
641
|
+
{ status: 500 }
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
`;
|
|
646
|
+
}
|
|
647
|
+
// ─────────────────────────────────────────────────────────
|
|
648
|
+
// Provider Sync Route — /api/providers/[id]/sync/route.ts
|
|
649
|
+
// Fetches models from the provider API and upserts them into the DB
|
|
650
|
+
// ─────────────────────────────────────────────────────────
|
|
651
|
+
function generateProviderSyncRoute() {
|
|
652
|
+
return `// @chimerai component=ProviderSyncRoute version=1.0
|
|
653
|
+
/**
|
|
654
|
+
* Provider Sync API Route
|
|
655
|
+
* POST /api/providers/[id]/sync — Fetch models from provider API and save to DB
|
|
656
|
+
*/
|
|
657
|
+
|
|
658
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
659
|
+
import { getServerSession } from 'next-auth';
|
|
660
|
+
import { authOptions } from '@/lib/auth';
|
|
661
|
+
import { prisma } from '@/lib/prisma';
|
|
662
|
+
import { decrypt } from '@/lib/encryption';
|
|
663
|
+
|
|
664
|
+
/** SQLite stores Json/String[] as plain String — serialize at runtime */
|
|
665
|
+
const _isSqlite = (process.env.DATABASE_URL || '').startsWith('file:');
|
|
666
|
+
function _toDb(v: any): any { return _isSqlite && v != null && typeof v !== 'string' ? JSON.stringify(v) : v; }
|
|
667
|
+
|
|
668
|
+
const KEYLESS_PROVIDERS = ['ollama'];
|
|
669
|
+
|
|
670
|
+
function getDefaultBaseUrl(type: string): string {
|
|
671
|
+
switch (type) {
|
|
672
|
+
case 'openai': return 'https://api.openai.com/v1';
|
|
673
|
+
case 'anthropic': return 'https://api.anthropic.com/v1';
|
|
674
|
+
case 'ollama': return 'http://localhost:11434';
|
|
675
|
+
case 'groq': return 'https://api.groq.com/openai/v1';
|
|
676
|
+
case 'google': return 'https://generativelanguage.googleapis.com/v1beta';
|
|
677
|
+
default: return 'https://api.openai.com/v1';
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Well-known model metadata for cost/context enrichment */
|
|
682
|
+
const MODEL_METADATA: Record<string, { contextWindow?: number; inputCost?: number; outputCost?: number; capabilities?: string[] }> = {
|
|
683
|
+
'gpt-4o': { contextWindow: 128000, inputCost: 2.5, outputCost: 10, capabilities: ['chat', 'vision'] },
|
|
684
|
+
'gpt-4o-mini': { contextWindow: 128000, inputCost: 0.15, outputCost: 0.6, capabilities: ['chat'] },
|
|
685
|
+
'gpt-4-turbo': { contextWindow: 128000, inputCost: 10, outputCost: 30, capabilities: ['chat', 'vision'] },
|
|
686
|
+
'gpt-4': { contextWindow: 8192, inputCost: 30, outputCost: 60, capabilities: ['chat'] },
|
|
687
|
+
'gpt-3.5-turbo': { contextWindow: 16385, inputCost: 0.5, outputCost: 1.5, capabilities: ['chat'] },
|
|
688
|
+
'o1': { contextWindow: 200000, inputCost: 15, outputCost: 60, capabilities: ['chat'] },
|
|
689
|
+
'o1-mini': { contextWindow: 128000, inputCost: 3, outputCost: 12, capabilities: ['chat'] },
|
|
690
|
+
'o3-mini': { contextWindow: 200000, inputCost: 1.1, outputCost: 4.4, capabilities: ['chat'] },
|
|
691
|
+
'text-embedding-3-small': { contextWindow: 8191, inputCost: 0.02, outputCost: 0, capabilities: ['embedding'] },
|
|
692
|
+
'text-embedding-3-large': { contextWindow: 8191, inputCost: 0.13, outputCost: 0, capabilities: ['embedding'] },
|
|
693
|
+
'claude-sonnet-4-20250514': { contextWindow: 200000, inputCost: 3, outputCost: 15, capabilities: ['chat', 'vision'] },
|
|
694
|
+
'claude-3-5-sonnet-20241022':{ contextWindow: 200000, inputCost: 3, outputCost: 15, capabilities: ['chat', 'vision'] },
|
|
695
|
+
'claude-3-haiku-20240307': { contextWindow: 200000, inputCost: 0.25, outputCost: 1.25, capabilities: ['chat'] },
|
|
696
|
+
'claude-3-opus-20240229': { contextWindow: 200000, inputCost: 15, outputCost: 75, capabilities: ['chat', 'vision'] },
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
/** Filter: only keep models useful for chat/embedding */
|
|
700
|
+
function isUsefulModel(modelId: string, providerType: string): boolean {
|
|
701
|
+
if (providerType === 'ollama') return true;
|
|
702
|
+
if (providerType === 'anthropic') return true;
|
|
703
|
+
if (providerType === 'openai' || providerType === 'groq') {
|
|
704
|
+
if (modelId.startsWith('ft:')) return false;
|
|
705
|
+
if (/^(whisper|tts|dall-e|babbage|davinci|canary)/.test(modelId)) return false;
|
|
706
|
+
return /^(gpt-|o1|o3|o4|text-embedding|chatgpt)/.test(modelId);
|
|
707
|
+
}
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function humanName(modelId: string): string {
|
|
712
|
+
return modelId
|
|
713
|
+
.replace(/-/g, ' ')
|
|
714
|
+
.replace(/\\b\\w/g, (c: string) => c.toUpperCase())
|
|
715
|
+
.replace(/Gpt /g, 'GPT ')
|
|
716
|
+
.replace(/^O1/g, 'O1')
|
|
717
|
+
.replace(/^O3/g, 'O3');
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async function fetchOpenAIModels(baseUrl: string, apiKey: string) {
|
|
721
|
+
const resp = await fetch(baseUrl + '/models', {
|
|
722
|
+
headers: { Authorization: 'Bearer ' + apiKey },
|
|
723
|
+
signal: AbortSignal.timeout(15000),
|
|
724
|
+
});
|
|
725
|
+
if (!resp.ok) throw new Error('OpenAI API returned ' + resp.status);
|
|
726
|
+
const data = await resp.json();
|
|
727
|
+
return (data.data || []).map((m: any) => ({ modelId: m.id, name: humanName(m.id) }));
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function fetchAnthropicModels() {
|
|
731
|
+
// Anthropic has no /models endpoint — return well-known models
|
|
732
|
+
return [
|
|
733
|
+
{ modelId: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
|
|
734
|
+
{ modelId: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet' },
|
|
735
|
+
{ modelId: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku' },
|
|
736
|
+
{ modelId: 'claude-3-opus-20240229', name: 'Claude 3 Opus' },
|
|
737
|
+
];
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function fetchOllamaModels(baseUrl: string) {
|
|
741
|
+
const resp = await fetch(baseUrl + '/api/tags', { signal: AbortSignal.timeout(10000) });
|
|
742
|
+
if (!resp.ok) throw new Error('Ollama API returned ' + resp.status);
|
|
743
|
+
const data = await resp.json();
|
|
744
|
+
return (data.models || []).map((m: any) => ({
|
|
745
|
+
modelId: m.name || m.model,
|
|
746
|
+
name: (m.name || m.model).replace(/:latest$/, ''),
|
|
747
|
+
}));
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
export async function POST(
|
|
751
|
+
request: NextRequest,
|
|
752
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
753
|
+
) {
|
|
754
|
+
const { id } = await params;
|
|
755
|
+
try {
|
|
756
|
+
const session = await getServerSession(authOptions);
|
|
757
|
+
if (!session?.user) {
|
|
758
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const provider = await prisma.provider.findUnique({ where: { id: id } });
|
|
762
|
+
if (!provider) {
|
|
763
|
+
return NextResponse.json({ error: 'Provider not found' }, { status: 404 });
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const baseUrl = provider.baseUrl || getDefaultBaseUrl(provider.type);
|
|
767
|
+
const requiresKey = !KEYLESS_PROVIDERS.includes(provider.type);
|
|
768
|
+
|
|
769
|
+
let apiKey: string | null = null;
|
|
770
|
+
if (provider.apiKey) {
|
|
771
|
+
try { apiKey = decrypt(provider.apiKey); } catch {
|
|
772
|
+
return NextResponse.json({ error: 'Failed to decrypt API key' }, { status: 500 });
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
if (requiresKey && !apiKey) {
|
|
776
|
+
return NextResponse.json({ error: 'No API key configured' }, { status: 400 });
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Fetch models from provider API
|
|
780
|
+
let rawModels: { modelId: string; name: string }[] = [];
|
|
781
|
+
switch (provider.type) {
|
|
782
|
+
case 'openai':
|
|
783
|
+
case 'groq':
|
|
784
|
+
rawModels = await fetchOpenAIModels(baseUrl, apiKey || '');
|
|
785
|
+
break;
|
|
786
|
+
case 'anthropic':
|
|
787
|
+
rawModels = await fetchAnthropicModels();
|
|
788
|
+
break;
|
|
789
|
+
case 'ollama':
|
|
790
|
+
rawModels = await fetchOllamaModels(baseUrl);
|
|
791
|
+
break;
|
|
792
|
+
default:
|
|
793
|
+
try { rawModels = await fetchOpenAIModels(baseUrl, apiKey || ''); } catch { rawModels = []; }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Filter useful models
|
|
797
|
+
const models = rawModels.filter(m => isUsefulModel(m.modelId, provider.type));
|
|
798
|
+
|
|
799
|
+
// Upsert models into DB
|
|
800
|
+
let created = 0;
|
|
801
|
+
let updated = 0;
|
|
802
|
+
for (const m of models) {
|
|
803
|
+
const meta = MODEL_METADATA[m.modelId] || {};
|
|
804
|
+
const capabilities = meta.capabilities || ['chat'];
|
|
805
|
+
const existing = await (prisma as any).model.findFirst({
|
|
806
|
+
where: { providerId: provider.id, modelId: m.modelId },
|
|
807
|
+
});
|
|
808
|
+
if (existing) {
|
|
809
|
+
await (prisma as any).model.update({
|
|
810
|
+
where: { id: existing.id },
|
|
811
|
+
data: { name: m.name, isAvailable: true, isDeprecated: false },
|
|
812
|
+
});
|
|
813
|
+
updated++;
|
|
814
|
+
} else {
|
|
815
|
+
await (prisma as any).model.create({
|
|
816
|
+
data: {
|
|
817
|
+
providerId: provider.id,
|
|
818
|
+
modelId: m.modelId,
|
|
819
|
+
name: m.name,
|
|
820
|
+
capabilities: _toDb(capabilities),
|
|
821
|
+
contextWindow: meta.contextWindow || 4096,
|
|
822
|
+
inputCost: meta.inputCost || 0,
|
|
823
|
+
outputCost: meta.outputCost || 0,
|
|
824
|
+
isAvailable: true,
|
|
825
|
+
},
|
|
826
|
+
});
|
|
827
|
+
created++;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return NextResponse.json({
|
|
832
|
+
success: true,
|
|
833
|
+
synced: models.length,
|
|
834
|
+
created,
|
|
835
|
+
updated,
|
|
836
|
+
models: models.map(m => m.modelId),
|
|
837
|
+
});
|
|
838
|
+
} catch (error: any) {
|
|
839
|
+
console.error('Error syncing models:', error);
|
|
840
|
+
return NextResponse.json(
|
|
841
|
+
{ error: 'Failed to sync models', details: error.message },
|
|
842
|
+
{ status: 500 }
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
`;
|
|
847
|
+
}
|
|
848
|
+
// ─────────────────────────────────────────────────────────
|
|
849
|
+
// Internal Providers Route — /api/internal/providers/route.ts
|
|
850
|
+
// ─────────────────────────────────────────────────────────
|
|
851
|
+
function generateInternalProvidersRoute() {
|
|
852
|
+
return `// @chimerai component=InternalProvidersRoute version=1.0
|
|
853
|
+
/**
|
|
854
|
+
* Internal Provider API — List all active providers
|
|
855
|
+
* GET /api/internal/providers
|
|
856
|
+
*
|
|
857
|
+
* Used by the Python AI Service to fetch provider configs + decrypted API keys.
|
|
858
|
+
* Protected by INTERNAL_API_TOKEN (Bearer token).
|
|
859
|
+
*
|
|
860
|
+
* WARNING: Returns DECRYPTED API keys — never expose externally!
|
|
861
|
+
*/
|
|
862
|
+
|
|
863
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
864
|
+
import { prisma } from '@/lib/prisma';
|
|
865
|
+
import { decrypt } from '@/lib/encryption';
|
|
866
|
+
|
|
867
|
+
/** SQLite stores Json/String[] as plain String — deserialize at runtime */
|
|
868
|
+
const _isSqlite = (process.env.DATABASE_URL || '').startsWith('file:');
|
|
869
|
+
function _fromDb(v: any): any { if (!_isSqlite || typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return v; } }
|
|
870
|
+
|
|
871
|
+
function validateInternalToken(request: NextRequest): boolean {
|
|
872
|
+
const authHeader = request.headers.get('authorization') || '';
|
|
873
|
+
const token = authHeader.replace(/^Bearer\\s+/i, '');
|
|
874
|
+
const expected = process.env.INTERNAL_API_TOKEN;
|
|
875
|
+
|
|
876
|
+
if (!expected || expected.length < 32) {
|
|
877
|
+
console.error('INTERNAL_API_TOKEN is not configured or too short');
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
return token === expected;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
export async function GET(request: NextRequest) {
|
|
885
|
+
if (!validateInternalToken(request)) {
|
|
886
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
try {
|
|
890
|
+
const { searchParams } = new URL(request.url);
|
|
891
|
+
const type = searchParams.get('type');
|
|
892
|
+
const status = searchParams.get('status') || 'active';
|
|
893
|
+
|
|
894
|
+
const providers = await prisma.provider.findMany({
|
|
895
|
+
where: {
|
|
896
|
+
...(status && { status }),
|
|
897
|
+
...(type && { type }),
|
|
898
|
+
},
|
|
899
|
+
include: {
|
|
900
|
+
models: {
|
|
901
|
+
where: { isAvailable: true, isDeprecated: false },
|
|
902
|
+
select: {
|
|
903
|
+
id: true, modelId: true, name: true,
|
|
904
|
+
capabilities: true, contextWindow: true,
|
|
905
|
+
inputCost: true, outputCost: true,
|
|
906
|
+
},
|
|
907
|
+
},
|
|
908
|
+
},
|
|
909
|
+
orderBy: [{ isDefault: 'desc' }, { priority: 'asc' }],
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
const result = providers.map((p: any) => {
|
|
913
|
+
let apiKey: string | null = null;
|
|
914
|
+
try {
|
|
915
|
+
if (p.apiKey) apiKey = decrypt(p.apiKey);
|
|
916
|
+
} catch {
|
|
917
|
+
console.warn(\`Failed to decrypt API key for provider \${p.id}\`);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
return {
|
|
921
|
+
id: p.id,
|
|
922
|
+
name: p.name,
|
|
923
|
+
type: p.type,
|
|
924
|
+
base_url: p.baseUrl,
|
|
925
|
+
api_key: apiKey,
|
|
926
|
+
config: _fromDb(p.config),
|
|
927
|
+
status: p.status,
|
|
928
|
+
is_default: p.isDefault,
|
|
929
|
+
priority: p.priority,
|
|
930
|
+
models: p.models.map((m: any) => ({
|
|
931
|
+
id: m.id,
|
|
932
|
+
model_id: m.modelId,
|
|
933
|
+
name: m.name,
|
|
934
|
+
capabilities: _fromDb(m.capabilities),
|
|
935
|
+
context_window: m.contextWindow,
|
|
936
|
+
input_cost: m.inputCost,
|
|
937
|
+
output_cost: m.outputCost,
|
|
938
|
+
})),
|
|
939
|
+
};
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
return NextResponse.json({ providers: result });
|
|
943
|
+
} catch (error: any) {
|
|
944
|
+
console.error('Error fetching internal providers:', error);
|
|
945
|
+
return NextResponse.json(
|
|
946
|
+
{ error: 'Failed to fetch providers', details: error.message },
|
|
947
|
+
{ status: 500 }
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
`;
|
|
952
|
+
}
|
|
953
|
+
// ─────────────────────────────────────────────────────────
|
|
954
|
+
// Internal Provider [id] Route — /api/internal/providers/[id]/route.ts
|
|
955
|
+
// ─────────────────────────────────────────────────────────
|
|
956
|
+
function generateInternalProviderIdRoute() {
|
|
957
|
+
return `// @chimerai component=InternalProviderIdRoute version=1.0
|
|
958
|
+
/**
|
|
959
|
+
* Internal Provider API — Get single provider by ID
|
|
960
|
+
* GET /api/internal/providers/[id]
|
|
961
|
+
*
|
|
962
|
+
* Returns full provider details with DECRYPTED API key.
|
|
963
|
+
* Protected by INTERNAL_API_TOKEN (Bearer token).
|
|
964
|
+
*/
|
|
965
|
+
|
|
966
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
967
|
+
import { prisma } from '@/lib/prisma';
|
|
968
|
+
import { decrypt } from '@/lib/encryption';
|
|
969
|
+
|
|
970
|
+
/** SQLite stores Json/String[] as plain String — deserialize at runtime */
|
|
971
|
+
const _isSqlite = (process.env.DATABASE_URL || '').startsWith('file:');
|
|
972
|
+
function _fromDb(v: any): any { if (!_isSqlite || typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return v; } }
|
|
973
|
+
|
|
974
|
+
function validateInternalToken(request: NextRequest): boolean {
|
|
975
|
+
const authHeader = request.headers.get('authorization') || '';
|
|
976
|
+
const token = authHeader.replace(/^Bearer\\s+/i, '');
|
|
977
|
+
const expected = process.env.INTERNAL_API_TOKEN;
|
|
978
|
+
if (!expected || expected.length < 32) return false;
|
|
979
|
+
return token === expected;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
export async function GET(
|
|
983
|
+
request: NextRequest,
|
|
984
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
985
|
+
) {
|
|
986
|
+
const { id } = await params;
|
|
987
|
+
if (!validateInternalToken(request)) {
|
|
988
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
try {
|
|
992
|
+
const provider = await prisma.provider.findUnique({
|
|
993
|
+
where: { id: id },
|
|
994
|
+
include: {
|
|
995
|
+
models: {
|
|
996
|
+
where: { isAvailable: true, isDeprecated: false },
|
|
997
|
+
select: {
|
|
998
|
+
id: true, modelId: true, name: true,
|
|
999
|
+
capabilities: true, contextWindow: true,
|
|
1000
|
+
inputCost: true, outputCost: true,
|
|
1001
|
+
},
|
|
1002
|
+
},
|
|
1003
|
+
},
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
if (!provider) {
|
|
1007
|
+
return NextResponse.json({ error: 'Provider not found' }, { status: 404 });
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
let apiKey: string | null = null;
|
|
1011
|
+
try {
|
|
1012
|
+
if (provider.apiKey) apiKey = decrypt(provider.apiKey);
|
|
1013
|
+
} catch {
|
|
1014
|
+
console.warn(\`Failed to decrypt API key for provider \${provider.id}\`);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
return NextResponse.json({
|
|
1018
|
+
id: provider.id,
|
|
1019
|
+
name: provider.name,
|
|
1020
|
+
type: provider.type,
|
|
1021
|
+
base_url: provider.baseUrl,
|
|
1022
|
+
api_key: apiKey,
|
|
1023
|
+
config: _fromDb(provider.config),
|
|
1024
|
+
status: provider.status,
|
|
1025
|
+
is_default: provider.isDefault,
|
|
1026
|
+
priority: provider.priority,
|
|
1027
|
+
models: provider.models.map((m: any) => ({
|
|
1028
|
+
id: m.id,
|
|
1029
|
+
model_id: m.modelId,
|
|
1030
|
+
name: m.name,
|
|
1031
|
+
capabilities: _fromDb(m.capabilities),
|
|
1032
|
+
context_window: m.contextWindow,
|
|
1033
|
+
input_cost: m.inputCost,
|
|
1034
|
+
output_cost: m.outputCost,
|
|
1035
|
+
})),
|
|
1036
|
+
});
|
|
1037
|
+
} catch (error: any) {
|
|
1038
|
+
console.error('Error fetching internal provider:', error);
|
|
1039
|
+
return NextResponse.json(
|
|
1040
|
+
{ error: 'Failed to fetch provider', details: error.message },
|
|
1041
|
+
{ status: 500 }
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
`;
|
|
1046
|
+
}
|
|
1047
|
+
// ─────────────────────────────────────────────────────────
|
|
1048
|
+
// Internal Usage Route — /api/internal/providers/[id]/usage/route.ts
|
|
1049
|
+
// ─────────────────────────────────────────────────────────
|
|
1050
|
+
function generateInternalProviderUsageRoute() {
|
|
1051
|
+
return `// @chimerai component=InternalProviderUsageRoute version=1.0
|
|
1052
|
+
/**
|
|
1053
|
+
* Internal Provider API — Report usage / token consumption
|
|
1054
|
+
* POST /api/internal/providers/[id]/usage
|
|
1055
|
+
*
|
|
1056
|
+
* Called by the Python AI Service after each AI call to track
|
|
1057
|
+
* token usage, costs, and credits in the central ApiUsage table.
|
|
1058
|
+
* Protected by INTERNAL_API_TOKEN (Bearer token).
|
|
1059
|
+
*/
|
|
1060
|
+
|
|
1061
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
1062
|
+
import { prisma } from '@/lib/prisma';
|
|
1063
|
+
|
|
1064
|
+
function validateInternalToken(request: NextRequest): boolean {
|
|
1065
|
+
const authHeader = request.headers.get('authorization') || '';
|
|
1066
|
+
const token = authHeader.replace(/^Bearer\\s+/i, '');
|
|
1067
|
+
const expected = process.env.INTERNAL_API_TOKEN;
|
|
1068
|
+
if (!expected || expected.length < 32) return false;
|
|
1069
|
+
return token === expected;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
interface UsagePayload {
|
|
1073
|
+
user_id: string;
|
|
1074
|
+
model: string;
|
|
1075
|
+
endpoint: string;
|
|
1076
|
+
prompt_tokens: number;
|
|
1077
|
+
completion_tokens: number;
|
|
1078
|
+
total_tokens?: number;
|
|
1079
|
+
cost?: number;
|
|
1080
|
+
success?: boolean;
|
|
1081
|
+
error_message?: string;
|
|
1082
|
+
response_time?: number;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const TOKENS_PER_CREDIT = 1000;
|
|
1086
|
+
|
|
1087
|
+
export async function POST(
|
|
1088
|
+
request: NextRequest,
|
|
1089
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
1090
|
+
) {
|
|
1091
|
+
const { id } = await params;
|
|
1092
|
+
if (!validateInternalToken(request)) {
|
|
1093
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
try {
|
|
1097
|
+
const body: UsagePayload = await request.json();
|
|
1098
|
+
|
|
1099
|
+
if (!body.user_id || !body.model || !body.endpoint) {
|
|
1100
|
+
return NextResponse.json(
|
|
1101
|
+
{ error: 'Missing required fields: user_id, model, endpoint' },
|
|
1102
|
+
{ status: 400 }
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const providerId = id;
|
|
1107
|
+
const totalTokens = body.total_tokens ?? (body.prompt_tokens + body.completion_tokens);
|
|
1108
|
+
const creditsUsed = Math.ceil(totalTokens / TOKENS_PER_CREDIT);
|
|
1109
|
+
|
|
1110
|
+
// If cost not provided, try to compute from model pricing
|
|
1111
|
+
let cost = body.cost ?? 0;
|
|
1112
|
+
if (!body.cost && totalTokens > 0) {
|
|
1113
|
+
try {
|
|
1114
|
+
const model = await prisma.model.findFirst({
|
|
1115
|
+
where: { providerId, modelId: body.model },
|
|
1116
|
+
});
|
|
1117
|
+
if (model) {
|
|
1118
|
+
cost =
|
|
1119
|
+
(body.prompt_tokens * model.inputCost) / 1_000_000 +
|
|
1120
|
+
(body.completion_tokens * model.outputCost) / 1_000_000;
|
|
1121
|
+
}
|
|
1122
|
+
} catch {
|
|
1123
|
+
// Pricing lookup failed — use 0
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const usage = await prisma.apiUsage.create({
|
|
1128
|
+
data: {
|
|
1129
|
+
userId: body.user_id,
|
|
1130
|
+
providerId,
|
|
1131
|
+
model: body.model,
|
|
1132
|
+
endpoint: body.endpoint,
|
|
1133
|
+
promptTokens: body.prompt_tokens,
|
|
1134
|
+
completionTokens: body.completion_tokens,
|
|
1135
|
+
totalTokens,
|
|
1136
|
+
tokensUsed: totalTokens,
|
|
1137
|
+
creditsUsed,
|
|
1138
|
+
cost,
|
|
1139
|
+
success: body.success ?? true,
|
|
1140
|
+
errorMessage: body.error_message || null,
|
|
1141
|
+
responseTime: body.response_time || 0,
|
|
1142
|
+
},
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
return NextResponse.json({
|
|
1146
|
+
success: true,
|
|
1147
|
+
usage_id: usage.id,
|
|
1148
|
+
credits_used: creditsUsed,
|
|
1149
|
+
cost,
|
|
1150
|
+
});
|
|
1151
|
+
} catch (error: any) {
|
|
1152
|
+
console.error('Error reporting usage:', error);
|
|
1153
|
+
return NextResponse.json(
|
|
1154
|
+
{ error: 'Failed to report usage', details: error.message },
|
|
1155
|
+
{ status: 500 }
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
`;
|
|
1160
|
+
}
|
|
1161
|
+
// ─────────────────────────────────────────────────────────
|
|
1162
|
+
// Notify Provider Change Utility — lib/notify-provider-change.ts
|
|
1163
|
+
// ─────────────────────────────────────────────────────────
|
|
1164
|
+
function generateNotifyProviderChangeLib() {
|
|
1165
|
+
return `// @chimerai component=NotifyProviderChangeLib version=1.0
|
|
1166
|
+
/**
|
|
1167
|
+
* Notify the AI Service that provider configuration has changed.
|
|
1168
|
+
* Triggers cache invalidation so the service fetches fresh provider data.
|
|
1169
|
+
*
|
|
1170
|
+
* Non-critical: failures are logged but don't throw.
|
|
1171
|
+
*/
|
|
1172
|
+
|
|
1173
|
+
export async function notifyProviderChange(): Promise<void> {
|
|
1174
|
+
const aiServiceUrl = process.env.AI_SERVICE_URL || 'http://localhost:8002';
|
|
1175
|
+
const internalToken = process.env.INTERNAL_API_TOKEN;
|
|
1176
|
+
|
|
1177
|
+
if (!internalToken) {
|
|
1178
|
+
console.warn('Cannot notify AI service: INTERNAL_API_TOKEN not configured');
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
try {
|
|
1183
|
+
const resp = await fetch(\`\${aiServiceUrl}/api/internal/invalidate-cache\`, {
|
|
1184
|
+
method: 'POST',
|
|
1185
|
+
headers: {
|
|
1186
|
+
Authorization: \`Bearer \${internalToken}\`,
|
|
1187
|
+
'Content-Type': 'application/json',
|
|
1188
|
+
},
|
|
1189
|
+
signal: AbortSignal.timeout(5000),
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
if (resp.ok) {
|
|
1193
|
+
console.log('AI service provider cache invalidated');
|
|
1194
|
+
} else {
|
|
1195
|
+
console.warn(\`Failed to invalidate AI service cache: \${resp.status}\`);
|
|
1196
|
+
}
|
|
1197
|
+
} catch (error: any) {
|
|
1198
|
+
// Non-critical: AI service might not be running
|
|
1199
|
+
console.debug(\`Could not reach AI service: \${error.message}\`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
`;
|
|
1203
|
+
}
|