hongshi-files commited on
Commit
e7476bb
·
verified ·
1 Parent(s): e86c08a

Update main.ts

Browse files
Files changed (1) hide show
  1. main.ts +260 -911
main.ts CHANGED
@@ -1,925 +1,274 @@
1
- /*
2
- Copyright (c) 2025 Neuroplexus
3
-
4
- This software is provided "as is", without warranty of any kind, express or
5
- implied, including but not limited to the warranties of merchantability,
6
- fitness for a particular purpose and noninfringement. In no event shall the
7
- authors or copyright holders be liable for any claim, damages or other
8
- liability, whether in an action of contract, tort or otherwise, arising from,
9
- out of or in connection with the software or the use or other dealings in the
10
- software.
11
-
12
- This software is licensed under the Neuroplexus Non-Commercial Share-Alike License (the "License");
13
- you may not use this file except in compliance with the License.
14
-
15
- Terms and Conditions:
16
-
17
- 1. Non-Commercial Use Only:
18
-
19
- This software and its associated documentation (collectively, the "Software")
20
- are strictly prohibited from being used, directly or indirectly, for any
21
- commercial purpose. "Commercial purpose" includes, but is not limited to:
22
- * Use in a product or service offered for sale or other consideration.
23
- * Use in a product or service that provides a competitive advantage in a
24
- commercial setting.
25
- * Use in internal business operations that generate revenue or provide
26
- cost savings directly attributable to the Software.
27
- * Use in training or educational programs for which a fee is charged.
28
- * Use to support any for-profit entity, regardless of whether the software
29
- itself is sold.
30
- * Reselling, or sublicensing this software.
31
- If you require a commercial license, please contact Neuroplexus at isneuroplexus@duck.com.
32
-
33
- 2. Attribution and Original Author/Project Notice:
34
-
35
- Any use, distribution, or modification of the Software (in whole or in part)
36
- must prominently include the following:
37
- * The original copyright notice: `Copyright (c) 2025 Neuroplexus`
38
- * A clear and unambiguous statement identifying Neuroplexus as the original
39
- author of the Software.
40
- * A link or reference to the original project location (e.g., a URL to a
41
- repository, if applicable). For example: "Based on the Neuroplexus
42
- HuanyuanInterface project, available at https://linux.do/t/topic/507324.
43
-
44
- 3. Share-Alike (Derivative Works):
45
-
46
- If you modify the Software, any distribution of the modified version (the
47
- "Derivative Work") must be licensed under the *same* terms and conditions as
48
- this License (Neuroplexus Non-Commercial Share-Alike License). This means:
49
- * The Derivative Work must also be restricted to non-commercial use.
50
- * The Derivative Work must include the attribution requirements outlined
51
- in Section 2.
52
- * The source code of the Derivative Work must be made available under
53
- this same License.
54
-
55
- 4. Modification Notices:
56
-
57
- Any Derivative Work must include prominent notices stating that you have
58
- modified the Software, and the date and nature of the changes made. These
59
- notices must be placed:
60
- * In the source code files that have been modified.
61
- * In a separate `CHANGELOG` or `MODIFICATIONS` file included with the
62
- Derivative Work's distribution. This file should clearly list all
63
- modifications made to the original Software.
64
-
65
- 5. No Endorsement:
66
-
67
- The names of Neuroplexus or its contributors may not be used to endorse or
68
- promote products derived from this Software without specific prior written
69
- permission.
70
-
71
- 6. Termination:
72
-
73
- This License automatically terminates if you violate any of its terms and
74
- conditions. Upon termination, you must cease all use, distribution, and
75
- modification of the Software and destroy all copies in your possession.
76
-
77
- 7. Severability:
78
-
79
- If any provision of this License is held to be invalid or unenforceable, the
80
- remaining provisions shall remain in full force and effect.
81
-
82
- 8. Governing Law:
83
-
84
- This License shall be governed by and construed in accordance with the laws
85
- of New South Wales, Australia, without
86
- regard to its conflict of law principles.
87
-
88
- 9. Entire Agreement:
89
-
90
- This license constitutes the entire agreement with respect to the software.
91
- Neuroplexus is not bound by any additional provisions that may appear in any
92
- communication from you.
93
- */
94
-
95
- import { Application, Router, Context } from "https://deno.land/x/oak@v12.6.1/mod.ts";
96
- import { Buffer } from "https://deno.land/std@0.152.0/io/buffer.ts"; // Not used, can be removed
97
-
98
- const HUNYUAN_API_URL = "http://llm.hunyuan.tencent.com/aide/api/v2/triton_image/demo_text_chat/"; // Consider making this configurable
99
- const DEFAULT_STAFFNAME = "staryxzhang"; // Consider making this configurable
100
- const DEFAULT_WSID = "10697"; // Consider making this configurable
101
- const API_KEY = "7auGXNATFSKl7dc"; // Consider loading this from an environment variable or config file
102
-
103
- interface HunyuanMessage {
104
- role: string;
105
- content: string;
106
- reasoning_content?: string;
107
- }
108
-
109
- interface HunyuanRequest {
110
- stream: boolean;
111
- model: string;
112
- query_id: string;
113
- messages: HunyuanMessage[];
114
- stream_moderation: boolean;
115
- enable_enhancement: boolean;
116
- }
117
-
118
- // These interfaces can be combined for better readability
119
- interface OpenAIChoiceBase {
120
- index: number;
121
- finish_reason: string | null;
122
- }
123
-
124
- interface OpenAIChoiceDelta extends OpenAIChoiceBase {
125
- delta: {
126
- role?: string;
127
- content?: string;
128
- reasoning_content?: string;
129
- };
130
- }
131
-
132
- interface OpenAIChoiceNonStream extends OpenAIChoiceBase {
133
- message: {
134
- role: string;
135
- content: string;
136
- reasoning_content?: string;
137
- };
138
- }
139
-
140
- interface OpenAIStreamResponse {
141
- id: string;
142
- object: string;
143
- created: number;
144
- model: string;
145
- system_fingerprint: string;
146
- choices: OpenAIChoiceDelta[];
147
- note?: string; // Rarely used, consider removing if not needed
148
- }
149
-
150
- interface OpenAIResponseNonStream {
151
- id: string;
152
- object: string;
153
- created: number;
154
- model: string;
155
- choices: OpenAIChoiceNonStream[];
156
- usage?: { // Placeholder for now
157
- prompt_tokens: number;
158
- completion_tokens: number;
159
- total_tokens: number;
160
- };
161
- }
162
-
163
- interface OpenAIModel {
164
- id: string;
165
- object: string;
166
- created: number;
167
- owned_by: string;
168
- }
169
-
170
- interface OpenAIModelsResponse {
171
- object: string;
172
- data: OpenAIModel[];
173
- }
174
-
175
- // Helper function to get Hunyuan model name from OpenAI model name
176
- function getHunyuanModelName(openaiModelName: string): string {
177
- switch (openaiModelName) {
178
- case "hunyuan-turbos-latest":
179
- return "hunyuan-turbos-latest";
180
- case "hunyuan-t1-latest": // Fallthrough is intentional
181
- default:
182
- return "hunyuan-t1-latest";
183
- }
184
- }
185
-
186
- async function hunyuanToOpenAIStream(
187
- hunyuanResponse: Response,
188
- openaiModelName: string,
189
- ): Promise<ReadableStream<string>> {
190
- const decoder = new TextDecoder("utf-8");
191
- let buffer = "";
192
-
193
- return new ReadableStream<string>({
194
- async start(controller) {
195
- if (!hunyuanResponse.body) {
196
- controller.close();
197
- return;
198
- }
199
- const reader = hunyuanResponse.body.getReader();
200
-
201
- try {
202
- while (true) {
203
- const { done, value } = await reader.read();
204
- if (done) { break; }
205
- buffer += decoder.decode(value);
206
-
207
- let boundary = buffer.indexOf("\n\n");
208
- while (boundary !== -1) {
209
- const chunk = buffer.substring(0, boundary).trim();
210
- buffer = buffer.substring(boundary + 2);
211
- boundary = buffer.indexOf("\n\n");
212
-
213
- if (chunk.startsWith("data:")) {
214
- const jsonStr = chunk.substring(5).trim();
215
- if (jsonStr === "[DONE]") {
216
- controller.enqueue(`data: [DONE]\n\n`);
217
- continue;
218
- }
219
- try {
220
- const hunyuanData = JSON.parse(jsonStr);
221
- const openaiData: OpenAIStreamResponse = {
222
- id: hunyuanData.id,
223
- object: "chat.completion.chunk",
224
- created: hunyuanData.created,
225
- model: openaiModelName,
226
- system_fingerprint: hunyuanData.system_fingerprint,
227
- choices: hunyuanData.choices.map((choice): OpenAIChoiceDelta => ({
228
- delta: {
229
- role: choice.delta.role,
230
- content: choice.delta.content,
231
- reasoning_content: choice.delta.reasoning_content,
232
- },
233
- index: choice.index,
234
- finish_reason: choice.finish_reason,
235
- })),
236
- };
237
- controller.enqueue(`data: ${JSON.stringify(openaiData)}\n\n`);
238
- } catch (error) {
239
- console.error("Error parsing stream chunk:", error, jsonStr);
240
- }
241
- }
242
- }
243
- }
244
- } finally {
245
- reader.releaseLock();
246
- controller.close();
247
- }
248
- },
249
  });
250
- }
251
- async function hunyuanToOpenAINonStream(
252
- hunyuanResponse: Response,
253
- openaiModelName: string,
254
- ): Promise<OpenAIResponseNonStream> {
255
- const decoder = new TextDecoder("utf-8");
256
- let buffer = "";
257
- let allChoices: OpenAIChoiceNonStream[] = []; // Accumulate choices
258
- let finalId = "";
259
- let finalCreated = 0;
260
- let finalModel = openaiModelName;
261
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
263
- if (!hunyuanResponse.body) {
264
- throw new Error("Hunyuan response body is empty.");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  }
266
- const reader = hunyuanResponse.body.getReader();
267
-
268
- try {
269
- while (true) {
270
- const { done, value } = await reader.read();
271
- if (done) {
272
- break;
273
- }
274
- const text = decoder.decode(value);
275
- buffer += text;
276
-
277
- let boundary = buffer.indexOf("\n\n");
278
- while (boundary !== -1) {
279
- const chunk = buffer.substring(0, boundary).trim();
280
- buffer = buffer.substring(boundary + 2);
281
- boundary = buffer.indexOf("\n\n");
282
-
283
- if (chunk.startsWith("data:")) {
284
- const jsonStr = chunk.substring(5).trim();
285
 
286
- if (jsonStr === "[DONE]") {
287
- continue;
288
- }
289
-
290
- try {
291
- const hunyuanData: OpenAIStreamResponse = JSON.parse(jsonStr); // Correct type
292
- finalId = hunyuanData.id; // Get id and created from last chunk
293
- finalCreated = hunyuanData.created;
294
-
295
- // Accumulate choices, extracting content correctly.
296
- hunyuanData.choices.forEach(choice => {
297
- const existingChoice = allChoices.find(c => c.index === choice.index);
298
- if (existingChoice) {
299
- //append new content
300
- existingChoice.message.content += choice.delta.content || "";
301
- existingChoice.message.reasoning_content = (existingChoice.message.reasoning_content || "") + (choice.delta.reasoning_content || "");
302
- if (choice.finish_reason) {
303
- existingChoice.finish_reason = choice.finish_reason;
304
- }
305
- } else {
306
- //new choice
307
- allChoices.push({
308
- message: {
309
- role: choice.delta.role || "assistant", // Default to "assistant" if role is missing
310
- content: choice.delta.content || "",
311
- reasoning_content: choice.delta.reasoning_content,
312
- },
313
- index: choice.index,
314
- finish_reason: choice.finish_reason,
315
- });
316
- }
317
- });
318
-
319
- } catch (error) {
320
- console.error("Error parsing Hunyuan response chunk:", error, "Chunk:", jsonStr);
321
- throw new Error(`Error parsing Hunyuan response: ${error}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  }
323
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  }
325
- }
326
- } finally {
327
- reader.releaseLock();
328
- }
329
-
330
- if (allChoices.length === 0) {
331
- throw new Error("Failed to receive data from Hunyuan API.");
332
- }
333
-
334
- const openaiResponse: OpenAIResponseNonStream = {
335
- id: finalId,
336
- object: "chat.completion",
337
- created: finalCreated,
338
- model: finalModel,
339
- choices: allChoices,
340
- usage: { // Still placeholder, see notes below
341
- prompt_tokens: 0,
342
- completion_tokens: 0,
343
- total_tokens: 0,
344
- },
345
- };
346
- return openaiResponse;
347
- }
348
 
349
- async function handleChatCompletion(ctx: Context) {
350
- try {
351
- const authHeader = ctx.request.headers.get("Authorization");
352
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
353
- ctx.response.status = 401;
354
- ctx.response.body = { error: "Unauthorized: Missing or invalid API key" };
355
- return;
 
 
 
 
 
 
 
 
 
 
 
356
  }
357
- const apiKey = authHeader.substring(7);
358
-
359
- const body = await ctx.request.body({ type: "json" }).value;
360
-
361
- if (!body || !body.messages || !Array.isArray(body.messages)) {
362
- ctx.response.status = 400;
363
- ctx.response.body = { error: "Invalid request body: 'messages' array is required." };
364
- return;
365
- }
366
-
367
- const openaiModel = body.model || "hunyuan-t1-latest";
368
- const hunyuanModel = getHunyuanModelName(openaiModel);
369
- const stream = body.stream !== undefined ? body.stream : true;
370
-
371
- const hunyuanMessages: HunyuanMessage[] = body.messages.map((msg: any) => ({
372
- role: msg.role,
373
- content: msg.content,
374
- reasoning_content: msg.reasoning_content, // Pass through reasoning_content
375
- }));
376
 
377
- const hunyuanRequest: HunyuanRequest = {
378
- stream: true, // Always stream to Hunyuan, then handle streaming/non-streaming for OpenAI
379
- model: hunyuanModel,
380
- query_id: crypto.randomUUID().replaceAll("-", ""),
381
- messages: hunyuanMessages,
382
- stream_moderation: true,
383
- enable_enhancement: false,
384
- };
385
 
 
 
 
 
 
386
 
387
- const hunyuanResponse = await fetch(HUNYUAN_API_URL, {
388
- method: "POST",
389
- headers: {
390
- "Host": "llm.hunyuan.tencent.com",
391
- "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0", // Consider making this configurable
392
- "Accept": "*/*",
393
- "Accept-Language": "en-US,en;q=0.5", // Consider making this configurable
394
- "Accept-Encoding": "gzip, deflate, br, zstd",
395
- "Referer": "https://llm.hunyuan.tencent.com/",
396
- "Content-Type": "application/json",
397
- "model": hunyuanModel, // Use the determined Hunyuan model
398
- "polaris": "stream-server-online-sbs-10697",
399
- "Authorization": `Bearer ${apiKey}`,
400
- "Wsid": DEFAULT_WSID,
401
- "staffname": DEFAULT_STAFFNAME,
402
- "Origin": "https://llm.hunyuan.tencent.com",
403
- "DNT": "1",
404
- "Sec-GPC": "1",
405
- "Connection": "keep-alive",
406
- "Sec-Fetch-Dest": "empty",
407
- "Sec-Fetch-Mode": "cors",
408
- "Sec-Fetch-Site": "same-origin",
409
- "Priority": "u=0",
410
- "Pragma": "no-cache",
411
- "Cache-Control": "no-cache",
412
- "TE": "trailers",
413
- },
414
- body: JSON.stringify(hunyuanRequest),
415
- });
416
-
417
- if (!hunyuanResponse.ok) {
418
- const errorText = await hunyuanResponse.text();
419
- console.error("Hunyuan API error:", hunyuanResponse.status, errorText);
420
- ctx.response.status = hunyuanResponse.status;
421
- ctx.response.body = { error: `Hunyuan API error: ${hunyuanResponse.status} - ${errorText}` };
422
- return;
423
- }
424
-
425
- if (stream) {
426
- const openaiStream = await hunyuanToOpenAIStream(hunyuanResponse, openaiModel);
427
- ctx.response.body = openaiStream;
428
- ctx.response.type = "text/event-stream";
429
- } else {
430
- const openaiResponse = await hunyuanToOpenAINonStream(hunyuanResponse, openaiModel);
431
- ctx.response.body = openaiResponse;
432
- ctx.response.type = "application/json";
433
- }
434
-
435
- } catch (error) {
436
- console.error("Error in chat completion:", error);
437
- ctx.response.status = 500;
438
- ctx.response.body = { error: "Internal Server Error" };
439
- }
440
- }
441
-
442
- async function handleModels(ctx: Context) {
443
- const models: OpenAIModelsResponse = {
444
- object: "list",
445
- data: [
446
- {
447
- id: "hunyuan-t1-latest",
448
- object: "model",
449
- created: Math.floor(Date.now() / 1000),
450
- owned_by: "tencent",
451
- },
452
- {
453
- id: "hunyuan-turbos-latest",
454
- object: "model",
455
- created: Math.floor(Date.now() / 1000), // Use current timestamp
456
- owned_by: "tencent",
457
- }
458
- ],
459
- };
460
- ctx.response.body = models;
461
- ctx.response.type = "application/json";
462
- }
463
-
464
- const sharedStyles = `
465
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
466
-
467
- :root {
468
- --background: #f0f2f5;
469
- --foreground: #2e3440;
470
- --primary: #5e81ac;
471
- --primary-foreground: #eceff4;
472
- --card: #ffffff;
473
- --card-foreground: #2e3440;
474
- --muted: #d8dee9;
475
- --muted-foreground: #4c566a;
476
- --border: #d8dee9;
477
- --radius: 8px;
478
- --header-bg: #3b4252;
479
- --header-fg: #eceff4;
480
- --link-color: #81a1c1;
481
- }
482
-
483
- * { margin: 0; padding: 0; box-sizing: border-box; }
484
-
485
- body {
486
- font-family: 'Inter', sans-serif;
487
- background-color: var(--background);
488
- color: var(--foreground);
489
- display: flex;
490
- flex-direction: column;
491
- min-height: 100vh;
492
- line-height: 1.6;
493
- }
494
-
495
- .header {
496
- background-color: var(--header-bg);
497
- color: var(--header-fg);
498
- padding: 1rem 0;
499
- width: 100%;
500
- text-align: center;
501
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
502
- }
503
-
504
- .header-content {
505
- display: flex;
506
- justify-content: space-between;
507
- align-items: center;
508
- max-width: 48rem;
509
- margin: 0 auto;
510
- padding: 0 1rem;
511
- }
512
-
513
- .header a {
514
- color: var(--header-fg);
515
- text-decoration: none;
516
- margin: 0 1rem;
517
- font-weight: 500;
518
- transition: color 0.2s;
519
- }
520
-
521
- .header a:hover {
522
- color: var(--link-color);
523
- }
524
- .branding {
525
- font-size: 1.25rem;
526
- font-weight: 600;
527
- }
528
-
529
-
530
- .container {
531
- width: 100%;
532
- max-width: 48rem;
533
- margin: 1.5rem auto;
534
- padding: 0 1rem;
535
- flex-grow: 1;
536
- }
537
-
538
- .card {
539
- background-color: var(--card);
540
- border-radius: var(--radius);
541
- box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
542
- padding: 1.5rem;
543
- margin-bottom: 1.5rem;
544
- }
545
-
546
- h1 {
547
- font-size: 2.25rem;
548
- font-weight: 700;
549
- margin-bottom: 1rem;
550
- color: var(--foreground);
551
- text-align: center;
552
- }
553
-
554
- h2 {
555
- font-size: 1.75rem;
556
- font-weight: 600;
557
- margin-bottom: 1rem;
558
- color: var(--foreground);
559
- }
560
-
561
- h3 {
562
- font-size: 1.25rem;
563
- font-weight: 600;
564
- margin-top: 1rem;
565
- margin-bottom: 0.5rem;
566
- color: var(--foreground);
567
- }
568
-
569
- p {
570
- color: var(--muted-foreground);
571
- font-size: 1rem;
572
- margin-bottom: 1rem;
573
- line-height: 1.5;
574
- }
575
-
576
- a {
577
- color: var(--link-color);
578
- text-decoration: none;
579
- }
580
- a:hover {
581
- text-decoration: underline;
582
- }
583
-
584
- pre {
585
- background-color: var(--muted);
586
- padding: 1rem;
587
- border-radius: var(--radius);
588
- overflow-x: auto;
589
- margin-bottom: 1rem;
590
- border: 1px solid var(--border);
591
- line-height: 1.4;
592
- }
593
-
594
- code {
595
- font-family: 'Courier New', Courier, monospace;
596
- font-size: 0.875rem;
597
- }
598
-
599
- .button {
600
- display: inline-flex;
601
- align-items: center;
602
- justify-content: center;
603
- white-space: nowrap;
604
- border-radius: var(--radius);
605
- height: 2.75rem;
606
- padding: 0 1.25rem;
607
- font-size: 1rem;
608
- font-weight: 500;
609
- transition: all 0.2s;
610
- cursor: pointer;
611
- text-decoration: none;
612
- background-color: var(--primary);
613
- color: var(--primary-foreground);
614
- border: none;
615
- }
616
-
617
- .button:hover {
618
- opacity: 0.9;
619
- }
620
-
621
- .footer {
622
- margin-top: auto;
623
- padding: 1rem 0;
624
- text-align: center;
625
- color: var(--muted-foreground);
626
- border-top: 1px solid var(--border);
627
- width: 100%;
628
- }
629
-
630
- .footer a {
631
- color: var(--link-color);
632
- }
633
- `;
634
-
635
-
636
- const header = `
637
- <div class="header">
638
- <div class="header-content">
639
- <span class="branding">Hunyuan Proxy</span>
640
- <div>
641
- <a href="/">Home</a>
642
- <a href="/playground">Playground</a>
643
- <a href="/docs">Docs</a>
644
- <a href="/getkey">Get API Key</a>
645
- </div>
646
- </div>
647
- </div>
648
- `;
649
-
650
-
651
- const homePage = `
652
- <!DOCTYPE html>
653
- <html lang="en">
654
- <head>
655
- <meta charset="UTF-8">
656
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
657
- <title>Hunyuan OpenAI Proxy</title>
658
- <style>${sharedStyles}</style>
659
- </head>
660
- <body>
661
- ${header}
662
- <div class="container">
663
- <h1>Hunyuan OpenAI Proxy</h1>
664
- <div class="card">
665
- <h2>Welcome</h2>
666
- <p>This is a proxy server that converts the Tencent Hunyuan LLM API to an OpenAI-compatible API.</p>
667
- <p>You can use this proxy to access the Hunyuan LLM with any OpenAI-compatible client.</p>
668
- </div>
669
- </div>
670
- <div class="footer">
671
- <p>Powered by <a href="https://neuroplexus.my" target="_blank">Neuroplexus</a></p>
672
- </div>
673
- </body>
674
- </html>
675
- `;
676
-
677
-
678
- const playgroundPage = `
679
- <!DOCTYPE html>
680
- <html lang="en">
681
- <head>
682
- <meta charset="UTF-8">
683
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
684
- <title>Hunyuan Playground</title>
685
- <style>${sharedStyles}
686
- textarea, #output {
687
- width: 100%;
688
- border: 1px solid var(--border);
689
- border-radius: var(--radius);
690
- padding: 1rem;
691
- margin-bottom: 1rem;
692
- font-family: inherit;
693
- font-size: 1rem;
694
- resize: vertical;
695
- color: var(--foreground);
696
- }
697
- textarea { min-height: 10rem; }
698
- #output { min-height: 15rem; background-color: var(--muted); overflow-y: auto; }
699
- .button { width: 100%; }
700
-
701
- </style>
702
- </head>
703
- <body>
704
- ${header}
705
- <div class="container">
706
- <h1>Hunyuan Playground</h1>
707
- <div class="card">
708
- <textarea id="input" placeholder="Enter your prompt here..."></textarea>
709
- <button class="button" onclick="sendMessage()">Send</button>
710
- <div id="output"></div>
711
- </div>
712
- </div>
713
- <div class="footer">
714
- <p>Powered by <a href="https://neuroplexus.my" target="_blank">Neuroplexus</a></p>
715
- </div>
716
- <script>
717
- const apiKey = "${API_KEY}"; // Consider making this dynamic
718
- async function sendMessage() {
719
- const input = document.getElementById('input').value;
720
- const outputDiv = document.getElementById('output');
721
- outputDiv.innerHTML = '';
722
-
723
- const response = await fetch('/v1/chat/completions', {
724
- method: 'POST',
725
- headers: {
726
- 'Content-Type': 'application/json',
727
- 'Authorization': 'Bearer ' + apiKey,
728
- },
729
- body: JSON.stringify({
730
- messages: [{ role: 'user', content: input }],
731
- stream: true,
732
- }),
733
- });
734
-
735
- if (!response.ok) {
736
- outputDiv.innerHTML = 'Error: ' + response.statusText;
737
- return;
738
- }
739
-
740
- const reader = response.body.getReader();
741
- const decoder = new TextDecoder('utf-8');
742
- let buffer = '';
743
-
744
- try {
745
- while (true) {
746
- const { done, value } = await reader.read();
747
- if (done) break;
748
-
749
- buffer += decoder.decode(value, { stream: true });
750
-
751
- let boundary = buffer.indexOf("\\n\\n");
752
- while (boundary !== -1) {
753
- const chunk = buffer.substring(0, boundary).trim();
754
- buffer = buffer.substring(boundary + 2);
755
- boundary = buffer.indexOf("\\n\\n");
756
-
757
- if (chunk.startsWith("data:")) {
758
- const jsonStr = chunk.substring(5).trim();
759
-
760
- if (jsonStr === "[DONE]") {
761
- continue
762
- }
763
- try {
764
- const data = JSON.parse(jsonStr);
765
- if (data.choices && data.choices[0] && data.choices[0].delta && data.choices[0].delta.content) {
766
- outputDiv.innerHTML += data.choices[0].delta.content;
767
- outputDiv.scrollTop = outputDiv.scrollHeight;
768
- }
769
- } catch (error) {
770
- console.error("Error parsing JSON:", error);
771
- outputDiv.innerHTML += "Error parsing response chunk. ";
772
- }
773
- }
774
- }
775
- }
776
- } finally {
777
- reader.releaseLock();
778
- }
779
- }
780
- </script>
781
- </body>
782
- </html>
783
- `;
784
-
785
- const docsPage = `
786
- <!DOCTYPE html>
787
- <html lang="en">
788
- <head>
789
- <meta charset="UTF-8">
790
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
791
- <title>API Documentation</title>
792
- <style>${sharedStyles}</style>
793
- </head>
794
- <body>
795
- ${header}
796
- <div class="container">
797
- <h1>API Documentation</h1>
798
- <div class="card">
799
- <h2>Chat Completions</h2>
800
- <p>This endpoint mimics the OpenAI Chat Completion API.</p>
801
- <h3>Endpoint</h3>
802
- <pre><code>POST /v1/chat/completions</code></pre>
803
- <h3>Request Headers</h3>
804
- <pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
805
- <pre><code>Content-Type: application/json</code></pre>
806
-
807
- <h3>Request Body (Example)</h3>
808
- <pre><code>{
809
- "messages": [
810
- {
811
- "role": "user",
812
- "content": "Hello, who are you?"
813
- }
814
- ],
815
- "model": "hunyuan-t1-latest",
816
- "stream": true
817
- }</code></pre>
818
- <p>Supported models: <code>hunyuan-t1-latest</code>, <code>hunyuan-turbos-latest</code>. To make a non-streaming request, set <code>"stream": false</code> in the request body.</p>
819
- <h3>Response</h3>
820
- <p>Returns a stream of Server-Sent Events (SSE) in the OpenAI format for streaming requests, or a JSON object for non-streaming requests.</p>
821
- </div>
822
-
823
- <div class="card">
824
- <h2>Models</h2>
825
- <p>Get a list of available models.</p>
826
- <h3>Endpoint</h3>
827
- <pre><code>GET /v1/models</code></pre>
828
- <h3>Response (Example)</h3>
829
- <pre><code>
830
- {
831
- "object": "list",
832
- "data": [
833
- {
834
- "id": "hunyuan-t1-latest",
835
- "object": "model",
836
- "created": 1678886400,
837
- "owned_by": "tencent"
838
- },
839
- {
840
- "id": "hunyuan-turbos-latest",
841
- "object": "model",
842
- "created": 1700000000,
843
- "owned_by": "tencent"
844
- }
845
- ]
846
- }
847
- </code></pre>
848
- </div>
849
- <div class="card">
850
- <h2>Get API Key</h2>
851
- <p>Retrieves the API key.</p>
852
- <h3>Endpoint</h3>
853
- <pre><code>GET /getkey</code></pre>
854
- </div>
855
- </div>
856
- <div class="footer">
857
- <p>Powered by <a href="https://neuroplexus.my" target="_blank">Neuroplexus</a></p>
858
- </div>
859
- </body>
860
- </html>
861
- `;
862
-
863
- const getKeyPage = `
864
- <!DOCTYPE html>
865
- <html lang="en">
866
- <head>
867
- <meta charset="UTF-8">
868
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
869
- <title>Get API Key</title>
870
- <style>${sharedStyles}</style>
871
- </head>
872
- <body>
873
- ${header}
874
- <div class="container">
875
- <h1>Get API Key</h1>
876
- <div class="card">
877
- <p>Your API Key is: <code>${API_KEY}</code></p>
878
- </div>
879
- </div>
880
- <div class="footer">
881
- <p>Powered by <a href="https://neuroplexus.my" target="_blank">Neuroplexus</a></p>
882
- </div>
883
- </body>
884
- </html>
885
- `;
886
- async function handleGetKey(ctx: Context) {
887
- const acceptHeader = ctx.request.headers.get("Accept");
888
- if (acceptHeader && acceptHeader.includes("application/json")) {
889
- ctx.response.body = { key: API_KEY };
890
- ctx.response.type = "application/json";
891
- } else {
892
- ctx.response.body = getKeyPage;
893
- ctx.response.type = "text/html";
894
- }
895
- }
896
-
897
- async function handleHomePage(ctx: Context) {
898
- ctx.response.body = homePage;
899
- ctx.response.type = "text/html";
900
- }
901
-
902
- async function handlePlayground(ctx: Context) {
903
- ctx.response.body = playgroundPage;
904
- ctx.response.type = "text/html";
905
- }
906
-
907
- async function handleDocs(ctx: Context) {
908
- ctx.response.body = docsPage;
909
- ctx.response.type = "text/html";
910
  }
911
 
912
- const router = new Router();
913
- router.post("/v1/chat/completions", handleChatCompletion);
914
- router.get("/v1/models", handleModels);
915
- router.get("/getkey", handleGetKey);
916
- router.get("/", handleHomePage);
917
- router.get("/playground", handlePlayground);
918
- router.get("/docs", handleDocs);
919
-
920
- const app = new Application();
921
- app.use(router.routes());
922
- app.use(router.allowedMethods());
923
-
924
- console.log("Server listening on port 8000");
925
- await app.listen({ port: 7860 });
 
1
+ const lunaryApiBaseUrl = "https://api.lunary.ai/v1";
2
+
3
+ // Using Deno.Kv for caching (requires --allow-env and --allow-read for .env or config)
4
+ const kv = await Deno.openKv();
5
+
6
+ async function getOrgId(apiKey: string): Promise<string | null> {
7
+ // Direct storage of API key as the cache key
8
+ const cachedOrgId = await kv.get(["orgIdCache", apiKey]);
9
+
10
+ if (cachedOrgId.value) {
11
+ console.log(`Cache hit for orgId: ${cachedOrgId.value}`);
12
+ return cachedOrgId.value as string;
13
+ }
14
+
15
+ console.log("Cache miss for orgId, fetching from Lunary API...");
16
+
17
+ const response = await fetch(`${lunaryApiBaseUrl}/users/me/org`, {
18
+ method: "GET",
19
+ headers: {
20
+ Authorization: `Bearer ${apiKey}`,
21
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0",
22
+ "Accept": "application/json",
23
+ "Accept-Language": "en-US,en;q=0.5",
24
+ "Referer": "https://app.lunary.ai/",
25
+ "Origin": "https://app.lunary.ai",
26
+ "DNT": "1",
27
+ "Sec-GPC": "1",
28
+ "Connection": "keep-alive",
29
+ "Sec-Fetch-Dest": "empty",
30
+ "Sec-Fetch-Mode": "cors",
31
+ "Sec-Fetch-Site": "same-site",
32
+ "Priority": "u=4",
33
+ "Pragma": "no-cache",
34
+ "Cache-Control": "no-cache",
35
+ "TE": "trailers",
36
+ },
37
+ });
38
+
39
+ if (!response.ok) {
40
+ console.error(`Failed to get orgId: ${response.status} - ${await response.text()}`);
41
+ return null;
42
+ }
43
+
44
+ const data = await response.json();
45
+ const orgId = data.id;
46
+
47
+ if (orgId) {
48
+ // Cache the orgId for 24 hours (adjust TTL as needed)
49
+ await kv.set(["orgIdCache", apiKey], orgId, { expireIn: 24 * 60 * 60 * 1000 });
50
+ console.log(`OrgId cached: ${orgId}`);
51
+ return orgId;
52
+ }
53
+
54
+ return null;
55
+ }
56
+
57
+ async function handler(req: Request): Promise<Response> {
58
+ if (req.method !== "POST") {
59
+ return new Response("Method Not Allowed", { status: 405 });
60
+ }
61
+
62
+ const authHeader = req.headers.get("Authorization");
63
+ const apiKey = authHeader?.split(" ")[1];
64
+ if (!apiKey) {
65
+ return new Response("Unauthorized", { status: 401 });
66
+ }
67
+
68
+ const orgId = await getOrgId(apiKey);
69
+ if (!orgId) {
70
+ return new Response("Could not retrieve organization ID for the provided API key.", { status: 400 });
71
+ }
72
+
73
+ let requestBody;
74
+ try {
75
+ requestBody = await req.json();
76
+ } catch (error) {
77
+ return new Response("Invalid JSON", { status: 400 });
78
+ }
79
+
80
+ const { messages, model, stream, ...extraParams } = requestBody;
81
+
82
+ if (!messages || !Array.isArray(messages)) {
83
+ return new Response("Missing or invalid 'messages' parameter", {
84
+ status: 400,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  });
86
+ }
 
 
 
 
 
 
 
 
 
 
87
 
88
+ if (!model || typeof model !== "string") {
89
+ return new Response("Missing or invalid 'model' parameter", {
90
+ status: 400,
91
+ });
92
+ }
93
+
94
+ const modelParts = model.split("/");
95
+ if (modelParts.length !== 2) {
96
+ return new Response(
97
+ "Invalid 'model' format. Expected 'provider/model_id'",
98
+ { status: 400 }
99
+ );
100
+ }
101
+
102
+ const [provider, modelId] = modelParts;
103
+
104
+ const lunaryRequestBody = {
105
+ content: messages,
106
+ extra: {
107
+ model: {
108
+ id: modelId,
109
+ name: modelId, // Lunary uses name and id as the same
110
+ provider: provider,
111
+ },
112
+ temperature: extraParams.temperature ?? 1, // Default temperature
113
+ stream: false, // This API doesn't support streaming directly
114
+ tools: extraParams.tools,
115
+ ...extraParams, // Include other extra parameters
116
+ },
117
+ variables: extraParams.variables ?? {}, // Include variables if provided
118
+ };
119
 
120
+ const lunaryResponse = await fetch(
121
+ `${lunaryApiBaseUrl}/orgs/${orgId}/playground`,
122
+ {
123
+ method: "POST",
124
+ headers: {
125
+ "Content-Type": "application/json",
126
+ Authorization: `Bearer ${apiKey}`, // Use the original API key for the playground request
127
+ // Add realistic browser headers
128
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0",
129
+ "Accept": "*/*",
130
+ "Accept-Language": "en-US,en;q=0.5",
131
+ "Referer": "https://app.lunary.ai/",
132
+ "Origin": "https://app.lunary.ai",
133
+ "DNT": "1",
134
+ "Sec-GPC": "1",
135
+ "Connection": "keep-alive",
136
+ "Sec-Fetch-Dest": "empty",
137
+ "Sec-Fetch-Mode": "cors",
138
+ "Sec-Fetch-Site": "same-site",
139
+ "Priority": "u=4",
140
+ "Pragma": "no-cache",
141
+ "Cache-Control": "no-cache",
142
+ "TE": "trailers",
143
+ },
144
+ body: JSON.stringify(lunaryRequestBody),
145
  }
146
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
+ if (!lunaryResponse.ok) {
149
+ const errorText = await lunaryResponse.text();
150
+ console.error(`Lunary Playground API error: ${lunaryResponse.status} - ${errorText}`);
151
+ return new Response(`Lunary Playground API error: ${lunaryResponse.status} - ${errorText}`, {
152
+ status: lunaryResponse.status,
153
+ });
154
+ }
155
+
156
+ const lunaryData = await lunaryResponse.json();
157
+
158
+ const openaiFormattedResponse = {
159
+ id: lunaryData.id,
160
+ object: "chat.completion",
161
+ created: lunaryData.created,
162
+ model: lunaryData.model, // Lunary returns the actual model name
163
+ choices: lunaryData.choices.map((choice: any) => ({
164
+ index: choice.index,
165
+ message: {
166
+ role: choice.message.role,
167
+ content: choice.message.content,
168
+ tool_calls: choice.message.tool_calls,
169
+ },
170
+ logprobs: choice.logprobs,
171
+ finish_reason: choice.finish_reason,
172
+ })),
173
+ usage: lunaryData.usage,
174
+ system_fingerprint: lunaryData.system_fingerprint,
175
+ };
176
+
177
+ if (stream) {
178
+ const encoder = new TextEncoder();
179
+ const stream = new ReadableStream({
180
+ async start(controller) {
181
+ // Simulate streaming by sending chunks of the content
182
+ // In a real streaming scenario, you would process the upstream
183
+ // stream and send chunks as they arrive. Since Lunary's playground
184
+ // doesn't stream, we'll break down the final response.
185
+
186
+ // Send the initial chunk with role and potential tool_calls if any
187
+ if (openaiFormattedResponse.choices && openaiFormattedResponse.choices.length > 0) {
188
+ const firstChoice = openaiFormattedResponse.choices[0];
189
+ const initialChunk = {
190
+ id: openaiFormattedResponse.id,
191
+ object: "chat.completion.chunk",
192
+ created: openaiFormattedResponse.created,
193
+ model: openaiFormattedResponse.model,
194
+ choices: [
195
+ {
196
+ index: 0,
197
+ delta: {
198
+ role: firstChoice.message.role,
199
+ tool_calls: firstChoice.message.tool_calls // Include tool_calls if present
200
+ },
201
+ logprobs: firstChoice.logprobs, // Include logprobs if present
202
+ finish_reason: null, // Indicate not finished yet
203
  }
204
+ ],
205
+ system_fingerprint: openaiFormattedResponse.system_fingerprint,
206
+ };
207
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(initialChunk)}\n\n`));
208
+
209
+ // Break down the content into smaller chunks
210
+ const content = firstChoice.message.content || "";
211
+ const chunkSize = 10; // Adjust chunk size as needed for a more realistic stream feel
212
+ for (let i = 0; i < content.length; i += chunkSize) {
213
+ const chunkContent = content.substring(i, i + chunkSize);
214
+ const contentChunk = {
215
+ id: openaiFormattedResponse.id,
216
+ object: "chat.completion.chunk",
217
+ created: openaiFormattedResponse.created,
218
+ model: openaiFormattedResponse.model,
219
+ choices: [
220
+ {
221
+ index: 0,
222
+ delta: {
223
+ content: chunkContent
224
+ },
225
+ logprobs: null, // Logprobs are typically not sent in content chunks
226
+ finish_reason: null,
227
+ }
228
+ ],
229
+ system_fingerprint: openaiFormattedResponse.system_fingerprint,
230
+ };
231
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(contentChunk)}\n\n`));
232
+ // Add a small delay to better simulate streaming
233
+ await new Promise(resolve => setTimeout(resolve, 20)); // Adjust delay as needed
234
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
 
236
+ // Send the final chunk with the finish_reason
237
+ const finalChunk = {
238
+ id: openaiFormattedResponse.id,
239
+ object: "chat.completion.chunk",
240
+ created: openaiFormattedResponse.created,
241
+ model: openaiFormattedResponse.model,
242
+ choices: [
243
+ {
244
+ index: 0,
245
+ delta: {}, // Delta is empty for the last chunk
246
+ logprobs: null,
247
+ finish_reason: firstChoice.finish_reason,
248
+ }
249
+ ],
250
+ system_fingerprint: openaiFormattedResponse.system_fingerprint,
251
+ usage: openaiFormattedResponse.usage, // Include usage in the final chunk
252
+ };
253
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(finalChunk)}\n\n`));
254
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
 
 
 
 
 
 
 
 
 
256
 
257
+ // Send the [DONE] marker
258
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
259
+ controller.close();
260
+ },
261
+ });
262
 
263
+ return new Response(stream, {
264
+ headers: { "Content-Type": "text/event-stream" },
265
+ });
266
+ } else {
267
+ // Non-streaming: return the JSON response directly
268
+ return new Response(JSON.stringify(openaiFormattedResponse), {
269
+ headers: { "Content-Type": "application/json" },
270
+ });
271
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  }
273
 
274
+ Deno.serve(handler);