@k2b/cloud 0.26.0 → 0.27.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/package.json +3 -3
- package/src/_internal/define-app.ts +8 -1
- package/src/_internal/process-identity.ts +7 -1
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/ai/admin.ts +1 -0
- package/src/ai/browser-code-contracts.ts +14 -2
- package/src/ai/browser.ts +8 -1
- package/src/ai/capabilities.ts +82 -22
- package/src/ai/chat/builtin-tools.tsx +31 -23
- package/src/ai/chat/file-tools.tsx +4 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +11 -4
- package/src/ai/chat/messages.ts +8 -2
- package/src/ai/chat/presentation.tsx +29 -9
- package/src/ai/chat/turn-view.tsx +47 -12
- package/src/ai/client/controller.ts +60 -31
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +7 -2
- package/src/ai/code-mode-skill.ts +27 -27
- package/src/ai/code-runtime-tools.ts +10 -1
- package/src/ai/code-source-contracts.ts +54 -4
- package/src/ai/code-source-tools.ts +10 -3
- package/src/ai/credentials.ts +17 -3
- package/src/ai/data-analysis-skill.ts +2 -2
- package/src/ai/default-tools.ts +2 -2
- package/src/ai/executor.ts +148 -84
- package/src/ai/file-context.ts +14 -2
- package/src/ai/file-tools.ts +17 -3
- package/src/ai/files-store.ts +134 -11
- package/src/ai/grids-skill.ts +2 -2
- package/src/ai/index.ts +7 -0
- package/src/ai/memories.ts +14 -0
- package/src/ai/migrate.ts +125 -0
- package/src/ai/model-request-settings.ts +98 -0
- package/src/ai/protocol.ts +6 -0
- package/src/ai/provider.ts +7 -1
- package/src/ai/quota-provider.ts +2 -2
- package/src/ai/request-headers.ts +117 -0
- package/src/ai/routes.ts +34 -6
- package/src/ai/runtime.ts +1 -1
- package/src/ai/settings.ts +19 -2
- package/src/ai/skill-seeds.ts +31 -3
- package/src/ai/skills.ts +26 -0
- package/src/ai/solid.ts +1 -1
- package/src/ai/store.ts +109 -57
- package/src/ai/stream.ts +180 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +25 -0
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-policy.ts +247 -0
- package/src/ai/types.ts +24 -3
- package/src/api/admin-ai-quotas.ts +36 -1
- package/src/api/admin-core-settings.ts +16 -23
- package/src/api/admin-outgoing-mail.ts +62 -0
- package/src/api/index.ts +2 -0
- package/src/cli/admin/ai-quotas.ts +70 -1
- package/src/cli/admin/index.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +118 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/outgoing-mail.ts +77 -0
- package/src/contracts/registry.ts +2 -0
- package/src/services/index.ts +3 -0
- package/src/services/notifications/email.ts +16 -26
- package/src/services/outgoing-mail/index.ts +19 -0
- package/src/services/outgoing-mail/store.ts +286 -0
- package/src/services/outgoing-mail/test-send.ts +40 -0
- package/src/services/outgoing-mail/transport.ts +13 -0
- package/src/services/settings/core-settings.ts +1 -38
- package/src/services/settings/store.ts +5 -1
- package/src/shared/ai-model-request-settings.ts +21 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-request-options.ts +185 -0
- package/src/ssr/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/workspace-navigation.ts +7 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { audit } from "../audit";
|
|
2
|
+
import { get } from "../settings";
|
|
3
|
+
import { type MailAuditContext, OutgoingMailError, resolveMailCredentials } from "./store";
|
|
4
|
+
import { buildMailTransport } from "./transport";
|
|
5
|
+
|
|
6
|
+
export const smtpFailureMessage = (error: unknown, secrets: readonly (string | null)[]): string => {
|
|
7
|
+
let message = error instanceof Error ? error.message : "SMTP delivery failed.";
|
|
8
|
+
for (const secret of secrets.filter((s): s is string => !!s)) {
|
|
9
|
+
for (const representation of [secret, Buffer.from(secret).toString("base64")])
|
|
10
|
+
message = message.split(representation).join("[REDACTED]");
|
|
11
|
+
}
|
|
12
|
+
return message;
|
|
13
|
+
};
|
|
14
|
+
export const outgoingMailTest = {
|
|
15
|
+
async send(key: string, recipient: string, context: MailAuditContext): Promise<void> {
|
|
16
|
+
const profile = await resolveMailCredentials(key);
|
|
17
|
+
const event = { ...context, action: "outgoing_mail.profile.test", target: { type: "outgoing_mail_profile", id: key } };
|
|
18
|
+
// Fail before the external effect if audit storage is unavailable.
|
|
19
|
+
await audit.record({ ...event, outcome: "allowed" });
|
|
20
|
+
const transport = buildMailTransport(profile);
|
|
21
|
+
try {
|
|
22
|
+
await transport.sendMail({
|
|
23
|
+
from: { address: profile.fromAddress, name: profile.fromName ?? (await get<string>("app.name")) },
|
|
24
|
+
to: recipient,
|
|
25
|
+
subject: "Cloud test email",
|
|
26
|
+
text: "This is a test email from Cloud. SMTP delivery is configured correctly if you received it.",
|
|
27
|
+
});
|
|
28
|
+
} catch (error) {
|
|
29
|
+
const message = smtpFailureMessage(error, [
|
|
30
|
+
profile.smtpPassword,
|
|
31
|
+
profile.smtpUser,
|
|
32
|
+
profile.smtpUser ? `\0${profile.smtpUser}\0${profile.smtpPassword ?? ""}` : null,
|
|
33
|
+
]);
|
|
34
|
+
await audit.recordResultAfterSideEffect({ ...event, result: { ok: false, error: { code: "smtp_failed", message, status: 500 } } });
|
|
35
|
+
throw new OutgoingMailError("smtp_failed", message, 502);
|
|
36
|
+
} finally {
|
|
37
|
+
transport.close();
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createTransport } from "nodemailer";
|
|
2
|
+
import type { resolveMailCredentials } from "./store";
|
|
3
|
+
|
|
4
|
+
export const buildMailTransport = (profile: Awaited<ReturnType<typeof resolveMailCredentials>>) =>
|
|
5
|
+
createTransport({
|
|
6
|
+
host: profile.smtpHost,
|
|
7
|
+
port: profile.smtpPort,
|
|
8
|
+
secure: profile.smtpSecure,
|
|
9
|
+
...(profile.smtpUser ? { auth: { user: profile.smtpUser, pass: profile.smtpPassword ?? "" } } : {}),
|
|
10
|
+
connectionTimeout: 10000,
|
|
11
|
+
greetingTimeout: 10000,
|
|
12
|
+
socketTimeout: 60000,
|
|
13
|
+
});
|
|
@@ -292,7 +292,7 @@ export const CORE_SETTINGS = {
|
|
|
292
292
|
default: 30,
|
|
293
293
|
min: 0,
|
|
294
294
|
description:
|
|
295
|
-
"Maximum running time per AI turn. Zero or an empty field disables this limit. Existing turns keep their original budget; request and tool timeouts still apply.",
|
|
295
|
+
"Maximum running time per AI turn. In the last tenth, the Assistant stops using tools and answers. Zero or an empty field disables this limit. Existing turns keep their original budget; request and tool timeouts still apply.",
|
|
296
296
|
},
|
|
297
297
|
"ai.max_tool_result_chars": {
|
|
298
298
|
kind: "number",
|
|
@@ -672,43 +672,6 @@ export const CORE_SETTINGS = {
|
|
|
672
672
|
description: "Account request denial email template (HTML). Subject: Account Request Update",
|
|
673
673
|
templateVars: ["FIRST_NAME", "REASON", "CONTACT_EMAIL", "APP_NAME"] as readonly string[],
|
|
674
674
|
},
|
|
675
|
-
"mail.noreply.smtp_host": {
|
|
676
|
-
kind: "string",
|
|
677
|
-
label: "SMTP Host",
|
|
678
|
-
default: "",
|
|
679
|
-
description: "SMTP server hostname",
|
|
680
|
-
placeholder: "e.g. smtp.example.org",
|
|
681
|
-
},
|
|
682
|
-
"mail.noreply.smtp_port": {
|
|
683
|
-
kind: "number",
|
|
684
|
-
label: "SMTP Port",
|
|
685
|
-
default: 587,
|
|
686
|
-
min: 1,
|
|
687
|
-
max: 65535,
|
|
688
|
-
description: "SMTP server port (587 for STARTTLS, 465 for SSL)",
|
|
689
|
-
},
|
|
690
|
-
"mail.noreply.from": {
|
|
691
|
-
kind: "email",
|
|
692
|
-
label: "From Address",
|
|
693
|
-
default: "",
|
|
694
|
-
description: "From email address",
|
|
695
|
-
placeholder: "e.g. noreply@example.org",
|
|
696
|
-
},
|
|
697
|
-
"mail.noreply.user": {
|
|
698
|
-
kind: "string",
|
|
699
|
-
label: "SMTP User",
|
|
700
|
-
default: "",
|
|
701
|
-
description: "SMTP username",
|
|
702
|
-
placeholder: "e.g. noreply@example.org",
|
|
703
|
-
},
|
|
704
|
-
"mail.noreply.password": {
|
|
705
|
-
kind: "secret",
|
|
706
|
-
label: "SMTP Password",
|
|
707
|
-
default: "",
|
|
708
|
-
description: "SMTP password",
|
|
709
|
-
placeholder: "SMTP password",
|
|
710
|
-
},
|
|
711
|
-
|
|
712
675
|
// ── Security ────────────────────────────────────────────────────────────
|
|
713
676
|
"security.rate_limit_per_second": {
|
|
714
677
|
kind: "number",
|
|
@@ -136,7 +136,11 @@ export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unk
|
|
|
136
136
|
*/
|
|
137
137
|
export const allKnownKeys = (): string[] => SETTINGS.map((d) => d.key);
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
// Retain the prior SMTP configuration for one release so operators can roll back.
|
|
140
|
+
const retainedLegacyKeys = ["smtp_host", "smtp_port", "from", "user", "password"].map((key) => `mail.noreply.${key}`);
|
|
141
|
+
|
|
142
|
+
const knownKeysWith = (extraKnownKeys: readonly string[]) =>
|
|
143
|
+
Array.from(new Set([...allKnownKeys(), ...retainedLegacyKeys, ...extraKnownKeys]));
|
|
140
144
|
|
|
141
145
|
export const listLegacyKeys = async (extraKnownKeys: readonly string[] = []): Promise<LegacySettingRow[]> => {
|
|
142
146
|
const knownKeys = knownKeysWith(extraKnownKeys);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { AiExtraBodySchema, AiReasoningEffortSchema, AiRequestHeadersSchema } from "./ai-request-options";
|
|
3
|
+
|
|
4
|
+
export const AiModelRequestSettingsUpdateSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
expected: z.string().min(1),
|
|
7
|
+
reasoningEffort: AiReasoningEffortSchema.nullable(),
|
|
8
|
+
extraBody: AiExtraBodySchema.nullable().optional(),
|
|
9
|
+
requestHeaders: AiRequestHeadersSchema.optional(),
|
|
10
|
+
clearRequestHeaders: z.boolean().optional(),
|
|
11
|
+
})
|
|
12
|
+
.strict();
|
|
13
|
+
export type AiModelRequestSettingsUpdate = z.infer<typeof AiModelRequestSettingsUpdateSchema>;
|
|
14
|
+
export const AiModelRequestSettingsSchema = z.object({
|
|
15
|
+
id: z.string(),
|
|
16
|
+
reasoningEffort: z.string().nullable(),
|
|
17
|
+
extraBody: z.record(z.string(), z.unknown()).nullable(),
|
|
18
|
+
requestHeaderNames: z.array(z.string()),
|
|
19
|
+
revision: z.string(),
|
|
20
|
+
});
|
|
21
|
+
export type AiModelRequestSettings = z.infer<typeof AiModelRequestSettingsSchema>;
|
|
@@ -58,7 +58,7 @@ Use search_tools only when the needed operation is unknown. If a loaded Skill or
|
|
|
58
58
|
{%- if appToolsEnabled %}
|
|
59
59
|
|
|
60
60
|
# Cloud app tools
|
|
61
|
-
Installed apps publish live Queries and Actions through tool discovery. Calls run with the current user's permissions and the owning app authorizes every call; catalog visibility is not access. Search with a known appId when possible, load only needed names, and treat Query or Action as read/write metadata rather than a search filter. Reuse returned typed resource refs unchanged.
|
|
61
|
+
Installed apps publish live Queries and Actions through tool discovery. Calls run with the current user's permissions and the owning app authorizes every call; catalog visibility is not access. Search with a known appId when possible, load only needed names, and treat Query or Action as read/write metadata rather than a search filter. Reuse returned typed resource refs unchanged. If load_tools reports a tool as unavailable, follow its reason: look up an unknown name once with search_tools; otherwise do not search or retry for it again in this turn, and continue with what is available or tell the user what is missing. Claim success only after the call succeeds.
|
|
62
62
|
{%- endif %}
|
|
63
63
|
{%- if hasFiles %}
|
|
64
64
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// nessi 0.15 deep-merges extraBody last. Reserve identity/content, tool/loop
|
|
4
|
+
// control, structured output, and profile-owned generation settings across all
|
|
5
|
+
// adapters so an escape hatch cannot replace Cloud's request or quota budget.
|
|
6
|
+
export const AI_RESERVED_EXTRA_BODY_KEYS = [
|
|
7
|
+
"model",
|
|
8
|
+
"models",
|
|
9
|
+
"route",
|
|
10
|
+
"messages",
|
|
11
|
+
"system",
|
|
12
|
+
"contents",
|
|
13
|
+
"systemInstruction",
|
|
14
|
+
"tools",
|
|
15
|
+
"tool_choice",
|
|
16
|
+
"toolConfig",
|
|
17
|
+
"parallel_tool_calls",
|
|
18
|
+
"stream",
|
|
19
|
+
"stream_options",
|
|
20
|
+
"response_format",
|
|
21
|
+
"structured_outputs",
|
|
22
|
+
"format",
|
|
23
|
+
"temperature",
|
|
24
|
+
"n",
|
|
25
|
+
"max_tokens",
|
|
26
|
+
"max_completion_tokens",
|
|
27
|
+
"reasoning_effort",
|
|
28
|
+
"reasoning",
|
|
29
|
+
] as const;
|
|
30
|
+
const keyName = (key: string) => key.replace(/_/g, "").toLowerCase();
|
|
31
|
+
const reserved = new Set(AI_RESERVED_EXTRA_BODY_KEYS.map(keyName));
|
|
32
|
+
const unsafeKeys = new Set(["__proto__", "constructor", "prototype"]);
|
|
33
|
+
const nestedReserved = new Map<string, ReadonlySet<string>>([
|
|
34
|
+
[
|
|
35
|
+
keyName("generationConfig"),
|
|
36
|
+
new Set(["responseSchema", "responseJsonSchema", "responseMimeType", "temperature", "maxOutputTokens", "candidateCount"].map(keyName)),
|
|
37
|
+
],
|
|
38
|
+
[keyName("output_config"), new Set(["format"].map(keyName))],
|
|
39
|
+
[keyName("options"), new Set(["temperature", "num_predict"].map(keyName))],
|
|
40
|
+
]);
|
|
41
|
+
const plainObject = (value: unknown): value is Record<string, unknown> =>
|
|
42
|
+
value !== null &&
|
|
43
|
+
typeof value === "object" &&
|
|
44
|
+
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
45
|
+
|
|
46
|
+
export const AiReasoningEffortSchema = z.preprocess(
|
|
47
|
+
(value) => (typeof value === "string" && !value.trim() ? undefined : value),
|
|
48
|
+
z
|
|
49
|
+
.string()
|
|
50
|
+
.trim()
|
|
51
|
+
.min(1)
|
|
52
|
+
.max(32)
|
|
53
|
+
.regex(/^[a-z0-9_-]+$/)
|
|
54
|
+
.optional(),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
export const AiExtraBodySchema = z
|
|
58
|
+
.custom<Record<string, unknown>>(plainObject, "Extra parameters must be a plain JSON object.")
|
|
59
|
+
.superRefine((body, ctx) => {
|
|
60
|
+
if (!plainObject(body)) return;
|
|
61
|
+
try {
|
|
62
|
+
if (new TextEncoder().encode(JSON.stringify(body)).byteLength > 8192) {
|
|
63
|
+
ctx.addIssue({ code: "custom", message: "Extra parameters exceed 8 KiB of UTF-8 JSON." });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
ctx.addIssue({ code: "custom", message: "Extra parameters must contain serializable JSON values." });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
let invalidJson = false;
|
|
71
|
+
const seen = new Set<object>();
|
|
72
|
+
const pending: { value: unknown; path: (string | number)[] }[] = [{ value: body, path: [] }];
|
|
73
|
+
while (pending.length) {
|
|
74
|
+
const entry = pending.pop()!;
|
|
75
|
+
const { value, path } = entry;
|
|
76
|
+
if (
|
|
77
|
+
value === null ||
|
|
78
|
+
typeof value === "string" ||
|
|
79
|
+
typeof value === "boolean" ||
|
|
80
|
+
(typeof value === "number" && Number.isFinite(value))
|
|
81
|
+
)
|
|
82
|
+
continue;
|
|
83
|
+
if (!Array.isArray(value) && !plainObject(value)) {
|
|
84
|
+
invalidJson = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (seen.has(value)) continue;
|
|
88
|
+
seen.add(value);
|
|
89
|
+
for (const [key, child] of Object.entries(value)) {
|
|
90
|
+
if (unsafeKeys.has(key))
|
|
91
|
+
ctx.addIssue({ code: "custom", path: [...path, key], message: "Prototype keys are not allowed in extra parameters." });
|
|
92
|
+
pending.push({ value: child, path: [...path, key] });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (invalidJson) {
|
|
96
|
+
ctx.addIssue({ code: "custom", message: "Extra parameters must contain only JSON values." });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (const key of Object.keys(body)) {
|
|
100
|
+
const normalizedKey = keyName(key);
|
|
101
|
+
if (reserved.has(normalizedKey))
|
|
102
|
+
ctx.addIssue({
|
|
103
|
+
code: "custom",
|
|
104
|
+
path: [key],
|
|
105
|
+
message:
|
|
106
|
+
normalizedKey === "reasoning" || normalizedKey === "reasoningeffort"
|
|
107
|
+
? "Use the thinking-level field (reasoningEffort) instead."
|
|
108
|
+
: "This parameter is owned by Cloud; use the model profile fields instead.",
|
|
109
|
+
});
|
|
110
|
+
const nested = body[key];
|
|
111
|
+
const reservedChildren = nestedReserved.get(normalizedKey);
|
|
112
|
+
if (reservedChildren && !plainObject(nested))
|
|
113
|
+
ctx.addIssue({
|
|
114
|
+
code: "custom",
|
|
115
|
+
path: [key],
|
|
116
|
+
message: "This parameter must be a plain JSON object so Cloud can preserve its owned settings.",
|
|
117
|
+
});
|
|
118
|
+
if (plainObject(nested))
|
|
119
|
+
for (const child of Object.keys(nested)) {
|
|
120
|
+
if (reservedChildren?.has(keyName(child)))
|
|
121
|
+
ctx.addIssue({
|
|
122
|
+
code: "custom",
|
|
123
|
+
path: [key, child],
|
|
124
|
+
message: "This parameter is owned by Cloud's generation or structured-output settings.",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
.meta({
|
|
130
|
+
type: "object",
|
|
131
|
+
additionalProperties: true,
|
|
132
|
+
description: "Extra provider parameters (at most 8192 bytes of UTF-8 JSON); Cloud-owned request keys are reserved.",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const reservedHeaders = new Set(["content-type", "content-length", "host", "connection", "transfer-encoding"]);
|
|
136
|
+
// Validate with metadata-only messages: never include a submitted header value.
|
|
137
|
+
export const AiRequestHeadersSchema = z
|
|
138
|
+
.custom<Record<string, string | null>>(plainObject, "Extra headers must be a JSON object of names to strings or null.")
|
|
139
|
+
.superRefine((headers, ctx) => {
|
|
140
|
+
if (!plainObject(headers)) return;
|
|
141
|
+
if (Object.keys(headers).length > 32) ctx.addIssue({ code: "custom", message: "At most 32 extra headers are allowed." });
|
|
142
|
+
const names = new Set<string>();
|
|
143
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
144
|
+
const lower = name.toLowerCase();
|
|
145
|
+
if (name.length > 128 || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name))
|
|
146
|
+
ctx.addIssue({ code: "custom", path: [name], message: "Header names must be HTTP tokens of at most 128 characters." });
|
|
147
|
+
if (names.has(lower)) ctx.addIssue({ code: "custom", path: [name], message: "Duplicate header names are case-insensitive." });
|
|
148
|
+
names.add(lower);
|
|
149
|
+
if (reservedHeaders.has(lower)) ctx.addIssue({ code: "custom", path: [name], message: "This HTTP header is reserved." });
|
|
150
|
+
if (value !== null && (typeof value !== "string" || value.length > 4096 || /[^\t\x20-\x7E]/.test(value)))
|
|
151
|
+
ctx.addIssue({
|
|
152
|
+
code: "custom",
|
|
153
|
+
path: [name],
|
|
154
|
+
message: "Header values must be strings of at most 4096 printable ASCII characters, spaces or tabs, or null to remove.",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
.meta({
|
|
159
|
+
type: "object",
|
|
160
|
+
maxProperties: 32,
|
|
161
|
+
propertyNames: { type: "string", maxLength: 128, pattern: "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$" },
|
|
162
|
+
additionalProperties: { anyOf: [{ type: "string", maxLength: 4096, pattern: "^[\\t\\x20-\\x7E]*$" }, { type: "null" }] },
|
|
163
|
+
writeOnly: true,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
export const AI_REQUEST_HEADERS_PROVIDER_ERROR = "Extra headers are only supported for vLLM and OpenAI-compatible endpoints.";
|
|
167
|
+
export const providerSupportsRequestHeaders = (provider: string): boolean => provider === "vllm" || provider === "openai-compatible";
|
|
168
|
+
|
|
169
|
+
/** Case-insensitive PATCH. Canonical Authorization avoids duplicate casing in nessi's Bearer overwrite. */
|
|
170
|
+
export const patchAiRequestHeaders = (current: Record<string, string>, patch: Record<string, string | null>): Record<string, string> => {
|
|
171
|
+
AiRequestHeadersSchema.parse(patch);
|
|
172
|
+
const result: Record<string, string> = Object.fromEntries(Object.entries(current));
|
|
173
|
+
for (const [name, value] of Object.entries(patch)) {
|
|
174
|
+
for (const stored of Object.keys(result)) if (stored.toLowerCase() === name.toLowerCase()) delete result[stored];
|
|
175
|
+
if (value !== null)
|
|
176
|
+
Object.defineProperty(result, name.toLowerCase() === "authorization" ? "Authorization" : name, {
|
|
177
|
+
value,
|
|
178
|
+
enumerable: true,
|
|
179
|
+
configurable: true,
|
|
180
|
+
writable: true,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
AiRequestHeadersSchema.parse(result);
|
|
184
|
+
return result;
|
|
185
|
+
};
|
|
@@ -14,7 +14,7 @@ export const buildAdminGroups = (apps: readonly RuntimeContext["apps"][number][]
|
|
|
14
14
|
const t = platformMessages.resolve([locale]).t;
|
|
15
15
|
const settingsLinks: AdminLink[] = [
|
|
16
16
|
{ href: "/admin/settings?tab=general", icon: "ti-app-window", label: t.general },
|
|
17
|
-
{ href: "/admin/
|
|
17
|
+
{ href: "/admin/outgoing-mail", icon: "ti-mail", label: t.outgoingMail },
|
|
18
18
|
{ href: "/admin/settings?tab=pdf-rendering", icon: "ti-file-type-pdf", label: t.pdfRendering },
|
|
19
19
|
{ href: "/admin/settings?tab=email-templates", icon: "ti-template", label: t.emailTemplates },
|
|
20
20
|
{ href: "/admin/settings?tab=security", icon: "ti-shield-lock", label: t.security },
|
|
@@ -22,6 +22,7 @@ export const platformMessages = i18n.define({
|
|
|
22
22
|
linuxAccess: "Linux identities",
|
|
23
23
|
pdfRendering: "PDF rendering",
|
|
24
24
|
emailTemplates: "Email templates",
|
|
25
|
+
outgoingMail: "Outgoing mail",
|
|
25
26
|
security: "Security",
|
|
26
27
|
legal: "Legal",
|
|
27
28
|
providers: "Providers",
|
|
@@ -90,6 +91,7 @@ export const platformMessages = i18n.define({
|
|
|
90
91
|
linuxAccess: "Linux-Identitäten",
|
|
91
92
|
pdfRendering: "PDF-Erstellung",
|
|
92
93
|
emailTemplates: "E-Mail-Vorlagen",
|
|
94
|
+
outgoingMail: "Ausgehende Mail",
|
|
93
95
|
security: "Sicherheit",
|
|
94
96
|
legal: "Rechtliches",
|
|
95
97
|
providers: "Anbieter",
|
|
@@ -35,7 +35,11 @@ function validItems(value: unknown): value is NavigationItem[] {
|
|
|
35
35
|
if (!entry || typeof entry !== "object" || typeof entry.id !== "string" || typeof entry.label !== "string") return false;
|
|
36
36
|
if (entry.href !== undefined && typeof entry.href !== "string") return false;
|
|
37
37
|
if (entry.action !== undefined && typeof entry.action !== "string") return false;
|
|
38
|
-
return (
|
|
38
|
+
return (
|
|
39
|
+
(entry.children === undefined || validItems(entry.children)) &&
|
|
40
|
+
(entry.actions === undefined || validItems(entry.actions)) &&
|
|
41
|
+
(entry.inlineActions === undefined || validItems(entry.inlineActions))
|
|
42
|
+
);
|
|
39
43
|
})
|
|
40
44
|
);
|
|
41
45
|
}
|
|
@@ -59,6 +63,8 @@ export function readWorkspaceNavigation(): WorkspaceNavigationState | undefined
|
|
|
59
63
|
...(item.expanded !== undefined ? { expanded: undefined, defaultExpanded: item.expanded } : {}),
|
|
60
64
|
...(item.children ? { children: links(item.children) } : {}),
|
|
61
65
|
...(item.actions ? { actions: links(item.actions) } : {}),
|
|
66
|
+
// Inline actions only run actions, so each waits for the owner.
|
|
67
|
+
...(item.inlineActions ? { inlineActions: item.inlineActions.map((action) => ({ ...action, disabled: true })) } : {}),
|
|
62
68
|
}));
|
|
63
69
|
const items = links(value.items);
|
|
64
70
|
return {
|