koishi-plugin-freeluna 0.1.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/lib/config.d.ts +3 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.js +540 -0
- package/lib/logger.d.ts +7 -0
- package/lib/remoteConfig.d.ts +5 -0
- package/lib/routes/chat.d.ts +3 -0
- package/lib/routes/models.d.ts +3 -0
- package/lib/stream.d.ts +20 -0
- package/lib/types.d.ts +54 -0
- package/package.json +43 -0
- package/public/index.json +18 -0
- package/public/providers/chatjimmy.js +79 -0
- package/public/providers/ecylt.js +124 -0
- package/readme.md +5 -0
- package/src/config.ts +38 -0
- package/src/index.ts +72 -0
- package/src/logger.ts +35 -0
- package/src/remoteConfig.ts +144 -0
- package/src/routes/chat.ts +160 -0
- package/src/routes/models.ts +85 -0
- package/src/stream.ts +74 -0
- package/src/types.ts +85 -0
package/lib/config.d.ts
ADDED
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
import type { Config as ConfigType } from './types';
|
|
3
|
+
export declare const name = "freeluna";
|
|
4
|
+
export declare const reusable = false;
|
|
5
|
+
export declare const filter = false;
|
|
6
|
+
export declare const inject: {
|
|
7
|
+
required: string[];
|
|
8
|
+
};
|
|
9
|
+
export declare const usage = "\n---\n\n<p>\uD83C\uDF19 <strong>FreeLuna</strong> - \u514D\u8D39 LLM API \u670D\u52A1</p>\n<p>\u27A3 \u6302\u8F7D OpenAI \u517C\u5BB9\u63A5\u53E3\uFF0C\u52A8\u6001\u52A0\u8F7D\u514D\u8D39 API \u914D\u7F6E</p>\n<p>\u27A3 \u65E0\u9700\u9891\u7E41\u66F4\u65B0\u63D2\u4EF6\uFF0C\u53EA\u9700\u66F4\u65B0\u8FDC\u7A0B\u914D\u7F6E\u6587\u4EF6\u5373\u53EF\u5207\u6362\u514D\u8D39 API</p>\n\n---\n\n\u793A\u4F8B\u7528\u6CD5\uFF1A\u4F7F\u7528 <code>chatluna-openai-like-adapter</code> \u9002\u914D\u5668\uFF0C\n\n1. \u586B\u5165\u8BF7\u6C42\u5730\u5740\uFF08\u9ED8\u8BA4\uFF09\n\n `http://localhost:5140/freeluna/openai-compatible/v1`\n\n\n2. \u586B\u5165\u79D8\u94A5\uFF08\u9ED8\u8BA4\uFF09\n\n <code>sk-freeluna-default</code>\n\n3. \u5F00\u542F<code>chatluna-openai-like-adapter</code> \u9002\u914D\u5668\uFF0C\n\n \u7136\u540E\u4F7F\u7528`freeluna-`\u524D\u7F00\u7684\u6A21\u578B\u5373\u53EF\uFF01\n\n\n---\n";
|
|
10
|
+
export declare const Config: import("schemastery")<ConfigType>;
|
|
11
|
+
export declare function apply(ctx: Context, config: ConfigType): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name2 in all)
|
|
10
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var src_exports = {};
|
|
32
|
+
__export(src_exports, {
|
|
33
|
+
Config: () => Config,
|
|
34
|
+
apply: () => apply,
|
|
35
|
+
filter: () => filter,
|
|
36
|
+
inject: () => inject,
|
|
37
|
+
name: () => name,
|
|
38
|
+
reusable: () => reusable,
|
|
39
|
+
usage: () => usage
|
|
40
|
+
});
|
|
41
|
+
module.exports = __toCommonJS(src_exports);
|
|
42
|
+
|
|
43
|
+
// src/config.ts
|
|
44
|
+
var import_koishi = require("koishi");
|
|
45
|
+
var defaultApiKeys = [
|
|
46
|
+
{ token: "sk-freeluna-default" }
|
|
47
|
+
];
|
|
48
|
+
var ConfigSchema = import_koishi.Schema.intersect([
|
|
49
|
+
import_koishi.Schema.object({
|
|
50
|
+
basePath: import_koishi.Schema.string().default("/freeluna").description("插件基础路由前缀,所有路由都挂载在此路径下"),
|
|
51
|
+
remoteIndexUrl: import_koishi.Schema.string().default("https://cdn.jsdelivr.net/gh/koishi-shangxue-plugins/koishi-shangxue-apps@main/plugins/freeluna/public/index.json").description("远程提供商注册表 URL(JSON 格式)<br>插件启动时加载一次,重启插件可刷新"),
|
|
52
|
+
apiKeys: import_koishi.Schema.array(import_koishi.Schema.object({
|
|
53
|
+
token: import_koishi.Schema.string().description("API Key 令牌")
|
|
54
|
+
})).role("table").default(defaultApiKeys).description("API Key 列表<br>只有携带有效 Key(Bearer Token)的请求才会被处理")
|
|
55
|
+
}).description("基础设置"),
|
|
56
|
+
import_koishi.Schema.object({
|
|
57
|
+
localDebug: import_koishi.Schema.boolean().experimental().default(false).description("本地调试模式:启用后从本地 public/ 目录加载提供商配置和 JS,而非远程 URL")
|
|
58
|
+
}).description("调试设置"),
|
|
59
|
+
import_koishi.Schema.object({
|
|
60
|
+
loggerInfo: import_koishi.Schema.boolean().experimental().default(false).description("启用详细日志输出"),
|
|
61
|
+
loggerDebug: import_koishi.Schema.boolean().experimental().default(false).description("启用调试日志模式(包含请求/响应详情)").experimental()
|
|
62
|
+
}).description("日志设置")
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
// src/logger.ts
|
|
66
|
+
var import_koishi2 = require("koishi");
|
|
67
|
+
var devLogger = new import_koishi2.Logger("DEV:freeluna");
|
|
68
|
+
var loggerError;
|
|
69
|
+
var loggerInfo;
|
|
70
|
+
var logInfo;
|
|
71
|
+
var logDebug;
|
|
72
|
+
function initLogger(ctx, config) {
|
|
73
|
+
loggerInfo = /* @__PURE__ */ __name((message, ...args) => {
|
|
74
|
+
ctx.logger.info(message, ...args);
|
|
75
|
+
}, "loggerInfo");
|
|
76
|
+
loggerError = /* @__PURE__ */ __name((message, ...args) => {
|
|
77
|
+
ctx.logger.error(message, ...args);
|
|
78
|
+
}, "loggerError");
|
|
79
|
+
logInfo = /* @__PURE__ */ __name((message, ...args) => {
|
|
80
|
+
if (config.loggerInfo) {
|
|
81
|
+
devLogger.info(message, ...args);
|
|
82
|
+
}
|
|
83
|
+
}, "logInfo");
|
|
84
|
+
logDebug = /* @__PURE__ */ __name((message, ...args) => {
|
|
85
|
+
if (config.loggerDebug) {
|
|
86
|
+
devLogger.info(message, ...args);
|
|
87
|
+
}
|
|
88
|
+
}, "logDebug");
|
|
89
|
+
}
|
|
90
|
+
__name(initLogger, "initLogger");
|
|
91
|
+
|
|
92
|
+
// src/remoteConfig.ts
|
|
93
|
+
var import_node_fs = require("node:fs");
|
|
94
|
+
var import_node_path = require("node:path");
|
|
95
|
+
var import_node_vm = __toESM(require("node:vm"));
|
|
96
|
+
var indexCache = null;
|
|
97
|
+
var moduleCache = /* @__PURE__ */ new Map();
|
|
98
|
+
function clearConfigCache() {
|
|
99
|
+
indexCache = null;
|
|
100
|
+
moduleCache.clear();
|
|
101
|
+
logInfo("所有缓存已清除");
|
|
102
|
+
}
|
|
103
|
+
__name(clearConfigCache, "clearConfigCache");
|
|
104
|
+
async function fetchRemoteText(url) {
|
|
105
|
+
logInfo("远程拉取:", url);
|
|
106
|
+
const res = await fetch(url, {
|
|
107
|
+
headers: { "Accept": "*/*" },
|
|
108
|
+
signal: AbortSignal.timeout(15e3)
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
111
|
+
return res.text();
|
|
112
|
+
}
|
|
113
|
+
__name(fetchRemoteText, "fetchRemoteText");
|
|
114
|
+
function readLocalFile(relPath) {
|
|
115
|
+
const localPath = (0, import_node_path.resolve)(__dirname, "../public", relPath);
|
|
116
|
+
return (0, import_node_fs.readFileSync)(localPath, "utf-8");
|
|
117
|
+
}
|
|
118
|
+
__name(readLocalFile, "readLocalFile");
|
|
119
|
+
async function loadProviderIndex(config) {
|
|
120
|
+
if (indexCache) {
|
|
121
|
+
logDebug("使用缓存的注册表");
|
|
122
|
+
return indexCache;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const text = config.localDebug ? readLocalFile("index.json") : await fetchRemoteText(config.remoteIndexUrl);
|
|
126
|
+
const parsed = JSON.parse(text);
|
|
127
|
+
logInfo("注册表加载成功,提供商数量:", parsed.providers?.length ?? 0);
|
|
128
|
+
indexCache = parsed;
|
|
129
|
+
return parsed;
|
|
130
|
+
} catch (err) {
|
|
131
|
+
loggerError("加载注册表失败:", err instanceof Error ? err.message : err);
|
|
132
|
+
loggerError("如遇问题请重启插件重新加载配置");
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
__name(loadProviderIndex, "loadProviderIndex");
|
|
137
|
+
function executeProviderJs(jsCode, providerName) {
|
|
138
|
+
const moduleObj = { exports: {} };
|
|
139
|
+
const sandbox = {
|
|
140
|
+
module: moduleObj,
|
|
141
|
+
exports: moduleObj.exports,
|
|
142
|
+
fetch,
|
|
143
|
+
console,
|
|
144
|
+
setTimeout,
|
|
145
|
+
clearTimeout,
|
|
146
|
+
Promise,
|
|
147
|
+
AbortSignal,
|
|
148
|
+
JSON,
|
|
149
|
+
Error,
|
|
150
|
+
URL
|
|
151
|
+
};
|
|
152
|
+
import_node_vm.default.createContext(sandbox);
|
|
153
|
+
const script = new import_node_vm.default.Script(jsCode, { filename: `${providerName}.js` });
|
|
154
|
+
script.runInContext(sandbox);
|
|
155
|
+
const mod = moduleObj.exports;
|
|
156
|
+
if (typeof mod.chat !== "function") {
|
|
157
|
+
throw new Error(`提供商 JS "${providerName}" 未导出 chat 函数`);
|
|
158
|
+
}
|
|
159
|
+
return mod;
|
|
160
|
+
}
|
|
161
|
+
__name(executeProviderJs, "executeProviderJs");
|
|
162
|
+
async function loadProviderModule(entry, config) {
|
|
163
|
+
const cached = moduleCache.get(entry.name);
|
|
164
|
+
if (cached) {
|
|
165
|
+
logDebug("使用缓存的提供商模块:", entry.name);
|
|
166
|
+
return cached;
|
|
167
|
+
}
|
|
168
|
+
let jsCode;
|
|
169
|
+
if (config.localDebug) {
|
|
170
|
+
const localPath = entry.localJsPath;
|
|
171
|
+
if (!localPath) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`提供商 "${entry.name}" 未配置 localJsPath,本地调试模式下无法加载。请在 index.json 中为该提供商添加 localJsPath 字段(相对于 public/ 目录)`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
jsCode = readLocalFile(localPath);
|
|
177
|
+
} else {
|
|
178
|
+
jsCode = await fetchRemoteText(entry.jsUrl);
|
|
179
|
+
}
|
|
180
|
+
const mod = executeProviderJs(jsCode, entry.name);
|
|
181
|
+
moduleCache.set(entry.name, mod);
|
|
182
|
+
return mod;
|
|
183
|
+
}
|
|
184
|
+
__name(loadProviderModule, "loadProviderModule");
|
|
185
|
+
async function findProvider(name2, config) {
|
|
186
|
+
const index = await loadProviderIndex(config);
|
|
187
|
+
if (!index) return null;
|
|
188
|
+
const entry = index.providers.find((p) => p.name === name2);
|
|
189
|
+
if (!entry) return null;
|
|
190
|
+
try {
|
|
191
|
+
const mod = await loadProviderModule(entry, config);
|
|
192
|
+
return { entry, module: mod };
|
|
193
|
+
} catch (err) {
|
|
194
|
+
loggerError(`提供商 "${name2}" 加载失败:`, err instanceof Error ? err.message : err);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
__name(findProvider, "findProvider");
|
|
199
|
+
async function loadAllProviders(config) {
|
|
200
|
+
const index = await loadProviderIndex(config);
|
|
201
|
+
if (!index || index.providers.length === 0) return [];
|
|
202
|
+
const results = [];
|
|
203
|
+
for (const entry of index.providers) {
|
|
204
|
+
try {
|
|
205
|
+
const mod = await loadProviderModule(entry, config);
|
|
206
|
+
results.push({ entry, module: mod });
|
|
207
|
+
} catch (err) {
|
|
208
|
+
loggerError(`提供商 "${entry.name}" 加载失败:`, err instanceof Error ? err.message : err);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return results;
|
|
212
|
+
}
|
|
213
|
+
__name(loadAllProviders, "loadAllProviders");
|
|
214
|
+
|
|
215
|
+
// src/routes/models.ts
|
|
216
|
+
function setCorsHeaders(koaCtx) {
|
|
217
|
+
koaCtx.set("Access-Control-Allow-Origin", "*");
|
|
218
|
+
koaCtx.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
219
|
+
koaCtx.set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With, Accept, Origin");
|
|
220
|
+
koaCtx.set("Access-Control-Allow-Credentials", "true");
|
|
221
|
+
koaCtx.set("Access-Control-Max-Age", "86400");
|
|
222
|
+
koaCtx.set("Access-Control-Expose-Headers", "Content-Length, Content-Type");
|
|
223
|
+
koaCtx.set("Access-Control-Allow-Private-Network", "true");
|
|
224
|
+
}
|
|
225
|
+
__name(setCorsHeaders, "setCorsHeaders");
|
|
226
|
+
function registerModelRoutes(ctx, config) {
|
|
227
|
+
const base = config.basePath;
|
|
228
|
+
async function handleModels(koaCtx) {
|
|
229
|
+
setCorsHeaders(koaCtx);
|
|
230
|
+
logInfo("/models 请求来源:", koaCtx.ip, "路径:", koaCtx.path, "| UA:", koaCtx.headers["user-agent"] ?? "-");
|
|
231
|
+
logDebug("/models 请求头:", JSON.stringify(koaCtx.headers, null, 2));
|
|
232
|
+
const index = await loadProviderIndex(config);
|
|
233
|
+
const providers = index?.providers ?? [];
|
|
234
|
+
logInfo("/models 提供商数量:", providers.length);
|
|
235
|
+
const modelList = providers.map((p) => ({
|
|
236
|
+
id: `freeluna-${p.name}`,
|
|
237
|
+
object: "model",
|
|
238
|
+
created: Math.floor(Date.now() / 1e3),
|
|
239
|
+
owned_by: "freeluna"
|
|
240
|
+
}));
|
|
241
|
+
const responseBody = {
|
|
242
|
+
object: "list",
|
|
243
|
+
data: modelList
|
|
244
|
+
};
|
|
245
|
+
logDebug("/models 响应:", JSON.stringify(responseBody, null, 2));
|
|
246
|
+
koaCtx.body = responseBody;
|
|
247
|
+
}
|
|
248
|
+
__name(handleModels, "handleModels");
|
|
249
|
+
ctx.server.options(`${base}/openai-compatible/v1/models`, async (koaCtx) => {
|
|
250
|
+
setCorsHeaders(koaCtx);
|
|
251
|
+
koaCtx.status = 204;
|
|
252
|
+
koaCtx.body = "";
|
|
253
|
+
});
|
|
254
|
+
ctx.server.get(`${base}/openai-compatible/v1/models`, handleModels);
|
|
255
|
+
ctx.server.options(`${base}/dashboard/billing/usage`, async (koaCtx) => {
|
|
256
|
+
setCorsHeaders(koaCtx);
|
|
257
|
+
koaCtx.status = 204;
|
|
258
|
+
koaCtx.body = "";
|
|
259
|
+
});
|
|
260
|
+
ctx.server.get(`${base}/dashboard/billing/usage`, async (koaCtx) => {
|
|
261
|
+
setCorsHeaders(koaCtx);
|
|
262
|
+
koaCtx.body = {
|
|
263
|
+
object: "list",
|
|
264
|
+
total_usage: 0
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
ctx.server.options(`${base}/dashboard/billing/subscription`, async (koaCtx) => {
|
|
268
|
+
setCorsHeaders(koaCtx);
|
|
269
|
+
koaCtx.status = 204;
|
|
270
|
+
koaCtx.body = "";
|
|
271
|
+
});
|
|
272
|
+
ctx.server.get(`${base}/dashboard/billing/subscription`, async (koaCtx) => {
|
|
273
|
+
setCorsHeaders(koaCtx);
|
|
274
|
+
koaCtx.body = {
|
|
275
|
+
object: "billing_subscription",
|
|
276
|
+
has_payment_method: true,
|
|
277
|
+
soft_limit_usd: 1919810,
|
|
278
|
+
hard_limit_usd: 1919810,
|
|
279
|
+
system_hard_limit_usd: 1919810,
|
|
280
|
+
access_until: 0
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
__name(registerModelRoutes, "registerModelRoutes");
|
|
285
|
+
|
|
286
|
+
// src/stream.ts
|
|
287
|
+
function createStreamResponse(content, model) {
|
|
288
|
+
const id = `chatcmpl-freeluna-${Date.now()}`;
|
|
289
|
+
const created = Math.floor(Date.now() / 1e3);
|
|
290
|
+
const chunks = [];
|
|
291
|
+
chunks.push({
|
|
292
|
+
id,
|
|
293
|
+
object: "chat.completion.chunk",
|
|
294
|
+
created,
|
|
295
|
+
model,
|
|
296
|
+
choices: [{
|
|
297
|
+
index: 0,
|
|
298
|
+
delta: { role: "assistant" },
|
|
299
|
+
finish_reason: null
|
|
300
|
+
}]
|
|
301
|
+
});
|
|
302
|
+
for (const char of content) {
|
|
303
|
+
chunks.push({
|
|
304
|
+
id,
|
|
305
|
+
object: "chat.completion.chunk",
|
|
306
|
+
created,
|
|
307
|
+
model,
|
|
308
|
+
choices: [{
|
|
309
|
+
index: 0,
|
|
310
|
+
delta: { content: char },
|
|
311
|
+
finish_reason: null
|
|
312
|
+
}]
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
chunks.push({
|
|
316
|
+
id,
|
|
317
|
+
object: "chat.completion.chunk",
|
|
318
|
+
created,
|
|
319
|
+
model,
|
|
320
|
+
choices: [{
|
|
321
|
+
index: 0,
|
|
322
|
+
delta: {},
|
|
323
|
+
finish_reason: "stop"
|
|
324
|
+
}]
|
|
325
|
+
});
|
|
326
|
+
return chunks.map((chunk) => `data: ${JSON.stringify(chunk)}
|
|
327
|
+
|
|
328
|
+
`).join("") + "data: [DONE]\n\n";
|
|
329
|
+
}
|
|
330
|
+
__name(createStreamResponse, "createStreamResponse");
|
|
331
|
+
function buildChatResponse(content, modelName) {
|
|
332
|
+
return {
|
|
333
|
+
id: `chatcmpl-freeluna-${Date.now()}`,
|
|
334
|
+
object: "chat.completion",
|
|
335
|
+
created: Math.floor(Date.now() / 1e3),
|
|
336
|
+
model: modelName,
|
|
337
|
+
choices: [
|
|
338
|
+
{
|
|
339
|
+
index: 0,
|
|
340
|
+
message: {
|
|
341
|
+
role: "assistant",
|
|
342
|
+
content
|
|
343
|
+
},
|
|
344
|
+
finish_reason: "stop"
|
|
345
|
+
}
|
|
346
|
+
],
|
|
347
|
+
usage: {
|
|
348
|
+
prompt_tokens: 0,
|
|
349
|
+
completion_tokens: 0,
|
|
350
|
+
total_tokens: 0
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
__name(buildChatResponse, "buildChatResponse");
|
|
355
|
+
|
|
356
|
+
// src/routes/chat.ts
|
|
357
|
+
function setCorsHeaders2(koaCtx) {
|
|
358
|
+
koaCtx.set("Access-Control-Allow-Origin", "*");
|
|
359
|
+
koaCtx.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
360
|
+
koaCtx.set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With, Accept, Origin");
|
|
361
|
+
koaCtx.set("Access-Control-Allow-Credentials", "true");
|
|
362
|
+
koaCtx.set("Access-Control-Max-Age", "86400");
|
|
363
|
+
koaCtx.set("Access-Control-Expose-Headers", "Content-Length, Content-Type");
|
|
364
|
+
koaCtx.set("Access-Control-Allow-Private-Network", "true");
|
|
365
|
+
}
|
|
366
|
+
__name(setCorsHeaders2, "setCorsHeaders");
|
|
367
|
+
function registerChatRoute(ctx, config) {
|
|
368
|
+
const base = config.basePath;
|
|
369
|
+
const chatPath = `${base}/openai-compatible/v1/chat/completions`;
|
|
370
|
+
ctx.server.options(chatPath, async (koaCtx) => {
|
|
371
|
+
setCorsHeaders2(koaCtx);
|
|
372
|
+
koaCtx.status = 204;
|
|
373
|
+
koaCtx.body = "";
|
|
374
|
+
});
|
|
375
|
+
ctx.server.get(chatPath, async (koaCtx) => {
|
|
376
|
+
setCorsHeaders2(koaCtx);
|
|
377
|
+
koaCtx.status = 405;
|
|
378
|
+
koaCtx.body = { error: { message: "Method Not Allowed", type: "invalid_request_error" } };
|
|
379
|
+
});
|
|
380
|
+
ctx.server.post(chatPath, async (koaCtx) => {
|
|
381
|
+
setCorsHeaders2(koaCtx);
|
|
382
|
+
const startTime = Date.now();
|
|
383
|
+
try {
|
|
384
|
+
const authHeader = koaCtx.headers.authorization;
|
|
385
|
+
const providedToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
386
|
+
if (!providedToken) {
|
|
387
|
+
loggerError("请求未携带 Authorization 头");
|
|
388
|
+
koaCtx.status = 401;
|
|
389
|
+
koaCtx.body = {
|
|
390
|
+
error: {
|
|
391
|
+
message: `无效的令牌 (request id: ${Date.now()})`,
|
|
392
|
+
type: "invalid_api_key"
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const validKey = config.apiKeys?.find((k) => k.token === providedToken);
|
|
398
|
+
if (!validKey) {
|
|
399
|
+
loggerError("Token 验证失败,提供的 Token:", providedToken);
|
|
400
|
+
koaCtx.status = 401;
|
|
401
|
+
koaCtx.body = {
|
|
402
|
+
error: {
|
|
403
|
+
message: `无效的令牌 (request id: ${Date.now()})`,
|
|
404
|
+
type: "invalid_api_key"
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const body = koaCtx.request.body;
|
|
410
|
+
logDebug("收到对话请求,model:", body?.model, "stream:", body?.stream, "messages:", body?.messages?.length);
|
|
411
|
+
if (!body || !body.messages || !Array.isArray(body.messages)) {
|
|
412
|
+
koaCtx.status = 400;
|
|
413
|
+
koaCtx.body = {
|
|
414
|
+
error: { message: "Invalid request format: messages is required", type: "invalid_request_error" }
|
|
415
|
+
};
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const index = await loadProviderIndex(config);
|
|
419
|
+
if (!index || index.providers.length === 0) {
|
|
420
|
+
loggerError("注册表为空或加载失败");
|
|
421
|
+
koaCtx.status = 503;
|
|
422
|
+
koaCtx.body = {
|
|
423
|
+
error: { message: "暂无可用的免费 API 提供商,请稍后重试", type: "service_unavailable" }
|
|
424
|
+
};
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const rawModel = body.model || `freeluna-${index.providers[0].name}`;
|
|
428
|
+
const providerName = rawModel.startsWith("freeluna-") ? rawModel.slice("freeluna-".length) : rawModel;
|
|
429
|
+
let provider = await findProvider(providerName, config);
|
|
430
|
+
if (!provider) {
|
|
431
|
+
logInfo(`模型 "${rawModel}" 未找到,使用默认提供商:`, index.providers[0].name);
|
|
432
|
+
provider = await findProvider(index.providers[0].name, config);
|
|
433
|
+
}
|
|
434
|
+
if (!provider) {
|
|
435
|
+
loggerError("无法加载任何提供商模块");
|
|
436
|
+
koaCtx.status = 503;
|
|
437
|
+
koaCtx.body = {
|
|
438
|
+
error: { message: "提供商模块加载失败,请稍后重试", type: "service_unavailable" }
|
|
439
|
+
};
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
logInfo(`使用提供商: ${provider.entry.name}`);
|
|
443
|
+
const options = {
|
|
444
|
+
model: body.model,
|
|
445
|
+
temperature: body.temperature,
|
|
446
|
+
max_tokens: body.max_tokens,
|
|
447
|
+
stream: body.stream
|
|
448
|
+
};
|
|
449
|
+
const replyText = await provider.module.chat(body.messages, options);
|
|
450
|
+
const elapsed = Date.now() - startTime;
|
|
451
|
+
logInfo(`提供商响应成功,耗时: ${elapsed}ms,回复长度: ${replyText.length}`);
|
|
452
|
+
logDebug("回复内容:", replyText.substring(0, 200));
|
|
453
|
+
const isStream = body.stream === true;
|
|
454
|
+
if (isStream) {
|
|
455
|
+
koaCtx.status = 200;
|
|
456
|
+
koaCtx.set("Content-Type", "text/event-stream");
|
|
457
|
+
koaCtx.set("Cache-Control", "no-cache");
|
|
458
|
+
koaCtx.set("Connection", "keep-alive");
|
|
459
|
+
koaCtx.body = createStreamResponse(replyText, provider.entry.name);
|
|
460
|
+
} else {
|
|
461
|
+
koaCtx.status = 200;
|
|
462
|
+
koaCtx.body = buildChatResponse(replyText, provider.entry.name);
|
|
463
|
+
}
|
|
464
|
+
} catch (err) {
|
|
465
|
+
const elapsed = Date.now() - startTime;
|
|
466
|
+
loggerError(`处理对话请求出错 (耗时: ${elapsed}ms):`, err instanceof Error ? err.message : err);
|
|
467
|
+
koaCtx.status = 500;
|
|
468
|
+
koaCtx.body = {
|
|
469
|
+
error: {
|
|
470
|
+
message: "内部服务器错误,请稍后重试",
|
|
471
|
+
type: "server_error",
|
|
472
|
+
details: err instanceof Error ? err.message : String(err)
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
__name(registerChatRoute, "registerChatRoute");
|
|
479
|
+
|
|
480
|
+
// src/index.ts
|
|
481
|
+
var name = "freeluna";
|
|
482
|
+
var reusable = false;
|
|
483
|
+
var filter = false;
|
|
484
|
+
var inject = {
|
|
485
|
+
required: ["server"]
|
|
486
|
+
};
|
|
487
|
+
var usage = `
|
|
488
|
+
---
|
|
489
|
+
|
|
490
|
+
<p>🌙 <strong>FreeLuna</strong> - 免费 LLM API 服务</p>
|
|
491
|
+
<p>➣ 挂载 OpenAI 兼容接口,动态加载免费 API 配置</p>
|
|
492
|
+
<p>➣ 无需频繁更新插件,只需更新远程配置文件即可切换免费 API</p>
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
496
|
+
示例用法:使用 <code>chatluna-openai-like-adapter</code> 适配器,
|
|
497
|
+
|
|
498
|
+
1. 填入请求地址(默认)
|
|
499
|
+
|
|
500
|
+
\`http://localhost:5140/freeluna/openai-compatible/v1\`
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
2. 填入秘钥(默认)
|
|
504
|
+
|
|
505
|
+
<code>sk-freeluna-default</code>
|
|
506
|
+
|
|
507
|
+
3. 开启<code>chatluna-openai-like-adapter</code> 适配器,
|
|
508
|
+
|
|
509
|
+
然后使用\`freeluna-\`前缀的模型即可!
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
---
|
|
513
|
+
`;
|
|
514
|
+
var Config = ConfigSchema;
|
|
515
|
+
function apply(ctx, config) {
|
|
516
|
+
ctx.on("ready", async () => {
|
|
517
|
+
initLogger(ctx, config);
|
|
518
|
+
registerModelRoutes(ctx, config);
|
|
519
|
+
registerChatRoute(ctx, config);
|
|
520
|
+
loggerInfo(`服务已启动:http://localhost:${ctx.server.port}${config.basePath}/openai-compatible/v1/chat/completions`);
|
|
521
|
+
const providers = await loadAllProviders(config);
|
|
522
|
+
if (providers.length === 0) {
|
|
523
|
+
loggerInfo("警告:未能加载任何提供商,请检查配置后重启插件");
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
ctx.on("dispose", () => {
|
|
527
|
+
clearConfigCache();
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
__name(apply, "apply");
|
|
531
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
532
|
+
0 && (module.exports = {
|
|
533
|
+
Config,
|
|
534
|
+
apply,
|
|
535
|
+
filter,
|
|
536
|
+
inject,
|
|
537
|
+
name,
|
|
538
|
+
reusable,
|
|
539
|
+
usage
|
|
540
|
+
});
|
package/lib/logger.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
import type { Config } from './types';
|
|
3
|
+
export declare let loggerError: (message: unknown, ...args: unknown[]) => void;
|
|
4
|
+
export declare let loggerInfo: (message: unknown, ...args: unknown[]) => void;
|
|
5
|
+
export declare let logInfo: (message: unknown, ...args: unknown[]) => void;
|
|
6
|
+
export declare let logDebug: (message: unknown, ...args: unknown[]) => void;
|
|
7
|
+
export declare function initLogger(ctx: Context, config: Config): void;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Config, ProviderIndex, LoadedProvider } from './types';
|
|
2
|
+
export declare function clearConfigCache(): void;
|
|
3
|
+
export declare function loadProviderIndex(config: Config): Promise<ProviderIndex | null>;
|
|
4
|
+
export declare function findProvider(name: string, config: Config): Promise<LoadedProvider | null>;
|
|
5
|
+
export declare function loadAllProviders(config: Config): Promise<LoadedProvider[]>;
|
package/lib/stream.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare function createStreamResponse(content: string, model: string): string;
|
|
2
|
+
export declare function buildChatResponse(content: string, modelName: string): {
|
|
3
|
+
id: string;
|
|
4
|
+
object: string;
|
|
5
|
+
created: number;
|
|
6
|
+
model: string;
|
|
7
|
+
choices: {
|
|
8
|
+
index: number;
|
|
9
|
+
message: {
|
|
10
|
+
role: string;
|
|
11
|
+
content: string;
|
|
12
|
+
};
|
|
13
|
+
finish_reason: string;
|
|
14
|
+
}[];
|
|
15
|
+
usage: {
|
|
16
|
+
prompt_tokens: number;
|
|
17
|
+
completion_tokens: number;
|
|
18
|
+
total_tokens: number;
|
|
19
|
+
};
|
|
20
|
+
};
|
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface ApiKeyEntry {
|
|
2
|
+
token: string;
|
|
3
|
+
}
|
|
4
|
+
export interface Config {
|
|
5
|
+
basePath: string;
|
|
6
|
+
remoteIndexUrl: string;
|
|
7
|
+
apiKeys: ApiKeyEntry[];
|
|
8
|
+
localDebug: boolean;
|
|
9
|
+
loggerInfo: boolean;
|
|
10
|
+
loggerDebug: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface ProviderEntry {
|
|
13
|
+
name: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
jsUrl: string;
|
|
16
|
+
localJsPath?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ProviderIndex {
|
|
19
|
+
version?: string;
|
|
20
|
+
updatedAt?: string;
|
|
21
|
+
providers: ProviderEntry[];
|
|
22
|
+
}
|
|
23
|
+
export interface ProviderModule {
|
|
24
|
+
name: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
chat: (messages: ChatMessage[], options?: ChatOptions) => Promise<string>;
|
|
27
|
+
}
|
|
28
|
+
export interface ChatMessage {
|
|
29
|
+
role: 'system' | 'user' | 'assistant';
|
|
30
|
+
content: string;
|
|
31
|
+
}
|
|
32
|
+
export interface ChatOptions {
|
|
33
|
+
model?: string;
|
|
34
|
+
temperature?: number;
|
|
35
|
+
max_tokens?: number;
|
|
36
|
+
stream?: boolean;
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
export interface ChatCompletionRequest {
|
|
40
|
+
model: string;
|
|
41
|
+
messages: ChatMessage[];
|
|
42
|
+
stream?: boolean;
|
|
43
|
+
temperature?: number;
|
|
44
|
+
max_tokens?: number;
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
export interface LoadedProvider {
|
|
48
|
+
entry: ProviderEntry;
|
|
49
|
+
module: ProviderModule;
|
|
50
|
+
}
|
|
51
|
+
export interface CacheEntry<T> {
|
|
52
|
+
data: T;
|
|
53
|
+
expireAt: number;
|
|
54
|
+
}
|