ldrouter 1.5.1

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +101 -0
  4. package/dist/cli.js +13 -0
  5. package/dist/server/app.js +138 -0
  6. package/dist/server/auth/api-key.js +75 -0
  7. package/dist/server/auth/crypto.js +94 -0
  8. package/dist/server/auth/ids.js +40 -0
  9. package/dist/server/auth/middleware.js +36 -0
  10. package/dist/server/auth/recovery.js +11 -0
  11. package/dist/server/caching/store.js +119 -0
  12. package/dist/server/config/index.js +96 -0
  13. package/dist/server/db/index.js +64 -0
  14. package/dist/server/db/migrate.js +408 -0
  15. package/dist/server/db/repositories/audit.js +75 -0
  16. package/dist/server/db/repositories/settings.js +63 -0
  17. package/dist/server/db/schema.js +396 -0
  18. package/dist/server/errors.js +65 -0
  19. package/dist/server/gateway/runner.js +745 -0
  20. package/dist/server/logging/logger.js +35 -0
  21. package/dist/server/maintenance/retention.js +48 -0
  22. package/dist/server/metrics/registry.js +169 -0
  23. package/dist/server/protocols/anthropic.js +154 -0
  24. package/dist/server/protocols/canonical.js +201 -0
  25. package/dist/server/providers/index.js +89 -0
  26. package/dist/server/routes/admin/aliases.js +98 -0
  27. package/dist/server/routes/admin/api-keys.js +194 -0
  28. package/dist/server/routes/admin/audit.js +19 -0
  29. package/dist/server/routes/admin/auth.js +124 -0
  30. package/dist/server/routes/admin/backup.js +113 -0
  31. package/dist/server/routes/admin/combos.js +198 -0
  32. package/dist/server/routes/admin/dashboard.js +55 -0
  33. package/dist/server/routes/admin/models.js +178 -0
  34. package/dist/server/routes/admin/providers.js +212 -0
  35. package/dist/server/routes/admin/requests.js +156 -0
  36. package/dist/server/routes/admin/settings.js +197 -0
  37. package/dist/server/routes/admin/setup.js +80 -0
  38. package/dist/server/routes/admin/stats.js +180 -0
  39. package/dist/server/routes/admin.js +39 -0
  40. package/dist/server/routes/gateway/anthropic.js +112 -0
  41. package/dist/server/routes/gateway/openai.js +257 -0
  42. package/dist/server/routes/gateway.js +7 -0
  43. package/dist/server/routes/health.js +27 -0
  44. package/dist/server/routing/capabilities.js +52 -0
  45. package/dist/server/routing/circuit.js +37 -0
  46. package/dist/server/routing/combo.js +100 -0
  47. package/dist/server/routing/quota.js +51 -0
  48. package/dist/server/routing/ratelimit.js +58 -0
  49. package/dist/server/routing/resolver.js +43 -0
  50. package/dist/server/security/redact.js +111 -0
  51. package/dist/server/selfupdate/index.js +154 -0
  52. package/dist/server/upstream/client.js +179 -0
  53. package/dist/server/util/cidr.js +91 -0
  54. package/dist/server/util/client-ip.js +15 -0
  55. package/dist/server/util/stable-json.js +19 -0
  56. package/dist/shared/types.js +2 -0
  57. package/dist/web/assets/index-COSbvF8Z.css +1 -0
  58. package/dist/web/assets/index-DbnEzuxq.js +251 -0
  59. package/dist/web/favicon.png +0 -0
  60. package/dist/web/index.html +15 -0
  61. package/dist/web/logo.png +0 -0
  62. package/migrations/0001_initial_schema.sql +323 -0
  63. package/migrations/0002_source_api_key_secrets.sql +7 -0
  64. package/package.json +117 -0
@@ -0,0 +1,112 @@
1
+ // Anthropic-compatible gateway routes: /v1/messages, /v1/messages/count_tokens.
2
+ import { z } from 'zod';
3
+ import { authenticateGatewayKey } from '../../auth/api-key.js';
4
+ import { resolveClientIp } from '../../util/client-ip.js';
5
+ import { anthropicToCanonical } from '../../protocols/anthropic.js';
6
+ import { GatewayError, toAnthropicError } from '../../errors.js';
7
+ import { GatewayRunner } from '../../gateway/runner.js';
8
+ import { uuid } from '../../auth/ids.js';
9
+ const MessagesBody = z.object({
10
+ model: z.string().min(1),
11
+ messages: z.array(z.any()).min(1),
12
+ system: z.union([z.string(), z.array(z.any())]).optional(),
13
+ max_tokens: z.number().int().min(1).optional(),
14
+ temperature: z.number().optional(),
15
+ top_p: z.number().optional(),
16
+ stop_sequences: z.array(z.string()).optional(),
17
+ stream: z.boolean().optional(),
18
+ tools: z.array(z.any()).optional(),
19
+ tool_choice: z.any().optional(),
20
+ thinking: z.object({ type: z.literal('enabled'), budget_tokens: z.number().int().min(1) }).optional(),
21
+ });
22
+ const CountTokensBody = MessagesBody.omit({ stream: true });
23
+ export async function registerAnthropicRoutes(app) {
24
+ const runner = new GatewayRunner();
25
+ app.get('/v1/messages', async (_req, reply) => {
26
+ reply.code(405).send(toAnthropicError(new GatewayError('invalid_request_error', 'Use POST /v1/messages', { status: 405 }), ''));
27
+ });
28
+ app.post('/v1/messages', async (req, reply) => {
29
+ const key = authenticateGatewayHeaders(req);
30
+ const body = MessagesBody.parse(req.body);
31
+ const ar = body;
32
+ if (!ar.max_tokens) {
33
+ throw new GatewayError('invalid_request_error', 'max_tokens is required', { status: 400 });
34
+ }
35
+ const canonical = anthropicToCanonical(ar);
36
+ const ctx = {
37
+ requestId: req.id || uuid(),
38
+ clientIp: resolveClientIp(req),
39
+ protocol: 'anthropic',
40
+ endpoint: 'messages',
41
+ requestedModel: body.model,
42
+ key,
43
+ reply,
44
+ };
45
+ try {
46
+ const outcome = await runner.execute({ canonical, protocol: 'anthropic', endpoint: 'messages' }, ctx);
47
+ if (body.stream) {
48
+ if (reply.raw.headersSent || reply.raw.writableEnded) {
49
+ reply.hijack();
50
+ return reply;
51
+ }
52
+ if (!outcome.success) {
53
+ const g = new GatewayError(outcome.errorType ?? 'gateway_error', outcome.errorMessage ?? 'Gateway error', { status: outcome.httpStatus });
54
+ reply.code(outcome.httpStatus).send(toAnthropicError(g, ctx.requestId));
55
+ return;
56
+ }
57
+ reply.type('text/event-stream').send('data: [DONE]\n\n');
58
+ return;
59
+ }
60
+ if (!outcome.success) {
61
+ const g = new GatewayError(outcome.errorType ?? 'gateway_error', outcome.errorMessage ?? 'Gateway error', { status: outcome.httpStatus });
62
+ reply.code(outcome.httpStatus).send(toAnthropicError(g, ctx.requestId));
63
+ return;
64
+ }
65
+ reply.header('x-request-id', ctx.requestId);
66
+ reply.send({
67
+ id: `msg_${ctx.requestId}`,
68
+ type: 'message',
69
+ role: 'assistant',
70
+ model: body.model,
71
+ content: [
72
+ ...(outcome.text ? [{ type: 'text', text: outcome.text }] : []),
73
+ ...(outcome.toolCalls ?? []).map((tc) => ({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.input })),
74
+ ],
75
+ stop_reason: outcome.finishReason,
76
+ usage: {
77
+ input_tokens: outcome.usage.input,
78
+ output_tokens: outcome.usage.output,
79
+ ...(outcome.usage.cacheRead ? { cache_read_input_tokens: outcome.usage.cacheRead } : {}),
80
+ ...(outcome.usage.cacheWrite ? { cache_creation_input_tokens: outcome.usage.cacheWrite } : {}),
81
+ },
82
+ });
83
+ }
84
+ catch (e) {
85
+ if (e instanceof GatewayError) {
86
+ reply.code(e.status).send(toAnthropicError(e, ctx.requestId));
87
+ return;
88
+ }
89
+ throw e;
90
+ }
91
+ });
92
+ app.post('/v1/messages/count_tokens', async (req, reply) => {
93
+ const body = CountTokensBody.parse(req.body);
94
+ const ar = body;
95
+ const canonical = anthropicToCanonical(ar);
96
+ // Estimate: 1 token per 4 chars, conservative for v1 without provider call.
97
+ const json = JSON.stringify(canonical.messages) + (canonical.system ?? '');
98
+ const inputTokens = Math.ceil(json.length / 4);
99
+ reply.header('x-request-id', req.id);
100
+ reply.send({ input_tokens: inputTokens });
101
+ });
102
+ }
103
+ function authenticateGatewayHeaders(req) {
104
+ const key = authenticateGatewayKey(req);
105
+ if (!key)
106
+ throw new GatewayError('authentication_error', 'Missing API key', { status: 401 });
107
+ if (!key.enabled)
108
+ throw new GatewayError('authentication_error', 'API key disabled', { status: 401 });
109
+ if (key.expiresAt && new Date(key.expiresAt).getTime() < Date.now())
110
+ throw new GatewayError('authentication_error', 'API key expired', { status: 401 });
111
+ return key;
112
+ }
@@ -0,0 +1,257 @@
1
+ // OpenAI-compatible gateway routes: /v1/models, /v1/chat/completions, /v1/responses.
2
+ import { z } from 'zod';
3
+ import { eq, inArray } from 'drizzle-orm';
4
+ import { getDb, schema } from '../../db/index.js';
5
+ import { authenticateGatewayKey } from '../../auth/api-key.js';
6
+ import { resolveClientIp } from '../../util/client-ip.js';
7
+ import { openAIToCanonical, openAIModelList } from '../../protocols/canonical.js';
8
+ import { GatewayError, toOpenAIError } from '../../errors.js';
9
+ import { GatewayRunner } from '../../gateway/runner.js';
10
+ import { uuid } from '../../auth/ids.js';
11
+ const ChatBody = z.object({
12
+ model: z.string().min(1),
13
+ messages: z.array(z.any()).min(1),
14
+ tools: z.array(z.any()).optional(),
15
+ tool_choice: z.any().optional(),
16
+ stream: z.boolean().optional(),
17
+ temperature: z.number().optional(),
18
+ top_p: z.number().optional(),
19
+ max_tokens: z.number().int().min(1).optional(),
20
+ stop: z.union([z.array(z.string()), z.string()]).optional(),
21
+ response_format: z.any().optional(),
22
+ reasoning_effort: z.enum(['low', 'medium', 'high']).optional(),
23
+ });
24
+ const ResponsesBody = z.object({
25
+ model: z.string().min(1),
26
+ input: z.any(),
27
+ stream: z.boolean().optional(),
28
+ // Accept and pass through; full Responses support is a v1 subset.
29
+ });
30
+ export async function registerOpenAIRoutes(app) {
31
+ const runner = new GatewayRunner();
32
+ app.get('/v1/models', async (req) => {
33
+ const key = authenticateGatewayHeaders(req);
34
+ const db = getDb();
35
+ let models = db.select().from(schema.models).all();
36
+ if (!key.allowAllModels) {
37
+ const perms = db.select().from(schema.apiKeyModelPermissions).where(eq(schema.apiKeyModelPermissions.apiKeyId, key.id)).all();
38
+ const allowedModelIds = new Set(perms.filter((p) => p.targetKind === 'model').map((p) => p.targetId));
39
+ models = models.filter((m) => allowedModelIds.has(m.id) || (m.enabled && m.upstreamAvailable));
40
+ }
41
+ else {
42
+ models = models.filter((m) => m.enabled && m.upstreamAvailable);
43
+ }
44
+ const ids = listRoutableModelIds(models, key, db);
45
+ return openAIModelList(ids.map((id) => ({ publicModelId: id, upstreamModelId: id })));
46
+ });
47
+ app.post('/v1/chat/completions', async (req, reply) => {
48
+ const key = authenticateGatewayHeaders(req);
49
+ const body = ChatBody.parse(req.body);
50
+ const req1 = body;
51
+ const canonical = openAIToCanonical(req1);
52
+ const ctx = {
53
+ requestId: req.id || uuid(),
54
+ clientIp: resolveClientIp(req),
55
+ protocol: 'openai',
56
+ endpoint: 'chat/completions',
57
+ requestedModel: body.model,
58
+ key,
59
+ reply,
60
+ };
61
+ try {
62
+ const outcome = await runner.execute({ canonical, protocol: 'openai', endpoint: 'chat/completions' }, ctx);
63
+ if (req1.stream) {
64
+ // The runner streams to reply.raw once the upstream commits. If the raw
65
+ // response has begun (head sent or ended), it's already fully handled —
66
+ // hijack so Fastify does not append its own reply.
67
+ if (reply.raw.headersSent || reply.raw.writableEnded) {
68
+ reply.hijack();
69
+ return reply;
70
+ }
71
+ // Nothing was streamed: the upstream failed before the first chunk, so
72
+ // the client should get a regular protocol error instead of a dangling
73
+ // stream.
74
+ if (!outcome.success) {
75
+ const g = new GatewayError(outcome.errorType ?? 'gateway_error', outcome.errorMessage ?? 'Gateway error', { status: outcome.httpStatus });
76
+ reply.code(outcome.httpStatus).send(toOpenAIError(g, ctx.requestId));
77
+ return;
78
+ }
79
+ // Success with no streamed chunks — terminate the SSE cleanly.
80
+ reply.type('text/event-stream').send('data: [DONE]\n\n');
81
+ return;
82
+ }
83
+ if (!outcome.success) {
84
+ const g = new GatewayError(outcome.errorType ?? 'gateway_error', outcome.errorMessage ?? 'Gateway error', { status: outcome.httpStatus });
85
+ reply.code(outcome.httpStatus).send(toOpenAIError(g, ctx.requestId));
86
+ return;
87
+ }
88
+ reply.header('x-request-id', ctx.requestId);
89
+ reply.send({
90
+ id: `chatcmpl-${ctx.requestId}`,
91
+ object: 'chat.completion',
92
+ created: Math.floor(Date.now() / 1000),
93
+ model: body.model,
94
+ choices: [
95
+ {
96
+ index: 0,
97
+ message: {
98
+ role: 'assistant',
99
+ content: outcome.text,
100
+ ...(outcome.toolCalls && outcome.toolCalls.length
101
+ ? { tool_calls: outcome.toolCalls.map((tc) => ({ id: tc.id, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.input) } })) }
102
+ : {}),
103
+ },
104
+ finish_reason: outcome.finishReason,
105
+ },
106
+ ],
107
+ usage: {
108
+ prompt_tokens: outcome.usage.input,
109
+ completion_tokens: outcome.usage.output,
110
+ total_tokens: outcome.usage.total,
111
+ ...(outcome.usage.cacheRead ? { prompt_tokens_details: { cached_tokens: outcome.usage.cacheRead } } : {}),
112
+ ...(outcome.usage.reasoning ? { completion_tokens_details: { reasoning_tokens: outcome.usage.reasoning } } : {}),
113
+ },
114
+ });
115
+ }
116
+ catch (e) {
117
+ if (e instanceof GatewayError) {
118
+ reply.code(e.status).send(toOpenAIError(e, ctx.requestId));
119
+ return;
120
+ }
121
+ throw e;
122
+ }
123
+ });
124
+ app.post('/v1/responses', async (req, reply) => {
125
+ const key = authenticateGatewayHeaders(req);
126
+ // v1 subset: accept Responses-style input, flatten to chat-completions messages.
127
+ const body = ResponsesBody.parse(req.body);
128
+ const flat = responsesInputToChat(body.input);
129
+ const chatBody = {
130
+ model: body.model,
131
+ messages: flat.messages,
132
+ tools: flat.tools,
133
+ stream: body.stream,
134
+ };
135
+ const canonical = openAIToCanonical(chatBody);
136
+ const ctx = {
137
+ requestId: req.id || uuid(),
138
+ clientIp: resolveClientIp(req),
139
+ protocol: 'openai',
140
+ endpoint: 'responses',
141
+ requestedModel: body.model,
142
+ key,
143
+ reply,
144
+ };
145
+ try {
146
+ const outcome = await runner.execute({ canonical, protocol: 'openai', endpoint: 'responses' }, ctx);
147
+ if (body.stream) {
148
+ if (reply.raw.headersSent || reply.raw.writableEnded) {
149
+ reply.hijack();
150
+ return reply;
151
+ }
152
+ if (!outcome.success) {
153
+ const g = new GatewayError(outcome.errorType ?? 'gateway_error', outcome.errorMessage ?? 'Gateway error', { status: outcome.httpStatus });
154
+ reply.code(outcome.httpStatus).send(toOpenAIError(g, ctx.requestId));
155
+ return;
156
+ }
157
+ reply.type('text/event-stream').send('data: [DONE]\n\n');
158
+ return;
159
+ }
160
+ if (!outcome.success) {
161
+ const g = new GatewayError(outcome.errorType ?? 'gateway_error', outcome.errorMessage ?? 'Gateway error', { status: outcome.httpStatus });
162
+ reply.code(outcome.httpStatus).send(toOpenAIError(g, ctx.requestId));
163
+ return;
164
+ }
165
+ reply.header('x-request-id', ctx.requestId);
166
+ reply.send({
167
+ id: `resp-${ctx.requestId}`,
168
+ object: 'response',
169
+ created_at: Math.floor(Date.now() / 1000),
170
+ model: body.model,
171
+ output: [
172
+ {
173
+ type: 'message',
174
+ role: 'assistant',
175
+ content: [{ type: 'output_text', text: outcome.text ?? '' }],
176
+ },
177
+ ],
178
+ usage: {
179
+ input_tokens: outcome.usage.input,
180
+ output_tokens: outcome.usage.output,
181
+ total_tokens: outcome.usage.total,
182
+ },
183
+ });
184
+ }
185
+ catch (e) {
186
+ if (e instanceof GatewayError) {
187
+ reply.code(e.status).send(toOpenAIError(e, ctx.requestId));
188
+ return;
189
+ }
190
+ throw e;
191
+ }
192
+ });
193
+ }
194
+ export function listRoutableModelIds(models, key, db) {
195
+ const modelIds = new Set(models.map((m) => m.id));
196
+ const modelById = new Map(models.map((m) => [m.id, m.publicModelId]));
197
+ let combos = db.select().from(schema.combos).where(eq(schema.combos.enabled, true)).all();
198
+ const members = combos.length > 0
199
+ ? db.select().from(schema.comboMembers).where(inArray(schema.comboMembers.comboId, combos.map((c) => c.id))).all()
200
+ : [];
201
+ const memberByCombo = new Map();
202
+ for (const mm of members) {
203
+ if (!memberByCombo.has(mm.comboId))
204
+ memberByCombo.set(mm.comboId, new Set());
205
+ memberByCombo.get(mm.comboId).add(mm.modelId);
206
+ }
207
+ // A combo with no routable members would always fail at runtime — hide it.
208
+ combos = combos.filter((c) => {
209
+ const ids = memberByCombo.get(c.id);
210
+ return ids !== undefined && ids.size > 0 && [...ids].some((id) => modelIds.has(id));
211
+ });
212
+ let aliases = db.select().from(schema.modelAliases).where(eq(schema.modelAliases.enabled, true)).all();
213
+ aliases = aliases.filter((a) => {
214
+ if (a.targetKind === 'model')
215
+ return modelById.has(a.targetId);
216
+ return combos.some((c) => c.id === a.targetId);
217
+ });
218
+ const ids = models.map((m) => m.publicModelId).concat(combos.map((c) => c.publicModelId), aliases.map((a) => a.alias));
219
+ if (key.allowAllModels)
220
+ return ids;
221
+ const perms = db.select().from(schema.apiKeyModelPermissions).where(eq(schema.apiKeyModelPermissions.apiKeyId, key.id)).all();
222
+ const allowModels = new Set(perms.filter((p) => p.targetKind === 'model').map((p) => p.targetId));
223
+ const allowCombos = new Set(perms.filter((p) => p.targetKind === 'combo').map((p) => p.targetId));
224
+ return ids.filter((id) => models.some((m) => m.publicModelId === id && allowModels.has(m.id))
225
+ || combos.some((c) => c.publicModelId === id && allowCombos.has(c.id))
226
+ || aliases.some((a) => a.alias === id && (a.targetKind === 'model' ? allowModels.has(a.targetId) : allowCombos.has(a.targetId))));
227
+ }
228
+ function authenticateGatewayHeaders(req) {
229
+ const key = authenticateGatewayKey(req);
230
+ if (!key)
231
+ throw new GatewayError('authentication_error', 'Missing API key', { status: 401 });
232
+ if (!key.enabled)
233
+ throw new GatewayError('authentication_error', 'API key disabled', { status: 401 });
234
+ if (key.expiresAt && new Date(key.expiresAt).getTime() < Date.now())
235
+ throw new GatewayError('authentication_error', 'API key expired', { status: 401 });
236
+ return key;
237
+ }
238
+ function responsesInputToChat(input) {
239
+ // Minimal Responses-to-Chat mapping (v1 subset).
240
+ if (typeof input === 'string')
241
+ return { messages: [{ role: 'user', content: input }] };
242
+ if (Array.isArray(input)) {
243
+ const messages = [];
244
+ for (const item of input) {
245
+ if (item && typeof item === 'object' && 'role' in item && 'content' in item) {
246
+ messages.push({ role: item.role, content: item.content });
247
+ }
248
+ else if (item && typeof item === 'object' && 'type' in item) {
249
+ const t = item.type;
250
+ if (t === 'message')
251
+ messages.push({ role: (item.role ?? 'user'), content: item.content });
252
+ }
253
+ }
254
+ return { messages };
255
+ }
256
+ return { messages: [{ role: 'user', content: '' }] };
257
+ }
@@ -0,0 +1,7 @@
1
+ // Gateway public API routes (mounted at /v1/*). Auth via ld-.. API keys.
2
+ import { registerOpenAIRoutes } from './gateway/openai.js';
3
+ import { registerAnthropicRoutes } from './gateway/anthropic.js';
4
+ export async function registerGatewayRoutes(app) {
5
+ await registerOpenAIRoutes(app);
6
+ await registerAnthropicRoutes(app);
7
+ }
@@ -0,0 +1,27 @@
1
+ // Operational routes: /health, /ready, /metrics.
2
+ import { getDb, schema } from '../db/index.js';
3
+ import { sql } from 'drizzle-orm';
4
+ import { loadConfig } from '../config/index.js';
5
+ import { metricsRegistry } from '../metrics/registry.js';
6
+ export async function registerHealthRoutes(app) {
7
+ app.get('/health', async () => {
8
+ return { status: 'ok', version: loadConfig().appVersion };
9
+ });
10
+ app.get('/ready', async (_req, reply) => {
11
+ try {
12
+ const db = getDb();
13
+ const row = db.select({ c: sql `1` }).from(schema.appSettings).get();
14
+ if (!row) {
15
+ reply.code(503).send({ status: 'not_ready', reason: 'settings_missing' });
16
+ return;
17
+ }
18
+ return { status: 'ready' };
19
+ }
20
+ catch (e) {
21
+ reply.code(503).send({ status: 'not_ready', reason: 'db_error', detail: e.message });
22
+ }
23
+ });
24
+ app.get('/metrics', async (_req, reply) => {
25
+ reply.type('text/plain; version=0.0.4; charset=utf-8').send(metricsRegistry.render());
26
+ });
27
+ }
@@ -0,0 +1,52 @@
1
+ // Derive capability requirements from a canonical request.
2
+ export function deriveRequiredCapabilities(req) {
3
+ let tools = false;
4
+ let imageInput = false;
5
+ let audioInput = false;
6
+ for (const m of req.messages) {
7
+ for (const b of m.content ?? []) {
8
+ if (b.type === 'image')
9
+ imageInput = true;
10
+ if (b.type === 'audio')
11
+ audioInput = true;
12
+ }
13
+ }
14
+ if (req.tools && req.tools.length > 0)
15
+ tools = true;
16
+ // tool_use/tool_result blocks imply tool calling
17
+ for (const m of req.messages) {
18
+ for (const b of m.content ?? []) {
19
+ if (b.type === 'tool_use' || b.type === 'tool_result')
20
+ tools = true;
21
+ }
22
+ }
23
+ let structuredOutput = false;
24
+ if (req.responseFormat && (req.responseFormat.type === 'json_object' || req.responseFormat.type === 'json_schema'))
25
+ structuredOutput = true;
26
+ return {
27
+ streaming: Boolean(req.stream),
28
+ tools,
29
+ structuredOutput,
30
+ imageInput,
31
+ audioInput,
32
+ reasoning: Boolean(req.reasoning),
33
+ responses: false,
34
+ };
35
+ }
36
+ export function modelMeets(caps, req) {
37
+ if (req.streaming && !caps.streaming)
38
+ return false;
39
+ if (req.tools && !caps.tools)
40
+ return false;
41
+ if (req.structuredOutput && !caps.structured_output)
42
+ return false;
43
+ if (req.imageInput && !caps.image_input)
44
+ return false;
45
+ if (req.audioInput && !caps.audio_input)
46
+ return false;
47
+ if (req.reasoning && !caps.reasoning)
48
+ return false;
49
+ if (req.responses && !caps.responses)
50
+ return false;
51
+ return true;
52
+ }
@@ -0,0 +1,37 @@
1
+ // In-memory circuit breaker state per provider.
2
+ const store = new Map();
3
+ export function circuitState(providerId) {
4
+ const s = store.get(providerId);
5
+ if (!s)
6
+ return 'closed';
7
+ return s.state;
8
+ }
9
+ export function isOpen(providerId) {
10
+ return circuitState(providerId) === 'open';
11
+ }
12
+ export function recordSuccess(providerId) {
13
+ store.set(providerId, { state: 'closed', consecutiveFailures: 0, openedAt: 0 });
14
+ }
15
+ export function recordFailure(providerId, threshold, _cooldownSeconds) {
16
+ const s = store.get(providerId) ?? { state: 'closed', consecutiveFailures: 0, openedAt: 0 };
17
+ s.consecutiveFailures += 1;
18
+ if (s.state === 'half_open' || s.consecutiveFailures >= threshold) {
19
+ s.state = 'open';
20
+ s.openedAt = Date.now();
21
+ // Auto-transition to half-open after cooldown via getEffectiveState
22
+ }
23
+ store.set(providerId, s);
24
+ }
25
+ export function getEffectiveState(providerId, cooldownSeconds) {
26
+ const s = store.get(providerId);
27
+ if (!s)
28
+ return 'closed';
29
+ if (s.state === 'open' && Date.now() - s.openedAt > cooldownSeconds * 1000) {
30
+ s.state = 'half_open';
31
+ store.set(providerId, s);
32
+ }
33
+ return s.state;
34
+ }
35
+ export function halfOpenProbeAllowed(providerId) {
36
+ return circuitState(providerId) === 'half_open';
37
+ }
@@ -0,0 +1,100 @@
1
+ // Combo routing: fallback (ordered) or weighted round-robin.
2
+ import { eq } from 'drizzle-orm';
3
+ import { getDb, schema } from '../db/index.js';
4
+ import { modelMeets } from './capabilities.js';
5
+ export function loadCombo(comboId) {
6
+ const db = getDb();
7
+ const c = db.select().from(schema.combos).where(eq(schema.combos.id, comboId)).get();
8
+ if (!c)
9
+ return null;
10
+ const members = db.select().from(schema.comboMembers).where(eq(schema.comboMembers.comboId, comboId)).all();
11
+ return {
12
+ comboId: c.id,
13
+ mode: c.mode,
14
+ maxTotalAttempts: c.maxTotalAttempts,
15
+ members: members.map((m) => ({ id: m.id, modelId: m.modelId, position: m.position, weight: m.weight, enabled: m.enabled })),
16
+ trigger: {
17
+ connection: c.fallbackOnConnection,
18
+ connectTimeout: c.fallbackOnConnectTimeout,
19
+ firstTokenTimeout: c.fallbackOnFirstTokenTimeout,
20
+ on408: c.fallbackOn408,
21
+ on429: c.fallbackOn429,
22
+ on5xx: c.fallbackOn5xx,
23
+ },
24
+ };
25
+ }
26
+ export function selectCandidates(combo, allModels, req) {
27
+ // Resolve each combo member to a candidate and apply filters
28
+ const map = new Map(allModels.map((m) => [m.modelId, m]));
29
+ const candidates = [];
30
+ for (const m of combo.members) {
31
+ if (!m.enabled)
32
+ continue;
33
+ const c = map.get(m.modelId);
34
+ if (!c)
35
+ continue;
36
+ if (!c.enabled)
37
+ continue;
38
+ if (!c.upstreamAvailable)
39
+ continue;
40
+ if (c.circuitOpen)
41
+ continue;
42
+ if (!modelMeets(c.capabilities, req))
43
+ continue;
44
+ candidates.push(c);
45
+ }
46
+ return candidates;
47
+ }
48
+ export function orderCandidates(combo, candidates) {
49
+ if (combo.mode === 'fallback') {
50
+ // Preserve declared position order
51
+ return [...candidates].sort((a, b) => {
52
+ const am = combo.members.find((m) => m.modelId === a.modelId);
53
+ const bm = combo.members.find((m) => m.modelId === b.modelId);
54
+ return (am?.position ?? 0) - (bm?.position ?? 0);
55
+ });
56
+ }
57
+ // Weighted round-robin: stable order with weighted lead bias.
58
+ // We rotate via a process-local cursor keyed by combo id.
59
+ const cursor = nextCursor(combo.comboId, candidates);
60
+ return cursor;
61
+ }
62
+ const comboCursors = new Map();
63
+ function nextCursor(comboId, candidates) {
64
+ if (candidates.length === 0)
65
+ return [];
66
+ // Compute total weight of available candidates
67
+ const totalWeight = candidates.reduce((s, c) => {
68
+ const m = (candidates.find((x) => x.modelId === c.modelId));
69
+ void m;
70
+ return s + 1; // weight normalization happens upstream
71
+ }, 0);
72
+ void totalWeight;
73
+ // Simple modulo rotation for determinism in tests
74
+ const cur = (comboCursors.get(comboId) ?? 0) % candidates.length;
75
+ comboCursors.set(comboId, cur + 1);
76
+ return [...candidates.slice(cur), ...candidates.slice(0, cur)];
77
+ }
78
+ export function shouldFallback(combo, reason) {
79
+ switch (reason.type) {
80
+ case 'connection_error':
81
+ return combo.trigger.connection;
82
+ case 'connect_timeout':
83
+ return combo.trigger.connectTimeout;
84
+ case 'first_token_timeout':
85
+ return combo.trigger.firstTokenTimeout;
86
+ case 'http_status':
87
+ if (reason.status === 408)
88
+ return combo.trigger.on408;
89
+ if (reason.status === 429)
90
+ return combo.trigger.on429;
91
+ if (reason.status && reason.status >= 500 && reason.status < 600)
92
+ return combo.trigger.on5xx;
93
+ return false;
94
+ case 'stream_partial':
95
+ // Per spec: never fallback after stream content has been sent.
96
+ return false;
97
+ default:
98
+ return false;
99
+ }
100
+ }
@@ -0,0 +1,51 @@
1
+ // Daily / monthly token quota enforcement using usage_daily/usage_monthly tables.
2
+ import { and, eq, sql } from 'drizzle-orm';
3
+ import { getDb, schema } from '../db/index.js';
4
+ export function checkDailyMonthly(keyId, dailyLimit, monthlyLimit, tokens) {
5
+ const db = getDb();
6
+ const now = new Date();
7
+ const day = now.toISOString().slice(0, 10);
8
+ const month = now.toISOString().slice(0, 7);
9
+ if (dailyLimit != null && dailyLimit > 0) {
10
+ const row = db.select({ t: schema.usageDaily.totalTokens }).from(schema.usageDaily).where(and(eq(schema.usageDaily.day, day), eq(schema.usageDaily.apiKeyId, keyId))).get();
11
+ const used = row?.t ?? 0;
12
+ if (used + tokens > dailyLimit)
13
+ return { allowed: false, reason: 'daily' };
14
+ }
15
+ if (monthlyLimit != null && monthlyLimit > 0) {
16
+ const row = db.select({ t: schema.usageMonthly.totalTokens }).from(schema.usageMonthly).where(and(eq(schema.usageMonthly.month, month), eq(schema.usageMonthly.apiKeyId, keyId))).get();
17
+ const used = row?.t ?? 0;
18
+ if (used + tokens > monthlyLimit)
19
+ return { allowed: false, reason: 'monthly' };
20
+ }
21
+ return { allowed: true };
22
+ }
23
+ export function consumeUsage(keyId, inputTokens, outputTokens) {
24
+ const db = getDb();
25
+ const now = new Date();
26
+ const day = now.toISOString().slice(0, 10);
27
+ const month = now.toISOString().slice(0, 7);
28
+ const total = inputTokens + outputTokens;
29
+ db.insert(schema.usageDaily)
30
+ .values({ day, apiKeyId: keyId, inputTokens, outputTokens, totalTokens: total })
31
+ .onConflictDoUpdate({
32
+ target: [schema.usageDaily.day, schema.usageDaily.apiKeyId],
33
+ set: {
34
+ inputTokens: sql `input_tokens + ${inputTokens}`,
35
+ outputTokens: sql `output_tokens + ${outputTokens}`,
36
+ totalTokens: sql `total_tokens + ${total}`,
37
+ },
38
+ })
39
+ .run();
40
+ db.insert(schema.usageMonthly)
41
+ .values({ month, apiKeyId: keyId, inputTokens, outputTokens, totalTokens: total })
42
+ .onConflictDoUpdate({
43
+ target: [schema.usageMonthly.month, schema.usageMonthly.apiKeyId],
44
+ set: {
45
+ inputTokens: sql `input_tokens + ${inputTokens}`,
46
+ outputTokens: sql `output_tokens + ${outputTokens}`,
47
+ totalTokens: sql `total_tokens + ${total}`,
48
+ },
49
+ })
50
+ .run();
51
+ }