@loreai/gateway 0.14.0 → 0.15.0

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/src/server.ts DELETED
@@ -1,250 +0,0 @@
1
- /**
2
- * HTTP server for the Lore gateway proxy.
3
- *
4
- * Routes:
5
- * POST /v1/messages → Anthropic protocol (Phase 1)
6
- * POST /v1/chat/completions → OpenAI protocol (Phase 2 stub)
7
- * GET /v1/models → Passthrough to upstream
8
- * GET /health → Health check
9
- *
10
- * Uses `Bun.serve()` — this package targets Bun exclusively.
11
- */
12
- import type { GatewayConfig } from "./config";
13
- import type { GatewayRequest } from "./translate/types";
14
- import { parseAnthropicRequest } from "./translate/anthropic";
15
- import { parseOpenAIRequest, buildOpenAIResponse } from "./translate/openai";
16
- import { accumulateSSEResponse } from "./stream/anthropic";
17
- import { handleRequest } from "./pipeline";
18
-
19
- // ---------------------------------------------------------------------------
20
- // Version — best-effort from package.json, falls back gracefully
21
- // ---------------------------------------------------------------------------
22
-
23
- let version = "unknown";
24
- try {
25
- // Bun resolves JSON imports; use require for sync + no top-level await
26
- const pkg = require("../package.json") as { version?: string };
27
- if (pkg.version) version = pkg.version;
28
- } catch {
29
- // Not critical — health endpoint will report "unknown"
30
- }
31
-
32
- // ---------------------------------------------------------------------------
33
- // CORS headers — permissive for localhost development
34
- // ---------------------------------------------------------------------------
35
-
36
- const CORS_HEADERS: Record<string, string> = {
37
- "access-control-allow-origin": "*",
38
- "access-control-allow-methods": "GET, POST, OPTIONS",
39
- "access-control-allow-headers": "*",
40
- "access-control-max-age": "86400",
41
- };
42
-
43
- function withCors(response: Response): Response {
44
- for (const [key, value] of Object.entries(CORS_HEADERS)) {
45
- response.headers.set(key, value);
46
- }
47
- return response;
48
- }
49
-
50
- // ---------------------------------------------------------------------------
51
- // Helpers
52
- // ---------------------------------------------------------------------------
53
-
54
- /** Convert Bun's Headers object to a plain Record<string, string>. */
55
- function headersToRecord(headers: Headers): Record<string, string> {
56
- const record: Record<string, string> = {};
57
- headers.forEach((value, key) => {
58
- record[key] = value;
59
- });
60
- return record;
61
- }
62
-
63
- function jsonResponse(body: unknown, status = 200): Response {
64
- return withCors(
65
- new Response(JSON.stringify(body), {
66
- status,
67
- headers: { "content-type": "application/json" },
68
- }),
69
- );
70
- }
71
-
72
- function errorResponse(
73
- status: number,
74
- type: string,
75
- message: string,
76
- ): Response {
77
- return jsonResponse(
78
- {
79
- type: "error",
80
- error: { type, message },
81
- },
82
- status,
83
- );
84
- }
85
-
86
- // ---------------------------------------------------------------------------
87
- // Route handlers
88
- // ---------------------------------------------------------------------------
89
-
90
- async function handleAnthropicMessages(
91
- req: Request,
92
- config: GatewayConfig,
93
- ): Promise<Response> {
94
- let body: unknown;
95
- try {
96
- body = await req.json();
97
- } catch {
98
- return errorResponse(400, "invalid_request_error", "Invalid JSON body");
99
- }
100
-
101
- let gatewayReq: GatewayRequest;
102
- try {
103
- gatewayReq = parseAnthropicRequest(body, headersToRecord(req.headers));
104
- } catch (e) {
105
- const msg = e instanceof Error ? e.message : "Failed to parse request";
106
- return errorResponse(400, "invalid_request_error", msg);
107
- }
108
-
109
- try {
110
- const result = await handleRequest(gatewayReq, config);
111
- // Pipeline returns a Response directly (streaming or non-streaming)
112
- return withCors(result);
113
- } catch (e) {
114
- const msg = e instanceof Error ? e.message : "Pipeline error";
115
- console.error(`[lore] pipeline error: ${msg}`);
116
- return errorResponse(502, "api_error", `Gateway pipeline error: ${msg}`);
117
- }
118
- }
119
-
120
- async function handleModelsPassthrough(config: GatewayConfig): Promise<Response> {
121
- try {
122
- const upstream = await fetch(`${config.upstreamAnthropic}/v1/models`, {
123
- headers: { "content-type": "application/json" },
124
- });
125
- // Clone to a new Response so we can append CORS headers
126
- const response = new Response(upstream.body, {
127
- status: upstream.status,
128
- statusText: upstream.statusText,
129
- headers: new Headers(upstream.headers),
130
- });
131
- return withCors(response);
132
- } catch (e) {
133
- const msg = e instanceof Error ? e.message : "Upstream unreachable";
134
- return errorResponse(502, "api_error", `Failed to fetch models: ${msg}`);
135
- }
136
- }
137
-
138
- function handleHealth(): Response {
139
- return jsonResponse({ status: "ok", version });
140
- }
141
-
142
- async function handleOpenAIChatCompletions(
143
- req: Request,
144
- config: GatewayConfig,
145
- ): Promise<Response> {
146
- let body: unknown;
147
- try {
148
- body = await req.json();
149
- } catch {
150
- return errorResponse(400, "invalid_request_error", "Invalid JSON body");
151
- }
152
-
153
- let gatewayReq: GatewayRequest;
154
- try {
155
- gatewayReq = parseOpenAIRequest(body, headersToRecord(req.headers));
156
- } catch (e) {
157
- const msg = e instanceof Error ? e.message : "Failed to parse request";
158
- return errorResponse(400, "invalid_request_error", msg);
159
- }
160
-
161
- let pipelineResp: Response;
162
- try {
163
- pipelineResp = await handleRequest(gatewayReq, config);
164
- } catch (e) {
165
- const msg = e instanceof Error ? e.message : "Pipeline error";
166
- console.error(`[lore] pipeline error: ${msg}`);
167
- return errorResponse(502, "api_error", `Gateway pipeline error: ${msg}`);
168
- }
169
-
170
- // Pipeline always returns internal Anthropic-format response.
171
- // Translate back to OpenAI format before returning to the client.
172
- if (!pipelineResp.ok) {
173
- // Upstream or pipeline error — forward as-is
174
- return withCors(pipelineResp);
175
- }
176
-
177
- const contentType = pipelineResp.headers.get("content-type") ?? "";
178
- if (contentType.includes("text/event-stream")) {
179
- // Streaming: accumulate the internal SSE then re-emit as OpenAI SSE
180
- const accumulated = await accumulateSSEResponse(pipelineResp);
181
- return withCors(buildOpenAIResponse(accumulated, true));
182
- }
183
-
184
- // Non-streaming: translate JSON body
185
- const respBody = await pipelineResp.json();
186
- return withCors(buildOpenAIResponse(respBody, false));
187
- }
188
-
189
- // ---------------------------------------------------------------------------
190
- // Server
191
- // ---------------------------------------------------------------------------
192
-
193
- export function startServer(config: GatewayConfig): {
194
- stop: () => void;
195
- port: number;
196
- } {
197
- const server = Bun.serve({
198
- port: config.port,
199
- hostname: config.host,
200
-
201
- async fetch(req: Request): Promise<Response> {
202
- const url = new URL(req.url);
203
- const { pathname } = url;
204
- const method = req.method;
205
-
206
- // CORS preflight
207
- if (method === "OPTIONS") {
208
- return withCors(new Response(null, { status: 204 }));
209
- }
210
-
211
- if (config.debug) {
212
- console.error(`[lore] ${method} ${pathname}`);
213
- }
214
-
215
- try {
216
- // POST /v1/messages — Anthropic protocol
217
- if (method === "POST" && pathname === "/v1/messages") {
218
- return await handleAnthropicMessages(req, config);
219
- }
220
-
221
- // POST /v1/chat/completions — OpenAI protocol
222
- if (method === "POST" && pathname === "/v1/chat/completions") {
223
- return await handleOpenAIChatCompletions(req, config);
224
- }
225
-
226
- // GET /v1/models — passthrough
227
- if (method === "GET" && pathname === "/v1/models") {
228
- return await handleModelsPassthrough(config);
229
- }
230
-
231
- // GET /health — health check
232
- if (method === "GET" && pathname === "/health") {
233
- return handleHealth();
234
- }
235
-
236
- // 404 for everything else
237
- return errorResponse(404, "not_found", `No route for ${method} ${pathname}`);
238
- } catch (e) {
239
- const msg = e instanceof Error ? e.message : "Internal server error";
240
- console.error(`[lore] uncaught error: ${msg}`);
241
- return errorResponse(500, "api_error", msg);
242
- }
243
- },
244
- });
245
-
246
- return {
247
- stop: () => server.stop(),
248
- port: server.port ?? config.port,
249
- };
250
- }
package/src/session.ts DELETED
@@ -1,207 +0,0 @@
1
- /**
2
- * Session identification for the Lore gateway proxy.
3
- *
4
- * Raw LLM API requests carry no session ID, so the gateway injects a
5
- * text-block marker `[lore:<base62>]` into the first response of a new
6
- * session. Subsequent requests from the same session echo it back in
7
- * the message history, allowing the gateway to correlate turns.
8
- *
9
- * The session ID packs 8 random bytes + 4 bytes of unix timestamp
10
- * (seconds, big-endian) into 12 bytes, then base62-encodes them to a
11
- * compact alphanumeric string (~17 chars).
12
- *
13
- * A SHA-256 fingerprint of the first user message serves as a
14
- * belt-and-suspenders fallback for sessions that haven't received their
15
- * marker yet (e.g. the very first request before any response).
16
- *
17
- * This module has zero dependencies on `@loreai/core` — pure utility.
18
- */
19
-
20
- // ---------------------------------------------------------------------------
21
- // Base62 encoding
22
- // ---------------------------------------------------------------------------
23
-
24
- const BASE62_ALPHABET =
25
- "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
26
- const BASE = 62n;
27
-
28
- /**
29
- * Encode a byte array to a base62 string.
30
- *
31
- * Interprets `bytes` as an unsigned big-endian integer, then repeatedly
32
- * divmods by 62, mapping each remainder to `BASE62_ALPHABET`. The result
33
- * is reversed so the most-significant digit comes first and zero-padded
34
- * to `minLength` for consistent output width.
35
- */
36
- export function base62Encode(bytes: Uint8Array, minLength = 0): string {
37
- let n = 0n;
38
- for (const b of bytes) {
39
- n = (n << 8n) | BigInt(b);
40
- }
41
-
42
- if (n === 0n) return BASE62_ALPHABET[0].repeat(Math.max(1, minLength));
43
-
44
- const chars: string[] = [];
45
- while (n > 0n) {
46
- chars.push(BASE62_ALPHABET[Number(n % BASE)]);
47
- n /= BASE;
48
- }
49
-
50
- chars.reverse();
51
-
52
- // Pad to minLength for consistent width
53
- while (chars.length < minLength) {
54
- chars.unshift(BASE62_ALPHABET[0]);
55
- }
56
-
57
- return chars.join("");
58
- }
59
-
60
- // ---------------------------------------------------------------------------
61
- // Session ID generation
62
- // ---------------------------------------------------------------------------
63
-
64
- /** 12 bytes → base62 → at most 17 alphanumeric characters. */
65
- const SESSION_ID_MIN_LENGTH = 17;
66
-
67
- /**
68
- * Generate a new session ID.
69
- *
70
- * Layout (12 bytes):
71
- * [0..7] — 8 random bytes (session hash)
72
- * [8..11] — 4 bytes unix timestamp (seconds, big-endian)
73
- */
74
- export function generateSessionID(): string {
75
- const buf = new Uint8Array(12);
76
- crypto.getRandomValues(buf.subarray(0, 8));
77
-
78
- const ts = Math.floor(Date.now() / 1000);
79
- const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
80
- view.setUint32(8, ts >>> 0, false); // big-endian
81
-
82
- return base62Encode(buf, SESSION_ID_MIN_LENGTH);
83
- }
84
-
85
- // ---------------------------------------------------------------------------
86
- // Marker formatting / parsing
87
- // ---------------------------------------------------------------------------
88
-
89
- const MARKER_RE = /\[lore:([a-zA-Z0-9]+)\]/;
90
-
91
- /** Format a session ID as the injectable text marker. */
92
- export function formatMarker(sessionID: string): string {
93
- return `[lore:${sessionID}]`;
94
- }
95
-
96
- /**
97
- * Extract a session ID from a marker string, or `null` if the text
98
- * does not contain a valid marker.
99
- */
100
- export function parseMarker(text: string): string | null {
101
- const m = MARKER_RE.exec(text);
102
- return m ? m[1] : null;
103
- }
104
-
105
- // ---------------------------------------------------------------------------
106
- // Message scanning
107
- // ---------------------------------------------------------------------------
108
-
109
- /**
110
- * Extract text from a single message's content field.
111
- *
112
- * Handles both Anthropic-style content (array of `{type:"text", text}` blocks)
113
- * and OpenAI-style content (plain string).
114
- */
115
- function extractTextParts(content: unknown): string[] {
116
- if (typeof content === "string") return [content];
117
-
118
- if (Array.isArray(content)) {
119
- const texts: string[] = [];
120
- for (const block of content) {
121
- if (
122
- block &&
123
- typeof block === "object" &&
124
- "type" in block &&
125
- block.type === "text" &&
126
- "text" in block &&
127
- typeof block.text === "string"
128
- ) {
129
- texts.push(block.text);
130
- }
131
- }
132
- return texts;
133
- }
134
-
135
- return [];
136
- }
137
-
138
- /**
139
- * Scan a message array for a `[lore:<sessionID>]` marker inside any
140
- * text content block. Returns the extracted session ID or `null`.
141
- */
142
- export function scanForMarker(
143
- messages: Array<{ role: string; content: unknown }>,
144
- ): string | null {
145
- for (const msg of messages) {
146
- for (const text of extractTextParts(msg.content)) {
147
- const id = parseMarker(text);
148
- if (id) return id;
149
- }
150
- }
151
- return null;
152
- }
153
-
154
- // ---------------------------------------------------------------------------
155
- // Fingerprinting (fallback)
156
- // ---------------------------------------------------------------------------
157
-
158
- /**
159
- * Compute a SHA-256 fingerprint from the first user message's content,
160
- * optionally incorporating the model name and an auth credential suffix.
161
- *
162
- * Returns the first 16 hex characters of the hash. Used as the primary
163
- * session correlator — combined with message-count proximity to
164
- * disambiguate forked sessions that share the same first message.
165
- *
166
- * Including `model` and `authSuffix` ensures that a key change or model
167
- * switch creates a new session rather than reusing an existing one.
168
- */
169
- export async function fingerprintMessages(
170
- messages: Array<{ role: string; content: unknown }>,
171
- extras?: { model?: string; authSuffix?: string },
172
- ): Promise<string> {
173
- let firstUserContent = "";
174
- for (const msg of messages) {
175
- if (msg.role === "user") {
176
- const texts = extractTextParts(msg.content);
177
- firstUserContent = texts.join("");
178
- break;
179
- }
180
- }
181
-
182
- const material =
183
- firstUserContent + (extras?.model ?? "") + (extras?.authSuffix ?? "");
184
- const encoded = new TextEncoder().encode(material);
185
- const hash = await crypto.subtle.digest("SHA-256", encoded);
186
- const bytes = new Uint8Array(hash);
187
-
188
- // First 16 hex chars (8 bytes)
189
- let hex = "";
190
- for (let i = 0; i < 8; i++) {
191
- hex += bytes[i].toString(16).padStart(2, "0");
192
- }
193
- return hex;
194
- }
195
-
196
- // ---------------------------------------------------------------------------
197
- // Message-count proximity matching
198
- // ---------------------------------------------------------------------------
199
-
200
- /**
201
- * Maximum message count difference for two requests to be considered
202
- * part of the same session. Normal turns add 2–6 messages (user +
203
- * assistant + tool calls); a forked session drops to the fork point.
204
- * A threshold of 20 accommodates bursts of tool-call messages while
205
- * reliably distinguishing forks (which typically differ by 50+).
206
- */
207
- export const MESSAGE_COUNT_PROXIMITY_THRESHOLD = 20;