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.
- package/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/dist/cli.js +13 -0
- package/dist/server/app.js +138 -0
- package/dist/server/auth/api-key.js +75 -0
- package/dist/server/auth/crypto.js +94 -0
- package/dist/server/auth/ids.js +40 -0
- package/dist/server/auth/middleware.js +36 -0
- package/dist/server/auth/recovery.js +11 -0
- package/dist/server/caching/store.js +119 -0
- package/dist/server/config/index.js +96 -0
- package/dist/server/db/index.js +64 -0
- package/dist/server/db/migrate.js +408 -0
- package/dist/server/db/repositories/audit.js +75 -0
- package/dist/server/db/repositories/settings.js +63 -0
- package/dist/server/db/schema.js +396 -0
- package/dist/server/errors.js +65 -0
- package/dist/server/gateway/runner.js +745 -0
- package/dist/server/logging/logger.js +35 -0
- package/dist/server/maintenance/retention.js +48 -0
- package/dist/server/metrics/registry.js +169 -0
- package/dist/server/protocols/anthropic.js +154 -0
- package/dist/server/protocols/canonical.js +201 -0
- package/dist/server/providers/index.js +89 -0
- package/dist/server/routes/admin/aliases.js +98 -0
- package/dist/server/routes/admin/api-keys.js +194 -0
- package/dist/server/routes/admin/audit.js +19 -0
- package/dist/server/routes/admin/auth.js +124 -0
- package/dist/server/routes/admin/backup.js +113 -0
- package/dist/server/routes/admin/combos.js +198 -0
- package/dist/server/routes/admin/dashboard.js +55 -0
- package/dist/server/routes/admin/models.js +178 -0
- package/dist/server/routes/admin/providers.js +212 -0
- package/dist/server/routes/admin/requests.js +156 -0
- package/dist/server/routes/admin/settings.js +197 -0
- package/dist/server/routes/admin/setup.js +80 -0
- package/dist/server/routes/admin/stats.js +180 -0
- package/dist/server/routes/admin.js +39 -0
- package/dist/server/routes/gateway/anthropic.js +112 -0
- package/dist/server/routes/gateway/openai.js +257 -0
- package/dist/server/routes/gateway.js +7 -0
- package/dist/server/routes/health.js +27 -0
- package/dist/server/routing/capabilities.js +52 -0
- package/dist/server/routing/circuit.js +37 -0
- package/dist/server/routing/combo.js +100 -0
- package/dist/server/routing/quota.js +51 -0
- package/dist/server/routing/ratelimit.js +58 -0
- package/dist/server/routing/resolver.js +43 -0
- package/dist/server/security/redact.js +111 -0
- package/dist/server/selfupdate/index.js +154 -0
- package/dist/server/upstream/client.js +179 -0
- package/dist/server/util/cidr.js +91 -0
- package/dist/server/util/client-ip.js +15 -0
- package/dist/server/util/stable-json.js +19 -0
- package/dist/shared/types.js +2 -0
- package/dist/web/assets/index-COSbvF8Z.css +1 -0
- package/dist/web/assets/index-DbnEzuxq.js +251 -0
- package/dist/web/favicon.png +0 -0
- package/dist/web/index.html +15 -0
- package/dist/web/logo.png +0 -0
- package/migrations/0001_initial_schema.sql +323 -0
- package/migrations/0002_source_api_key_secrets.sql +7 -0
- package/package.json +117 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
import { loadConfig } from '../config/index.js';
|
|
3
|
+
let _logger = null;
|
|
4
|
+
export function getLogger() {
|
|
5
|
+
if (_logger)
|
|
6
|
+
return _logger;
|
|
7
|
+
const cfg = loadConfig();
|
|
8
|
+
_logger = pino({
|
|
9
|
+
level: cfg.logLevel,
|
|
10
|
+
base: { app: 'latedev-router', version: cfg.appVersion },
|
|
11
|
+
redact: {
|
|
12
|
+
paths: [
|
|
13
|
+
'req.headers.authorization',
|
|
14
|
+
'req.headers["x-api-key"]',
|
|
15
|
+
'req.headers.cookie',
|
|
16
|
+
'res.headers["set-cookie"]',
|
|
17
|
+
'apiKey',
|
|
18
|
+
'apiKeyPlain',
|
|
19
|
+
'provider.apiKey',
|
|
20
|
+
'provider.encrypted_api_key',
|
|
21
|
+
'masterKey',
|
|
22
|
+
'totpSecret',
|
|
23
|
+
'recoveryCodes',
|
|
24
|
+
'password',
|
|
25
|
+
'currentPassword',
|
|
26
|
+
'newPassword',
|
|
27
|
+
],
|
|
28
|
+
censor: '[redacted]',
|
|
29
|
+
},
|
|
30
|
+
transport: cfg.env === 'development'
|
|
31
|
+
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:HH:MM:ss' } }
|
|
32
|
+
: undefined,
|
|
33
|
+
});
|
|
34
|
+
return _logger;
|
|
35
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Retention cleanup: chunked deletion of old request logs.
|
|
2
|
+
// Audit logs are NEVER deleted here.
|
|
3
|
+
import { lt, sql } from 'drizzle-orm';
|
|
4
|
+
import { getDb, schema } from '../db/index.js';
|
|
5
|
+
import { getSettings } from '../db/repositories/settings.js';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
const BATCH_SIZE = 1000;
|
|
9
|
+
export function runRetentionCleanup(now = new Date(), opts = {}) {
|
|
10
|
+
const s = getSettings();
|
|
11
|
+
const cutoff = new Date(now.getTime() - s.retentionDays * 24 * 3600 * 1000).toISOString();
|
|
12
|
+
const db = getDb();
|
|
13
|
+
let deletedAttempts = 0;
|
|
14
|
+
let deletedRequests = 0;
|
|
15
|
+
if (opts.dryRun) {
|
|
16
|
+
const row = db.select({ c: sql `COUNT(*)` }).from(schema.requests).where(lt(schema.requests.createdAt, cutoff)).get();
|
|
17
|
+
return { deletedRequests: Number(row?.c ?? 0), deletedAttempts: 0, dryRun: true, cutoff };
|
|
18
|
+
}
|
|
19
|
+
// Cascade delete attempts
|
|
20
|
+
while (true) {
|
|
21
|
+
const rows = db
|
|
22
|
+
.select({ id: schema.requests.id })
|
|
23
|
+
.from(schema.requests)
|
|
24
|
+
.where(lt(schema.requests.createdAt, cutoff))
|
|
25
|
+
.limit(BATCH_SIZE)
|
|
26
|
+
.all();
|
|
27
|
+
if (rows.length === 0)
|
|
28
|
+
break;
|
|
29
|
+
const ids = rows.map((r) => r.id);
|
|
30
|
+
const del = db.delete(schema.requests).where(sql `id IN (${sql.join(ids.map((i) => sql `${i}`), sql `, `)})`).run();
|
|
31
|
+
deletedRequests += del.changes ?? 0;
|
|
32
|
+
if (rows.length < BATCH_SIZE)
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
return { deletedRequests, deletedAttempts, dryRun: false, cutoff };
|
|
36
|
+
}
|
|
37
|
+
export function applyDbSizeGuard(dataDir) {
|
|
38
|
+
const s = getSettings();
|
|
39
|
+
const dbFile = path.join(dataDir, 'data.sqlite');
|
|
40
|
+
const walFile = path.join(dataDir, 'data.sqlite-wal');
|
|
41
|
+
const before = (fs.existsSync(dbFile) ? fs.statSync(dbFile).size : 0) + (fs.existsSync(walFile) ? fs.statSync(walFile).size : 0);
|
|
42
|
+
const limitBytes = s.dbSizeLimitMb * 1024 * 1024;
|
|
43
|
+
if (before <= limitBytes)
|
|
44
|
+
return { triggered: false, before, after: before };
|
|
45
|
+
const result = runRetentionCleanup();
|
|
46
|
+
const after = (fs.existsSync(dbFile) ? fs.statSync(dbFile).size : 0) + (fs.existsSync(walFile) ? fs.statSync(walFile).size : 0);
|
|
47
|
+
return { triggered: result.deletedRequests > 0, before, after };
|
|
48
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Prometheus-style metrics registry. Lightweight, no external dep.
|
|
2
|
+
class CounterImpl {
|
|
3
|
+
name;
|
|
4
|
+
help;
|
|
5
|
+
series = [];
|
|
6
|
+
constructor(name, help) {
|
|
7
|
+
this.name = name;
|
|
8
|
+
this.help = help;
|
|
9
|
+
}
|
|
10
|
+
inc(labels = {}, n = 1) {
|
|
11
|
+
const found = this.series.find((s) => sameLabels(s.labels, labels));
|
|
12
|
+
if (found)
|
|
13
|
+
found.value += n;
|
|
14
|
+
else
|
|
15
|
+
this.series.push({ labels, value: n });
|
|
16
|
+
registryMetrics.push(this);
|
|
17
|
+
}
|
|
18
|
+
value(labels = {}) {
|
|
19
|
+
const found = this.series.find((s) => sameLabels(s.labels, labels));
|
|
20
|
+
return found?.value ?? 0;
|
|
21
|
+
}
|
|
22
|
+
render() {
|
|
23
|
+
if (this.series.length === 0)
|
|
24
|
+
return `# HELP ${this.name} ${this.help}\n# TYPE ${this.name} counter\n`;
|
|
25
|
+
return `# HELP ${this.name} ${this.help}\n# TYPE ${this.name} counter\n${this.series
|
|
26
|
+
.map((s) => `${this.name}${labelString(s.labels)} ${s.value}`)
|
|
27
|
+
.join('\n')}\n`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const DEFAULT_BUCKETS_MS = [10, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000, 120000, 300000];
|
|
31
|
+
class HistogramImpl {
|
|
32
|
+
name;
|
|
33
|
+
help;
|
|
34
|
+
buckets;
|
|
35
|
+
series = [];
|
|
36
|
+
constructor(name, help, buckets) {
|
|
37
|
+
this.name = name;
|
|
38
|
+
this.help = help;
|
|
39
|
+
this.buckets = buckets;
|
|
40
|
+
}
|
|
41
|
+
observe(valueMs, labels = {}) {
|
|
42
|
+
let s = this.series.find((x) => sameLabels(x.labels, labels));
|
|
43
|
+
if (!s) {
|
|
44
|
+
s = { labels, buckets: [...this.buckets], counts: new Array(this.buckets.length).fill(0), count: 0, sum: 0 };
|
|
45
|
+
this.series.push(s);
|
|
46
|
+
}
|
|
47
|
+
s.count += 1;
|
|
48
|
+
s.sum += valueMs;
|
|
49
|
+
for (let i = 0; i < this.buckets.length; i++) {
|
|
50
|
+
if (valueMs <= this.buckets[i])
|
|
51
|
+
s.counts[i] += 1;
|
|
52
|
+
}
|
|
53
|
+
registryMetrics.push(this);
|
|
54
|
+
}
|
|
55
|
+
snapshot() {
|
|
56
|
+
return this.series.map((s) => ({
|
|
57
|
+
buckets: s.buckets.map((le, i) => ({ le, count: s.counts[i] })),
|
|
58
|
+
count: s.count,
|
|
59
|
+
sum: s.sum,
|
|
60
|
+
labels: Object.keys(s.labels),
|
|
61
|
+
}))[0] ?? { buckets: [], count: 0, sum: 0, labels: [] };
|
|
62
|
+
}
|
|
63
|
+
render() {
|
|
64
|
+
if (this.series.length === 0)
|
|
65
|
+
return `# HELP ${this.name} ${this.help}\n# TYPE ${this.name} histogram\n`;
|
|
66
|
+
let out = `# HELP ${this.name} ${this.help}\n# TYPE ${this.name} histogram\n`;
|
|
67
|
+
for (const s of this.series) {
|
|
68
|
+
for (let i = 0; i < s.buckets.length; i++) {
|
|
69
|
+
const count = s.counts[i];
|
|
70
|
+
out += `${this.name}_bucket{${labelString({ ...s.labels, le: String(s.buckets[i]) }, true)} ${count}\n`;
|
|
71
|
+
}
|
|
72
|
+
out += `${this.name}_bucket{${labelString({ ...s.labels, le: '+Inf' }, true)} ${s.count}\n`;
|
|
73
|
+
out += `${this.name}_count${labelString(s.labels)} ${s.count}\n`;
|
|
74
|
+
out += `${this.name}_sum${labelString(s.labels)} ${s.sum}\n`;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
class GaugeImpl {
|
|
80
|
+
name;
|
|
81
|
+
help;
|
|
82
|
+
series = [];
|
|
83
|
+
constructor(name, help) {
|
|
84
|
+
this.name = name;
|
|
85
|
+
this.help = help;
|
|
86
|
+
}
|
|
87
|
+
set(v, labels = {}) {
|
|
88
|
+
const f = this.series.find((s) => sameLabels(s.labels, labels));
|
|
89
|
+
if (f)
|
|
90
|
+
f.value = v;
|
|
91
|
+
else
|
|
92
|
+
this.series.push({ labels, value: v });
|
|
93
|
+
registryMetrics.push(this);
|
|
94
|
+
}
|
|
95
|
+
inc(labels = {}, n = 1) { this.set(this.value(labels) + n, labels); }
|
|
96
|
+
dec(labels = {}, n = 1) { this.set(this.value(labels) - n, labels); }
|
|
97
|
+
value(labels = {}) { return this.series.find((s) => sameLabels(s.labels, labels))?.value ?? 0; }
|
|
98
|
+
render() {
|
|
99
|
+
if (this.series.length === 0)
|
|
100
|
+
return `# HELP ${this.name} ${this.help}\n# TYPE ${this.name} gauge\n`;
|
|
101
|
+
return `# HELP ${this.name} ${this.help}\n# TYPE ${this.name} gauge\n${this.series
|
|
102
|
+
.map((s) => `${this.name}${labelString(s.labels)} ${s.value}`)
|
|
103
|
+
.join('\n')}\n`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function sameLabels(a, b) {
|
|
107
|
+
const ak = Object.keys(a);
|
|
108
|
+
const bk = Object.keys(b);
|
|
109
|
+
if (ak.length !== bk.length)
|
|
110
|
+
return false;
|
|
111
|
+
for (const k of ak)
|
|
112
|
+
if (a[k] !== b[k])
|
|
113
|
+
return false;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
function labelString(labels, skipEmpty = false) {
|
|
117
|
+
const keys = Object.keys(labels);
|
|
118
|
+
if (keys.length === 0)
|
|
119
|
+
return '';
|
|
120
|
+
const filtered = skipEmpty ? keys.filter((k) => labels[k] !== undefined) : keys;
|
|
121
|
+
if (filtered.length === 0)
|
|
122
|
+
return '';
|
|
123
|
+
return `{${filtered.map((k) => `${k}="${(labels[k] ?? '').replace(/"/g, '\\"')}"`).join(',')}}`;
|
|
124
|
+
}
|
|
125
|
+
const registryMetrics = [];
|
|
126
|
+
export const metricsRegistry = {
|
|
127
|
+
_logger: null,
|
|
128
|
+
init(logger) {
|
|
129
|
+
this._logger = logger;
|
|
130
|
+
},
|
|
131
|
+
counter(name, help) {
|
|
132
|
+
return new CounterImpl(name, help);
|
|
133
|
+
},
|
|
134
|
+
histogram(name, help, bucketsMs = DEFAULT_BUCKETS_MS) {
|
|
135
|
+
return new HistogramImpl(name, help, bucketsMs);
|
|
136
|
+
},
|
|
137
|
+
gauge(name, help) {
|
|
138
|
+
return new GaugeImpl(name, help);
|
|
139
|
+
},
|
|
140
|
+
render() {
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
let out = '';
|
|
143
|
+
for (const m of registryMetrics) {
|
|
144
|
+
if (seen.has(m))
|
|
145
|
+
continue;
|
|
146
|
+
seen.add(m);
|
|
147
|
+
out += m.render();
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
// Predefined metrics used across the app
|
|
153
|
+
export const metrics = {
|
|
154
|
+
requestsTotal: metricsRegistry.counter('latedev_requests_total', 'Total gateway requests by protocol and status class'),
|
|
155
|
+
requestDuration: metricsRegistry.histogram('latedev_request_duration_ms', 'Gateway request duration in ms'),
|
|
156
|
+
requestTtft: metricsRegistry.histogram('latedev_request_ttft_ms', 'Time to first token in ms'),
|
|
157
|
+
attemptsTotal: metricsRegistry.counter('latedev_upstream_attempts_total', 'Upstream attempts by provider and result'),
|
|
158
|
+
attemptDuration: metricsRegistry.histogram('latedev_upstream_attempt_duration_ms', 'Upstream attempt duration in ms'),
|
|
159
|
+
tokensInput: metricsRegistry.counter('latedev_tokens_input_total', 'Input tokens processed'),
|
|
160
|
+
tokensOutput: metricsRegistry.counter('latedev_tokens_output_total', 'Output tokens processed'),
|
|
161
|
+
tokensCacheRead: metricsRegistry.counter('latedev_tokens_cache_read_total', 'Provider cache read tokens'),
|
|
162
|
+
tokensCacheWrite: metricsRegistry.counter('latedev_tokens_cache_write_total', 'Provider cache write tokens'),
|
|
163
|
+
tokensReasoning: metricsRegistry.counter('latedev_tokens_reasoning_total', 'Reasoning tokens processed'),
|
|
164
|
+
fallbackCount: metricsRegistry.counter('latedev_fallback_total', 'Number of fallback transitions'),
|
|
165
|
+
activeRequests: metricsRegistry.gauge('latedev_active_requests', 'Number of in-flight gateway requests'),
|
|
166
|
+
circuitState: metricsRegistry.gauge('latedev_provider_circuit_state', 'Provider circuit state (0=closed, 1=open)'),
|
|
167
|
+
rateLimited: metricsRegistry.counter('latedev_rate_limited_total', 'Number of rate-limit denials'),
|
|
168
|
+
cacheHits: metricsRegistry.counter('latedev_gateway_cache_hits_total', 'Gateway response cache hits'),
|
|
169
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Anthropic <-> canonical protocol layer.
|
|
2
|
+
export function anthropicToCanonical(req) {
|
|
3
|
+
const messages = [];
|
|
4
|
+
for (const m of req.messages) {
|
|
5
|
+
if (m.role === 'user') {
|
|
6
|
+
messages.push({ role: 'user', content: parseAnthropicUserContent(m.content) });
|
|
7
|
+
}
|
|
8
|
+
else if (m.role === 'assistant') {
|
|
9
|
+
const blocks = [];
|
|
10
|
+
const arr = Array.isArray(m.content) ? m.content : null;
|
|
11
|
+
if (arr) {
|
|
12
|
+
for (const b of arr) {
|
|
13
|
+
if (b.type === 'text' && b.text)
|
|
14
|
+
blocks.push({ type: 'text', text: b.text });
|
|
15
|
+
if (b.type === 'tool_use' && b.id && b.name)
|
|
16
|
+
blocks.push({ type: 'tool_use', toolUse: { id: b.id, name: b.name, input: b.input ?? {} } });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
blocks.push({ type: 'text', text: String(m.content) });
|
|
21
|
+
}
|
|
22
|
+
messages.push({ role: 'assistant', content: blocks });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const tools = req.tools?.map((t) => ({ name: t.name, description: t.description, inputSchema: t.input_schema }));
|
|
26
|
+
let system;
|
|
27
|
+
if (typeof req.system === 'string')
|
|
28
|
+
system = req.system;
|
|
29
|
+
else if (Array.isArray(req.system))
|
|
30
|
+
system = req.system.map((b) => b.text).join('\n');
|
|
31
|
+
return {
|
|
32
|
+
model: req.model,
|
|
33
|
+
messages,
|
|
34
|
+
system,
|
|
35
|
+
tools,
|
|
36
|
+
temperature: req.temperature,
|
|
37
|
+
topP: req.top_p,
|
|
38
|
+
maxOutputTokens: req.max_tokens,
|
|
39
|
+
stop: req.stop_sequences,
|
|
40
|
+
stream: Boolean(req.stream),
|
|
41
|
+
reasoning: req.thinking ? { budgetTokens: req.thinking.budget_tokens } : undefined,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function parseAnthropicUserContent(content) {
|
|
45
|
+
if (typeof content === 'string')
|
|
46
|
+
return [{ type: 'text', text: content }];
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const b of content) {
|
|
49
|
+
if (b.type === 'text' && b.text)
|
|
50
|
+
out.push({ type: 'text', text: b.text });
|
|
51
|
+
if (b.type === 'image' && b.source) {
|
|
52
|
+
if (b.source.type === 'base64') {
|
|
53
|
+
out.push({ type: 'image', image: { base64: b.source.data, mimeType: b.source.media_type } });
|
|
54
|
+
}
|
|
55
|
+
else if (b.source.type === 'url') {
|
|
56
|
+
out.push({ type: 'image', image: { url: b.source.data } });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (b.type === 'tool_result') {
|
|
60
|
+
out.push({
|
|
61
|
+
type: 'tool_result',
|
|
62
|
+
toolResult: {
|
|
63
|
+
toolUseId: b.tool_use_id ?? '',
|
|
64
|
+
content: typeof b.content === 'string' ? b.content : JSON.stringify(b.content ?? null),
|
|
65
|
+
isError: b.is_error,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
export function canonicalToAnthropicRequest(req, targetModel) {
|
|
73
|
+
const messages = [];
|
|
74
|
+
for (const m of req.messages) {
|
|
75
|
+
if (m.role === 'user') {
|
|
76
|
+
const allText = m.content.every((b) => b.type === 'text');
|
|
77
|
+
if (allText) {
|
|
78
|
+
messages.push({ role: 'user', content: m.content.map((b) => b.text ?? '').join('') });
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const blocks = [];
|
|
82
|
+
for (const b of m.content) {
|
|
83
|
+
if (b.type === 'text' && b.text)
|
|
84
|
+
blocks.push({ type: 'text', text: b.text });
|
|
85
|
+
if (b.type === 'image' && b.image?.base64)
|
|
86
|
+
blocks.push({ type: 'image', source: { type: 'base64', media_type: b.image.mimeType ?? 'image/png', data: b.image.base64 } });
|
|
87
|
+
if (b.type === 'image' && b.image?.url)
|
|
88
|
+
blocks.push({ type: 'image', source: { type: 'url', media_type: 'image/png', data: b.image.url } });
|
|
89
|
+
if (b.type === 'tool_result')
|
|
90
|
+
blocks.push({ type: 'tool_result', tool_use_id: b.toolResult.toolUseId, content: b.toolResult.content, is_error: b.toolResult.isError });
|
|
91
|
+
}
|
|
92
|
+
messages.push({ role: 'user', content: blocks });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else if (m.role === 'assistant') {
|
|
96
|
+
const blocks = [];
|
|
97
|
+
for (const b of m.content) {
|
|
98
|
+
if (b.type === 'text' && b.text)
|
|
99
|
+
blocks.push({ type: 'text', text: b.text });
|
|
100
|
+
if (b.type === 'tool_use' && b.toolUse)
|
|
101
|
+
blocks.push({ type: 'tool_use', id: b.toolUse.id, name: b.toolUse.name, input: b.toolUse.input });
|
|
102
|
+
}
|
|
103
|
+
messages.push({ role: 'assistant', content: blocks });
|
|
104
|
+
}
|
|
105
|
+
// role 'tool' is mapped into user with tool_result in Anthropic — already done above
|
|
106
|
+
}
|
|
107
|
+
const out = {
|
|
108
|
+
model: targetModel,
|
|
109
|
+
messages,
|
|
110
|
+
stream: req.stream,
|
|
111
|
+
max_tokens: req.maxOutputTokens ?? 1024,
|
|
112
|
+
};
|
|
113
|
+
if (req.system)
|
|
114
|
+
out.system = req.system;
|
|
115
|
+
if (req.temperature !== undefined)
|
|
116
|
+
out.temperature = req.temperature;
|
|
117
|
+
if (req.topP !== undefined)
|
|
118
|
+
out.top_p = req.topP;
|
|
119
|
+
if (req.stop)
|
|
120
|
+
out.stop_sequences = req.stop;
|
|
121
|
+
if (req.tools)
|
|
122
|
+
out.tools = req.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.inputSchema }));
|
|
123
|
+
if (req.reasoning?.budgetTokens)
|
|
124
|
+
out.thinking = { type: 'enabled', budget_tokens: req.reasoning.budgetTokens };
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
export function anthropicResponseToCanonical(res, requestedModel) {
|
|
128
|
+
const text = res.content.filter((b) => b.type === 'text').map((b) => b.text).join('');
|
|
129
|
+
const toolCalls = res.content.filter((b) => b.type === 'tool_use').map((b) => {
|
|
130
|
+
const t = b;
|
|
131
|
+
return { id: t.id, name: t.name, input: t.input };
|
|
132
|
+
});
|
|
133
|
+
return {
|
|
134
|
+
model: requestedModel,
|
|
135
|
+
text,
|
|
136
|
+
toolCalls,
|
|
137
|
+
finishReason: res.stop_reason ?? null,
|
|
138
|
+
usage: {
|
|
139
|
+
input: res.usage.input_tokens ?? 0,
|
|
140
|
+
output: res.usage.output_tokens ?? 0,
|
|
141
|
+
cacheRead: res.usage.cache_read_input_tokens ?? 0,
|
|
142
|
+
cacheWrite: res.usage.cache_creation_input_tokens ?? 0,
|
|
143
|
+
reasoning: 0,
|
|
144
|
+
total: (res.usage.input_tokens ?? 0) + (res.usage.output_tokens ?? 0),
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export function anthropicModelList(models) {
|
|
149
|
+
return {
|
|
150
|
+
data: models.map((m) => ({ type: 'model', id: m.publicModelId, display_name: m.upstreamModelId })),
|
|
151
|
+
first_id: models[0]?.publicModelId ?? null,
|
|
152
|
+
has_more: false,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Canonical protocol layer: OpenAI <-> canonical, Anthropic <-> canonical.
|
|
2
|
+
import { GatewayError } from '../errors.js';
|
|
3
|
+
export function openAIToCanonical(req) {
|
|
4
|
+
const messages = [];
|
|
5
|
+
let systemText;
|
|
6
|
+
for (const m of req.messages) {
|
|
7
|
+
if (m.role === 'system' || m.role === 'developer') {
|
|
8
|
+
const t = extractText(m.content);
|
|
9
|
+
if (t)
|
|
10
|
+
systemText = (systemText ? systemText + '\n' : '') + t;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (m.role === 'user') {
|
|
14
|
+
messages.push({ role: 'user', content: normalizeContent(m.content) });
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (m.role === 'assistant') {
|
|
18
|
+
const blocks = [];
|
|
19
|
+
if (typeof m.content === 'string' && m.content)
|
|
20
|
+
blocks.push({ type: 'text', text: m.content });
|
|
21
|
+
else if (Array.isArray(m.content)) {
|
|
22
|
+
for (const c of m.content) {
|
|
23
|
+
if (c.type === 'text' && c.text)
|
|
24
|
+
blocks.push({ type: 'text', text: c.text });
|
|
25
|
+
else if (c.type === 'image_url' && c.image_url)
|
|
26
|
+
blocks.push({ type: 'image', image: { url: c.image_url.url } });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (m.tool_calls) {
|
|
30
|
+
for (const tc of m.tool_calls) {
|
|
31
|
+
blocks.push({ type: 'tool_use', toolUse: { id: tc.id, name: tc.function.name, input: safeJson(tc.function.arguments) } });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
messages.push({ role: 'assistant', content: blocks });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (m.role === 'tool') {
|
|
38
|
+
const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
|
|
39
|
+
messages.push({
|
|
40
|
+
role: 'tool',
|
|
41
|
+
content: [
|
|
42
|
+
{
|
|
43
|
+
type: 'tool_result',
|
|
44
|
+
toolResult: { toolUseId: m.tool_call_id ?? 'unknown', content },
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const tools = req.tools?.map((t) => ({
|
|
51
|
+
name: t.function.name,
|
|
52
|
+
description: t.function.description,
|
|
53
|
+
inputSchema: t.function.parameters,
|
|
54
|
+
}));
|
|
55
|
+
let responseFormat;
|
|
56
|
+
if (req.response_format) {
|
|
57
|
+
if (req.response_format.type === 'json_schema' && req.response_format.json_schema) {
|
|
58
|
+
responseFormat = { type: 'json_schema', jsonSchema: req.response_format.json_schema.schema };
|
|
59
|
+
}
|
|
60
|
+
else if (req.response_format.type === 'json_object') {
|
|
61
|
+
responseFormat = { type: 'json_object' };
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
responseFormat = { type: 'text' };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
model: req.model,
|
|
69
|
+
messages,
|
|
70
|
+
system: systemText,
|
|
71
|
+
tools,
|
|
72
|
+
temperature: req.temperature,
|
|
73
|
+
topP: req.top_p,
|
|
74
|
+
maxOutputTokens: req.max_tokens,
|
|
75
|
+
stop: Array.isArray(req.stop) ? req.stop : req.stop ? [req.stop] : undefined,
|
|
76
|
+
stream: Boolean(req.stream),
|
|
77
|
+
responseFormat,
|
|
78
|
+
reasoning: req.reasoning_effort ? { effort: req.reasoning_effort } : undefined,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function canonicalToOpenAIRequest(req, targetModel) {
|
|
82
|
+
const out = {
|
|
83
|
+
model: targetModel,
|
|
84
|
+
stream: req.stream,
|
|
85
|
+
messages: [],
|
|
86
|
+
};
|
|
87
|
+
if (req.system)
|
|
88
|
+
out.messages.push({ role: 'system', content: req.system });
|
|
89
|
+
for (const m of req.messages) {
|
|
90
|
+
if (m.role === 'user') {
|
|
91
|
+
const text = m.content.map((b) => (b.type === 'text' ? b.text : '')).join('');
|
|
92
|
+
if (m.content.every((b) => b.type === 'text')) {
|
|
93
|
+
out.messages.push({ role: 'user', content: text });
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const blocks = [];
|
|
97
|
+
for (const b of m.content) {
|
|
98
|
+
if (b.type === 'text' && b.text)
|
|
99
|
+
blocks.push({ type: 'text', text: b.text });
|
|
100
|
+
if (b.type === 'image' && b.image?.url)
|
|
101
|
+
blocks.push({ type: 'image_url', image_url: { url: b.image.url } });
|
|
102
|
+
if (b.type === 'image' && b.image?.base64)
|
|
103
|
+
blocks.push({ type: 'image_url', image_url: { url: `data:${b.image.mimeType ?? 'image/png'};base64,${b.image.base64}` } });
|
|
104
|
+
}
|
|
105
|
+
out.messages.push({ role: 'user', content: blocks });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else if (m.role === 'assistant') {
|
|
109
|
+
const text = m.content.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('');
|
|
110
|
+
const toolCalls = m.content.filter((b) => b.type === 'tool_use').map((b) => ({
|
|
111
|
+
id: b.toolUse.id,
|
|
112
|
+
type: 'function',
|
|
113
|
+
function: { name: b.toolUse.name, arguments: typeof b.toolUse.input === 'string' ? b.toolUse.input : JSON.stringify(b.toolUse.input) },
|
|
114
|
+
}));
|
|
115
|
+
out.messages.push({ role: 'assistant', content: text || null, ...(toolCalls.length ? { tool_calls: toolCalls } : {}) });
|
|
116
|
+
}
|
|
117
|
+
else if (m.role === 'tool') {
|
|
118
|
+
for (const b of m.content) {
|
|
119
|
+
if (b.type === 'tool_result') {
|
|
120
|
+
out.messages.push({ role: 'tool', content: typeof b.toolResult.content === 'string' ? b.toolResult.content : JSON.stringify(b.toolResult.content), tool_call_id: b.toolResult.toolUseId });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (req.tools)
|
|
126
|
+
out.tools = req.tools.map((t) => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.inputSchema } }));
|
|
127
|
+
if (req.temperature !== undefined)
|
|
128
|
+
out.temperature = req.temperature;
|
|
129
|
+
if (req.topP !== undefined)
|
|
130
|
+
out.top_p = req.topP;
|
|
131
|
+
if (req.maxOutputTokens !== undefined)
|
|
132
|
+
out.max_tokens = req.maxOutputTokens;
|
|
133
|
+
if (req.stop)
|
|
134
|
+
out.stop = req.stop;
|
|
135
|
+
if (req.responseFormat) {
|
|
136
|
+
if (req.responseFormat.type === 'json_schema' && req.responseFormat.jsonSchema) {
|
|
137
|
+
out.response_format = { type: 'json_schema', json_schema: { schema: req.responseFormat.jsonSchema } };
|
|
138
|
+
}
|
|
139
|
+
else if (req.responseFormat.type === 'json_object') {
|
|
140
|
+
out.response_format = { type: 'json_object' };
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
out.response_format = { type: 'text' };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (req.reasoning?.effort)
|
|
147
|
+
out.reasoning_effort = req.reasoning.effort;
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
export function openAIResponseToCanonical(res, requestedModel) {
|
|
151
|
+
const choice = res.choices[0];
|
|
152
|
+
return {
|
|
153
|
+
model: requestedModel,
|
|
154
|
+
text: choice?.message?.content ?? '',
|
|
155
|
+
toolCalls: (choice?.message?.tool_calls ?? []).map((tc) => ({ id: tc.id, name: tc.function.name, input: safeJson(tc.function.arguments) })),
|
|
156
|
+
finishReason: choice?.finish_reason ?? null,
|
|
157
|
+
usage: {
|
|
158
|
+
input: res.usage?.prompt_tokens ?? 0,
|
|
159
|
+
output: res.usage?.completion_tokens ?? 0,
|
|
160
|
+
cacheRead: res.usage?.prompt_tokens_details?.cached_tokens ?? 0,
|
|
161
|
+
cacheWrite: 0,
|
|
162
|
+
reasoning: res.usage?.completion_tokens_details?.reasoning_tokens ?? 0,
|
|
163
|
+
total: res.usage?.total_tokens ?? 0,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function extractText(content) {
|
|
168
|
+
if (typeof content === 'string')
|
|
169
|
+
return content;
|
|
170
|
+
if (Array.isArray(content)) {
|
|
171
|
+
return content.map((b) => (b.type === 'text' ? b.text ?? '' : '')).join('');
|
|
172
|
+
}
|
|
173
|
+
return '';
|
|
174
|
+
}
|
|
175
|
+
function normalizeContent(content) {
|
|
176
|
+
if (typeof content === 'string')
|
|
177
|
+
return [{ type: 'text', text: content }];
|
|
178
|
+
if (Array.isArray(content)) {
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const b of content) {
|
|
181
|
+
if (b.type === 'text' && b.text)
|
|
182
|
+
out.push({ type: 'text', text: b.text });
|
|
183
|
+
if (b.type === 'image_url' && b.image_url)
|
|
184
|
+
out.push({ type: 'image', image: { url: b.image_url.url } });
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
throw new GatewayError('invalid_request_error', 'Unsupported message content', { status: 400 });
|
|
189
|
+
}
|
|
190
|
+
function safeJson(s) {
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(s);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return s;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// OpenAI model list
|
|
199
|
+
export function openAIModelList(models) {
|
|
200
|
+
return { object: 'list', data: models.map((m) => ({ id: m.publicModelId, object: 'model', created: 0, owned_by: m.publicModelId.split('/')[0] })) };
|
|
201
|
+
}
|