babelgate 0.0.0 → 1.1.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 (46) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +467 -1
  4. package/dist/app.js +40 -0
  5. package/dist/cli.js +203 -0
  6. package/dist/config/env-config.js +99 -0
  7. package/dist/index.js +60 -0
  8. package/dist/middleware/error-handler.js +158 -0
  9. package/dist/middleware/request-logger.js +66 -0
  10. package/dist/middleware/require-api-key.js +80 -0
  11. package/dist/middleware/resolve-source.js +28 -0
  12. package/dist/routes/proxy.js +74 -0
  13. package/dist/services/http-client.js +220 -0
  14. package/dist/services/proxy.service.js +329 -0
  15. package/dist/services/stream-pipeline.js +119 -0
  16. package/dist/services/upstream-errors.js +34 -0
  17. package/dist/transformers/adapters/anthropic.adapter.js +292 -0
  18. package/dist/transformers/adapters/anthropic.messages.js +261 -0
  19. package/dist/transformers/adapters/anthropic.stream.js +378 -0
  20. package/dist/transformers/adapters/gemini.adapter.js +418 -0
  21. package/dist/transformers/adapters/gemini.schema.js +61 -0
  22. package/dist/transformers/adapters/gemini.stream.js +341 -0
  23. package/dist/transformers/adapters/gemini.tools.js +246 -0
  24. package/dist/transformers/adapters/index.js +141 -0
  25. package/dist/transformers/adapters/ollama.adapter.js +288 -0
  26. package/dist/transformers/adapters/ollama.stream.js +294 -0
  27. package/dist/transformers/adapters/ollama.tools.js +107 -0
  28. package/dist/transformers/adapters/openai.adapter.js +460 -0
  29. package/dist/transformers/adapters/openai.stream.js +229 -0
  30. package/dist/transformers/index.js +110 -0
  31. package/dist/types/anthropic.types.js +2 -0
  32. package/dist/types/canonical.schema.js +160 -0
  33. package/dist/types/canonical.types.js +8 -0
  34. package/dist/types/gemini.types.js +2 -0
  35. package/dist/types/ollama.types.js +4 -0
  36. package/dist/types/openai.types.js +2 -0
  37. package/dist/utils/canonical-error.js +77 -0
  38. package/dist/utils/errors.js +95 -0
  39. package/dist/utils/ids.js +17 -0
  40. package/dist/utils/logger.js +34 -0
  41. package/dist/utils/model-map.js +39 -0
  42. package/dist/utils/sse.js +147 -0
  43. package/dist/utils/stream-reassemble.js +104 -0
  44. package/dist/utils/token-estimate.js +36 -0
  45. package/dist/utils/tool-args.js +26 -0
  46. package/package.json +55 -7
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ /**
3
+ * Stream wire utilities (spec §3.1, §3.3): incremental UTF-8 decoding,
4
+ * Server-Sent Events parsing/formatting and NDJSON line splitting.
5
+ * All are pure, stateful factories — one instance per request.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.createUtf8Decoder = createUtf8Decoder;
9
+ exports.createSseParser = createSseParser;
10
+ exports.formatSse = formatSse;
11
+ exports.createLineSplitter = createLineSplitter;
12
+ /**
13
+ * Incremental UTF-8 decoder: byte chunks may split a multi-byte character
14
+ * anywhere; strings pass through. Feeding an empty Uint8Array flushes any
15
+ * bytes left over at end-of-stream (a truncated trailing character becomes
16
+ * U+FFFD) and resets the decoder.
17
+ */
18
+ function createUtf8Decoder() {
19
+ const decoder = new TextDecoder("utf-8");
20
+ return (chunk) => {
21
+ if (typeof chunk === "string")
22
+ return chunk;
23
+ return chunk.byteLength === 0
24
+ ? decoder.decode()
25
+ : decoder.decode(chunk, { stream: true });
26
+ };
27
+ }
28
+ /**
29
+ * Take the complete lines out of `buffer`, keyed on "\n" (a trailing "\r"
30
+ * is stripped, so "\r\n" split across chunks is handled). With `flush`,
31
+ * a final unterminated line is emitted too.
32
+ */
33
+ function takeLines(buffer, flush) {
34
+ const lines = [];
35
+ let start = 0;
36
+ for (let i = 0; i < buffer.length; i++) {
37
+ if (buffer[i] === "\n") {
38
+ let line = buffer.slice(start, i);
39
+ if (line.endsWith("\r"))
40
+ line = line.slice(0, -1);
41
+ lines.push(line);
42
+ start = i + 1;
43
+ }
44
+ }
45
+ let rest = buffer.slice(start);
46
+ if (flush && rest !== "") {
47
+ if (rest.endsWith("\r"))
48
+ rest = rest.slice(0, -1);
49
+ if (rest !== "")
50
+ lines.push(rest);
51
+ rest = "";
52
+ }
53
+ return { lines, rest };
54
+ }
55
+ /**
56
+ * Incremental SSE parser. Handles "\n" and "\r\n" endings, multi-line
57
+ * `data:` fields, and comment lines starting with ":"; `id:`, `retry:`
58
+ * and unknown fields are ignored. Events are dispatched on blank lines;
59
+ * one still buffered at end() is dispatched there. An event with no
60
+ * `data:` field is dropped.
61
+ */
62
+ function createSseParser() {
63
+ let buffer = "";
64
+ let dataLines = [];
65
+ let hasData = false;
66
+ let eventType;
67
+ function dispatch(out) {
68
+ if (hasData) {
69
+ out.push({
70
+ ...(eventType !== undefined && { event: eventType }),
71
+ data: dataLines.join("\n"),
72
+ });
73
+ }
74
+ dataLines = [];
75
+ hasData = false;
76
+ eventType = undefined;
77
+ }
78
+ function processLines(lines, flush) {
79
+ const out = [];
80
+ for (const line of lines) {
81
+ if (line === "") {
82
+ dispatch(out);
83
+ continue;
84
+ }
85
+ if (line.startsWith(":"))
86
+ continue; // comment
87
+ const colon = line.indexOf(":");
88
+ const field = colon === -1 ? line : line.slice(0, colon);
89
+ let value = colon === -1 ? "" : line.slice(colon + 1);
90
+ if (value.startsWith(" "))
91
+ value = value.slice(1);
92
+ if (field === "data") {
93
+ dataLines.push(value);
94
+ hasData = true;
95
+ }
96
+ else if (field === "event") {
97
+ eventType = value;
98
+ }
99
+ }
100
+ if (flush)
101
+ dispatch(out);
102
+ return out;
103
+ }
104
+ return {
105
+ feed(text) {
106
+ buffer += text;
107
+ const taken = takeLines(buffer, false);
108
+ buffer = taken.rest;
109
+ return processLines(taken.lines, false);
110
+ },
111
+ end() {
112
+ const taken = takeLines(buffer, true);
113
+ buffer = taken.rest;
114
+ return processLines(taken.lines, true);
115
+ },
116
+ };
117
+ }
118
+ /** Render one SSE event: optional `event:` line, one `data:` per line. */
119
+ function formatSse(data, event) {
120
+ const dataLines = data
121
+ .split("\n")
122
+ .map((line) => `data: ${line}`)
123
+ .join("\n");
124
+ return `${event !== undefined ? `event: ${event}\n` : ""}${dataLines}\n\n`;
125
+ }
126
+ /**
127
+ * Incremental splitter for line-delimited bodies (Ollama NDJSON).
128
+ * Returns non-empty lines only; a trailing partial line is held until
129
+ * it completes or end() flushes it.
130
+ */
131
+ function createLineSplitter() {
132
+ let buffer = "";
133
+ function take(flush) {
134
+ const taken = takeLines(buffer, flush);
135
+ buffer = taken.rest;
136
+ return taken.lines.filter((line) => line !== "");
137
+ }
138
+ return {
139
+ feed(text) {
140
+ buffer += text;
141
+ return take(false);
142
+ },
143
+ end() {
144
+ return take(true);
145
+ },
146
+ };
147
+ }
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.reassembleStream = reassembleStream;
4
+ const errors_1 = require("./errors");
5
+ /**
6
+ * Fold a canonical event stream back into a CanonicalResponse — the inverse
7
+ * of streaming, used to compare streams against their non-streaming twins.
8
+ * Throws TransformError when the events break the spec §3.1 stream rules;
9
+ * an `error` event throws a ProxyError carrying that event's kind/status.
10
+ */
11
+ function reassembleStream(events) {
12
+ let started = false;
13
+ let done = false;
14
+ let id = "";
15
+ let model = "";
16
+ let text = "";
17
+ let usage;
18
+ let finishReason;
19
+ const calls = new Map();
20
+ let nextIndex = 0;
21
+ function fail(why) {
22
+ throw new errors_1.TransformError(`Invalid canonical stream: ${why}`);
23
+ }
24
+ for (const event of events) {
25
+ if (done)
26
+ fail(`event "${event.type}" after the stream ended`);
27
+ if (!started && event.type !== "message_start") {
28
+ fail(`event "${event.type}" before message_start`);
29
+ }
30
+ switch (event.type) {
31
+ case "message_start":
32
+ if (started)
33
+ fail("duplicate message_start");
34
+ started = true;
35
+ id = event.id;
36
+ model = event.model;
37
+ break;
38
+ case "text_delta":
39
+ text += event.text;
40
+ break;
41
+ case "tool_call_start":
42
+ if (event.index !== nextIndex) {
43
+ fail(`tool_call_start index ${event.index}, expected ${nextIndex}`);
44
+ }
45
+ calls.set(event.index, {
46
+ id: event.id,
47
+ name: event.name,
48
+ args: "",
49
+ ended: false,
50
+ });
51
+ nextIndex++;
52
+ break;
53
+ case "tool_call_delta": {
54
+ const call = calls.get(event.index);
55
+ if (!call || call.ended) {
56
+ fail(`tool_call_delta for index ${event.index} with no open call`);
57
+ }
58
+ call.args += event.argumentsDelta;
59
+ break;
60
+ }
61
+ case "tool_call_end": {
62
+ const call = calls.get(event.index);
63
+ if (!call || call.ended) {
64
+ fail(`tool_call_end for index ${event.index} with no open call`);
65
+ }
66
+ call.ended = true;
67
+ break;
68
+ }
69
+ case "usage":
70
+ if (usage)
71
+ fail("duplicate usage event");
72
+ usage = event.usage;
73
+ break;
74
+ case "message_end":
75
+ finishReason = event.finishReason;
76
+ done = true;
77
+ break;
78
+ case "error":
79
+ done = true;
80
+ throw new errors_1.ProxyError(event.error.message, event.error.status, event.error.kind.toUpperCase());
81
+ }
82
+ }
83
+ if (!done)
84
+ fail("stream ended without message_end or error");
85
+ const toolCalls = [...calls.entries()]
86
+ .sort(([a], [b]) => a - b)
87
+ .map(([, c]) => ({
88
+ id: c.id,
89
+ type: "function",
90
+ function: { name: c.name, arguments: c.args },
91
+ }));
92
+ return {
93
+ id,
94
+ model,
95
+ message: {
96
+ role: "assistant",
97
+ content: text,
98
+ ...(toolCalls.length > 0 && { toolCalls }),
99
+ },
100
+ // done without throwing ⇒ a message_end set finishReason
101
+ finishReason: finishReason,
102
+ ...(usage && { usage }),
103
+ };
104
+ }
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.estimateInputTokens = estimateInputTokens;
4
+ /**
5
+ * Local input-token estimate (spec §3.5): ceil(chars / 4) over the system
6
+ * prompt, message text, tool-call arguments, tool-result content and
7
+ * JSON.stringify(tools). A rough count — used for the locally answered
8
+ * count_tokens endpoint and the Anthropic message_start estimate, never
9
+ * billed against a real tokenizer.
10
+ */
11
+ function estimateInputTokens(req) {
12
+ // Adapters keep the system text in BOTH systemPrompt and a system-role
13
+ // message — count it once, via whichever field carries it.
14
+ let chars = req.systemPrompt?.length ?? 0;
15
+ const systemInPrompt = req.systemPrompt !== undefined;
16
+ for (const message of req.messages) {
17
+ if (message.role === "system" && systemInPrompt) {
18
+ // duplicates req.systemPrompt — already counted
19
+ }
20
+ else if (typeof message.content === "string") {
21
+ chars += message.content.length;
22
+ }
23
+ else {
24
+ for (const part of message.content) {
25
+ if (part.type === "text" && part.text)
26
+ chars += part.text.length;
27
+ }
28
+ }
29
+ for (const call of message.toolCalls ?? []) {
30
+ chars += call.function.arguments.length;
31
+ }
32
+ }
33
+ if (req.tools)
34
+ chars += JSON.stringify(req.tools).length;
35
+ return Math.ceil(chars / 4);
36
+ }
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseToolArguments = parseToolArguments;
4
+ /**
5
+ * Tool-call argument parsing (spec §3.4). Canonical arguments are the JSON
6
+ * string exactly as the provider sent it; encoders that need an object
7
+ * call this. On failure the raw string is preserved under
8
+ * `_invalid_arguments` so no information is lost.
9
+ */
10
+ function parseToolArguments(json) {
11
+ if (json.trim() === "") {
12
+ return { args: {}, valid: true };
13
+ }
14
+ try {
15
+ const parsed = JSON.parse(json);
16
+ if (parsed !== null &&
17
+ typeof parsed === "object" &&
18
+ !Array.isArray(parsed)) {
19
+ return { args: parsed, valid: true };
20
+ }
21
+ }
22
+ catch {
23
+ // fall through to the invalid wrapper
24
+ }
25
+ return { args: { _invalid_arguments: json }, valid: false };
26
+ }
package/package.json CHANGED
@@ -1,13 +1,61 @@
1
1
  {
2
2
  "name": "babelgate",
3
- "version": "0.0.0",
4
- "description": "",
5
- "main": "index.js",
3
+ "version": "1.1.1",
4
+ "bin": {
5
+ "babelgate": "dist/cli.js"
6
+ },
7
+ "files": [
8
+ "dist",
9
+ "README.md",
10
+ "LICENSE",
11
+ "NOTICE"
12
+ ],
6
13
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
14
+ "start": "node dist/index.js",
15
+ "build": "tsc -p tsconfig.build.json",
16
+ "dev": "nodemon --watch src --ext ts --exec \"ts-node -r dotenv/config src/index.ts\"",
17
+ "typecheck": "tsc --noEmit",
18
+ "test": "vitest run",
19
+ "record:streams": "ts-node -r dotenv/config scripts/record-streams.ts",
20
+ "test:live": "vitest run --config vitest.live.config.ts",
21
+ "test:watch": "vitest",
22
+ "prepublishOnly": "./scripts/check.sh"
8
23
  },
9
24
  "keywords": [],
10
- "author": "",
11
- "license": "ISC",
12
- "type": "commonjs"
25
+ "author": "Dorrabb Khan",
26
+ "license": "Apache-2.0",
27
+ "description": "babelgate — proxy that translates between LLM API formats (OpenAI, Gemini, Ollama, Anthropic) via a canonical intermediate format",
28
+ "devDependencies": {
29
+ "@anthropic-ai/sdk": "^0.132.1",
30
+ "@google/generative-ai": "^0.24.1",
31
+ "@types/express": "^5.0.3",
32
+ "@types/node": "^24.1.0",
33
+ "@types/pino": "^7.0.4",
34
+ "@types/supertest": "^7.2.1",
35
+ "msw": "^2.15.0",
36
+ "nodemon": "^3.1.10",
37
+ "ollama": "^0.6.4",
38
+ "openai": "^7.30.1",
39
+ "pino-pretty": "^13.2.0",
40
+ "supertest": "^7.3.1",
41
+ "ts-node": "^10.9.2",
42
+ "typescript": "^5.8.3",
43
+ "vitest": "^3.2.4"
44
+ },
45
+ "dependencies": {
46
+ "dotenv": "^17.2.1",
47
+ "express": "^5.1.0",
48
+ "pino": "^10.4.0",
49
+ "pino-http": "^11.0.0",
50
+ "zod": "^4.6.5"
51
+ },
52
+ "engines": {
53
+ "node": ">=20"
54
+ },
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "git+https://github.com/dorrabbkhan/babelgate.git"
58
+ },
59
+ "bugs": "https://github.com/dorrabbkhan/babelgate/issues",
60
+ "homepage": "https://github.com/dorrabbkhan/babelgate#readme"
13
61
  }