@naisys/common 3.0.0-beta.10
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/assets/naisys-logo.webp +0 -0
- package/dist/agentConfigFile.js +129 -0
- package/dist/agentStatus.js +15 -0
- package/dist/authCache.js +41 -0
- package/dist/builtInModels.js +260 -0
- package/dist/configUtils.js +9 -0
- package/dist/constants.js +4 -0
- package/dist/costUtils.js +23 -0
- package/dist/errorHandler.js +44 -0
- package/dist/formatFileSize.js +10 -0
- package/dist/globalConfigLoader.js +58 -0
- package/dist/hateoas-types.js +42 -0
- package/dist/hateoas.js +66 -0
- package/dist/hostedServices.js +8 -0
- package/dist/index.js +19 -0
- package/dist/lenientJsonParser.js +19 -0
- package/dist/mimeTypes.js +25 -0
- package/dist/modelTypes.js +142 -0
- package/dist/securityHeaders.js +21 -0
- package/dist/sleep.js +4 -0
- package/dist/urlSafeKey.js +17 -0
- package/package.json +32 -0
|
Binary file
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { TARGET_MEGAPIXELS } from "./constants.js";
|
|
3
|
+
import { URL_SAFE_KEY_MESSAGE, URL_SAFE_KEY_REGEX } from "./urlSafeKey.js";
|
|
4
|
+
export const commandProtectionValues = [
|
|
5
|
+
"none",
|
|
6
|
+
"manual",
|
|
7
|
+
"semi-auto",
|
|
8
|
+
"auto",
|
|
9
|
+
];
|
|
10
|
+
// Zod schema for validation
|
|
11
|
+
export const AgentConfigFileSchema = z.object({
|
|
12
|
+
username: z
|
|
13
|
+
.string()
|
|
14
|
+
.min(1, "Username is required")
|
|
15
|
+
.regex(URL_SAFE_KEY_REGEX, URL_SAFE_KEY_MESSAGE)
|
|
16
|
+
.describe("The name the agent identifies itself with when communicating with other agents"),
|
|
17
|
+
title: z
|
|
18
|
+
.string()
|
|
19
|
+
.describe("Displayed to other agents to give context about this agent's role in the system"),
|
|
20
|
+
agentPrompt: z
|
|
21
|
+
.string()
|
|
22
|
+
.min(1, "Agent prompt is required")
|
|
23
|
+
.describe("Gives the agent instructions and/or purpose when starting up. Supports ${agent.*} and ${env.*} template variables"),
|
|
24
|
+
spendLimitDollars: z
|
|
25
|
+
.number()
|
|
26
|
+
.min(0, "Must be non-negative")
|
|
27
|
+
.optional()
|
|
28
|
+
.describe("Spend limit in dollars for this agent. When set, this agent is exempt from the global spend limit. Defaults to the SPEND_LIMIT_DOLLARS variable"),
|
|
29
|
+
spendLimitHours: z
|
|
30
|
+
.number()
|
|
31
|
+
.min(0, "Must be non-negative")
|
|
32
|
+
.optional()
|
|
33
|
+
.describe("Rolling time window in hours for spend limit, defaults to the SPEND_LIMIT_HOURS variable. If neither are set then the spend limit is fixed and not rolling"),
|
|
34
|
+
tokenMax: z
|
|
35
|
+
.number()
|
|
36
|
+
.int("Must be a whole number")
|
|
37
|
+
.min(1, "Must be at least 1")
|
|
38
|
+
.describe("How many tokens this agent is allocated per session before it must end or compact the context"),
|
|
39
|
+
shellModel: z
|
|
40
|
+
.string()
|
|
41
|
+
.min(1, "Shell model is required")
|
|
42
|
+
.describe("Primary LLM used for shell interactions"),
|
|
43
|
+
imageModel: z.string().optional().describe("Model used for image generation"),
|
|
44
|
+
mailEnabled: z
|
|
45
|
+
.boolean()
|
|
46
|
+
.optional()
|
|
47
|
+
.describe("Show mail commands to the agent. Mail encourages verbose communication which can be distracting"),
|
|
48
|
+
chatEnabled: z
|
|
49
|
+
.boolean()
|
|
50
|
+
.optional()
|
|
51
|
+
.describe("Show chat commands to the agent. Chat encourages more concise communication"),
|
|
52
|
+
webEnabled: z
|
|
53
|
+
.boolean()
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("Allow agent to browse the web with Lynx, a text based browser"),
|
|
56
|
+
completeSessionEnabled: z
|
|
57
|
+
.boolean()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("Allow the agent to end its session. Once ended, it can only be restarted explicitly or via mail if wakeOnMessage is enabled. Disable on root agents to prevent the system from going unresponsive"),
|
|
60
|
+
debugPauseSeconds: z
|
|
61
|
+
.number()
|
|
62
|
+
.int("Must be a whole number")
|
|
63
|
+
.min(0, "Must be non-negative")
|
|
64
|
+
.optional()
|
|
65
|
+
.describe("Seconds to wait at the debug prompt before auto-continuing, only applies when the agent's console is in focus. Unset waits indefinitely for manual input"),
|
|
66
|
+
wakeOnMessage: z
|
|
67
|
+
.boolean()
|
|
68
|
+
.optional()
|
|
69
|
+
.describe("When mail or chat is received, start the agent automatically, or wake it from its wait state"),
|
|
70
|
+
commandProtection: z
|
|
71
|
+
.enum(commandProtectionValues)
|
|
72
|
+
.optional()
|
|
73
|
+
.describe("None allows the LLM to run any command, Manual requires user confirmation for each command, and Auto uses a secondary LLM to try to validate a command is safe"),
|
|
74
|
+
initialCommands: z
|
|
75
|
+
.array(z.string())
|
|
76
|
+
.optional()
|
|
77
|
+
.describe("Shell commands to run at session start before the first LLM prompt, providing additional context to the agent"),
|
|
78
|
+
multipleCommandsEnabled: z
|
|
79
|
+
.boolean()
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("Allow the LLM to run multiple commands per turn. Faster but the LLM may get ahead of itself and produce errors"),
|
|
82
|
+
workspacesEnabled: z
|
|
83
|
+
.boolean()
|
|
84
|
+
.optional()
|
|
85
|
+
.describe("Experimental: Allows the LLM to pin files to the end of the context. Each turn the agent sees the latest version without old versions taking up context space"),
|
|
86
|
+
controlDesktop: z
|
|
87
|
+
.boolean()
|
|
88
|
+
.optional()
|
|
89
|
+
.describe(`Allow the agent to operate the desktop GUI. Requires a model with supportsComputerUse. Ideal screen resolution <= ${TARGET_MEGAPIXELS}MP to avoid downscaling`),
|
|
90
|
+
});
|
|
91
|
+
/**
|
|
92
|
+
* Thoughts on the admin user:
|
|
93
|
+
* 1. We need an admin user as a placeholder when no agents are running
|
|
94
|
+
* Especially when a hub client starts up and the hub has assigned no agents to the host
|
|
95
|
+
* 2. We want to be able to start agents and send mail from the placeholder so it needs to be an official user
|
|
96
|
+
* It is registered in the hub db as well so we don't need tons of special case code everywhere like `if (userId === adminUserId)` ...
|
|
97
|
+
* 3. The admin is also a source of ns-talk commands, gives the LLM someone to reply to
|
|
98
|
+
* 4. Calling it a debug user would be confusing with debug input mode, also considered calling it operator, but admin seems more intuitive
|
|
99
|
+
* 5. The hub supports agents running simultaneously across hosts, so each client can run an admin fine
|
|
100
|
+
* 6. Having it as an official user means mail will be logged by the hub as well which is helpful for debugging and monitoring
|
|
101
|
+
*/
|
|
102
|
+
export function buildDefaultAgentConfig(username) {
|
|
103
|
+
return {
|
|
104
|
+
username,
|
|
105
|
+
title: "Assistant",
|
|
106
|
+
shellModel: "none",
|
|
107
|
+
agentPrompt: "You are ${agent.username} a ${agent.title} with the job of helping out the admin with what they want to do.",
|
|
108
|
+
tokenMax: 20000,
|
|
109
|
+
debugPauseSeconds: 5,
|
|
110
|
+
mailEnabled: true,
|
|
111
|
+
chatEnabled: true,
|
|
112
|
+
webEnabled: true,
|
|
113
|
+
wakeOnMessage: true,
|
|
114
|
+
completeSessionEnabled: true,
|
|
115
|
+
multipleCommandsEnabled: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export const adminAgentConfig = {
|
|
119
|
+
username: "admin", // Must be "admin" for special handling in hub and supervisor
|
|
120
|
+
title: "Admin",
|
|
121
|
+
shellModel: "none",
|
|
122
|
+
agentPrompt: "Human admin for monitoring and control.",
|
|
123
|
+
tokenMax: 100_000,
|
|
124
|
+
mailEnabled: true,
|
|
125
|
+
chatEnabled: true,
|
|
126
|
+
wakeOnMessage: true,
|
|
127
|
+
webEnabled: true,
|
|
128
|
+
spendLimitDollars: 1, // Required on all agents
|
|
129
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function determineAgentStatus(opts) {
|
|
2
|
+
// Priority: disabled > offline > suspended > active > available
|
|
3
|
+
if (!opts.isEnabled)
|
|
4
|
+
return "disabled";
|
|
5
|
+
const isOffline = opts.assignedHostIds?.length
|
|
6
|
+
? !opts.assignedHostIds.some(opts.isHostOnline)
|
|
7
|
+
: !opts.hasNonRestrictedOnlineHost;
|
|
8
|
+
if (isOffline)
|
|
9
|
+
return "offline";
|
|
10
|
+
if (opts.isSuspended)
|
|
11
|
+
return "suspended";
|
|
12
|
+
if (opts.isActive)
|
|
13
|
+
return "active";
|
|
14
|
+
return "available";
|
|
15
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export class AuthCache {
|
|
2
|
+
cache = new Map();
|
|
3
|
+
ttlMs;
|
|
4
|
+
negativeTtlMs;
|
|
5
|
+
maxSize;
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
this.ttlMs = options.ttlMs ?? 60_000;
|
|
8
|
+
this.negativeTtlMs = options.negativeTtlMs ?? 10_000;
|
|
9
|
+
this.maxSize = options.maxSize ?? 1_000;
|
|
10
|
+
}
|
|
11
|
+
/** Returns the cached user, `null` for a negative hit, or `undefined` on cache miss. */
|
|
12
|
+
get(key) {
|
|
13
|
+
const entry = this.cache.get(key);
|
|
14
|
+
if (!entry)
|
|
15
|
+
return undefined;
|
|
16
|
+
if (Date.now() > entry.expiresAt) {
|
|
17
|
+
this.cache.delete(key);
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
return entry.user;
|
|
21
|
+
}
|
|
22
|
+
/** Cache a lookup result. Pass `null` to cache a negative (invalid token) result. */
|
|
23
|
+
set(key, user) {
|
|
24
|
+
if (this.cache.size >= this.maxSize) {
|
|
25
|
+
const firstKey = this.cache.keys().next().value;
|
|
26
|
+
this.cache.delete(firstKey);
|
|
27
|
+
}
|
|
28
|
+
this.cache.set(key, {
|
|
29
|
+
user,
|
|
30
|
+
expiresAt: Date.now() + (user ? this.ttlMs : this.negativeTtlMs),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** Remove a specific key from the cache (e.g. on logout). */
|
|
34
|
+
invalidate(key) {
|
|
35
|
+
this.cache.delete(key);
|
|
36
|
+
}
|
|
37
|
+
/** Clear the entire cache. */
|
|
38
|
+
clear() {
|
|
39
|
+
this.cache.clear();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { LlmApiType } from "./modelTypes.js";
|
|
2
|
+
// --- Built-in LLM models ---
|
|
3
|
+
// Prices are per 1M tokens in USD. Last updated: February 2026.
|
|
4
|
+
export const builtInLlmModels = [
|
|
5
|
+
{
|
|
6
|
+
key: LlmApiType.None,
|
|
7
|
+
label: "None",
|
|
8
|
+
versionName: LlmApiType.None,
|
|
9
|
+
apiType: LlmApiType.None,
|
|
10
|
+
apiKeyVar: "",
|
|
11
|
+
maxTokens: 10_000,
|
|
12
|
+
inputCost: 0,
|
|
13
|
+
outputCost: 0,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
key: LlmApiType.Mock,
|
|
17
|
+
label: "Mock",
|
|
18
|
+
versionName: LlmApiType.Mock,
|
|
19
|
+
apiType: LlmApiType.Mock,
|
|
20
|
+
apiKeyVar: "",
|
|
21
|
+
maxTokens: 10_000,
|
|
22
|
+
inputCost: 0,
|
|
23
|
+
outputCost: 0,
|
|
24
|
+
},
|
|
25
|
+
// ── Open Router ──────────────────────────────────────────────────────
|
|
26
|
+
{
|
|
27
|
+
key: "llama4",
|
|
28
|
+
label: "Llama 4 Maverick",
|
|
29
|
+
versionName: "meta-llama/llama-4-maverick",
|
|
30
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
31
|
+
apiType: LlmApiType.OpenAI,
|
|
32
|
+
apiKeyVar: "OPENROUTER_API_KEY",
|
|
33
|
+
maxTokens: 1_000_000,
|
|
34
|
+
inputCost: 0.15,
|
|
35
|
+
outputCost: 0.6,
|
|
36
|
+
supportsVision: true,
|
|
37
|
+
},
|
|
38
|
+
// ── xAI / Grok ──────────────────────────────────────────────────────
|
|
39
|
+
// https://docs.x.ai/developers/models
|
|
40
|
+
{
|
|
41
|
+
key: "grok4",
|
|
42
|
+
label: "Grok 4",
|
|
43
|
+
versionName: "grok-4",
|
|
44
|
+
baseUrl: "https://api.x.ai/v1",
|
|
45
|
+
apiType: LlmApiType.OpenAI,
|
|
46
|
+
apiKeyVar: "XAI_API_KEY",
|
|
47
|
+
maxTokens: 256_000,
|
|
48
|
+
inputCost: 3,
|
|
49
|
+
outputCost: 15,
|
|
50
|
+
cacheWriteCost: 0.75,
|
|
51
|
+
cacheReadCost: 0.75,
|
|
52
|
+
cacheTtlSeconds: 300,
|
|
53
|
+
supportsVision: true,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
key: "grok4fast",
|
|
57
|
+
label: "Grok 4.1 Fast",
|
|
58
|
+
versionName: "grok-4.1-fast",
|
|
59
|
+
baseUrl: "https://api.x.ai/v1",
|
|
60
|
+
apiType: LlmApiType.OpenAI,
|
|
61
|
+
apiKeyVar: "XAI_API_KEY",
|
|
62
|
+
maxTokens: 2_000_000,
|
|
63
|
+
inputCost: 0.2,
|
|
64
|
+
outputCost: 0.5,
|
|
65
|
+
cacheWriteCost: 0.05,
|
|
66
|
+
cacheReadCost: 0.05,
|
|
67
|
+
cacheTtlSeconds: 300,
|
|
68
|
+
supportsVision: true,
|
|
69
|
+
},
|
|
70
|
+
// ── OpenAI Models ────────────────────────────────────────────────────
|
|
71
|
+
// https://openai.com/api/pricing/
|
|
72
|
+
{
|
|
73
|
+
key: "gpt5",
|
|
74
|
+
label: "GPT 5.4",
|
|
75
|
+
versionName: "gpt-5.4",
|
|
76
|
+
apiType: LlmApiType.OpenAI,
|
|
77
|
+
apiKeyVar: "OPENAI_API_KEY",
|
|
78
|
+
maxTokens: 400_000,
|
|
79
|
+
inputCost: 2.5,
|
|
80
|
+
outputCost: 15.0,
|
|
81
|
+
cacheWriteCost: 0.25,
|
|
82
|
+
cacheReadCost: 0.25,
|
|
83
|
+
cacheTtlSeconds: 300,
|
|
84
|
+
supportsVision: true,
|
|
85
|
+
supportsComputerUse: true,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: "gpt5mini",
|
|
89
|
+
label: "GPT 5 Mini",
|
|
90
|
+
versionName: "gpt-5-mini",
|
|
91
|
+
apiType: LlmApiType.OpenAI,
|
|
92
|
+
apiKeyVar: "OPENAI_API_KEY",
|
|
93
|
+
maxTokens: 400_000,
|
|
94
|
+
inputCost: 0.25,
|
|
95
|
+
outputCost: 2.0,
|
|
96
|
+
cacheWriteCost: 0.025,
|
|
97
|
+
cacheReadCost: 0.025,
|
|
98
|
+
cacheTtlSeconds: 300,
|
|
99
|
+
supportsVision: true,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
key: "gpt5nano",
|
|
103
|
+
label: "GPT 5 Nano",
|
|
104
|
+
versionName: "gpt-5-nano",
|
|
105
|
+
apiType: LlmApiType.OpenAI,
|
|
106
|
+
apiKeyVar: "OPENAI_API_KEY",
|
|
107
|
+
maxTokens: 400_000,
|
|
108
|
+
inputCost: 0.05,
|
|
109
|
+
outputCost: 0.4,
|
|
110
|
+
cacheWriteCost: 0.005,
|
|
111
|
+
cacheReadCost: 0.005,
|
|
112
|
+
cacheTtlSeconds: 300,
|
|
113
|
+
supportsVision: true,
|
|
114
|
+
},
|
|
115
|
+
// ── Google Models ────────────────────────────────────────────────────
|
|
116
|
+
// https://ai.google.dev/gemini-api/docs/pricing
|
|
117
|
+
{
|
|
118
|
+
key: "gemini3pro",
|
|
119
|
+
label: "Gemini 3.1 Pro",
|
|
120
|
+
versionName: "gemini-3.1-pro-preview",
|
|
121
|
+
apiType: LlmApiType.Google,
|
|
122
|
+
apiKeyVar: "GOOGLE_API_KEY",
|
|
123
|
+
maxTokens: 2_000_000,
|
|
124
|
+
inputCost: 2.0,
|
|
125
|
+
outputCost: 12.0,
|
|
126
|
+
cacheWriteCost: 0.2,
|
|
127
|
+
cacheReadCost: 0.2,
|
|
128
|
+
cacheTtlSeconds: 300,
|
|
129
|
+
supportsVision: true,
|
|
130
|
+
supportsHearing: true,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
key: "gemini3flash",
|
|
134
|
+
label: "Gemini 3 Flash",
|
|
135
|
+
versionName: "gemini-3-flash-preview",
|
|
136
|
+
apiType: LlmApiType.Google,
|
|
137
|
+
apiKeyVar: "GOOGLE_API_KEY",
|
|
138
|
+
maxTokens: 1_000_000,
|
|
139
|
+
inputCost: 0.5,
|
|
140
|
+
outputCost: 3.0,
|
|
141
|
+
cacheWriteCost: 0.05,
|
|
142
|
+
cacheReadCost: 0.05,
|
|
143
|
+
cacheTtlSeconds: 300,
|
|
144
|
+
supportsVision: true,
|
|
145
|
+
supportsHearing: true,
|
|
146
|
+
supportsComputerUse: true,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
key: "gemini2pro",
|
|
150
|
+
label: "Gemini 2 Pro",
|
|
151
|
+
versionName: "gemini-2.5-computer-use-preview-10-2025",
|
|
152
|
+
apiType: LlmApiType.Google,
|
|
153
|
+
apiKeyVar: "GOOGLE_API_KEY",
|
|
154
|
+
maxTokens: 2_000_000,
|
|
155
|
+
inputCost: 2.0,
|
|
156
|
+
outputCost: 12.0,
|
|
157
|
+
cacheWriteCost: 0.2,
|
|
158
|
+
cacheReadCost: 0.2,
|
|
159
|
+
cacheTtlSeconds: 300,
|
|
160
|
+
supportsVision: true,
|
|
161
|
+
supportsHearing: true,
|
|
162
|
+
supportsComputerUse: true,
|
|
163
|
+
},
|
|
164
|
+
// ── Anthropic Models ─────────────────────────────────────────────────
|
|
165
|
+
// https://platform.claude.com/docs/en/about-claude/pricing
|
|
166
|
+
// Cache: 5m write = 1.25× input, read = 0.1× input
|
|
167
|
+
{
|
|
168
|
+
key: "claude4opus",
|
|
169
|
+
label: "Claude Opus 4.6",
|
|
170
|
+
versionName: "claude-opus-4-6",
|
|
171
|
+
apiType: LlmApiType.Anthropic,
|
|
172
|
+
apiKeyVar: "ANTHROPIC_API_KEY",
|
|
173
|
+
maxTokens: 200_000,
|
|
174
|
+
inputCost: 5,
|
|
175
|
+
outputCost: 25,
|
|
176
|
+
cacheWriteCost: 6.25,
|
|
177
|
+
cacheReadCost: 0.5,
|
|
178
|
+
cacheTtlSeconds: 300,
|
|
179
|
+
supportsVision: true,
|
|
180
|
+
supportsComputerUse: true,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
key: "claude4sonnet",
|
|
184
|
+
label: "Claude Sonnet 4.6",
|
|
185
|
+
versionName: "claude-sonnet-4-6",
|
|
186
|
+
apiType: LlmApiType.Anthropic,
|
|
187
|
+
apiKeyVar: "ANTHROPIC_API_KEY",
|
|
188
|
+
maxTokens: 200_000,
|
|
189
|
+
inputCost: 3,
|
|
190
|
+
outputCost: 15,
|
|
191
|
+
cacheWriteCost: 3.75,
|
|
192
|
+
cacheReadCost: 0.3,
|
|
193
|
+
cacheTtlSeconds: 300,
|
|
194
|
+
supportsVision: true,
|
|
195
|
+
supportsComputerUse: true,
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
key: "claude4haiku",
|
|
199
|
+
label: "Claude Haiku 4.5",
|
|
200
|
+
versionName: "claude-haiku-4-5",
|
|
201
|
+
apiType: LlmApiType.Anthropic,
|
|
202
|
+
apiKeyVar: "ANTHROPIC_API_KEY",
|
|
203
|
+
maxTokens: 200_000,
|
|
204
|
+
inputCost: 1,
|
|
205
|
+
outputCost: 5,
|
|
206
|
+
cacheWriteCost: 1.25,
|
|
207
|
+
cacheReadCost: 0.1,
|
|
208
|
+
cacheTtlSeconds: 300,
|
|
209
|
+
supportsVision: true,
|
|
210
|
+
supportsComputerUse: true,
|
|
211
|
+
},
|
|
212
|
+
];
|
|
213
|
+
// --- Built-in image models ---
|
|
214
|
+
// Costs are approximate per-image for 1024x1024.
|
|
215
|
+
export const builtInImageModels = [
|
|
216
|
+
{
|
|
217
|
+
key: "gptimage1high",
|
|
218
|
+
label: "GPT Image 1.5 High",
|
|
219
|
+
versionName: "gpt-image-1.5",
|
|
220
|
+
size: "1024x1024",
|
|
221
|
+
apiKeyVar: "OPENAI_API_KEY",
|
|
222
|
+
quality: "high",
|
|
223
|
+
cost: 0.17,
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
key: "gptimage1medium",
|
|
227
|
+
label: "GPT Image 1.5 Medium",
|
|
228
|
+
versionName: "gpt-image-1.5",
|
|
229
|
+
size: "1024x1024",
|
|
230
|
+
apiKeyVar: "OPENAI_API_KEY",
|
|
231
|
+
quality: "medium",
|
|
232
|
+
cost: 0.04,
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
key: "gptimage1low",
|
|
236
|
+
label: "GPT Image 1.5 Low",
|
|
237
|
+
versionName: "gpt-image-1.5",
|
|
238
|
+
size: "1024x1024",
|
|
239
|
+
apiKeyVar: "OPENAI_API_KEY",
|
|
240
|
+
quality: "low",
|
|
241
|
+
cost: 0.01,
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
key: "dalle3-1024-HD",
|
|
245
|
+
label: "DALL-E 3 1024 HD",
|
|
246
|
+
versionName: "dall-e-3",
|
|
247
|
+
size: "1024x1024",
|
|
248
|
+
apiKeyVar: "OPENAI_API_KEY",
|
|
249
|
+
quality: "hd",
|
|
250
|
+
cost: 0.08,
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
key: "dalle3-1024",
|
|
254
|
+
label: "DALL-E 3 1024",
|
|
255
|
+
versionName: "dall-e-3",
|
|
256
|
+
size: "1024x1024",
|
|
257
|
+
apiKeyVar: "OPENAI_API_KEY",
|
|
258
|
+
cost: 0.04,
|
|
259
|
+
},
|
|
260
|
+
];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculate the current period boundaries based on a given number of hours.
|
|
3
|
+
* Periods are fixed multiples of hours from midnight (server local time).
|
|
4
|
+
* The reason we don't slide the window is that we don't want to the llm to get stuck sending off a query
|
|
5
|
+
* only for the window to close again, and the llm cache to *expire* creating a cycle of constant cache misses
|
|
6
|
+
*/
|
|
7
|
+
export function calculatePeriodBoundaries(hours) {
|
|
8
|
+
const now = new Date();
|
|
9
|
+
// Get midnight of current day in local time
|
|
10
|
+
const midnight = new Date(now);
|
|
11
|
+
midnight.setHours(0, 0, 0, 0);
|
|
12
|
+
// Calculate milliseconds since midnight
|
|
13
|
+
const msSinceMidnight = now.getTime() - midnight.getTime();
|
|
14
|
+
const hoursSinceMidnight = msSinceMidnight / (1000 * 60 * 60);
|
|
15
|
+
// Calculate which period we're in (0, 1, 2, ...)
|
|
16
|
+
const periodIndex = Math.floor(hoursSinceMidnight / hours);
|
|
17
|
+
// Calculate period start and end
|
|
18
|
+
const periodStartHours = periodIndex * hours;
|
|
19
|
+
const periodEndHours = (periodIndex + 1) * hours;
|
|
20
|
+
const periodStart = new Date(midnight.getTime() + periodStartHours * 60 * 60 * 1000);
|
|
21
|
+
const periodEnd = new Date(midnight.getTime() + periodEndHours * 60 * 60 * 1000);
|
|
22
|
+
return { periodStart, periodEnd };
|
|
23
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Fastify error handler that bypasses route-level Zod response
|
|
3
|
+
* serializers by sending a pre-stringified JSON payload. Without this,
|
|
4
|
+
* an error whose shape doesn't match the route's response schema causes a
|
|
5
|
+
* cascading "Failed to serialize an error" from fastify-type-provider-zod.
|
|
6
|
+
*
|
|
7
|
+
* Interfaces are duck-typed so @naisys/common doesn't need a Fastify dependency.
|
|
8
|
+
*/
|
|
9
|
+
function errorLabel(statusCode) {
|
|
10
|
+
switch (statusCode) {
|
|
11
|
+
case 400:
|
|
12
|
+
return "Bad Request";
|
|
13
|
+
case 401:
|
|
14
|
+
return "Unauthorized";
|
|
15
|
+
case 403:
|
|
16
|
+
return "Forbidden";
|
|
17
|
+
case 404:
|
|
18
|
+
return "Not Found";
|
|
19
|
+
case 409:
|
|
20
|
+
return "Conflict";
|
|
21
|
+
default:
|
|
22
|
+
return statusCode >= 500 ? "Internal Server Error" : "Error";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function commonErrorHandler(error, request, reply) {
|
|
26
|
+
let statusCode = error.statusCode ?? 500;
|
|
27
|
+
let message = error.message;
|
|
28
|
+
// Prisma unique-constraint violation → 409 Conflict
|
|
29
|
+
if (error.name === "PrismaClientKnownRequestError" &&
|
|
30
|
+
error.code === "P2002") {
|
|
31
|
+
statusCode = 409;
|
|
32
|
+
message = "A record with that unique value already exists";
|
|
33
|
+
}
|
|
34
|
+
request.log.error({ err: error, url: request.url, method: request.method }, "Request error");
|
|
35
|
+
// Pre-stringify to bypass the route's Zod response serializer
|
|
36
|
+
reply
|
|
37
|
+
.status(statusCode)
|
|
38
|
+
.header("content-type", "application/json; charset=utf-8")
|
|
39
|
+
.send(JSON.stringify({
|
|
40
|
+
statusCode,
|
|
41
|
+
error: errorLabel(statusCode),
|
|
42
|
+
message,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Maximum allowed file size for attachments (10 MB) */
|
|
2
|
+
export const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
|
|
3
|
+
/** Format a byte count into a human-readable size string (e.g. "1.2 KB") */
|
|
4
|
+
export function formatFileSize(bytes) {
|
|
5
|
+
if (bytes < 1024)
|
|
6
|
+
return `${bytes} B`;
|
|
7
|
+
if (bytes < 1024 * 1024)
|
|
8
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
9
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
10
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { sanitizeSpendLimit } from "./configUtils.js";
|
|
2
|
+
/** Keys that should never be distributed to clients */
|
|
3
|
+
const EXCLUDED_KEYS = [
|
|
4
|
+
"HUB_ACCESS_KEY",
|
|
5
|
+
"HUB_PORT",
|
|
6
|
+
"NAISYS_FOLDER",
|
|
7
|
+
"NAISYS_HOSTNAME",
|
|
8
|
+
"NODE_ENV",
|
|
9
|
+
"SUPERVISOR_PORT",
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* Builds hub-distributable config from the provided env vars.
|
|
13
|
+
* @param variables - Env var source: process.env (ephemeral) or DB-sourced map (hub).
|
|
14
|
+
* @param shellExportKeys - Set of variable keys that should be exported to the shell.
|
|
15
|
+
* When undefined (e.g. .env fallback), all variables are exported for backwards compat.
|
|
16
|
+
*/
|
|
17
|
+
export function buildClientConfig(variables, shellExportKeys) {
|
|
18
|
+
const shellCommand = {
|
|
19
|
+
outputTokenMax: 7500,
|
|
20
|
+
timeoutSeconds: 10,
|
|
21
|
+
maxTimeoutSeconds: 60 * 5,
|
|
22
|
+
};
|
|
23
|
+
const retrySecondsMax = 30 * 60;
|
|
24
|
+
const webTokenMax = 5000;
|
|
25
|
+
const compactSessionEnabled = true;
|
|
26
|
+
const preemptiveCompactEnabled = true;
|
|
27
|
+
// Build variableMap, filtering out excluded keys and undefined values
|
|
28
|
+
const variableMap = {};
|
|
29
|
+
const shellVariableMap = {};
|
|
30
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
31
|
+
if (value !== undefined && !EXCLUDED_KEYS.includes(key)) {
|
|
32
|
+
variableMap[key] = value;
|
|
33
|
+
// When shellExportKeys is undefined (standalone .env mode), export all
|
|
34
|
+
if (!shellExportKeys || shellExportKeys.has(key)) {
|
|
35
|
+
shellVariableMap[key] = value;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const googleSearchEngineId = variableMap.GOOGLE_SEARCH_ENGINE_ID;
|
|
40
|
+
const spendLimitDollars = sanitizeSpendLimit(variableMap.SPEND_LIMIT_DOLLARS);
|
|
41
|
+
const spendLimitHours = sanitizeSpendLimit(variableMap.SPEND_LIMIT_HOURS);
|
|
42
|
+
const useToolsForLlmConsoleResponses = true;
|
|
43
|
+
const autoStartAgentsOnMessage = true;
|
|
44
|
+
return {
|
|
45
|
+
shellCommand,
|
|
46
|
+
retrySecondsMax,
|
|
47
|
+
webTokenMax,
|
|
48
|
+
compactSessionEnabled,
|
|
49
|
+
preemptiveCompactEnabled,
|
|
50
|
+
variableMap,
|
|
51
|
+
shellVariableMap,
|
|
52
|
+
googleSearchEngineId,
|
|
53
|
+
spendLimitDollars,
|
|
54
|
+
spendLimitHours,
|
|
55
|
+
useToolsForLlmConsoleResponses,
|
|
56
|
+
autoStartAgentsOnMessage,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
export const HateoasLinkSchema = z.object({
|
|
3
|
+
rel: z.string(),
|
|
4
|
+
href: z.string(),
|
|
5
|
+
method: z.string().optional(),
|
|
6
|
+
title: z.string().optional(),
|
|
7
|
+
schema: z.string().optional(),
|
|
8
|
+
});
|
|
9
|
+
export const AlternateEncodingSchema = z.object({
|
|
10
|
+
contentType: z.string(),
|
|
11
|
+
description: z.string().optional(),
|
|
12
|
+
fileFields: z.array(z.string()),
|
|
13
|
+
});
|
|
14
|
+
export const HateoasActionSchema = z.object({
|
|
15
|
+
rel: z.string(),
|
|
16
|
+
href: z.string(),
|
|
17
|
+
method: z.string(),
|
|
18
|
+
title: z.string().optional(),
|
|
19
|
+
schema: z.string().optional(),
|
|
20
|
+
body: z.record(z.string(), z.unknown()).optional(),
|
|
21
|
+
alternateEncoding: AlternateEncodingSchema.optional(),
|
|
22
|
+
disabled: z.boolean().optional(),
|
|
23
|
+
disabledReason: z.union([z.string(), z.array(z.string())]).optional(),
|
|
24
|
+
});
|
|
25
|
+
export const HateoasActionTemplateSchema = z.object({
|
|
26
|
+
rel: z.string(),
|
|
27
|
+
hrefTemplate: z.string(),
|
|
28
|
+
method: z.string(),
|
|
29
|
+
title: z.string().optional(),
|
|
30
|
+
schema: z.string().optional(),
|
|
31
|
+
body: z.record(z.string(), z.unknown()).optional(),
|
|
32
|
+
alternateEncoding: AlternateEncodingSchema.optional(),
|
|
33
|
+
});
|
|
34
|
+
export const HateoasLinkTemplateSchema = z.object({
|
|
35
|
+
rel: z.string(),
|
|
36
|
+
hrefTemplate: z.string(),
|
|
37
|
+
title: z.string().optional(),
|
|
38
|
+
});
|
|
39
|
+
export const HateoasLinksSchema = z.object({
|
|
40
|
+
_links: z.array(HateoasLinkSchema),
|
|
41
|
+
_actions: z.array(HateoasActionSchema).optional(),
|
|
42
|
+
});
|
package/dist/hateoas.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns the action if it exists and is enabled.
|
|
3
|
+
* Pass `{ includeDisabled: true }` to also return disabled actions
|
|
4
|
+
* (e.g. when rendering a disabled button with a tooltip).
|
|
5
|
+
*/
|
|
6
|
+
export function hasAction(actions, rel, opts) {
|
|
7
|
+
const a = actions?.find((a) => a.rel === rel);
|
|
8
|
+
if (!a)
|
|
9
|
+
return undefined;
|
|
10
|
+
if (a.disabled && !opts?.includeDisabled)
|
|
11
|
+
return undefined;
|
|
12
|
+
return a;
|
|
13
|
+
}
|
|
14
|
+
export function hasActionTemplate(templates, rel) {
|
|
15
|
+
return templates?.find((t) => t.rel === rel);
|
|
16
|
+
}
|
|
17
|
+
export function hasLinkTemplate(templates, rel) {
|
|
18
|
+
return templates?.find((t) => t.rel === rel);
|
|
19
|
+
}
|
|
20
|
+
export function resolveActions(defs, baseHref, ctx, checkPermission) {
|
|
21
|
+
const actions = [];
|
|
22
|
+
for (const def of defs) {
|
|
23
|
+
if (def.statuses) {
|
|
24
|
+
const status = ctx.status;
|
|
25
|
+
if (!status || !def.statuses.includes(status))
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (def.visibleWhen && !def.visibleWhen(ctx))
|
|
29
|
+
continue;
|
|
30
|
+
const hasPerm = !def.permission || checkPermission(def.permission);
|
|
31
|
+
if (def.hideWithoutPermission && !hasPerm)
|
|
32
|
+
continue;
|
|
33
|
+
const gate = !hasPerm && def.permission
|
|
34
|
+
? {
|
|
35
|
+
disabled: true,
|
|
36
|
+
disabledReason: `Requires ${def.permission} permission`,
|
|
37
|
+
}
|
|
38
|
+
: {};
|
|
39
|
+
const disabledReason = hasPerm && def.disabledWhen ? def.disabledWhen(ctx) : null;
|
|
40
|
+
actions.push({
|
|
41
|
+
rel: def.rel,
|
|
42
|
+
href: def.href ?? baseHref + (def.path ?? ""),
|
|
43
|
+
method: def.method,
|
|
44
|
+
title: def.title,
|
|
45
|
+
...(def.schema ? { schema: def.schema } : {}),
|
|
46
|
+
...(def.body ? { body: def.body } : {}),
|
|
47
|
+
...gate,
|
|
48
|
+
...(disabledReason ? { disabled: true, disabledReason } : {}),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return actions;
|
|
52
|
+
}
|
|
53
|
+
export function permGate(hasPerm, permission) {
|
|
54
|
+
return hasPerm
|
|
55
|
+
? {}
|
|
56
|
+
: {
|
|
57
|
+
disabled: true,
|
|
58
|
+
disabledReason: `Requires ${permission} permission`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Normalize a `disabledReason` (string | string[] | undefined) to a single display string. */
|
|
62
|
+
export function formatDisabledReason(reason) {
|
|
63
|
+
if (!reason)
|
|
64
|
+
return undefined;
|
|
65
|
+
return Array.isArray(reason) ? reason.join("\n") : reason;
|
|
66
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interfaces for services that can be dynamically loaded in "hosted" mode
|
|
3
|
+
* (running in the same process space to save memory).
|
|
4
|
+
*
|
|
5
|
+
* These are defined in @naisys/common so that both the caller and implementer
|
|
6
|
+
* share the same type without a compile-time dependency between them.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export * from "./agentConfigFile.js";
|
|
2
|
+
export * from "./agentStatus.js";
|
|
3
|
+
export * from "./authCache.js";
|
|
4
|
+
export * from "./builtInModels.js";
|
|
5
|
+
export * from "./configUtils.js";
|
|
6
|
+
export * from "./constants.js";
|
|
7
|
+
export * from "./costUtils.js";
|
|
8
|
+
export * from "./errorHandler.js";
|
|
9
|
+
export * from "./formatFileSize.js";
|
|
10
|
+
export * from "./globalConfigLoader.js";
|
|
11
|
+
export * from "./hateoas.js";
|
|
12
|
+
export * from "./hateoas-types.js";
|
|
13
|
+
export * from "./hostedServices.js";
|
|
14
|
+
export * from "./lenientJsonParser.js";
|
|
15
|
+
export * from "./mimeTypes.js";
|
|
16
|
+
export * from "./modelTypes.js";
|
|
17
|
+
export * from "./securityHeaders.js";
|
|
18
|
+
export * from "./sleep.js";
|
|
19
|
+
export * from "./urlSafeKey.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom JSON content-type parser that accepts empty bodies on POST endpoints.
|
|
3
|
+
* Without this, sending Content-Type: application/json with no body (common
|
|
4
|
+
* with HTTP clients calling bodiless state-transition actions like /start,
|
|
5
|
+
* /complete) causes a JSON parse error.
|
|
6
|
+
*
|
|
7
|
+
* Interface is duck-typed so @naisys/common doesn't need a Fastify dependency.
|
|
8
|
+
*/
|
|
9
|
+
export function registerLenientJsonParser(fastify) {
|
|
10
|
+
fastify.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) => {
|
|
11
|
+
try {
|
|
12
|
+
done(null, body.length > 0 ? JSON.parse(body) : {});
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
err.statusCode = 400;
|
|
16
|
+
done(err, undefined);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const MIME_TYPES = {
|
|
2
|
+
// Images
|
|
3
|
+
".jpg": "image/jpeg",
|
|
4
|
+
".jpeg": "image/jpeg",
|
|
5
|
+
".png": "image/png",
|
|
6
|
+
".gif": "image/gif",
|
|
7
|
+
".webp": "image/webp",
|
|
8
|
+
".svg": "image/svg+xml",
|
|
9
|
+
".bmp": "image/bmp",
|
|
10
|
+
// Audio
|
|
11
|
+
".wav": "audio/wav",
|
|
12
|
+
".mp3": "audio/mpeg",
|
|
13
|
+
".m4a": "audio/mp4",
|
|
14
|
+
".flac": "audio/flac",
|
|
15
|
+
".ogg": "audio/ogg",
|
|
16
|
+
".webm": "audio/webm",
|
|
17
|
+
// Documents
|
|
18
|
+
".pdf": "application/pdf",
|
|
19
|
+
".txt": "text/plain",
|
|
20
|
+
".json": "application/json",
|
|
21
|
+
};
|
|
22
|
+
export function mimeFromFilename(filename) {
|
|
23
|
+
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
|
|
24
|
+
return MIME_TYPES[ext] ?? "application/octet-stream";
|
|
25
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { builtInImageModels, builtInLlmModels } from "./builtInModels.js";
|
|
3
|
+
// --- Enums ---
|
|
4
|
+
export var LlmApiType;
|
|
5
|
+
(function (LlmApiType) {
|
|
6
|
+
LlmApiType["OpenAI"] = "openai";
|
|
7
|
+
LlmApiType["Google"] = "google";
|
|
8
|
+
LlmApiType["Anthropic"] = "anthropic";
|
|
9
|
+
LlmApiType["Mock"] = "mock";
|
|
10
|
+
LlmApiType["None"] = "none";
|
|
11
|
+
})(LlmApiType || (LlmApiType = {}));
|
|
12
|
+
// --- Model schemas ---
|
|
13
|
+
export const LlmModelSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
key: z.string().min(1),
|
|
16
|
+
label: z.string().min(1),
|
|
17
|
+
versionName: z.string().min(1),
|
|
18
|
+
apiType: z.enum(LlmApiType),
|
|
19
|
+
maxTokens: z.number().int().positive(),
|
|
20
|
+
baseUrl: z.string().optional(),
|
|
21
|
+
apiKeyVar: z.string(),
|
|
22
|
+
inputCost: z.number().default(0),
|
|
23
|
+
outputCost: z.number().default(0),
|
|
24
|
+
cacheWriteCost: z.number().optional(),
|
|
25
|
+
cacheReadCost: z.number().optional(),
|
|
26
|
+
cacheTtlSeconds: z.number().int().positive().optional(),
|
|
27
|
+
supportsVision: z.boolean().optional(),
|
|
28
|
+
supportsHearing: z.boolean().optional(),
|
|
29
|
+
supportsComputerUse: z.boolean().optional(),
|
|
30
|
+
})
|
|
31
|
+
.superRefine((data, ctx) => {
|
|
32
|
+
if (data.baseUrl &&
|
|
33
|
+
![LlmApiType.OpenAI, LlmApiType.Anthropic, LlmApiType.Google].includes(data.apiType)) {
|
|
34
|
+
ctx.addIssue({
|
|
35
|
+
code: z.ZodIssueCode.custom,
|
|
36
|
+
message: `baseUrl is only supported for OpenAI, Anthropic, and Google API types (got "${data.apiType}")`,
|
|
37
|
+
path: ["baseUrl"],
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
export const ImageModelSchema = z.object({
|
|
42
|
+
key: z.string().min(1),
|
|
43
|
+
label: z.string().min(1),
|
|
44
|
+
versionName: z.string().min(1),
|
|
45
|
+
size: z.string().min(1),
|
|
46
|
+
baseUrl: z.string().optional(),
|
|
47
|
+
apiKeyVar: z.string(),
|
|
48
|
+
cost: z.number(),
|
|
49
|
+
quality: z.enum(["standard", "hd", "high", "medium", "low"]).optional(),
|
|
50
|
+
});
|
|
51
|
+
// --- Custom models file schema ---
|
|
52
|
+
export const CustomModelsFileSchema = z.object({
|
|
53
|
+
llmModels: z.array(LlmModelSchema).optional(),
|
|
54
|
+
imageModels: z.array(ImageModelSchema).optional(),
|
|
55
|
+
});
|
|
56
|
+
// --- DB meta schemas (for JSON stored in models.meta column) ---
|
|
57
|
+
const LlmMetaSchema = z.object({
|
|
58
|
+
apiType: z.enum(LlmApiType),
|
|
59
|
+
maxTokens: z.number().int().positive(),
|
|
60
|
+
baseUrl: z.string().optional(),
|
|
61
|
+
apiKeyVar: z.string(),
|
|
62
|
+
inputCost: z.number().default(0),
|
|
63
|
+
outputCost: z.number().default(0),
|
|
64
|
+
cacheWriteCost: z.number().optional(),
|
|
65
|
+
cacheReadCost: z.number().optional(),
|
|
66
|
+
cacheTtlSeconds: z.number().int().positive().optional(),
|
|
67
|
+
supportsVision: z.boolean().optional(),
|
|
68
|
+
supportsHearing: z.boolean().optional(),
|
|
69
|
+
supportsComputerUse: z.boolean().optional(),
|
|
70
|
+
});
|
|
71
|
+
const ImageMetaSchema = z.object({
|
|
72
|
+
size: z.string().min(1),
|
|
73
|
+
baseUrl: z.string().optional(),
|
|
74
|
+
apiKeyVar: z.string(),
|
|
75
|
+
cost: z.number(),
|
|
76
|
+
quality: z.enum(["standard", "hd", "high", "medium", "low"]).optional(),
|
|
77
|
+
});
|
|
78
|
+
export function llmModelToDbFields(model, isBuiltin, isCustom) {
|
|
79
|
+
const { key, label, versionName, ...metaFields } = model;
|
|
80
|
+
return {
|
|
81
|
+
key,
|
|
82
|
+
type: "llm",
|
|
83
|
+
label,
|
|
84
|
+
version_name: versionName,
|
|
85
|
+
is_builtin: isBuiltin,
|
|
86
|
+
is_custom: isCustom,
|
|
87
|
+
meta: JSON.stringify(metaFields),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export function imageModelToDbFields(model, isBuiltin, isCustom) {
|
|
91
|
+
const { key, label, versionName, ...metaFields } = model;
|
|
92
|
+
return {
|
|
93
|
+
key,
|
|
94
|
+
type: "image",
|
|
95
|
+
label,
|
|
96
|
+
version_name: versionName,
|
|
97
|
+
is_builtin: isBuiltin,
|
|
98
|
+
is_custom: isCustom,
|
|
99
|
+
meta: JSON.stringify(metaFields),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export function dbFieldsToLlmModel(row) {
|
|
103
|
+
const meta = LlmMetaSchema.parse(JSON.parse(row.meta));
|
|
104
|
+
return {
|
|
105
|
+
key: row.key,
|
|
106
|
+
label: row.label,
|
|
107
|
+
versionName: row.version_name,
|
|
108
|
+
...meta,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export function dbFieldsToImageModel(row) {
|
|
112
|
+
const meta = ImageMetaSchema.parse(JSON.parse(row.meta));
|
|
113
|
+
return {
|
|
114
|
+
key: row.key,
|
|
115
|
+
label: row.label,
|
|
116
|
+
versionName: row.version_name,
|
|
117
|
+
...meta,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// --- Merge helpers ---
|
|
121
|
+
function mergeModels(builtIn, custom) {
|
|
122
|
+
if (!custom || custom.length === 0) {
|
|
123
|
+
return [...builtIn];
|
|
124
|
+
}
|
|
125
|
+
const result = [...builtIn];
|
|
126
|
+
for (const c of custom) {
|
|
127
|
+
const idx = result.findIndex((m) => m.key === c.key);
|
|
128
|
+
if (idx >= 0) {
|
|
129
|
+
result[idx] = c;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
result.push(c);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
export function getAllLlmModels(customLlmModels) {
|
|
138
|
+
return mergeModels(builtInLlmModels, customLlmModels);
|
|
139
|
+
}
|
|
140
|
+
export function getAllImageModels(customImageModels) {
|
|
141
|
+
return mergeModels(builtInImageModels, customImageModels);
|
|
142
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared security-header hook for Fastify servers.
|
|
3
|
+
*
|
|
4
|
+
* Interfaces are duck-typed so @naisys/common doesn't need a Fastify dependency.
|
|
5
|
+
*/
|
|
6
|
+
export function registerSecurityHeaders(fastify, options) {
|
|
7
|
+
const strictCsp = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self' data:; frame-ancestors 'none'";
|
|
8
|
+
// Scalar API reference needs inline scripts, eval (bundled Zod JIT),
|
|
9
|
+
// CDN assets, and outbound fetches to its proxy/registry services.
|
|
10
|
+
const apiReferenceCsp = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; connect-src 'self' ws: wss: https://proxy.scalar.com https://api.scalar.com; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.scalar.com; frame-ancestors 'none'";
|
|
11
|
+
fastify.addHook("onSend", (request, reply, _payload, done) => {
|
|
12
|
+
reply.header("X-Content-Type-Options", "nosniff");
|
|
13
|
+
reply.header("X-Frame-Options", "DENY");
|
|
14
|
+
const isApiReference = request.url.includes("/api-reference");
|
|
15
|
+
reply.header("Content-Security-Policy", isApiReference ? apiReferenceCsp : strictCsp);
|
|
16
|
+
if (options.enforceHsts) {
|
|
17
|
+
reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
18
|
+
}
|
|
19
|
+
done();
|
|
20
|
+
});
|
|
21
|
+
}
|
package/dist/sleep.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Characters allowed in URL path segments used as database keys (usernames, hostnames). */
|
|
2
|
+
export const URL_SAFE_KEY_REGEX = /^[a-zA-Z0-9_-]+$/;
|
|
3
|
+
export const URL_SAFE_KEY_MESSAGE = "Must contain only letters, numbers, hyphens, and underscores";
|
|
4
|
+
/** Sanitize a string into a URL-safe key (replace spaces/special chars with hyphens). */
|
|
5
|
+
export function toUrlSafeKey(input) {
|
|
6
|
+
return input
|
|
7
|
+
.trim()
|
|
8
|
+
.replace(/[^a-zA-Z0-9_-]/g, "-")
|
|
9
|
+
.replace(/-{2,}/g, "-")
|
|
10
|
+
.replace(/^-+|-+$/g, "");
|
|
11
|
+
}
|
|
12
|
+
/** Throws if the value is not a valid URL-safe key. */
|
|
13
|
+
export function assertUrlSafeKey(value, label) {
|
|
14
|
+
if (!URL_SAFE_KEY_REGEX.test(value)) {
|
|
15
|
+
throw new Error(`${label} "${value}" is not URL-safe. ${URL_SAFE_KEY_MESSAGE}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@naisys/common",
|
|
3
|
+
"version": "3.0.0-beta.10",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "[internal] Common utilities and constants for NAISYS",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"assets",
|
|
9
|
+
"!dist/**/*.map",
|
|
10
|
+
"!dist/**/*.d.ts",
|
|
11
|
+
"!dist/**/*.d.ts.map"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"clean": "rimraf dist",
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"npm:publish:dryrun": "npm publish --dry-run",
|
|
17
|
+
"npm:publish": "npm publish --access public"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"zod": "^4.3.6"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.9.3"
|
|
24
|
+
},
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./assets/*": "./assets/*"
|
|
31
|
+
}
|
|
32
|
+
}
|