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,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
+ }