@khanglvm/llm-router 1.0.5

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.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * OpenAI -> Claude request translator.
3
+ */
4
+
5
+ const DEFAULT_MAX_TOKENS = 1024;
6
+
7
+ function safeJsonParse(raw, fallback = {}) {
8
+ if (typeof raw !== "string" || raw.length === 0) return fallback;
9
+ try {
10
+ return JSON.parse(raw);
11
+ } catch {
12
+ return fallback;
13
+ }
14
+ }
15
+
16
+ function normalizeTextContent(content) {
17
+ if (typeof content === "string") return content;
18
+ if (Array.isArray(content)) {
19
+ const text = [];
20
+ for (const part of content) {
21
+ if (!part || typeof part !== "object") continue;
22
+ if ((part.type === "text" || part.type === "input_text") && typeof part.text === "string") {
23
+ text.push(part.text);
24
+ }
25
+ }
26
+ return text.join("\n");
27
+ }
28
+ return "";
29
+ }
30
+
31
+ function parseDataUrl(url) {
32
+ if (typeof url !== "string") return null;
33
+ const match = url.match(/^data:([^;]+);base64,(.+)$/);
34
+ if (!match) return null;
35
+ return {
36
+ mediaType: match[1],
37
+ data: match[2]
38
+ };
39
+ }
40
+
41
+ function convertOpenAIContentToClaudeBlocks(content) {
42
+ if (typeof content === "string") {
43
+ return [{ type: "text", text: content }];
44
+ }
45
+
46
+ if (!Array.isArray(content)) {
47
+ return [{ type: "text", text: "" }];
48
+ }
49
+
50
+ const blocks = [];
51
+ for (const part of content) {
52
+ if (!part || typeof part !== "object") continue;
53
+
54
+ if ((part.type === "text" || part.type === "input_text") && typeof part.text === "string") {
55
+ blocks.push({ type: "text", text: part.text });
56
+ continue;
57
+ }
58
+
59
+ if (part.type === "image_url" && part.image_url?.url) {
60
+ const parsed = parseDataUrl(part.image_url.url);
61
+ if (parsed) {
62
+ blocks.push({
63
+ type: "image",
64
+ source: {
65
+ type: "base64",
66
+ media_type: parsed.mediaType,
67
+ data: parsed.data
68
+ }
69
+ });
70
+ } else {
71
+ // Claude image blocks do not accept remote URLs directly.
72
+ blocks.push({
73
+ type: "text",
74
+ text: `[image_url:${part.image_url.url}]`
75
+ });
76
+ }
77
+ continue;
78
+ }
79
+ }
80
+
81
+ if (blocks.length === 0) {
82
+ blocks.push({ type: "text", text: "" });
83
+ }
84
+ return blocks;
85
+ }
86
+
87
+ function mapToolChoice(choice) {
88
+ if (!choice) return undefined;
89
+ if (typeof choice === "string") {
90
+ if (choice === "required") return { type: "any" };
91
+ if (choice === "none") return { type: "auto" };
92
+ return { type: "auto" };
93
+ }
94
+
95
+ if (choice.type === "function") {
96
+ return {
97
+ type: "tool",
98
+ name: choice.function?.name
99
+ };
100
+ }
101
+
102
+ if (choice.type === "auto") return { type: "auto" };
103
+ if (choice.type === "any") return { type: "any" };
104
+ if (choice.type === "tool") return { type: "tool", name: choice.name };
105
+
106
+ return { type: "auto" };
107
+ }
108
+
109
+ function normalizeSystemText(messages, explicitSystem) {
110
+ const parts = [];
111
+ if (typeof explicitSystem === "string" && explicitSystem.trim()) {
112
+ parts.push(explicitSystem);
113
+ } else if (Array.isArray(explicitSystem)) {
114
+ for (const item of explicitSystem) {
115
+ if (item?.type === "text" && typeof item.text === "string") {
116
+ parts.push(item.text);
117
+ }
118
+ }
119
+ }
120
+
121
+ for (const message of messages) {
122
+ if (message?.role !== "system") continue;
123
+ const text = normalizeTextContent(message.content);
124
+ if (text) parts.push(text);
125
+ }
126
+
127
+ return parts.join("\n").trim();
128
+ }
129
+
130
+ function convertOpenAIMessages(messages) {
131
+ const result = [];
132
+
133
+ for (const message of messages) {
134
+ if (!message || typeof message !== "object") continue;
135
+
136
+ if (message.role === "system") {
137
+ continue;
138
+ }
139
+
140
+ if (message.role === "tool") {
141
+ const toolUseId = message.tool_call_id || message.tool_use_id;
142
+ if (!toolUseId) continue;
143
+
144
+ const content = normalizeTextContent(message.content);
145
+ result.push({
146
+ role: "user",
147
+ content: [
148
+ {
149
+ type: "tool_result",
150
+ tool_use_id: toolUseId,
151
+ content
152
+ }
153
+ ]
154
+ });
155
+ continue;
156
+ }
157
+
158
+ if (message.role === "assistant") {
159
+ const contentBlocks = convertOpenAIContentToClaudeBlocks(message.content);
160
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
161
+
162
+ for (const call of toolCalls) {
163
+ if (!call || typeof call !== "object") continue;
164
+ contentBlocks.push({
165
+ type: "tool_use",
166
+ id: call.id || `tool_${Date.now()}`,
167
+ name: call.function?.name || "tool",
168
+ input: safeJsonParse(call.function?.arguments, {})
169
+ });
170
+ }
171
+
172
+ result.push({
173
+ role: "assistant",
174
+ content: contentBlocks
175
+ });
176
+ continue;
177
+ }
178
+
179
+ // default user role
180
+ result.push({
181
+ role: "user",
182
+ content: convertOpenAIContentToClaudeBlocks(message.content)
183
+ });
184
+ }
185
+
186
+ return result;
187
+ }
188
+
189
+ /**
190
+ * Convert OpenAI chat completion request to Claude messages request.
191
+ */
192
+ export function openAIToClaudeRequest(model, body, stream = false) {
193
+ const messages = Array.isArray(body?.messages) ? body.messages : [];
194
+ const result = {
195
+ model,
196
+ messages: convertOpenAIMessages(messages),
197
+ stream: Boolean(stream),
198
+ max_tokens: Number.isFinite(body?.max_tokens)
199
+ ? Number(body.max_tokens)
200
+ : (Number.isFinite(body?.max_completion_tokens) ? Number(body.max_completion_tokens) : DEFAULT_MAX_TOKENS)
201
+ };
202
+
203
+ const system = normalizeSystemText(messages, body?.system);
204
+ if (system) {
205
+ result.system = system;
206
+ }
207
+
208
+ if (body?.temperature !== undefined) {
209
+ result.temperature = body.temperature;
210
+ }
211
+
212
+ if (Array.isArray(body?.tools)) {
213
+ result.tools = body.tools
214
+ .map((tool) => {
215
+ if (!tool || typeof tool !== "object") return null;
216
+ if (tool.type === "function" && tool.function) {
217
+ return {
218
+ name: tool.function.name,
219
+ description: tool.function.description,
220
+ input_schema: tool.function.parameters || { type: "object", properties: {} }
221
+ };
222
+ }
223
+ if (tool.name) {
224
+ return {
225
+ name: tool.name,
226
+ description: tool.description,
227
+ input_schema: tool.input_schema || { type: "object", properties: {} }
228
+ };
229
+ }
230
+ return null;
231
+ })
232
+ .filter(Boolean);
233
+ }
234
+
235
+ const mappedChoice = mapToolChoice(body?.tool_choice);
236
+ if (mappedChoice) {
237
+ result.tool_choice = mappedChoice;
238
+ }
239
+
240
+ return result;
241
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Claude (Anthropic) -> OpenAI response translator (stream + non-stream helpers).
3
+ */
4
+
5
+ function mapStopReason(reason) {
6
+ switch (reason) {
7
+ case "max_tokens":
8
+ return "length";
9
+ case "tool_use":
10
+ return "tool_calls";
11
+ case "end_turn":
12
+ case "stop_sequence":
13
+ default:
14
+ return "stop";
15
+ }
16
+ }
17
+
18
+ function toOpenAIUsage(usage) {
19
+ if (!usage || typeof usage !== "object") return undefined;
20
+ const prompt = usage.input_tokens || 0;
21
+ const completion = usage.output_tokens || 0;
22
+ return {
23
+ prompt_tokens: prompt,
24
+ completion_tokens: completion,
25
+ total_tokens: prompt + completion
26
+ };
27
+ }
28
+
29
+ function stringifyToolInput(input) {
30
+ if (typeof input === "string") return input;
31
+ try {
32
+ return JSON.stringify(input || {});
33
+ } catch {
34
+ return "{}";
35
+ }
36
+ }
37
+
38
+ export function claudeToOpenAINonStreamResponse(message) {
39
+ const contentBlocks = Array.isArray(message?.content) ? message.content : [];
40
+ const textParts = [];
41
+ const toolCalls = [];
42
+
43
+ for (let i = 0; i < contentBlocks.length; i += 1) {
44
+ const block = contentBlocks[i];
45
+ if (!block || typeof block !== "object") continue;
46
+
47
+ if (block.type === "text" && typeof block.text === "string") {
48
+ textParts.push(block.text);
49
+ continue;
50
+ }
51
+
52
+ if (block.type === "tool_use") {
53
+ toolCalls.push({
54
+ id: block.id || `call_${i}`,
55
+ type: "function",
56
+ function: {
57
+ name: block.name || "tool",
58
+ arguments: stringifyToolInput(block.input)
59
+ }
60
+ });
61
+ }
62
+ }
63
+
64
+ const text = textParts.join("");
65
+ const responseMessage = {
66
+ role: "assistant",
67
+ content: text.length > 0 ? text : null
68
+ };
69
+
70
+ if (toolCalls.length > 0) {
71
+ responseMessage.tool_calls = toolCalls;
72
+ }
73
+
74
+ return {
75
+ id: message?.id?.startsWith("chatcmpl_") ? message.id : `chatcmpl_${message?.id || Date.now()}`,
76
+ object: "chat.completion",
77
+ created: Math.floor(Date.now() / 1000),
78
+ model: message?.model || "unknown",
79
+ choices: [
80
+ {
81
+ index: 0,
82
+ message: responseMessage,
83
+ finish_reason: mapStopReason(message?.stop_reason)
84
+ }
85
+ ],
86
+ usage: toOpenAIUsage(message?.usage)
87
+ };
88
+ }
89
+
90
+ export function initClaudeToOpenAIState() {
91
+ return {
92
+ chatId: `chatcmpl_${Date.now()}`,
93
+ created: Math.floor(Date.now() / 1000),
94
+ model: "unknown",
95
+ toolCallByBlockIndex: new Map(),
96
+ nextToolCallIndex: 0,
97
+ usage: undefined
98
+ };
99
+ }
100
+
101
+ function makeChunk(state, delta = {}, finishReason = null, usage = undefined) {
102
+ const chunk = {
103
+ id: state.chatId,
104
+ object: "chat.completion.chunk",
105
+ created: state.created,
106
+ model: state.model || "unknown",
107
+ choices: [
108
+ {
109
+ index: 0,
110
+ delta,
111
+ finish_reason: finishReason
112
+ }
113
+ ]
114
+ };
115
+
116
+ if (usage) {
117
+ chunk.usage = usage;
118
+ }
119
+
120
+ return chunk;
121
+ }
122
+
123
+ export function claudeEventToOpenAIChunks(eventType, event, state) {
124
+ if (!event || typeof event !== "object") {
125
+ if (eventType === "message_stop") return ["[DONE]"];
126
+ return [];
127
+ }
128
+
129
+ switch (eventType) {
130
+ case "message_start": {
131
+ const message = event.message || {};
132
+ state.chatId = message.id?.startsWith("chatcmpl_") ? message.id : `chatcmpl_${message.id || Date.now()}`;
133
+ state.model = message.model || state.model || "unknown";
134
+ state.usage = toOpenAIUsage(message.usage);
135
+ return [makeChunk(state, { role: "assistant" }, null)];
136
+ }
137
+
138
+ case "content_block_start": {
139
+ const block = event.content_block || {};
140
+ if (block.type !== "tool_use") return [];
141
+
142
+ const toolIndex = state.nextToolCallIndex++;
143
+ state.toolCallByBlockIndex.set(event.index, toolIndex);
144
+
145
+ return [
146
+ makeChunk(state, {
147
+ tool_calls: [
148
+ {
149
+ index: toolIndex,
150
+ id: block.id || `call_${toolIndex}`,
151
+ type: "function",
152
+ function: {
153
+ name: block.name || "tool",
154
+ arguments: ""
155
+ }
156
+ }
157
+ ]
158
+ }, null)
159
+ ];
160
+ }
161
+
162
+ case "content_block_delta": {
163
+ const delta = event.delta || {};
164
+ if (delta.type === "text_delta") {
165
+ return [makeChunk(state, { content: delta.text || "" }, null)];
166
+ }
167
+
168
+ if (delta.type === "input_json_delta") {
169
+ const toolIndex = state.toolCallByBlockIndex.get(event.index);
170
+ if (toolIndex === undefined) return [];
171
+ return [
172
+ makeChunk(state, {
173
+ tool_calls: [
174
+ {
175
+ index: toolIndex,
176
+ function: {
177
+ arguments: delta.partial_json || ""
178
+ }
179
+ }
180
+ ]
181
+ }, null)
182
+ ];
183
+ }
184
+
185
+ // thinking_delta and other Anthropic-specific blocks are ignored for OpenAI chat compat.
186
+ return [];
187
+ }
188
+
189
+ case "message_delta": {
190
+ const usage = toOpenAIUsage(event.usage);
191
+ if (usage) state.usage = usage;
192
+ const stopReason = event.delta?.stop_reason ? mapStopReason(event.delta.stop_reason) : null;
193
+ if (!stopReason && !usage) return [];
194
+ return [makeChunk(state, {}, stopReason, usage)];
195
+ }
196
+
197
+ case "message_stop":
198
+ return ["[DONE]"];
199
+
200
+ default:
201
+ return [];
202
+ }
203
+ }
204
+
@@ -0,0 +1,197 @@
1
+ /**
2
+ * OpenAI -> Claude (Anthropic) Response Translator
3
+ */
4
+
5
+ import { FORMATS } from "../formats.js";
6
+
7
+ /**
8
+ * Convert OpenAI stream chunk to Claude format
9
+ */
10
+ export function openaiToClaudeResponse(chunk, state) {
11
+ if (!chunk || !chunk.choices?.[0]) return null;
12
+
13
+ const results = [];
14
+ const choice = chunk.choices[0];
15
+ const delta = choice.delta;
16
+
17
+ // Track usage
18
+ if (chunk.usage && typeof chunk.usage === "object") {
19
+ const promptTokens = chunk.usage.prompt_tokens || 0;
20
+ const outputTokens = chunk.usage.completion_tokens || 0;
21
+
22
+ state.usage = {
23
+ input_tokens: promptTokens,
24
+ output_tokens: outputTokens
25
+ };
26
+ }
27
+
28
+ // First chunk - send message_start
29
+ if (!state.messageStartSent) {
30
+ state.messageStartSent = true;
31
+ state.messageId = chunk.id?.replace("chatcmpl-", "") || `msg_${Date.now()}`;
32
+ state.model = chunk.model || "unknown";
33
+ state.nextBlockIndex = 0;
34
+
35
+ results.push({
36
+ type: "message_start",
37
+ message: {
38
+ id: state.messageId,
39
+ type: "message",
40
+ role: "assistant",
41
+ model: state.model,
42
+ content: [],
43
+ stop_reason: null,
44
+ stop_sequence: null,
45
+ usage: { input_tokens: 0, output_tokens: 0 }
46
+ }
47
+ });
48
+ }
49
+
50
+ // Handle thinking/reasoning content
51
+ const reasoningContent = delta?.reasoning_content || delta?.reasoning;
52
+ if (reasoningContent) {
53
+ stopTextBlock(state, results);
54
+
55
+ if (!state.thinkingBlockStarted) {
56
+ state.thinkingBlockIndex = state.nextBlockIndex++;
57
+ state.thinkingBlockStarted = true;
58
+ results.push({
59
+ type: "content_block_start",
60
+ index: state.thinkingBlockIndex,
61
+ content_block: { type: "thinking", thinking: "" }
62
+ });
63
+ }
64
+
65
+ results.push({
66
+ type: "content_block_delta",
67
+ index: state.thinkingBlockIndex,
68
+ delta: { type: "thinking_delta", thinking: reasoningContent }
69
+ });
70
+ }
71
+
72
+ // Handle regular content
73
+ if (delta?.content) {
74
+ stopThinkingBlock(state, results);
75
+
76
+ if (!state.textBlockStarted) {
77
+ state.textBlockIndex = state.nextBlockIndex++;
78
+ state.textBlockStarted = true;
79
+ state.textBlockClosed = false;
80
+ results.push({
81
+ type: "content_block_start",
82
+ index: state.textBlockIndex,
83
+ content_block: { type: "text", text: "" }
84
+ });
85
+ }
86
+
87
+ results.push({
88
+ type: "content_block_delta",
89
+ index: state.textBlockIndex,
90
+ delta: { type: "text_delta", text: delta.content }
91
+ });
92
+ }
93
+
94
+ // Tool calls
95
+ if (delta?.tool_calls) {
96
+ for (const tc of delta.tool_calls) {
97
+ const idx = tc.index ?? 0;
98
+
99
+ if (tc.id) {
100
+ stopThinkingBlock(state, results);
101
+ stopTextBlock(state, results);
102
+
103
+ const toolBlockIndex = state.nextBlockIndex++;
104
+ state.toolCalls.set(idx, {
105
+ id: tc.id,
106
+ name: tc.function?.name || "",
107
+ blockIndex: toolBlockIndex
108
+ });
109
+
110
+ results.push({
111
+ type: "content_block_start",
112
+ index: toolBlockIndex,
113
+ content_block: {
114
+ type: "tool_use",
115
+ id: tc.id,
116
+ name: tc.function?.name || "",
117
+ input: {}
118
+ }
119
+ });
120
+ }
121
+
122
+ if (tc.function?.arguments) {
123
+ const toolInfo = state.toolCalls.get(idx);
124
+ if (toolInfo) {
125
+ results.push({
126
+ type: "content_block_delta",
127
+ index: toolInfo.blockIndex,
128
+ delta: { type: "input_json_delta", partial_json: tc.function.arguments }
129
+ });
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ // Finish
136
+ if (choice.finish_reason) {
137
+ stopThinkingBlock(state, results);
138
+ stopTextBlock(state, results);
139
+
140
+ // Stop all tool blocks
141
+ for (const [, toolInfo] of state.toolCalls) {
142
+ results.push({
143
+ type: "content_block_stop",
144
+ index: toolInfo.blockIndex
145
+ });
146
+ }
147
+
148
+ state.finishReason = choice.finish_reason;
149
+
150
+ const finalUsage = state.usage || { input_tokens: 0, output_tokens: 0 };
151
+ results.push({
152
+ type: "message_delta",
153
+ delta: { stop_reason: convertFinishReason(choice.finish_reason) },
154
+ usage: finalUsage
155
+ });
156
+ results.push({ type: "message_stop" });
157
+ }
158
+
159
+ return results.length > 0 ? results : null;
160
+ }
161
+
162
+ /**
163
+ * Stop thinking block
164
+ */
165
+ function stopThinkingBlock(state, results) {
166
+ if (!state.thinkingBlockStarted) return;
167
+ results.push({
168
+ type: "content_block_stop",
169
+ index: state.thinkingBlockIndex
170
+ });
171
+ state.thinkingBlockStarted = false;
172
+ }
173
+
174
+ /**
175
+ * Stop text block
176
+ */
177
+ function stopTextBlock(state, results) {
178
+ if (!state.textBlockStarted || state.textBlockClosed) return;
179
+ state.textBlockClosed = true;
180
+ results.push({
181
+ type: "content_block_stop",
182
+ index: state.textBlockIndex
183
+ });
184
+ state.textBlockStarted = false;
185
+ }
186
+
187
+ /**
188
+ * Convert finish reason
189
+ */
190
+ function convertFinishReason(reason) {
191
+ switch (reason) {
192
+ case "stop": return "end_turn";
193
+ case "length": return "max_tokens";
194
+ case "tool_calls": return "tool_use";
195
+ default: return "end_turn";
196
+ }
197
+ }
package/wrangler.toml ADDED
@@ -0,0 +1,20 @@
1
+ name = "llm-router-route"
2
+ main = "src/index.js"
3
+ compatibility_date = "2024-01-01"
4
+ workers_dev = false
5
+ preview_urls = false
6
+
7
+ # No custom routes are defined by default.
8
+ # Configure routes/domains per environment in your own deployment setup.
9
+
10
+ # Optional non-secret vars
11
+ [vars]
12
+ ENVIRONMENT = "production"
13
+
14
+ # Required secret(s):
15
+ # LLM_ROUTER_CONFIG_JSON - All-in-one JSON config exported by:
16
+ # llm-router deploy --export-only=true --out=.llm-router.worker.json
17
+ # wrangler secret put LLM_ROUTER_CONFIG_JSON < .llm-router.worker.json
18
+ #
19
+ # Optional override:
20
+ # LLM_ROUTER_MASTER_KEY - Overrides config.masterKey at runtime