@southwind-ai/config 0.1.2 → 0.2.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/index.ts +1 -0
- package/package.json +3 -2
- package/src/ai-openai-compatible.ts +187 -0
- package/src/platform.ts +169 -48
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@southwind-ai/config",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "UNLICENSED",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"index.ts",
|
|
11
|
+
"src/ai-openai-compatible.ts",
|
|
11
12
|
"src/**/*.ts",
|
|
12
13
|
"!src/**/*.test.ts"
|
|
13
14
|
],
|
|
14
15
|
"dependencies": {
|
|
15
|
-
"@southwind-ai/shared": "0.
|
|
16
|
+
"@southwind-ai/shared": "0.2.0",
|
|
16
17
|
"zod": "^4.4.3"
|
|
17
18
|
},
|
|
18
19
|
"exports": {
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineModuleConfig } from "./definition.js";
|
|
4
|
+
import {
|
|
5
|
+
commaSeparated,
|
|
6
|
+
loadModuleConfigSync,
|
|
7
|
+
strictInteger,
|
|
8
|
+
type ConfigEnvironment,
|
|
9
|
+
} from "./environment-loader.js";
|
|
10
|
+
import { EnvironmentSecretResolver } from "./secrets.js";
|
|
11
|
+
|
|
12
|
+
export interface OpenAiCompatibleDeploymentConfig {
|
|
13
|
+
baseUrl: string;
|
|
14
|
+
model: string;
|
|
15
|
+
apiKey?: string;
|
|
16
|
+
connectTimeoutMs: number;
|
|
17
|
+
requestTimeoutMs: number;
|
|
18
|
+
maxResponseBytes: number;
|
|
19
|
+
allowedHttpsHosts: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const deploymentSchema: z.ZodType<OpenAiCompatibleDeploymentConfig> = z
|
|
23
|
+
.strictObject({
|
|
24
|
+
baseUrl: z.string().transform(normalizeOpenAiCompatibleBaseUrl),
|
|
25
|
+
model: z.string().trim().min(1).max(200),
|
|
26
|
+
apiKey: z.string().trim().min(1).optional(),
|
|
27
|
+
connectTimeoutMs: z.number().int().min(100).max(30_000),
|
|
28
|
+
requestTimeoutMs: z.number().int().min(1_000).max(300_000),
|
|
29
|
+
maxResponseBytes: z
|
|
30
|
+
.number()
|
|
31
|
+
.int()
|
|
32
|
+
.min(1_024)
|
|
33
|
+
.max(16 * 1_024 * 1_024),
|
|
34
|
+
allowedHttpsHosts: z.array(z.string()).max(32),
|
|
35
|
+
})
|
|
36
|
+
.superRefine((config, context) => {
|
|
37
|
+
const endpoint = new URL(config.baseUrl);
|
|
38
|
+
if (
|
|
39
|
+
endpoint.protocol === "https:" &&
|
|
40
|
+
!config.allowedHttpsHosts.includes(endpoint.hostname.toLowerCase())
|
|
41
|
+
) {
|
|
42
|
+
context.addIssue({
|
|
43
|
+
code: "custom",
|
|
44
|
+
path: ["allowedHttpsHosts"],
|
|
45
|
+
message: "Endpoint 未命中 HTTPS Host allowlist",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const openAiCompatibleDeploymentConfigDefinition =
|
|
51
|
+
defineModuleConfig<OpenAiCompatibleDeploymentConfig>({
|
|
52
|
+
key: "ai.openai-compatible",
|
|
53
|
+
label: "OpenAI-compatible 模型部署",
|
|
54
|
+
ownedEnvironmentPrefixes: ["QWEN_", "AI_PROVIDER_"],
|
|
55
|
+
schema: deploymentSchema,
|
|
56
|
+
environment: [
|
|
57
|
+
{ key: "QWEN_API_URL", path: "baseUrl" },
|
|
58
|
+
{ key: "QWEN_MODEL", path: "model" },
|
|
59
|
+
{
|
|
60
|
+
key: "QWEN_API_KEY",
|
|
61
|
+
path: "apiKey",
|
|
62
|
+
secret: { provider: "env" },
|
|
63
|
+
},
|
|
64
|
+
integer("AI_PROVIDER_CONNECT_TIMEOUT_MS", "connectTimeoutMs", 5_000),
|
|
65
|
+
integer("AI_PROVIDER_REQUEST_TIMEOUT_MS", "requestTimeoutMs", 60_000),
|
|
66
|
+
integer(
|
|
67
|
+
"AI_PROVIDER_MAX_RESPONSE_BYTES",
|
|
68
|
+
"maxResponseBytes",
|
|
69
|
+
2 * 1_024 * 1_024,
|
|
70
|
+
),
|
|
71
|
+
{
|
|
72
|
+
key: "AI_PROVIDER_HTTPS_HOST_ALLOWLIST",
|
|
73
|
+
path: "allowedHttpsHosts",
|
|
74
|
+
defaultValue: [],
|
|
75
|
+
decode: normalizeOpenAiCompatibleHttpsHostAllowlist,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export function loadOpenAiCompatibleDeploymentConfig(
|
|
81
|
+
environment: ConfigEnvironment = process.env,
|
|
82
|
+
): OpenAiCompatibleDeploymentConfig {
|
|
83
|
+
return loadModuleConfigSync(
|
|
84
|
+
openAiCompatibleDeploymentConfigDefinition,
|
|
85
|
+
environment,
|
|
86
|
+
new EnvironmentSecretResolver(environment),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function normalizeOpenAiCompatibleBaseUrl(raw: string): string {
|
|
91
|
+
let url: URL;
|
|
92
|
+
try {
|
|
93
|
+
url = new URL(raw.trim());
|
|
94
|
+
} catch {
|
|
95
|
+
throw new Error("OpenAI-compatible Endpoint 无效");
|
|
96
|
+
}
|
|
97
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
98
|
+
throw new Error("OpenAI-compatible Endpoint 只允许 HTTP 或 HTTPS");
|
|
99
|
+
}
|
|
100
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
101
|
+
throw new Error("OpenAI-compatible Endpoint 不允许凭据、查询串或片段");
|
|
102
|
+
}
|
|
103
|
+
const hostname = url.hostname.toLowerCase();
|
|
104
|
+
if (isCloudMetadataHost(hostname)) {
|
|
105
|
+
throw new Error("OpenAI-compatible Endpoint 禁止访问云 metadata");
|
|
106
|
+
}
|
|
107
|
+
if (url.protocol === "http:" && !isPrivateHost(hostname)) {
|
|
108
|
+
throw new Error("公网 OpenAI-compatible Endpoint 必须使用 HTTPS");
|
|
109
|
+
}
|
|
110
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
111
|
+
return url.toString().replace(/\/$/, "");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function normalizeOpenAiCompatibleHttpsHostAllowlist(
|
|
115
|
+
raw: string,
|
|
116
|
+
): string[] {
|
|
117
|
+
const hosts = commaSeparated(raw).map((host) => normalizeHttpsHostname(host));
|
|
118
|
+
if (hosts.length > 32 || new Set(hosts).size !== hosts.length) {
|
|
119
|
+
throw new Error("Provider HTTPS Host allowlist 无效");
|
|
120
|
+
}
|
|
121
|
+
return hosts;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function integer(key: string, path: string, defaultValue: number) {
|
|
125
|
+
return { key, path, defaultValue, decode: strictInteger };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeHttpsHostname(raw: string): string {
|
|
129
|
+
const hostname = raw.trim().toLowerCase().replace(/\.$/, "");
|
|
130
|
+
if (
|
|
131
|
+
!hostname ||
|
|
132
|
+
hostname.includes("*") ||
|
|
133
|
+
hostname.includes("/") ||
|
|
134
|
+
hostname.includes(":") ||
|
|
135
|
+
hostname.length > 253
|
|
136
|
+
) {
|
|
137
|
+
throw new Error("Provider HTTPS Host allowlist 无效");
|
|
138
|
+
}
|
|
139
|
+
const labels = hostname.split(".");
|
|
140
|
+
if (
|
|
141
|
+
labels.some(
|
|
142
|
+
(label) =>
|
|
143
|
+
!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label) &&
|
|
144
|
+
!/^[a-z0-9]$/.test(label),
|
|
145
|
+
)
|
|
146
|
+
) {
|
|
147
|
+
throw new Error("Provider HTTPS Host allowlist 无效");
|
|
148
|
+
}
|
|
149
|
+
return hostname;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isPrivateHost(hostname: string): boolean {
|
|
153
|
+
if (
|
|
154
|
+
hostname === "localhost" ||
|
|
155
|
+
hostname.endsWith(".localhost") ||
|
|
156
|
+
hostname.endsWith(".internal") ||
|
|
157
|
+
hostname.endsWith(".local") ||
|
|
158
|
+
(!hostname.includes(".") && isIP(hostname) === 0)
|
|
159
|
+
) {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
if (isIP(hostname) === 4) {
|
|
163
|
+
const [first = 0, second = 0] = hostname.split(".").map(Number);
|
|
164
|
+
return (
|
|
165
|
+
first === 10 ||
|
|
166
|
+
first === 127 ||
|
|
167
|
+
(first === 172 && second >= 16 && second <= 31) ||
|
|
168
|
+
(first === 192 && second === 168)
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (isIP(hostname) === 6) {
|
|
172
|
+
return (
|
|
173
|
+
hostname === "::1" ||
|
|
174
|
+
hostname.startsWith("fc") ||
|
|
175
|
+
hostname.startsWith("fd")
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isCloudMetadataHost(hostname: string): boolean {
|
|
182
|
+
return (
|
|
183
|
+
hostname === "169.254.169.254" ||
|
|
184
|
+
hostname === "metadata.google.internal" ||
|
|
185
|
+
hostname === "metadata.azure.internal"
|
|
186
|
+
);
|
|
187
|
+
}
|
package/src/platform.ts
CHANGED
|
@@ -34,7 +34,23 @@ export interface PlatformConfig {
|
|
|
34
34
|
sessionCookieName: string;
|
|
35
35
|
sessionTtlSeconds: number;
|
|
36
36
|
passwordHash: "argon2id";
|
|
37
|
+
passwordMinLength: number;
|
|
38
|
+
passwordMaxLength: number;
|
|
37
39
|
};
|
|
40
|
+
storage: {
|
|
41
|
+
bulkDataMaxByteSize: number;
|
|
42
|
+
brandingLogoMaxByteSize: number;
|
|
43
|
+
};
|
|
44
|
+
mail: {
|
|
45
|
+
enabled: boolean;
|
|
46
|
+
host: string;
|
|
47
|
+
port: number;
|
|
48
|
+
secure: boolean;
|
|
49
|
+
fromAddress: string;
|
|
50
|
+
connectTimeoutMs: number;
|
|
51
|
+
smtpUrl?: string;
|
|
52
|
+
};
|
|
53
|
+
regional: { timeZone: string };
|
|
38
54
|
license: {
|
|
39
55
|
enabled: boolean;
|
|
40
56
|
publicKeyId?: string;
|
|
@@ -60,56 +76,111 @@ export interface ProjectConfig {
|
|
|
60
76
|
modules: string[];
|
|
61
77
|
}
|
|
62
78
|
|
|
63
|
-
const platformSchema: z.ZodType<PlatformConfig> = z
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
})
|
|
100
|
-
.optional(),
|
|
101
|
-
}),
|
|
102
|
-
audit: z.strictObject({
|
|
103
|
-
enabled: z.boolean(),
|
|
104
|
-
redactFields: z.array(z.string().min(1)).min(1),
|
|
105
|
-
}),
|
|
106
|
-
compatibility: z.strictObject({
|
|
107
|
-
ruoyiVue: z.strictObject({
|
|
79
|
+
const platformSchema: z.ZodType<PlatformConfig> = z
|
|
80
|
+
.strictObject({
|
|
81
|
+
name: z.string().trim().min(1).max(120),
|
|
82
|
+
logoUrl: z.string().trim().url().optional(),
|
|
83
|
+
enableAI: z.boolean(),
|
|
84
|
+
database: z.strictObject({
|
|
85
|
+
dialect: z.enum(["postgresql", "mysql", "dm", "kingbase"]),
|
|
86
|
+
url: z.string().trim().min(1).optional(),
|
|
87
|
+
legacyRuoYiMysqlUrl: z.string().trim().min(1).optional(),
|
|
88
|
+
legacyRuoYiRedisUrl: z.string().trim().min(1).optional(),
|
|
89
|
+
}),
|
|
90
|
+
cache: z.strictObject({
|
|
91
|
+
redisUrl: z.string().trim().min(1).optional(),
|
|
92
|
+
healthTimeoutMs: z.number().int().min(50).max(10_000),
|
|
93
|
+
}),
|
|
94
|
+
security: z.strictObject({
|
|
95
|
+
sessionCookieName: z.string().trim().min(1).max(120),
|
|
96
|
+
sessionTtlSeconds: z.number().int().min(60).max(2_592_000),
|
|
97
|
+
passwordHash: z.literal("argon2id"),
|
|
98
|
+
passwordMinLength: z.number().int().min(8).max(128),
|
|
99
|
+
passwordMaxLength: z.number().int().min(12).max(512),
|
|
100
|
+
}),
|
|
101
|
+
storage: z.strictObject({
|
|
102
|
+
bulkDataMaxByteSize: z
|
|
103
|
+
.number()
|
|
104
|
+
.int()
|
|
105
|
+
.min(1024)
|
|
106
|
+
.max(10 * 1024 ** 3),
|
|
107
|
+
brandingLogoMaxByteSize: z
|
|
108
|
+
.number()
|
|
109
|
+
.int()
|
|
110
|
+
.min(1024)
|
|
111
|
+
.max(50 * 1024 ** 2),
|
|
112
|
+
}),
|
|
113
|
+
mail: z.strictObject({
|
|
108
114
|
enabled: z.boolean(),
|
|
109
|
-
|
|
115
|
+
host: z.string().trim().max(253),
|
|
116
|
+
port: z.number().int().min(1).max(65_535),
|
|
117
|
+
secure: z.boolean(),
|
|
118
|
+
fromAddress: z.string().trim().max(320),
|
|
119
|
+
connectTimeoutMs: z.number().int().min(100).max(120_000),
|
|
120
|
+
smtpUrl: z.string().trim().min(1).optional(),
|
|
110
121
|
}),
|
|
111
|
-
|
|
112
|
-
|
|
122
|
+
regional: z.strictObject({
|
|
123
|
+
timeZone: z
|
|
124
|
+
.string()
|
|
125
|
+
.trim()
|
|
126
|
+
.min(1)
|
|
127
|
+
.max(100)
|
|
128
|
+
.refine(isSupportedTimeZone, "时区必须是有效的 IANA 时区"),
|
|
129
|
+
}),
|
|
130
|
+
license: z.strictObject({
|
|
131
|
+
enabled: z.boolean(),
|
|
132
|
+
publicKeyId: z.string().trim().min(1).optional(),
|
|
133
|
+
publicKey: z.string().trim().min(1).optional(),
|
|
134
|
+
machineCode: z.string().trim().min(1).optional(),
|
|
135
|
+
versions: z
|
|
136
|
+
.unknown()
|
|
137
|
+
.transform((value, context) => {
|
|
138
|
+
try {
|
|
139
|
+
return parseLicenseCatalog(value);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
context.addIssue({
|
|
142
|
+
code: "custom",
|
|
143
|
+
message: error instanceof Error ? error.message : "版本目录无效",
|
|
144
|
+
});
|
|
145
|
+
return z.NEVER;
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
.optional(),
|
|
149
|
+
}),
|
|
150
|
+
audit: z.strictObject({
|
|
151
|
+
enabled: z.boolean(),
|
|
152
|
+
redactFields: z.array(z.string().min(1)).min(1),
|
|
153
|
+
}),
|
|
154
|
+
compatibility: z.strictObject({
|
|
155
|
+
ruoyiVue: z.strictObject({
|
|
156
|
+
enabled: z.boolean(),
|
|
157
|
+
runtimeReadonly: z.boolean(),
|
|
158
|
+
}),
|
|
159
|
+
}),
|
|
160
|
+
})
|
|
161
|
+
.superRefine((config, context) => {
|
|
162
|
+
if (config.security.passwordMinLength > config.security.passwordMaxLength) {
|
|
163
|
+
context.addIssue({
|
|
164
|
+
code: "custom",
|
|
165
|
+
path: ["security", "passwordMinLength"],
|
|
166
|
+
message: "密码最小长度不能大于最大长度",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (config.mail.enabled && !config.mail.fromAddress) {
|
|
170
|
+
context.addIssue({
|
|
171
|
+
code: "custom",
|
|
172
|
+
path: ["mail", "fromAddress"],
|
|
173
|
+
message: "启用邮件传输时必须配置发件地址",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (config.mail.enabled && !config.mail.smtpUrl && !config.mail.host) {
|
|
177
|
+
context.addIssue({
|
|
178
|
+
code: "custom",
|
|
179
|
+
path: ["mail", "host"],
|
|
180
|
+
message: "启用邮件传输时必须配置 SMTP URL 或主机",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
});
|
|
113
184
|
|
|
114
185
|
export const platformConfigDefinition = defineModuleConfig<PlatformConfig>({
|
|
115
186
|
key: "platform.runtime",
|
|
@@ -118,9 +189,13 @@ export const platformConfigDefinition = defineModuleConfig<PlatformConfig>({
|
|
|
118
189
|
ownedEnvironmentPrefixes: [
|
|
119
190
|
"PLATFORM_",
|
|
120
191
|
"DATABASE_",
|
|
192
|
+
"PASSWORD_",
|
|
121
193
|
"REDIS_",
|
|
122
194
|
"RUOYI_",
|
|
123
195
|
"SESSION_",
|
|
196
|
+
"STORAGE_",
|
|
197
|
+
"MAIL_",
|
|
198
|
+
"REGIONAL_",
|
|
124
199
|
"AUDIT_",
|
|
125
200
|
],
|
|
126
201
|
ownedEnvironmentKeys: [
|
|
@@ -153,6 +228,43 @@ export const platformConfigDefinition = defineModuleConfig<PlatformConfig>({
|
|
|
153
228
|
strictInteger,
|
|
154
229
|
),
|
|
155
230
|
value("PLATFORM_PASSWORD_HASH", "security.passwordHash", "argon2id"),
|
|
231
|
+
value(
|
|
232
|
+
"PASSWORD_MIN_LENGTH",
|
|
233
|
+
"security.passwordMinLength",
|
|
234
|
+
12,
|
|
235
|
+
strictInteger,
|
|
236
|
+
),
|
|
237
|
+
value(
|
|
238
|
+
"PASSWORD_MAX_LENGTH",
|
|
239
|
+
"security.passwordMaxLength",
|
|
240
|
+
128,
|
|
241
|
+
strictInteger,
|
|
242
|
+
),
|
|
243
|
+
value(
|
|
244
|
+
"STORAGE_BULK_DATA_MAX_BYTE_SIZE",
|
|
245
|
+
"storage.bulkDataMaxByteSize",
|
|
246
|
+
512 * 1024 * 1024,
|
|
247
|
+
strictInteger,
|
|
248
|
+
),
|
|
249
|
+
value(
|
|
250
|
+
"STORAGE_BRANDING_LOGO_MAX_BYTE_SIZE",
|
|
251
|
+
"storage.brandingLogoMaxByteSize",
|
|
252
|
+
5 * 1024 * 1024,
|
|
253
|
+
strictInteger,
|
|
254
|
+
),
|
|
255
|
+
value("MAIL_ENABLED", "mail.enabled", false, strictBoolean),
|
|
256
|
+
value("MAIL_HOST", "mail.host", ""),
|
|
257
|
+
value("MAIL_PORT", "mail.port", 587, strictInteger),
|
|
258
|
+
value("MAIL_SECURE", "mail.secure", false, strictBoolean),
|
|
259
|
+
value("MAIL_FROM_ADDRESS", "mail.fromAddress", ""),
|
|
260
|
+
value(
|
|
261
|
+
"MAIL_CONNECT_TIMEOUT_MS",
|
|
262
|
+
"mail.connectTimeoutMs",
|
|
263
|
+
10_000,
|
|
264
|
+
strictInteger,
|
|
265
|
+
),
|
|
266
|
+
secret("MAIL_SMTP_URL", "mail.smtpUrl"),
|
|
267
|
+
value("REGIONAL_TIME_ZONE", "regional.timeZone", "Asia/Shanghai"),
|
|
156
268
|
value("LICENSE_ENABLED", "license.enabled", true, strictBoolean),
|
|
157
269
|
value("LICENSE_PUBLIC_KEY_ID", "license.publicKeyId"),
|
|
158
270
|
value("LICENSE_PUBLIC_KEY", "license.publicKey"),
|
|
@@ -210,3 +322,12 @@ function value(
|
|
|
210
322
|
function secret(key: string, path: string) {
|
|
211
323
|
return { key, path, secret: { provider: "env" as const } };
|
|
212
324
|
}
|
|
325
|
+
|
|
326
|
+
function isSupportedTimeZone(value: string): boolean {
|
|
327
|
+
try {
|
|
328
|
+
new Intl.DateTimeFormat("zh-CN", { timeZone: value });
|
|
329
|
+
return true;
|
|
330
|
+
} catch {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|