@rei-standard/amsg-server 2.1.0 → 2.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/README.md CHANGED
@@ -50,6 +50,10 @@ const rei = await createReiServer({
50
50
  // GET /api/v1/messages -> rei.handlers.messages.GET
51
51
  ```
52
52
 
53
+ ## 关于 `messageType: 'instant'`
54
+
55
+ > **Note**:新代码的 instant 消息请用 [@rei-standard/amsg-instant](../instant/README.md),跳过本端点的"建任务 → 处理 → 删任务" DB 来回。本端点的 `instant` 分支为兼容保留,行为不变、不会有运行时警告。
56
+
53
57
  ## AI 接口 `apiUrl` 约束
54
58
 
55
59
  当 `messageType` 为 `prompted` / `auto`,或 `instant` 使用 AI 配置时:
@@ -61,6 +65,21 @@ const rei = await createReiServer({
61
65
 
62
66
  如果上游返回 `405 Method Not Allowed`,通常表示 `apiUrl` 指向了基础域名而非聊天端点,请优先检查配置值。
63
67
 
68
+ ## 提示词字段(`completePrompt` vs `messages`,2.2.0+)
69
+
70
+ AI 配置消息的提示词可以用两种形态之一,**互斥二选一**:
71
+
72
+ - `completePrompt: string` —— 简单场景:内部包成单条 `{role:'user', content}` 后发给 LLM。
73
+ - `messages: Array<{ role: 'system'|'user'|'assistant'|'tool', content: string | unknown[] }>` —— 多轮 / 带 system role:**原样**转发给 LLM,不做任何 role 注入或重排。和上游主聊天路径调用 LLM 的 body 字节级一致。
74
+
75
+ 两个字段同时给 → `400 INVALID_PARAMETERS`;都不给且没 `userMessage`(仅 instant 类型允许 fallback)也是 400。`messages` 数组必须非空,role 必须是上面四种之一。
76
+
77
+ 可选 `temperature?: number` 透传给 LLM:`completePrompt` 路径未传时默认 0.8(保持旧行为);`messages` 路径未传时**不发**,让上游主路径自己决定。
78
+
79
+ ## 导出(新增)
80
+
81
+ - `validateLlmMessagesArray(messages)` — 同步预校验 messages 数组,返回 `string | null`(错误信息 / 通过)。和 `@rei-standard/amsg-instant` 的校验规则字节级一致。
82
+
64
83
  ## 一体化初始化流程
65
84
 
66
85
  1. 管理员配置环境变量(VAPID + tenant secrets)
package/dist/index.cjs CHANGED
@@ -47,6 +47,29 @@ function isValidUUIDv4(uuid) {
47
47
  const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
48
48
  return uuidV4Regex.test(uuid);
49
49
  }
50
+ var VALID_LLM_MESSAGE_ROLES = /* @__PURE__ */ new Set(["system", "user", "assistant", "tool"]);
51
+ function validateLlmMessagesArray(messages) {
52
+ if (!Array.isArray(messages) || messages.length === 0) {
53
+ return "messages must be a non-empty array";
54
+ }
55
+ for (let i = 0; i < messages.length; i++) {
56
+ const m = messages[i];
57
+ if (!m || typeof m !== "object" || Array.isArray(m)) {
58
+ return `messages[${i}] must be an object`;
59
+ }
60
+ if (!VALID_LLM_MESSAGE_ROLES.has(m.role)) {
61
+ return `messages[${i}].role must be one of system / user / assistant / tool`;
62
+ }
63
+ if (typeof m.content === "string") {
64
+ if (!m.content) return `messages[${i}].content must be a non-empty string`;
65
+ } else if (Array.isArray(m.content)) {
66
+ if (m.content.length === 0) return `messages[${i}].content array must be non-empty`;
67
+ } else {
68
+ return `messages[${i}].content must be a non-empty string or a non-empty array`;
69
+ }
70
+ }
71
+ return null;
72
+ }
50
73
  function validateScheduleMessagePayload(payload) {
51
74
  if (!payload.contactName || typeof payload.contactName !== "string") {
52
75
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { missingFields: ["contactName"] } };
@@ -74,9 +97,35 @@ function validateScheduleMessagePayload(payload) {
74
97
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { missingFields: ["userMessage (required for fixed type)"] } };
75
98
  }
76
99
  }
100
+ const promptCheck = (() => {
101
+ const hasCompletePrompt = payload.completePrompt !== void 0 && payload.completePrompt !== null && payload.completePrompt !== "";
102
+ const hasMessages = payload.messages !== void 0 && payload.messages !== null;
103
+ if (hasCompletePrompt && hasMessages) {
104
+ return {
105
+ error: { code: "INVALID_PARAMETERS", message: "exactly one of `completePrompt` or `messages` must be provided\uFF08\u4E24\u8005\u4E0D\u80FD\u540C\u65F6\u51FA\u73B0\uFF09", details: { invalidFields: ["completePrompt", "messages"] } },
106
+ hasCompletePrompt: true,
107
+ hasMessages: true
108
+ };
109
+ }
110
+ if (hasMessages) {
111
+ const err = validateLlmMessagesArray(payload.messages);
112
+ if (err) {
113
+ return {
114
+ error: { code: "INVALID_PARAMETERS", message: err, details: { invalidFields: ["messages"] } },
115
+ hasCompletePrompt: false,
116
+ hasMessages: true
117
+ };
118
+ }
119
+ }
120
+ return { error: null, hasCompletePrompt, hasMessages };
121
+ })();
122
+ if (promptCheck.error) {
123
+ return { valid: false, errorCode: promptCheck.error.code, errorMessage: promptCheck.error.message, details: promptCheck.error.details };
124
+ }
125
+ const hasPrompt = promptCheck.hasCompletePrompt || promptCheck.hasMessages;
77
126
  if (payload.messageType === "prompted" || payload.messageType === "auto") {
78
127
  const missingAiFields = [];
79
- if (!payload.completePrompt) missingAiFields.push("completePrompt");
128
+ if (!hasPrompt) missingAiFields.push("completePrompt or messages");
80
129
  if (!payload.apiUrl) missingAiFields.push("apiUrl");
81
130
  if (!payload.apiKey) missingAiFields.push("apiKey");
82
131
  if (!payload.primaryModel) missingAiFields.push("primaryModel");
@@ -88,12 +137,15 @@ function validateScheduleMessagePayload(payload) {
88
137
  if (payload.recurrenceType && payload.recurrenceType !== "none") {
89
138
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "instant \u7C7B\u578B\u7684 recurrenceType \u5FC5\u987B\u4E3A none", details: { invalidFields: ['recurrenceType (must be "none" for instant type)'] } };
90
139
  }
91
- const hasAiConfig = payload.completePrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
140
+ const hasAiConfig = hasPrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
92
141
  const hasUserMessage = payload.userMessage;
93
142
  if (!hasAiConfig && !hasUserMessage) {
94
- return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "instant \u7C7B\u578B\u5FC5\u987B\u63D0\u4F9B userMessage \u6216\u5B8C\u6574\u7684 AI \u914D\u7F6E", details: { missingFields: ["userMessage or (completePrompt + apiUrl + apiKey + primaryModel)"] } };
143
+ return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "instant \u7C7B\u578B\u5FC5\u987B\u63D0\u4F9B userMessage \u6216\u5B8C\u6574\u7684 AI \u914D\u7F6E", details: { missingFields: ["userMessage or ((completePrompt | messages) + apiUrl + apiKey + primaryModel)"] } };
95
144
  }
96
145
  }
146
+ if (payload.temperature !== void 0 && payload.temperature !== null && (typeof payload.temperature !== "number" || !Number.isFinite(payload.temperature))) {
147
+ return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["temperature (must be a finite number)"] } };
148
+ }
97
149
  if (payload.avatarUrl && !isValidUrl(payload.avatarUrl)) {
98
150
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["avatarUrl (invalid URL format)"] } };
99
151
  }
@@ -395,7 +447,8 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
395
447
  if (decryptedPayload.messageType === "fixed") {
396
448
  messageContent = decryptedPayload.userMessage;
397
449
  } else if (decryptedPayload.messageType === "instant") {
398
- if (decryptedPayload.completePrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
450
+ const hasPrompt = !!decryptedPayload.completePrompt || Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0;
451
+ if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
399
452
  messageContent = await _callAI(decryptedPayload);
400
453
  } else if (decryptedPayload.userMessage) {
401
454
  messageContent = decryptedPayload.userMessage;
@@ -537,11 +590,16 @@ async function _callAI(payload) {
537
590
  return content.trim();
538
591
  }
539
592
  function buildAiRequestBody(payload) {
593
+ const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0 ? payload.messages : [{ role: "user", content: payload.completePrompt }];
540
594
  const requestBody = {
541
595
  model: payload.primaryModel,
542
- messages: [{ role: "user", content: payload.completePrompt }],
543
- temperature: 0.8
596
+ messages: llmMessages
544
597
  };
598
+ if (payload.temperature !== void 0 && payload.temperature !== null) {
599
+ requestBody.temperature = payload.temperature;
600
+ } else if (!Array.isArray(payload.messages)) {
601
+ requestBody.temperature = 0.8;
602
+ }
545
603
  if (payload.maxTokens === void 0 || payload.maxTokens === null) {
546
604
  return requestBody;
547
605
  }
@@ -554,7 +612,7 @@ function buildAiRequestBody(payload) {
554
612
  function normalizeAiApiUrl(apiUrl) {
555
613
  if (typeof apiUrl !== "string" || !apiUrl.trim()) {
556
614
  throw new Error(
557
- "Invalid apiUrl: apiUrl is required. Please provide a full chat endpoint URL (for example: https://api.openai.com/v1/chat/completions)."
615
+ "Invalid apiUrl: apiUrl is required. Please provide a chat endpoint URL (for example: https://api.openai.com or https://api.openai.com/v1/chat/completions)."
558
616
  );
559
617
  }
560
618
  const trimmedApiUrl = apiUrl.trim();
@@ -563,10 +621,17 @@ function normalizeAiApiUrl(apiUrl) {
563
621
  parsedUrl = new URL(trimmedApiUrl);
564
622
  } catch (e3) {
565
623
  throw new Error(
566
- `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL that points to a full chat endpoint.`
624
+ `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
567
625
  );
568
626
  }
569
- parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, "") || "/";
627
+ let path = parsedUrl.pathname.replace(/\/+$/, "") || "/";
628
+ if (/\/chat\/completions$/.test(path)) {
629
+ } else if (path === "/") {
630
+ path = "/v1/chat/completions";
631
+ } else if (/\/v\d+$/.test(path)) {
632
+ path = `${path}/chat/completions`;
633
+ }
634
+ parsedUrl.pathname = path;
570
635
  return parsedUrl.toString();
571
636
  }
572
637
 
@@ -634,8 +699,13 @@ function createScheduleMessageHandler(ctx) {
634
699
  apiUrl: payload.apiUrl || null,
635
700
  apiKey: payload.apiKey || null,
636
701
  primaryModel: payload.primaryModel || null,
702
+ // Prompt is one-of: legacy completePrompt (string) OR messages (OpenAI-
703
+ // style array). Validation has already enforced exactly-one-of, so
704
+ // exactly one of these will be non-null when an AI config is provided.
637
705
  completePrompt: payload.completePrompt || null,
706
+ messages: Array.isArray(payload.messages) ? payload.messages : null,
638
707
  maxTokens: _nullishCoalesce(payload.maxTokens, () => ( null)),
708
+ temperature: _nullishCoalesce(payload.temperature, () => ( null)),
639
709
  pushSubscription: payload.pushSubscription,
640
710
  metadata: payload.metadata || {}
641
711
  };
@@ -947,6 +1017,18 @@ function createUpdateMessageHandler(ctx) {
947
1017
  if (Object.prototype.hasOwnProperty.call(updates, "maxTokens") && updates.maxTokens !== null && (!Number.isInteger(updates.maxTokens) || updates.maxTokens <= 0)) {
948
1018
  return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "\u66F4\u65B0\u6570\u636E\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["maxTokens"] } } } };
949
1019
  }
1020
+ if (updates.completePrompt && updates.messages !== void 0 && updates.messages !== null) {
1021
+ return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "completePrompt \u4E0E messages \u4E0D\u80FD\u540C\u65F6\u66F4\u65B0\uFF08\u4E8C\u9009\u4E00\uFF09", details: { invalidFields: ["completePrompt", "messages"] } } } };
1022
+ }
1023
+ if (updates.messages !== void 0 && updates.messages !== null) {
1024
+ const msgErr = validateLlmMessagesArray(updates.messages);
1025
+ if (msgErr) {
1026
+ return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: msgErr, details: { invalidFields: ["messages"] } } } };
1027
+ }
1028
+ }
1029
+ if (Object.prototype.hasOwnProperty.call(updates, "temperature") && updates.temperature !== null && (typeof updates.temperature !== "number" || !Number.isFinite(updates.temperature))) {
1030
+ return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "\u66F4\u65B0\u6570\u636E\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["temperature"] } } } };
1031
+ }
950
1032
  const existingTask = await db.getTaskByUuid(taskUuid, userId);
951
1033
  if (!existingTask) {
952
1034
  const taskStatus = await db.getTaskStatus(taskUuid, userId);
@@ -956,14 +1038,23 @@ function createUpdateMessageHandler(ctx) {
956
1038
  return { status: 409, body: { success: false, error: { code: "TASK_ALREADY_COMPLETED", message: "\u4EFB\u52A1\u5DF2\u5B8C\u6210\u6216\u5DF2\u5931\u8D25\uFF0C\u65E0\u6CD5\u66F4\u65B0" } } };
957
1039
  }
958
1040
  const existingData = JSON.parse(decryptFromStorage(existingTask.encrypted_payload, userKey));
1041
+ const promptUpdates = {};
1042
+ if (updates.completePrompt) {
1043
+ promptUpdates.completePrompt = updates.completePrompt;
1044
+ promptUpdates.messages = null;
1045
+ } else if (updates.messages !== void 0 && updates.messages !== null) {
1046
+ promptUpdates.messages = updates.messages;
1047
+ promptUpdates.completePrompt = null;
1048
+ }
959
1049
  const updatedData = {
960
1050
  ...existingData,
961
- ...updates.completePrompt && { completePrompt: updates.completePrompt },
1051
+ ...promptUpdates,
962
1052
  ...updates.userMessage && { userMessage: updates.userMessage },
963
1053
  ...updates.recurrenceType && { recurrenceType: updates.recurrenceType },
964
1054
  ...updates.avatarUrl && { avatarUrl: updates.avatarUrl },
965
1055
  ...updates.metadata && { metadata: updates.metadata },
966
- ...Object.prototype.hasOwnProperty.call(updates, "maxTokens") && { maxTokens: _nullishCoalesce(updates.maxTokens, () => ( null)) }
1056
+ ...Object.prototype.hasOwnProperty.call(updates, "maxTokens") && { maxTokens: _nullishCoalesce(updates.maxTokens, () => ( null)) },
1057
+ ...Object.prototype.hasOwnProperty.call(updates, "temperature") && { temperature: _nullishCoalesce(updates.temperature, () => ( null)) }
967
1058
  };
968
1059
  const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
969
1060
  const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : void 0;
@@ -1486,4 +1577,5 @@ async function createReiServer(config) {
1486
1577
 
1487
1578
 
1488
1579
 
1489
- exports.createAdapter = createAdapter; exports.createReiServer = createReiServer; exports.createTenantToken = createTenantToken; exports.decryptFromStorage = decryptFromStorage; exports.decryptPayload = decryptPayload; exports.deriveUserEncryptionKey = deriveUserEncryptionKey; exports.encryptForStorage = encryptForStorage; exports.isValidISO8601 = isValidISO8601; exports.isValidUUID = isValidUUID; exports.isValidUUIDv4 = isValidUUIDv4; exports.isValidUrl = isValidUrl; exports.validateScheduleMessagePayload = validateScheduleMessagePayload; exports.verifyTenantToken = verifyTenantToken;
1580
+
1581
+ exports.createAdapter = createAdapter; exports.createReiServer = createReiServer; exports.createTenantToken = createTenantToken; exports.decryptFromStorage = decryptFromStorage; exports.decryptPayload = decryptPayload; exports.deriveUserEncryptionKey = deriveUserEncryptionKey; exports.encryptForStorage = encryptForStorage; exports.isValidISO8601 = isValidISO8601; exports.isValidUUID = isValidUUID; exports.isValidUUIDv4 = isValidUUIDv4; exports.isValidUrl = isValidUrl; exports.validateLlmMessagesArray = validateLlmMessagesArray; exports.validateScheduleMessagePayload = validateScheduleMessagePayload; exports.verifyTenantToken = verifyTenantToken;
package/dist/index.d.cts CHANGED
@@ -101,6 +101,40 @@ function isValidUUIDv4(uuid) {
101
101
  return uuidV4Regex.test(uuid);
102
102
  }
103
103
 
104
+ const VALID_LLM_MESSAGE_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
105
+
106
+ /**
107
+ * Validate an OpenAI-style messages array. Same shape contract as
108
+ * `@rei-standard/amsg-instant` (kept in lockstep on purpose — both packages
109
+ * end up forwarding this to the same LLM body).
110
+ *
111
+ * @param {unknown} messages
112
+ * @returns {string | null} Error message, or null if valid.
113
+ */
114
+ function validateLlmMessagesArray(messages) {
115
+ if (!Array.isArray(messages) || messages.length === 0) {
116
+ return 'messages must be a non-empty array';
117
+ }
118
+ for (let i = 0; i < messages.length; i++) {
119
+ const m = messages[i];
120
+ if (!m || typeof m !== 'object' || Array.isArray(m)) {
121
+ return `messages[${i}] must be an object`;
122
+ }
123
+ if (!VALID_LLM_MESSAGE_ROLES.has(m.role)) {
124
+ return `messages[${i}].role must be one of system / user / assistant / tool`;
125
+ }
126
+ if (typeof m.content === 'string') {
127
+ if (!m.content) return `messages[${i}].content must be a non-empty string`;
128
+ } else if (Array.isArray(m.content)) {
129
+ if (m.content.length === 0) return `messages[${i}].content array must be non-empty`;
130
+ // Element schema is intentionally not enforced — passed through to LLM as-is.
131
+ } else {
132
+ return `messages[${i}].content must be a non-empty string or a non-empty array`;
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+
104
138
  /**
105
139
  * Validate the schedule-message request payload.
106
140
  *
@@ -146,9 +180,40 @@ function validateScheduleMessagePayload(payload) {
146
180
  }
147
181
  }
148
182
 
183
+ // ─── Prompt schema (shared by prompted / auto / instant AI configs) ──
184
+ //
185
+ // Callers provide *exactly one of* `completePrompt` (string) or `messages`
186
+ // (OpenAI-style array). Same contract as @rei-standard/amsg-instant; the
187
+ // server's LLM path forwards either verbatim.
188
+ const promptCheck = (() => {
189
+ const hasCompletePrompt = payload.completePrompt !== undefined && payload.completePrompt !== null && payload.completePrompt !== '';
190
+ const hasMessages = payload.messages !== undefined && payload.messages !== null;
191
+ if (hasCompletePrompt && hasMessages) {
192
+ return {
193
+ error: { code: 'INVALID_PARAMETERS', message: 'exactly one of `completePrompt` or `messages` must be provided(两者不能同时出现)', details: { invalidFields: ['completePrompt', 'messages'] } },
194
+ hasCompletePrompt: true, hasMessages: true,
195
+ };
196
+ }
197
+ if (hasMessages) {
198
+ const err = validateLlmMessagesArray(payload.messages);
199
+ if (err) {
200
+ return {
201
+ error: { code: 'INVALID_PARAMETERS', message: err, details: { invalidFields: ['messages'] } },
202
+ hasCompletePrompt: false, hasMessages: true,
203
+ };
204
+ }
205
+ }
206
+ return { error: null, hasCompletePrompt, hasMessages };
207
+ })();
208
+
209
+ if (promptCheck.error) {
210
+ return { valid: false, errorCode: promptCheck.error.code, errorMessage: promptCheck.error.message, details: promptCheck.error.details };
211
+ }
212
+ const hasPrompt = promptCheck.hasCompletePrompt || promptCheck.hasMessages;
213
+
149
214
  if (payload.messageType === 'prompted' || payload.messageType === 'auto') {
150
215
  const missingAiFields = [];
151
- if (!payload.completePrompt) missingAiFields.push('completePrompt');
216
+ if (!hasPrompt) missingAiFields.push('completePrompt or messages');
152
217
  if (!payload.apiUrl) missingAiFields.push('apiUrl');
153
218
  if (!payload.apiKey) missingAiFields.push('apiKey');
154
219
  if (!payload.primaryModel) missingAiFields.push('primaryModel');
@@ -161,13 +226,21 @@ function validateScheduleMessagePayload(payload) {
161
226
  if (payload.recurrenceType && payload.recurrenceType !== 'none') {
162
227
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型的 recurrenceType 必须为 none', details: { invalidFields: ['recurrenceType (must be "none" for instant type)'] } };
163
228
  }
164
- const hasAiConfig = payload.completePrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
229
+ const hasAiConfig = hasPrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
165
230
  const hasUserMessage = payload.userMessage;
166
231
  if (!hasAiConfig && !hasUserMessage) {
167
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型必须提供 userMessage 或完整的 AI 配置', details: { missingFields: ['userMessage or (completePrompt + apiUrl + apiKey + primaryModel)'] } };
232
+ return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型必须提供 userMessage 或完整的 AI 配置', details: { missingFields: ['userMessage or ((completePrompt | messages) + apiUrl + apiKey + primaryModel)'] } };
168
233
  }
169
234
  }
170
235
 
236
+ if (
237
+ payload.temperature !== undefined &&
238
+ payload.temperature !== null &&
239
+ (typeof payload.temperature !== 'number' || !Number.isFinite(payload.temperature))
240
+ ) {
241
+ return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['temperature (must be a finite number)'] } };
242
+ }
243
+
171
244
  if (payload.avatarUrl && !isValidUrl(payload.avatarUrl)) {
172
245
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['avatarUrl (invalid URL format)'] } };
173
246
  }
@@ -681,7 +754,8 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
681
754
  messageContent = decryptedPayload.userMessage;
682
755
 
683
756
  } else if (decryptedPayload.messageType === 'instant') {
684
- if (decryptedPayload.completePrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
757
+ const hasPrompt = !!decryptedPayload.completePrompt || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0);
758
+ if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
685
759
  messageContent = await _callAI(decryptedPayload);
686
760
  } else if (decryptedPayload.userMessage) {
687
761
  messageContent = decryptedPayload.userMessage;
@@ -887,12 +961,29 @@ async function _callAI(payload) {
887
961
  * @returns {Object}
888
962
  */
889
963
  function buildAiRequestBody(payload) {
964
+ // messages mode (added in v2.2.0): forward the caller's OpenAI-style array
965
+ // verbatim — same contract as @rei-standard/amsg-instant 0.5.0+. No auto
966
+ // role injection, no concatenation back to a single user message. Lets
967
+ // the upstream app preserve system / multi-turn context byte-for-byte
968
+ // across the schedule-message path.
969
+ const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0
970
+ ? payload.messages
971
+ : [{ role: 'user', content: payload.completePrompt }];
972
+
890
973
  const requestBody = {
891
974
  model: payload.primaryModel,
892
- messages: [{ role: 'user', content: payload.completePrompt }],
893
- temperature: 0.8
975
+ messages: llmMessages,
894
976
  };
895
977
 
978
+ // Match the instant package's behavior: only inject default temperature
979
+ // for the legacy completePrompt path; messages mode forwards whatever the
980
+ // upstream app set (or nothing) so behavior matches their main chat path.
981
+ if (payload.temperature !== undefined && payload.temperature !== null) {
982
+ requestBody.temperature = payload.temperature;
983
+ } else if (!Array.isArray(payload.messages)) {
984
+ requestBody.temperature = 0.8;
985
+ }
986
+
896
987
  if (payload.maxTokens === undefined || payload.maxTokens === null) {
897
988
  return requestBody;
898
989
  }
@@ -906,12 +997,27 @@ function buildAiRequestBody(payload) {
906
997
  }
907
998
 
908
999
  /**
909
- * Normalize AI API URL with minimal, non-breaking rules:
910
- * - trim whitespace
911
- * - remove redundant trailing slashes from pathname
912
- * - keep query string untouched
1000
+ * Normalize AI API URL for OpenAI-compatible chat endpoints.
1001
+ *
1002
+ * **Keep in sync** with `@rei-standard/amsg-instant`'s
1003
+ * `src/message-processor.js` `normalizeAiApiUrl` — same rules, same
1004
+ * tests. The two packages share this logic but each carry their own copy
1005
+ * to avoid an architectural dependency (server should not depend on the
1006
+ * stateless worker package).
913
1007
  *
914
- * Note: This function does NOT auto-append /v1 or /chat/completions.
1008
+ * Rules (idempotent — running it twice equals running it once):
1009
+ * - Already ends with `/chat/completions` → leave as-is.
1010
+ * - Bare host (no path or just `/`) → append `/v1/chat/completions`.
1011
+ * - Path ends with a version segment like `/v1`,
1012
+ * `/v2`, … (with or without trailing slash) → append only
1013
+ * `/chat/completions`; never doubles `/v1` for
1014
+ * callers who already include it.
1015
+ * - Anything else (custom path that doesn't match the
1016
+ * OpenAI shape, e.g. `/v1/messages` for
1017
+ * Anthropic-style proxies) → leave as-is. The caller
1018
+ * knows their own routing.
1019
+ *
1020
+ * Query string is preserved verbatim.
915
1021
  *
916
1022
  * @param {string} apiUrl
917
1023
  * @returns {string}
@@ -920,7 +1026,8 @@ function normalizeAiApiUrl(apiUrl) {
920
1026
  if (typeof apiUrl !== 'string' || !apiUrl.trim()) {
921
1027
  throw new Error(
922
1028
  'Invalid apiUrl: apiUrl is required. ' +
923
- 'Please provide a full chat endpoint URL (for example: https://api.openai.com/v1/chat/completions).'
1029
+ 'Please provide a chat endpoint URL ' +
1030
+ '(for example: https://api.openai.com or https://api.openai.com/v1/chat/completions).'
924
1031
  );
925
1032
  }
926
1033
 
@@ -931,12 +1038,23 @@ function normalizeAiApiUrl(apiUrl) {
931
1038
  parsedUrl = new URL(trimmedApiUrl);
932
1039
  } catch {
933
1040
  throw new Error(
934
- `Invalid apiUrl: "${apiUrl}". ` +
935
- 'Please provide a valid absolute URL that points to a full chat endpoint.'
1041
+ `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
936
1042
  );
937
1043
  }
938
1044
 
939
- parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, '') || '/';
1045
+ let path = parsedUrl.pathname.replace(/\/+$/, '') || '/';
1046
+
1047
+ if (/\/chat\/completions$/.test(path)) ; else if (path === '/') {
1048
+ // Bare host → assume OpenAI shape.
1049
+ path = '/v1/chat/completions';
1050
+ } else if (/\/v\d+$/.test(path)) {
1051
+ // Path ends in `/v1`, `/v2`, … — caller already versioned the URL.
1052
+ // Append only `/chat/completions`; never re-add `/v1`.
1053
+ path = `${path}/chat/completions`;
1054
+ }
1055
+ // Any other custom path is left untouched on purpose.
1056
+
1057
+ parsedUrl.pathname = path;
940
1058
  return parsedUrl.toString();
941
1059
  }
942
1060
 
@@ -1025,14 +1143,27 @@ function createScheduleMessageHandler(ctx) {
1025
1143
  apiUrl: payload.apiUrl || null,
1026
1144
  apiKey: payload.apiKey || null,
1027
1145
  primaryModel: payload.primaryModel || null,
1146
+ // Prompt is one-of: legacy completePrompt (string) OR messages (OpenAI-
1147
+ // style array). Validation has already enforced exactly-one-of, so
1148
+ // exactly one of these will be non-null when an AI config is provided.
1028
1149
  completePrompt: payload.completePrompt || null,
1150
+ messages: Array.isArray(payload.messages) ? payload.messages : null,
1029
1151
  maxTokens: payload.maxTokens ?? null,
1152
+ temperature: payload.temperature ?? null,
1030
1153
  pushSubscription: payload.pushSubscription,
1031
1154
  metadata: payload.metadata || {}
1032
1155
  };
1033
1156
 
1034
1157
  const encryptedPayload = encryptForStorage(JSON.stringify(fullTaskData), userKey);
1035
1158
 
1159
+ /**
1160
+ * @deprecated Soft-deprecated. For new code, use @rei-standard/amsg-instant.
1161
+ * This branch is kept for backward compatibility and the existing behavior
1162
+ * (create task → process → delete) is unchanged. The dedicated amsg-instant
1163
+ * package is stateless (no DB roundtrip), deployable to Cloudflare Workers,
1164
+ * and locks the encryption + push-payload contract behind a single version.
1165
+ * See packages/rei-standard-amsg/instant/README.md.
1166
+ */
1036
1167
  // Instant type: check VAPID before creating the task to avoid orphaned rows
1037
1168
  if (payload.messageType === 'instant') {
1038
1169
  if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
@@ -1086,6 +1217,13 @@ function createScheduleMessageHandler(ctx) {
1086
1217
  return { status: 500, body: { success: false, error: { code: 'TASK_CREATE_FAILED', message: '创建任务失败' } } };
1087
1218
  }
1088
1219
 
1220
+ /**
1221
+ * @deprecated Soft-deprecated. For new code, use @rei-standard/amsg-instant.
1222
+ * The "create-task → process-by-uuid → delete-task" sequence below is
1223
+ * preserved verbatim so existing clients keep working. New integrations
1224
+ * should call the dedicated amsg-instant endpoint instead — it skips this
1225
+ * DB round-trip entirely. See packages/rei-standard-amsg/instant/README.md.
1226
+ */
1089
1227
  // Instant type: send immediately
1090
1228
  if (payload.messageType === 'instant') {
1091
1229
  try {
@@ -1408,6 +1546,30 @@ function createUpdateMessageHandler(ctx) {
1408
1546
  return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['maxTokens'] } } } };
1409
1547
  }
1410
1548
 
1549
+ // Reject updates that try to set both completePrompt and messages at
1550
+ // once. We don't enforce one-of-required here (callers may patch other
1551
+ // fields), but the two prompt sources are mutually exclusive and must
1552
+ // stay that way in storage too.
1553
+ if (
1554
+ updates.completePrompt &&
1555
+ updates.messages !== undefined && updates.messages !== null
1556
+ ) {
1557
+ return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: 'completePrompt 与 messages 不能同时更新(二选一)', details: { invalidFields: ['completePrompt', 'messages'] } } } };
1558
+ }
1559
+ if (updates.messages !== undefined && updates.messages !== null) {
1560
+ const msgErr = validateLlmMessagesArray(updates.messages);
1561
+ if (msgErr) {
1562
+ return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: msgErr, details: { invalidFields: ['messages'] } } } };
1563
+ }
1564
+ }
1565
+ if (
1566
+ Object.prototype.hasOwnProperty.call(updates, 'temperature') &&
1567
+ updates.temperature !== null &&
1568
+ (typeof updates.temperature !== 'number' || !Number.isFinite(updates.temperature))
1569
+ ) {
1570
+ return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['temperature'] } } } };
1571
+ }
1572
+
1411
1573
  // Fetch existing task
1412
1574
  const existingTask = await db.getTaskByUuid(taskUuid, userId);
1413
1575
 
@@ -1421,14 +1583,27 @@ function createUpdateMessageHandler(ctx) {
1421
1583
 
1422
1584
  const existingData = JSON.parse(decryptFromStorage(existingTask.encrypted_payload, userKey));
1423
1585
 
1586
+ // When the caller switches prompt source (completePrompt ↔ messages),
1587
+ // null out the other so storage stays one-of (matches schedule-message
1588
+ // shape and prevents buildAiRequestBody from accidentally seeing both).
1589
+ const promptUpdates = {};
1590
+ if (updates.completePrompt) {
1591
+ promptUpdates.completePrompt = updates.completePrompt;
1592
+ promptUpdates.messages = null;
1593
+ } else if (updates.messages !== undefined && updates.messages !== null) {
1594
+ promptUpdates.messages = updates.messages;
1595
+ promptUpdates.completePrompt = null;
1596
+ }
1597
+
1424
1598
  const updatedData = {
1425
1599
  ...existingData,
1426
- ...(updates.completePrompt && { completePrompt: updates.completePrompt }),
1600
+ ...promptUpdates,
1427
1601
  ...(updates.userMessage && { userMessage: updates.userMessage }),
1428
1602
  ...(updates.recurrenceType && { recurrenceType: updates.recurrenceType }),
1429
1603
  ...(updates.avatarUrl && { avatarUrl: updates.avatarUrl }),
1430
1604
  ...(updates.metadata && { metadata: updates.metadata }),
1431
- ...(Object.prototype.hasOwnProperty.call(updates, 'maxTokens') && { maxTokens: updates.maxTokens ?? null })
1605
+ ...(Object.prototype.hasOwnProperty.call(updates, 'maxTokens') && { maxTokens: updates.maxTokens ?? null }),
1606
+ ...(Object.prototype.hasOwnProperty.call(updates, 'temperature') && { temperature: updates.temperature ?? null })
1432
1607
  };
1433
1608
 
1434
1609
  const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
@@ -2162,4 +2337,4 @@ async function createReiServer(config) {
2162
2337
  };
2163
2338
  }
2164
2339
 
2165
- export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateScheduleMessagePayload, verifyTenantToken };
2340
+ export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateLlmMessagesArray, validateScheduleMessagePayload, verifyTenantToken };
package/dist/index.d.ts CHANGED
@@ -101,6 +101,40 @@ function isValidUUIDv4(uuid) {
101
101
  return uuidV4Regex.test(uuid);
102
102
  }
103
103
 
104
+ const VALID_LLM_MESSAGE_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
105
+
106
+ /**
107
+ * Validate an OpenAI-style messages array. Same shape contract as
108
+ * `@rei-standard/amsg-instant` (kept in lockstep on purpose — both packages
109
+ * end up forwarding this to the same LLM body).
110
+ *
111
+ * @param {unknown} messages
112
+ * @returns {string | null} Error message, or null if valid.
113
+ */
114
+ function validateLlmMessagesArray(messages) {
115
+ if (!Array.isArray(messages) || messages.length === 0) {
116
+ return 'messages must be a non-empty array';
117
+ }
118
+ for (let i = 0; i < messages.length; i++) {
119
+ const m = messages[i];
120
+ if (!m || typeof m !== 'object' || Array.isArray(m)) {
121
+ return `messages[${i}] must be an object`;
122
+ }
123
+ if (!VALID_LLM_MESSAGE_ROLES.has(m.role)) {
124
+ return `messages[${i}].role must be one of system / user / assistant / tool`;
125
+ }
126
+ if (typeof m.content === 'string') {
127
+ if (!m.content) return `messages[${i}].content must be a non-empty string`;
128
+ } else if (Array.isArray(m.content)) {
129
+ if (m.content.length === 0) return `messages[${i}].content array must be non-empty`;
130
+ // Element schema is intentionally not enforced — passed through to LLM as-is.
131
+ } else {
132
+ return `messages[${i}].content must be a non-empty string or a non-empty array`;
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+
104
138
  /**
105
139
  * Validate the schedule-message request payload.
106
140
  *
@@ -146,9 +180,40 @@ function validateScheduleMessagePayload(payload) {
146
180
  }
147
181
  }
148
182
 
183
+ // ─── Prompt schema (shared by prompted / auto / instant AI configs) ──
184
+ //
185
+ // Callers provide *exactly one of* `completePrompt` (string) or `messages`
186
+ // (OpenAI-style array). Same contract as @rei-standard/amsg-instant; the
187
+ // server's LLM path forwards either verbatim.
188
+ const promptCheck = (() => {
189
+ const hasCompletePrompt = payload.completePrompt !== undefined && payload.completePrompt !== null && payload.completePrompt !== '';
190
+ const hasMessages = payload.messages !== undefined && payload.messages !== null;
191
+ if (hasCompletePrompt && hasMessages) {
192
+ return {
193
+ error: { code: 'INVALID_PARAMETERS', message: 'exactly one of `completePrompt` or `messages` must be provided(两者不能同时出现)', details: { invalidFields: ['completePrompt', 'messages'] } },
194
+ hasCompletePrompt: true, hasMessages: true,
195
+ };
196
+ }
197
+ if (hasMessages) {
198
+ const err = validateLlmMessagesArray(payload.messages);
199
+ if (err) {
200
+ return {
201
+ error: { code: 'INVALID_PARAMETERS', message: err, details: { invalidFields: ['messages'] } },
202
+ hasCompletePrompt: false, hasMessages: true,
203
+ };
204
+ }
205
+ }
206
+ return { error: null, hasCompletePrompt, hasMessages };
207
+ })();
208
+
209
+ if (promptCheck.error) {
210
+ return { valid: false, errorCode: promptCheck.error.code, errorMessage: promptCheck.error.message, details: promptCheck.error.details };
211
+ }
212
+ const hasPrompt = promptCheck.hasCompletePrompt || promptCheck.hasMessages;
213
+
149
214
  if (payload.messageType === 'prompted' || payload.messageType === 'auto') {
150
215
  const missingAiFields = [];
151
- if (!payload.completePrompt) missingAiFields.push('completePrompt');
216
+ if (!hasPrompt) missingAiFields.push('completePrompt or messages');
152
217
  if (!payload.apiUrl) missingAiFields.push('apiUrl');
153
218
  if (!payload.apiKey) missingAiFields.push('apiKey');
154
219
  if (!payload.primaryModel) missingAiFields.push('primaryModel');
@@ -161,13 +226,21 @@ function validateScheduleMessagePayload(payload) {
161
226
  if (payload.recurrenceType && payload.recurrenceType !== 'none') {
162
227
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型的 recurrenceType 必须为 none', details: { invalidFields: ['recurrenceType (must be "none" for instant type)'] } };
163
228
  }
164
- const hasAiConfig = payload.completePrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
229
+ const hasAiConfig = hasPrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
165
230
  const hasUserMessage = payload.userMessage;
166
231
  if (!hasAiConfig && !hasUserMessage) {
167
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型必须提供 userMessage 或完整的 AI 配置', details: { missingFields: ['userMessage or (completePrompt + apiUrl + apiKey + primaryModel)'] } };
232
+ return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型必须提供 userMessage 或完整的 AI 配置', details: { missingFields: ['userMessage or ((completePrompt | messages) + apiUrl + apiKey + primaryModel)'] } };
168
233
  }
169
234
  }
170
235
 
236
+ if (
237
+ payload.temperature !== undefined &&
238
+ payload.temperature !== null &&
239
+ (typeof payload.temperature !== 'number' || !Number.isFinite(payload.temperature))
240
+ ) {
241
+ return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['temperature (must be a finite number)'] } };
242
+ }
243
+
171
244
  if (payload.avatarUrl && !isValidUrl(payload.avatarUrl)) {
172
245
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['avatarUrl (invalid URL format)'] } };
173
246
  }
@@ -681,7 +754,8 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
681
754
  messageContent = decryptedPayload.userMessage;
682
755
 
683
756
  } else if (decryptedPayload.messageType === 'instant') {
684
- if (decryptedPayload.completePrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
757
+ const hasPrompt = !!decryptedPayload.completePrompt || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0);
758
+ if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
685
759
  messageContent = await _callAI(decryptedPayload);
686
760
  } else if (decryptedPayload.userMessage) {
687
761
  messageContent = decryptedPayload.userMessage;
@@ -887,12 +961,29 @@ async function _callAI(payload) {
887
961
  * @returns {Object}
888
962
  */
889
963
  function buildAiRequestBody(payload) {
964
+ // messages mode (added in v2.2.0): forward the caller's OpenAI-style array
965
+ // verbatim — same contract as @rei-standard/amsg-instant 0.5.0+. No auto
966
+ // role injection, no concatenation back to a single user message. Lets
967
+ // the upstream app preserve system / multi-turn context byte-for-byte
968
+ // across the schedule-message path.
969
+ const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0
970
+ ? payload.messages
971
+ : [{ role: 'user', content: payload.completePrompt }];
972
+
890
973
  const requestBody = {
891
974
  model: payload.primaryModel,
892
- messages: [{ role: 'user', content: payload.completePrompt }],
893
- temperature: 0.8
975
+ messages: llmMessages,
894
976
  };
895
977
 
978
+ // Match the instant package's behavior: only inject default temperature
979
+ // for the legacy completePrompt path; messages mode forwards whatever the
980
+ // upstream app set (or nothing) so behavior matches their main chat path.
981
+ if (payload.temperature !== undefined && payload.temperature !== null) {
982
+ requestBody.temperature = payload.temperature;
983
+ } else if (!Array.isArray(payload.messages)) {
984
+ requestBody.temperature = 0.8;
985
+ }
986
+
896
987
  if (payload.maxTokens === undefined || payload.maxTokens === null) {
897
988
  return requestBody;
898
989
  }
@@ -906,12 +997,27 @@ function buildAiRequestBody(payload) {
906
997
  }
907
998
 
908
999
  /**
909
- * Normalize AI API URL with minimal, non-breaking rules:
910
- * - trim whitespace
911
- * - remove redundant trailing slashes from pathname
912
- * - keep query string untouched
1000
+ * Normalize AI API URL for OpenAI-compatible chat endpoints.
1001
+ *
1002
+ * **Keep in sync** with `@rei-standard/amsg-instant`'s
1003
+ * `src/message-processor.js` `normalizeAiApiUrl` — same rules, same
1004
+ * tests. The two packages share this logic but each carry their own copy
1005
+ * to avoid an architectural dependency (server should not depend on the
1006
+ * stateless worker package).
913
1007
  *
914
- * Note: This function does NOT auto-append /v1 or /chat/completions.
1008
+ * Rules (idempotent — running it twice equals running it once):
1009
+ * - Already ends with `/chat/completions` → leave as-is.
1010
+ * - Bare host (no path or just `/`) → append `/v1/chat/completions`.
1011
+ * - Path ends with a version segment like `/v1`,
1012
+ * `/v2`, … (with or without trailing slash) → append only
1013
+ * `/chat/completions`; never doubles `/v1` for
1014
+ * callers who already include it.
1015
+ * - Anything else (custom path that doesn't match the
1016
+ * OpenAI shape, e.g. `/v1/messages` for
1017
+ * Anthropic-style proxies) → leave as-is. The caller
1018
+ * knows their own routing.
1019
+ *
1020
+ * Query string is preserved verbatim.
915
1021
  *
916
1022
  * @param {string} apiUrl
917
1023
  * @returns {string}
@@ -920,7 +1026,8 @@ function normalizeAiApiUrl(apiUrl) {
920
1026
  if (typeof apiUrl !== 'string' || !apiUrl.trim()) {
921
1027
  throw new Error(
922
1028
  'Invalid apiUrl: apiUrl is required. ' +
923
- 'Please provide a full chat endpoint URL (for example: https://api.openai.com/v1/chat/completions).'
1029
+ 'Please provide a chat endpoint URL ' +
1030
+ '(for example: https://api.openai.com or https://api.openai.com/v1/chat/completions).'
924
1031
  );
925
1032
  }
926
1033
 
@@ -931,12 +1038,23 @@ function normalizeAiApiUrl(apiUrl) {
931
1038
  parsedUrl = new URL(trimmedApiUrl);
932
1039
  } catch {
933
1040
  throw new Error(
934
- `Invalid apiUrl: "${apiUrl}". ` +
935
- 'Please provide a valid absolute URL that points to a full chat endpoint.'
1041
+ `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
936
1042
  );
937
1043
  }
938
1044
 
939
- parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, '') || '/';
1045
+ let path = parsedUrl.pathname.replace(/\/+$/, '') || '/';
1046
+
1047
+ if (/\/chat\/completions$/.test(path)) ; else if (path === '/') {
1048
+ // Bare host → assume OpenAI shape.
1049
+ path = '/v1/chat/completions';
1050
+ } else if (/\/v\d+$/.test(path)) {
1051
+ // Path ends in `/v1`, `/v2`, … — caller already versioned the URL.
1052
+ // Append only `/chat/completions`; never re-add `/v1`.
1053
+ path = `${path}/chat/completions`;
1054
+ }
1055
+ // Any other custom path is left untouched on purpose.
1056
+
1057
+ parsedUrl.pathname = path;
940
1058
  return parsedUrl.toString();
941
1059
  }
942
1060
 
@@ -1025,14 +1143,27 @@ function createScheduleMessageHandler(ctx) {
1025
1143
  apiUrl: payload.apiUrl || null,
1026
1144
  apiKey: payload.apiKey || null,
1027
1145
  primaryModel: payload.primaryModel || null,
1146
+ // Prompt is one-of: legacy completePrompt (string) OR messages (OpenAI-
1147
+ // style array). Validation has already enforced exactly-one-of, so
1148
+ // exactly one of these will be non-null when an AI config is provided.
1028
1149
  completePrompt: payload.completePrompt || null,
1150
+ messages: Array.isArray(payload.messages) ? payload.messages : null,
1029
1151
  maxTokens: payload.maxTokens ?? null,
1152
+ temperature: payload.temperature ?? null,
1030
1153
  pushSubscription: payload.pushSubscription,
1031
1154
  metadata: payload.metadata || {}
1032
1155
  };
1033
1156
 
1034
1157
  const encryptedPayload = encryptForStorage(JSON.stringify(fullTaskData), userKey);
1035
1158
 
1159
+ /**
1160
+ * @deprecated Soft-deprecated. For new code, use @rei-standard/amsg-instant.
1161
+ * This branch is kept for backward compatibility and the existing behavior
1162
+ * (create task → process → delete) is unchanged. The dedicated amsg-instant
1163
+ * package is stateless (no DB roundtrip), deployable to Cloudflare Workers,
1164
+ * and locks the encryption + push-payload contract behind a single version.
1165
+ * See packages/rei-standard-amsg/instant/README.md.
1166
+ */
1036
1167
  // Instant type: check VAPID before creating the task to avoid orphaned rows
1037
1168
  if (payload.messageType === 'instant') {
1038
1169
  if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
@@ -1086,6 +1217,13 @@ function createScheduleMessageHandler(ctx) {
1086
1217
  return { status: 500, body: { success: false, error: { code: 'TASK_CREATE_FAILED', message: '创建任务失败' } } };
1087
1218
  }
1088
1219
 
1220
+ /**
1221
+ * @deprecated Soft-deprecated. For new code, use @rei-standard/amsg-instant.
1222
+ * The "create-task → process-by-uuid → delete-task" sequence below is
1223
+ * preserved verbatim so existing clients keep working. New integrations
1224
+ * should call the dedicated amsg-instant endpoint instead — it skips this
1225
+ * DB round-trip entirely. See packages/rei-standard-amsg/instant/README.md.
1226
+ */
1089
1227
  // Instant type: send immediately
1090
1228
  if (payload.messageType === 'instant') {
1091
1229
  try {
@@ -1408,6 +1546,30 @@ function createUpdateMessageHandler(ctx) {
1408
1546
  return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['maxTokens'] } } } };
1409
1547
  }
1410
1548
 
1549
+ // Reject updates that try to set both completePrompt and messages at
1550
+ // once. We don't enforce one-of-required here (callers may patch other
1551
+ // fields), but the two prompt sources are mutually exclusive and must
1552
+ // stay that way in storage too.
1553
+ if (
1554
+ updates.completePrompt &&
1555
+ updates.messages !== undefined && updates.messages !== null
1556
+ ) {
1557
+ return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: 'completePrompt 与 messages 不能同时更新(二选一)', details: { invalidFields: ['completePrompt', 'messages'] } } } };
1558
+ }
1559
+ if (updates.messages !== undefined && updates.messages !== null) {
1560
+ const msgErr = validateLlmMessagesArray(updates.messages);
1561
+ if (msgErr) {
1562
+ return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: msgErr, details: { invalidFields: ['messages'] } } } };
1563
+ }
1564
+ }
1565
+ if (
1566
+ Object.prototype.hasOwnProperty.call(updates, 'temperature') &&
1567
+ updates.temperature !== null &&
1568
+ (typeof updates.temperature !== 'number' || !Number.isFinite(updates.temperature))
1569
+ ) {
1570
+ return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['temperature'] } } } };
1571
+ }
1572
+
1411
1573
  // Fetch existing task
1412
1574
  const existingTask = await db.getTaskByUuid(taskUuid, userId);
1413
1575
 
@@ -1421,14 +1583,27 @@ function createUpdateMessageHandler(ctx) {
1421
1583
 
1422
1584
  const existingData = JSON.parse(decryptFromStorage(existingTask.encrypted_payload, userKey));
1423
1585
 
1586
+ // When the caller switches prompt source (completePrompt ↔ messages),
1587
+ // null out the other so storage stays one-of (matches schedule-message
1588
+ // shape and prevents buildAiRequestBody from accidentally seeing both).
1589
+ const promptUpdates = {};
1590
+ if (updates.completePrompt) {
1591
+ promptUpdates.completePrompt = updates.completePrompt;
1592
+ promptUpdates.messages = null;
1593
+ } else if (updates.messages !== undefined && updates.messages !== null) {
1594
+ promptUpdates.messages = updates.messages;
1595
+ promptUpdates.completePrompt = null;
1596
+ }
1597
+
1424
1598
  const updatedData = {
1425
1599
  ...existingData,
1426
- ...(updates.completePrompt && { completePrompt: updates.completePrompt }),
1600
+ ...promptUpdates,
1427
1601
  ...(updates.userMessage && { userMessage: updates.userMessage }),
1428
1602
  ...(updates.recurrenceType && { recurrenceType: updates.recurrenceType }),
1429
1603
  ...(updates.avatarUrl && { avatarUrl: updates.avatarUrl }),
1430
1604
  ...(updates.metadata && { metadata: updates.metadata }),
1431
- ...(Object.prototype.hasOwnProperty.call(updates, 'maxTokens') && { maxTokens: updates.maxTokens ?? null })
1605
+ ...(Object.prototype.hasOwnProperty.call(updates, 'maxTokens') && { maxTokens: updates.maxTokens ?? null }),
1606
+ ...(Object.prototype.hasOwnProperty.call(updates, 'temperature') && { temperature: updates.temperature ?? null })
1432
1607
  };
1433
1608
 
1434
1609
  const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
@@ -2162,4 +2337,4 @@ async function createReiServer(config) {
2162
2337
  };
2163
2338
  }
2164
2339
 
2165
- export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateScheduleMessagePayload, verifyTenantToken };
2340
+ export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateLlmMessagesArray, validateScheduleMessagePayload, verifyTenantToken };
package/dist/index.mjs CHANGED
@@ -47,6 +47,29 @@ function isValidUUIDv4(uuid) {
47
47
  const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
48
48
  return uuidV4Regex.test(uuid);
49
49
  }
50
+ var VALID_LLM_MESSAGE_ROLES = /* @__PURE__ */ new Set(["system", "user", "assistant", "tool"]);
51
+ function validateLlmMessagesArray(messages) {
52
+ if (!Array.isArray(messages) || messages.length === 0) {
53
+ return "messages must be a non-empty array";
54
+ }
55
+ for (let i = 0; i < messages.length; i++) {
56
+ const m = messages[i];
57
+ if (!m || typeof m !== "object" || Array.isArray(m)) {
58
+ return `messages[${i}] must be an object`;
59
+ }
60
+ if (!VALID_LLM_MESSAGE_ROLES.has(m.role)) {
61
+ return `messages[${i}].role must be one of system / user / assistant / tool`;
62
+ }
63
+ if (typeof m.content === "string") {
64
+ if (!m.content) return `messages[${i}].content must be a non-empty string`;
65
+ } else if (Array.isArray(m.content)) {
66
+ if (m.content.length === 0) return `messages[${i}].content array must be non-empty`;
67
+ } else {
68
+ return `messages[${i}].content must be a non-empty string or a non-empty array`;
69
+ }
70
+ }
71
+ return null;
72
+ }
50
73
  function validateScheduleMessagePayload(payload) {
51
74
  if (!payload.contactName || typeof payload.contactName !== "string") {
52
75
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { missingFields: ["contactName"] } };
@@ -74,9 +97,35 @@ function validateScheduleMessagePayload(payload) {
74
97
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { missingFields: ["userMessage (required for fixed type)"] } };
75
98
  }
76
99
  }
100
+ const promptCheck = (() => {
101
+ const hasCompletePrompt = payload.completePrompt !== void 0 && payload.completePrompt !== null && payload.completePrompt !== "";
102
+ const hasMessages = payload.messages !== void 0 && payload.messages !== null;
103
+ if (hasCompletePrompt && hasMessages) {
104
+ return {
105
+ error: { code: "INVALID_PARAMETERS", message: "exactly one of `completePrompt` or `messages` must be provided\uFF08\u4E24\u8005\u4E0D\u80FD\u540C\u65F6\u51FA\u73B0\uFF09", details: { invalidFields: ["completePrompt", "messages"] } },
106
+ hasCompletePrompt: true,
107
+ hasMessages: true
108
+ };
109
+ }
110
+ if (hasMessages) {
111
+ const err = validateLlmMessagesArray(payload.messages);
112
+ if (err) {
113
+ return {
114
+ error: { code: "INVALID_PARAMETERS", message: err, details: { invalidFields: ["messages"] } },
115
+ hasCompletePrompt: false,
116
+ hasMessages: true
117
+ };
118
+ }
119
+ }
120
+ return { error: null, hasCompletePrompt, hasMessages };
121
+ })();
122
+ if (promptCheck.error) {
123
+ return { valid: false, errorCode: promptCheck.error.code, errorMessage: promptCheck.error.message, details: promptCheck.error.details };
124
+ }
125
+ const hasPrompt = promptCheck.hasCompletePrompt || promptCheck.hasMessages;
77
126
  if (payload.messageType === "prompted" || payload.messageType === "auto") {
78
127
  const missingAiFields = [];
79
- if (!payload.completePrompt) missingAiFields.push("completePrompt");
128
+ if (!hasPrompt) missingAiFields.push("completePrompt or messages");
80
129
  if (!payload.apiUrl) missingAiFields.push("apiUrl");
81
130
  if (!payload.apiKey) missingAiFields.push("apiKey");
82
131
  if (!payload.primaryModel) missingAiFields.push("primaryModel");
@@ -88,12 +137,15 @@ function validateScheduleMessagePayload(payload) {
88
137
  if (payload.recurrenceType && payload.recurrenceType !== "none") {
89
138
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "instant \u7C7B\u578B\u7684 recurrenceType \u5FC5\u987B\u4E3A none", details: { invalidFields: ['recurrenceType (must be "none" for instant type)'] } };
90
139
  }
91
- const hasAiConfig = payload.completePrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
140
+ const hasAiConfig = hasPrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
92
141
  const hasUserMessage = payload.userMessage;
93
142
  if (!hasAiConfig && !hasUserMessage) {
94
- return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "instant \u7C7B\u578B\u5FC5\u987B\u63D0\u4F9B userMessage \u6216\u5B8C\u6574\u7684 AI \u914D\u7F6E", details: { missingFields: ["userMessage or (completePrompt + apiUrl + apiKey + primaryModel)"] } };
143
+ return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "instant \u7C7B\u578B\u5FC5\u987B\u63D0\u4F9B userMessage \u6216\u5B8C\u6574\u7684 AI \u914D\u7F6E", details: { missingFields: ["userMessage or ((completePrompt | messages) + apiUrl + apiKey + primaryModel)"] } };
95
144
  }
96
145
  }
146
+ if (payload.temperature !== void 0 && payload.temperature !== null && (typeof payload.temperature !== "number" || !Number.isFinite(payload.temperature))) {
147
+ return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["temperature (must be a finite number)"] } };
148
+ }
97
149
  if (payload.avatarUrl && !isValidUrl(payload.avatarUrl)) {
98
150
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["avatarUrl (invalid URL format)"] } };
99
151
  }
@@ -395,7 +447,8 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
395
447
  if (decryptedPayload.messageType === "fixed") {
396
448
  messageContent = decryptedPayload.userMessage;
397
449
  } else if (decryptedPayload.messageType === "instant") {
398
- if (decryptedPayload.completePrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
450
+ const hasPrompt = !!decryptedPayload.completePrompt || Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0;
451
+ if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
399
452
  messageContent = await _callAI(decryptedPayload);
400
453
  } else if (decryptedPayload.userMessage) {
401
454
  messageContent = decryptedPayload.userMessage;
@@ -537,11 +590,16 @@ async function _callAI(payload) {
537
590
  return content.trim();
538
591
  }
539
592
  function buildAiRequestBody(payload) {
593
+ const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0 ? payload.messages : [{ role: "user", content: payload.completePrompt }];
540
594
  const requestBody = {
541
595
  model: payload.primaryModel,
542
- messages: [{ role: "user", content: payload.completePrompt }],
543
- temperature: 0.8
596
+ messages: llmMessages
544
597
  };
598
+ if (payload.temperature !== void 0 && payload.temperature !== null) {
599
+ requestBody.temperature = payload.temperature;
600
+ } else if (!Array.isArray(payload.messages)) {
601
+ requestBody.temperature = 0.8;
602
+ }
545
603
  if (payload.maxTokens === void 0 || payload.maxTokens === null) {
546
604
  return requestBody;
547
605
  }
@@ -554,7 +612,7 @@ function buildAiRequestBody(payload) {
554
612
  function normalizeAiApiUrl(apiUrl) {
555
613
  if (typeof apiUrl !== "string" || !apiUrl.trim()) {
556
614
  throw new Error(
557
- "Invalid apiUrl: apiUrl is required. Please provide a full chat endpoint URL (for example: https://api.openai.com/v1/chat/completions)."
615
+ "Invalid apiUrl: apiUrl is required. Please provide a chat endpoint URL (for example: https://api.openai.com or https://api.openai.com/v1/chat/completions)."
558
616
  );
559
617
  }
560
618
  const trimmedApiUrl = apiUrl.trim();
@@ -563,10 +621,17 @@ function normalizeAiApiUrl(apiUrl) {
563
621
  parsedUrl = new URL(trimmedApiUrl);
564
622
  } catch {
565
623
  throw new Error(
566
- `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL that points to a full chat endpoint.`
624
+ `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
567
625
  );
568
626
  }
569
- parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, "") || "/";
627
+ let path = parsedUrl.pathname.replace(/\/+$/, "") || "/";
628
+ if (/\/chat\/completions$/.test(path)) {
629
+ } else if (path === "/") {
630
+ path = "/v1/chat/completions";
631
+ } else if (/\/v\d+$/.test(path)) {
632
+ path = `${path}/chat/completions`;
633
+ }
634
+ parsedUrl.pathname = path;
570
635
  return parsedUrl.toString();
571
636
  }
572
637
 
@@ -634,8 +699,13 @@ function createScheduleMessageHandler(ctx) {
634
699
  apiUrl: payload.apiUrl || null,
635
700
  apiKey: payload.apiKey || null,
636
701
  primaryModel: payload.primaryModel || null,
702
+ // Prompt is one-of: legacy completePrompt (string) OR messages (OpenAI-
703
+ // style array). Validation has already enforced exactly-one-of, so
704
+ // exactly one of these will be non-null when an AI config is provided.
637
705
  completePrompt: payload.completePrompt || null,
706
+ messages: Array.isArray(payload.messages) ? payload.messages : null,
638
707
  maxTokens: payload.maxTokens ?? null,
708
+ temperature: payload.temperature ?? null,
639
709
  pushSubscription: payload.pushSubscription,
640
710
  metadata: payload.metadata || {}
641
711
  };
@@ -947,6 +1017,18 @@ function createUpdateMessageHandler(ctx) {
947
1017
  if (Object.prototype.hasOwnProperty.call(updates, "maxTokens") && updates.maxTokens !== null && (!Number.isInteger(updates.maxTokens) || updates.maxTokens <= 0)) {
948
1018
  return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "\u66F4\u65B0\u6570\u636E\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["maxTokens"] } } } };
949
1019
  }
1020
+ if (updates.completePrompt && updates.messages !== void 0 && updates.messages !== null) {
1021
+ return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "completePrompt \u4E0E messages \u4E0D\u80FD\u540C\u65F6\u66F4\u65B0\uFF08\u4E8C\u9009\u4E00\uFF09", details: { invalidFields: ["completePrompt", "messages"] } } } };
1022
+ }
1023
+ if (updates.messages !== void 0 && updates.messages !== null) {
1024
+ const msgErr = validateLlmMessagesArray(updates.messages);
1025
+ if (msgErr) {
1026
+ return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: msgErr, details: { invalidFields: ["messages"] } } } };
1027
+ }
1028
+ }
1029
+ if (Object.prototype.hasOwnProperty.call(updates, "temperature") && updates.temperature !== null && (typeof updates.temperature !== "number" || !Number.isFinite(updates.temperature))) {
1030
+ return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "\u66F4\u65B0\u6570\u636E\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["temperature"] } } } };
1031
+ }
950
1032
  const existingTask = await db.getTaskByUuid(taskUuid, userId);
951
1033
  if (!existingTask) {
952
1034
  const taskStatus = await db.getTaskStatus(taskUuid, userId);
@@ -956,14 +1038,23 @@ function createUpdateMessageHandler(ctx) {
956
1038
  return { status: 409, body: { success: false, error: { code: "TASK_ALREADY_COMPLETED", message: "\u4EFB\u52A1\u5DF2\u5B8C\u6210\u6216\u5DF2\u5931\u8D25\uFF0C\u65E0\u6CD5\u66F4\u65B0" } } };
957
1039
  }
958
1040
  const existingData = JSON.parse(decryptFromStorage(existingTask.encrypted_payload, userKey));
1041
+ const promptUpdates = {};
1042
+ if (updates.completePrompt) {
1043
+ promptUpdates.completePrompt = updates.completePrompt;
1044
+ promptUpdates.messages = null;
1045
+ } else if (updates.messages !== void 0 && updates.messages !== null) {
1046
+ promptUpdates.messages = updates.messages;
1047
+ promptUpdates.completePrompt = null;
1048
+ }
959
1049
  const updatedData = {
960
1050
  ...existingData,
961
- ...updates.completePrompt && { completePrompt: updates.completePrompt },
1051
+ ...promptUpdates,
962
1052
  ...updates.userMessage && { userMessage: updates.userMessage },
963
1053
  ...updates.recurrenceType && { recurrenceType: updates.recurrenceType },
964
1054
  ...updates.avatarUrl && { avatarUrl: updates.avatarUrl },
965
1055
  ...updates.metadata && { metadata: updates.metadata },
966
- ...Object.prototype.hasOwnProperty.call(updates, "maxTokens") && { maxTokens: updates.maxTokens ?? null }
1056
+ ...Object.prototype.hasOwnProperty.call(updates, "maxTokens") && { maxTokens: updates.maxTokens ?? null },
1057
+ ...Object.prototype.hasOwnProperty.call(updates, "temperature") && { temperature: updates.temperature ?? null }
967
1058
  };
968
1059
  const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
969
1060
  const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : void 0;
@@ -1484,6 +1575,7 @@ export {
1484
1575
  isValidUUID,
1485
1576
  isValidUUIDv4,
1486
1577
  isValidUrl,
1578
+ validateLlmMessagesArray,
1487
1579
  validateScheduleMessagePayload,
1488
1580
  verifyTenantToken
1489
1581
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-server",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "ReiStandard Active Messaging server SDK with pluggable database adapters",
5
5
  "repository": {
6
6
  "type": "git",