jishushell 0.4.30 → 0.5.22

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.
Files changed (226) hide show
  1. package/Dockerfile.hermes-slim +2 -5
  2. package/apps/anythingllm-container.yaml +287 -0
  3. package/apps/browserless-chromium-container.yaml +18 -6
  4. package/apps/filebrowser-container.yaml +164 -0
  5. package/apps/ollama-binary.yaml +44 -0
  6. package/apps/ollama-with-hollama-binary.yaml +45 -1
  7. package/apps/openclaw-binary.yaml +8 -0
  8. package/apps/openclaw-container.yaml +9 -1
  9. package/apps/openclaw-with-searxng-container.yaml +4 -0
  10. package/apps/searxng-container.yaml +5 -4
  11. package/apps/weknora-container.yaml +471 -0
  12. package/dist/cli/doctor.js +144 -16
  13. package/dist/cli/doctor.js.map +1 -1
  14. package/dist/cli/panel.js.map +1 -1
  15. package/dist/config.d.ts +19 -0
  16. package/dist/config.js +99 -1
  17. package/dist/config.js.map +1 -1
  18. package/dist/install.js +4 -4
  19. package/dist/install.js.map +1 -1
  20. package/dist/routes/auth.js +2 -2
  21. package/dist/routes/auth.js.map +1 -1
  22. package/dist/routes/backup.js +64 -11
  23. package/dist/routes/backup.js.map +1 -1
  24. package/dist/routes/external-mounts.d.ts +17 -0
  25. package/dist/routes/external-mounts.js +73 -0
  26. package/dist/routes/external-mounts.js.map +1 -0
  27. package/dist/routes/file-mounts.d.ts +13 -0
  28. package/dist/routes/file-mounts.js +90 -0
  29. package/dist/routes/file-mounts.js.map +1 -0
  30. package/dist/routes/files-organize.d.ts +28 -0
  31. package/dist/routes/files-organize.js +167 -0
  32. package/dist/routes/files-organize.js.map +1 -0
  33. package/dist/routes/files.d.ts +31 -0
  34. package/dist/routes/files.js +321 -0
  35. package/dist/routes/files.js.map +1 -0
  36. package/dist/routes/instances.js +87 -12
  37. package/dist/routes/instances.js.map +1 -1
  38. package/dist/routes/internal.d.ts +2 -0
  39. package/dist/routes/internal.js +59 -0
  40. package/dist/routes/internal.js.map +1 -0
  41. package/dist/routes/llm.js +29 -0
  42. package/dist/routes/llm.js.map +1 -1
  43. package/dist/routes/setup.js +9 -9
  44. package/dist/routes/setup.js.map +1 -1
  45. package/dist/routes/system.js +1 -1
  46. package/dist/routes/system.js.map +1 -1
  47. package/dist/routes/webdav.d.ts +17 -0
  48. package/dist/routes/webdav.js +114 -0
  49. package/dist/routes/webdav.js.map +1 -0
  50. package/dist/server.js +358 -6
  51. package/dist/server.js.map +1 -1
  52. package/dist/services/agent-apps/catalog.d.ts +3 -0
  53. package/dist/services/agent-apps/catalog.js +40 -13
  54. package/dist/services/agent-apps/catalog.js.map +1 -1
  55. package/dist/services/agent-apps/installers/shell-script.d.ts +1 -1
  56. package/dist/services/agent-apps/installers/shell-script.js +19 -2
  57. package/dist/services/agent-apps/installers/shell-script.js.map +1 -1
  58. package/dist/services/agent-apps/types.d.ts +3 -0
  59. package/dist/services/app/app-compiler.d.ts +1 -1
  60. package/dist/services/app/app-compiler.js +5 -5
  61. package/dist/services/app/app-compiler.js.map +1 -1
  62. package/dist/services/app/app-manager.d.ts +9 -0
  63. package/dist/services/app/app-manager.js +248 -43
  64. package/dist/services/app/app-manager.js.map +1 -1
  65. package/dist/services/app/custom-manager.js.map +1 -1
  66. package/dist/services/app/hermes-agent-manager.js +1 -0
  67. package/dist/services/app/hermes-agent-manager.js.map +1 -1
  68. package/dist/services/app/ollama-manager.js +1 -1
  69. package/dist/services/app/ollama-manager.js.map +1 -1
  70. package/dist/services/app/openclaw-manager.js +37 -5
  71. package/dist/services/app/openclaw-manager.js.map +1 -1
  72. package/dist/services/app/platform-transform.d.ts +32 -0
  73. package/dist/services/app/platform-transform.js +65 -0
  74. package/dist/services/app/platform-transform.js.map +1 -0
  75. package/dist/services/app-passwords.d.ts +61 -0
  76. package/dist/services/app-passwords.js +173 -0
  77. package/dist/services/app-passwords.js.map +1 -0
  78. package/dist/services/backup-manager.d.ts +11 -0
  79. package/dist/services/backup-manager.js +220 -8
  80. package/dist/services/backup-manager.js.map +1 -1
  81. package/dist/services/capability-endpoint-validator.js +26 -7
  82. package/dist/services/capability-endpoint-validator.js.map +1 -1
  83. package/dist/services/connection-apply.d.ts +2 -0
  84. package/dist/services/connection-apply.js +55 -1
  85. package/dist/services/connection-apply.js.map +1 -1
  86. package/dist/services/connection-resolver.js +1 -1
  87. package/dist/services/connection-resolver.js.map +1 -1
  88. package/dist/services/connection-transactor.d.ts +2 -0
  89. package/dist/services/connection-transactor.js +12 -2
  90. package/dist/services/connection-transactor.js.map +1 -1
  91. package/dist/services/external-mounts.d.ts +40 -0
  92. package/dist/services/external-mounts.js +187 -0
  93. package/dist/services/external-mounts.js.map +1 -0
  94. package/dist/services/files-manager.d.ts +252 -0
  95. package/dist/services/files-manager.js +1075 -0
  96. package/dist/services/files-manager.js.map +1 -0
  97. package/dist/services/files-mounts.d.ts +42 -0
  98. package/dist/services/files-mounts.js +207 -0
  99. package/dist/services/files-mounts.js.map +1 -0
  100. package/dist/services/instance-manager.js +90 -32
  101. package/dist/services/instance-manager.js.map +1 -1
  102. package/dist/services/llm-proxy/index.d.ts +28 -0
  103. package/dist/services/llm-proxy/index.js +76 -3
  104. package/dist/services/llm-proxy/index.js.map +1 -1
  105. package/dist/services/llm-proxy/ssrf.js +6 -2
  106. package/dist/services/llm-proxy/ssrf.js.map +1 -1
  107. package/dist/services/llm-proxy/validate-key.d.ts +41 -0
  108. package/dist/services/llm-proxy/validate-key.js +672 -0
  109. package/dist/services/llm-proxy/validate-key.js.map +1 -0
  110. package/dist/services/macos-launchd.d.ts +89 -0
  111. package/dist/services/macos-launchd.js +273 -0
  112. package/dist/services/macos-launchd.js.map +1 -0
  113. package/dist/services/nomad-manager.d.ts +11 -0
  114. package/dist/services/nomad-manager.js +343 -98
  115. package/dist/services/nomad-manager.js.map +1 -1
  116. package/dist/services/organize/applier.d.ts +46 -0
  117. package/dist/services/organize/applier.js +218 -0
  118. package/dist/services/organize/applier.js.map +1 -0
  119. package/dist/services/organize/rules.d.ts +57 -0
  120. package/dist/services/organize/rules.js +286 -0
  121. package/dist/services/organize/rules.js.map +1 -0
  122. package/dist/services/organize/scanner.d.ts +50 -0
  123. package/dist/services/organize/scanner.js +366 -0
  124. package/dist/services/organize/scanner.js.map +1 -0
  125. package/dist/services/organize/store.d.ts +14 -0
  126. package/dist/services/organize/store.js +82 -0
  127. package/dist/services/organize/store.js.map +1 -0
  128. package/dist/services/panel-manager.js +40 -11
  129. package/dist/services/panel-manager.js.map +1 -1
  130. package/dist/services/process-manager.js +3 -2
  131. package/dist/services/process-manager.js.map +1 -1
  132. package/dist/services/runtime/adapters/custom.js +56 -0
  133. package/dist/services/runtime/adapters/custom.js.map +1 -1
  134. package/dist/services/runtime/adapters/hermes.d.ts +4 -3
  135. package/dist/services/runtime/adapters/hermes.js +166 -64
  136. package/dist/services/runtime/adapters/hermes.js.map +1 -1
  137. package/dist/services/runtime/adapters/openclaw-routes.d.ts +8 -2
  138. package/dist/services/runtime/adapters/openclaw-routes.js +68 -0
  139. package/dist/services/runtime/adapters/openclaw-routes.js.map +1 -1
  140. package/dist/services/runtime/adapters/openclaw.d.ts +118 -0
  141. package/dist/services/runtime/adapters/openclaw.js +1459 -49
  142. package/dist/services/runtime/adapters/openclaw.js.map +1 -1
  143. package/dist/services/runtime/instance.d.ts +1 -1
  144. package/dist/services/runtime/instance.js +1 -1
  145. package/dist/services/runtime/instance.js.map +1 -1
  146. package/dist/services/runtime/mcp-shims/anythingllm-shim.d.ts +46 -0
  147. package/dist/services/runtime/mcp-shims/anythingllm-shim.js +281 -0
  148. package/dist/services/runtime/mcp-shims/anythingllm-shim.js.map +1 -0
  149. package/dist/services/runtime/mcp-shims/drive-shim.d.ts +54 -0
  150. package/dist/services/runtime/mcp-shims/drive-shim.js +489 -0
  151. package/dist/services/runtime/mcp-shims/drive-shim.js.map +1 -0
  152. package/dist/services/runtime/types.d.ts +31 -0
  153. package/dist/services/setup-manager.js +190 -68
  154. package/dist/services/setup-manager.js.map +1 -1
  155. package/dist/services/suggestions.js.map +1 -1
  156. package/dist/services/update-manager.js +32 -14
  157. package/dist/services/update-manager.js.map +1 -1
  158. package/dist/services/webdav/server.d.ts +24 -0
  159. package/dist/services/webdav/server.js +420 -0
  160. package/dist/services/webdav/server.js.map +1 -0
  161. package/dist/services/webdav/xml-builder.d.ts +73 -0
  162. package/dist/services/webdav/xml-builder.js +156 -0
  163. package/dist/services/webdav/xml-builder.js.map +1 -0
  164. package/dist/services/workspace-builder.d.ts +29 -0
  165. package/dist/services/workspace-builder.js +188 -0
  166. package/dist/services/workspace-builder.js.map +1 -0
  167. package/dist/types.d.ts +61 -0
  168. package/dist/utils/path-locks.d.ts +30 -0
  169. package/dist/utils/path-locks.js +63 -0
  170. package/dist/utils/path-locks.js.map +1 -0
  171. package/dist/utils/path-safety.d.ts +41 -0
  172. package/dist/utils/path-safety.js +119 -0
  173. package/dist/utils/path-safety.js.map +1 -0
  174. package/dist/utils/safe-write.d.ts +24 -0
  175. package/dist/utils/safe-write.js +82 -0
  176. package/dist/utils/safe-write.js.map +1 -0
  177. package/install/jishu-install.sh +247 -35
  178. package/install/jishu-uninstall.sh +45 -5
  179. package/package.json +20 -2
  180. package/public/assets/ApiKeyField-CvyAOcJS.js +1 -0
  181. package/public/assets/Dashboard-AuJESBlJ.js +1 -0
  182. package/public/assets/{HermesChatPanel-_GHoklgo.js → HermesChatPanel-CByPREwb.js} +1 -1
  183. package/public/assets/HermesConfigForm-DRda8FKX.js +4 -0
  184. package/public/assets/InitPassword-ka4wNpM5.js +1 -0
  185. package/public/assets/InstanceDetail-Cg1nS8HX.js +92 -0
  186. package/public/assets/Login-aPajuQzf.js +1 -0
  187. package/public/assets/NewInstance-Dd1ebNIx.js +1 -0
  188. package/public/assets/ProviderRecommendations-DFmADQ7V.js +1 -0
  189. package/public/assets/Settings-BYQnbLYL.js +1 -0
  190. package/public/assets/Setup-D05lwDOV.js +1 -0
  191. package/public/assets/WeixinLoginPanel-D89kdhP4.js +9 -0
  192. package/public/assets/index-HSXCsceK.css +1 -0
  193. package/public/assets/index-bnBu0nlQ.js +19 -0
  194. package/public/assets/registry-C_qeFTkZ.js +2 -0
  195. package/public/assets/usePolling-Bn93fe7M.js +1 -0
  196. package/public/assets/{vendor-i18n-ucpM0OR0.js → vendor-i18n-flxcMVeP.js} +2 -2
  197. package/public/assets/{vendor-react-Bk1hRGiY.js → vendor-react-ZC5T_huj.js} +7 -7
  198. package/public/index.html +4 -4
  199. package/scripts/check-app-spec.mjs +18 -4
  200. package/scripts/check-colima-launchd.mjs +230 -0
  201. package/scripts/check-new-file-tests.mjs +230 -0
  202. package/scripts/check-quarantine-expiry.mjs +105 -0
  203. package/scripts/perf/README.md +49 -0
  204. package/scripts/perf/auth.js +99 -0
  205. package/scripts/perf/config.js +63 -0
  206. package/scripts/perf/instances.js +143 -0
  207. package/scripts/perf/proxy.js +96 -0
  208. package/scripts/smoke/files-w1.sh +142 -0
  209. package/scripts/smoke-backend.mjs +122 -0
  210. package/scripts/smoke-post-publish.mjs +346 -0
  211. package/public/assets/Dashboard-rkWp-CXd.js +0 -1
  212. package/public/assets/HermesConfigForm-anDnwUp_.js +0 -4
  213. package/public/assets/InitPassword-ZU9_-hDr.js +0 -1
  214. package/public/assets/InstanceDetail-CN0FH1aw.js +0 -92
  215. package/public/assets/Login-BItXqYAJ.js +0 -1
  216. package/public/assets/NewInstance-BousE6kY.js +0 -1
  217. package/public/assets/ProviderRecommendations-DFYj7Fb6.js +0 -1
  218. package/public/assets/Settings-Bttc6QmM.js +0 -1
  219. package/public/assets/Setup-Bsxx1zgj.js +0 -1
  220. package/public/assets/WeixinLoginPanel-DPZpAKgO.js +0 -9
  221. package/public/assets/index-8xZy1z5k.css +0 -1
  222. package/public/assets/index-Dw3HhUYE.js +0 -19
  223. package/public/assets/input-paste-CrNVAyOy.js +0 -1
  224. package/public/assets/providers-DtNXh9JD.js +0 -1
  225. package/public/assets/registry-5s2UB6is.js +0 -2
  226. package/public/assets/usePolling-Do5Erqm_.js +0 -1
@@ -0,0 +1,672 @@
1
+ /**
2
+ * src/services/llm-proxy/validate-key.ts
3
+ *
4
+ * Protocol-aware API key validation. Dispatches different probe strategies
5
+ * based on the normalized `api` field to correctly validate keys for each
6
+ * provider family (OpenAI-compat, Anthropic native/gateway, Google, Ollama).
7
+ *
8
+ * Returns a structured response so the frontend can distinguish between
9
+ * "key wrong" (401), "endpoint unsupported" (404), and "network error".
10
+ *
11
+ * SECURITY: This module must NEVER log the apiKey parameter in any form.
12
+ */
13
+ import { LEGACY_PROVIDER_API_ALIASES } from "../../constants.js";
14
+ import { validateUpstreamUrl } from "./ssrf.js";
15
+ import { LOCAL_PROVIDER_IDS } from "./ssrf.js";
16
+ // ── Rate Limiter (10 req/min per IP) ───────────────────────────────────────
17
+ const VALIDATE_RATE_LIMIT_WINDOW_MS = 60_000;
18
+ const VALIDATE_RATE_LIMIT_MAX = 10;
19
+ const MAX_VALIDATE_BUCKETS = 5_000;
20
+ const validateBuckets = new Map();
21
+ export function checkValidateRateLimit(ip) {
22
+ const now = Date.now();
23
+ let bucket = validateBuckets.get(ip);
24
+ if (!bucket || now - bucket.windowStart > VALIDATE_RATE_LIMIT_WINDOW_MS) {
25
+ if (!validateBuckets.has(ip) && validateBuckets.size >= MAX_VALIDATE_BUCKETS) {
26
+ let evictKey;
27
+ for (const [k, v] of validateBuckets) {
28
+ if (now - v.windowStart > VALIDATE_RATE_LIMIT_WINDOW_MS) {
29
+ evictKey = k;
30
+ break;
31
+ }
32
+ }
33
+ if (evictKey === undefined)
34
+ evictKey = validateBuckets.keys().next().value;
35
+ if (evictKey !== undefined)
36
+ validateBuckets.delete(evictKey);
37
+ }
38
+ bucket = { count: 0, windowStart: now };
39
+ validateBuckets.set(ip, bucket);
40
+ }
41
+ if (bucket.count >= VALIDATE_RATE_LIMIT_MAX)
42
+ return false;
43
+ bucket.count++;
44
+ return true;
45
+ }
46
+ // Periodic cleanup
47
+ setInterval(() => {
48
+ const now = Date.now();
49
+ for (const [ip, bucket] of validateBuckets) {
50
+ if (now - bucket.windowStart > VALIDATE_RATE_LIMIT_WINDOW_MS * 2) {
51
+ validateBuckets.delete(ip);
52
+ }
53
+ }
54
+ }, VALIDATE_RATE_LIMIT_WINDOW_MS * 2).unref();
55
+ // ── Internal helpers ───────────────────────────────────────────────────────
56
+ const FETCH_TIMEOUT_MS = 15_000;
57
+ /** Maximum response body size we'll read (1 MB). Prevents OOM from malicious servers. */
58
+ const MAX_RESPONSE_BYTES = 1_024 * 1_024;
59
+ /**
60
+ * Read response as JSON with a body size limit to prevent OOM attacks.
61
+ * Returns null if body exceeds limit, is not JSON, or fails to parse.
62
+ */
63
+ async function safeReadJson(resp) {
64
+ const contentLength = resp.headers.get("content-length");
65
+ if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) {
66
+ try {
67
+ await resp.body?.cancel();
68
+ }
69
+ catch { /* ignore */ }
70
+ return null;
71
+ }
72
+ try {
73
+ const text = await resp.text();
74
+ if (text.length > MAX_RESPONSE_BYTES)
75
+ return null;
76
+ return JSON.parse(text);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ /**
83
+ * Normalize legacy `api` values to their canonical form.
84
+ * Also maps bare "openai" to "openai-completions" (Settings dropdown value).
85
+ */
86
+ function normalizeApi(api) {
87
+ if (!api)
88
+ return "openai-completions";
89
+ const mapped = LEGACY_PROVIDER_API_ALIASES[api];
90
+ if (mapped)
91
+ return mapped;
92
+ if (api === "openai")
93
+ return "openai-completions";
94
+ return api;
95
+ }
96
+ /**
97
+ * Determine provider ID for SSRF validation (same logic as routes/llm.ts).
98
+ */
99
+ function ssrfProviderId(providerId, api) {
100
+ if (providerId && LOCAL_PROVIDER_IDS.has(providerId))
101
+ return providerId;
102
+ return api === "ollama" ? "ollama" : (providerId || "remote-provider");
103
+ }
104
+ /**
105
+ * Perform SSRF validation on a URL. Returns true if the URL is safe.
106
+ * Throws only on hard rejections (private IP, link-local).
107
+ * Swallows DNS resolution failures (same pattern as probe.ts).
108
+ */
109
+ async function ssrfCheck(url, providerId) {
110
+ try {
111
+ await validateUpstreamUrl(url, providerId);
112
+ }
113
+ catch (err) {
114
+ const msg = String(err?.message || "");
115
+ // DNS resolution failure is acceptable — the real fetch will fail too
116
+ if (msg.includes("Cannot resolve provider hostname"))
117
+ return;
118
+ throw err;
119
+ }
120
+ }
121
+ /**
122
+ * Parse a models response body into a normalized array.
123
+ */
124
+ function parseModelsResponse(body) {
125
+ const list = Array.isArray(body?.data) ? body.data
126
+ : Array.isArray(body?.models) ? body.models
127
+ : Array.isArray(body) ? body
128
+ : [];
129
+ return list.map((m) => ({
130
+ id: String(m.id || m.model || ""),
131
+ name: m.name || m.id || undefined,
132
+ })).filter(m => m.id);
133
+ }
134
+ /**
135
+ * Map an HTTP status code or error to a ValidateKeyStatus.
136
+ */
137
+ function statusFromHttpCode(code) {
138
+ if (code === 401)
139
+ return "auth_failed";
140
+ if (code === 403)
141
+ return "forbidden";
142
+ if (code === 404)
143
+ return "unknown_error";
144
+ return "unknown_error";
145
+ }
146
+ // ── Protocol strategies ────────────────────────────────────────────────────
147
+ /**
148
+ * OpenAI-compatible: Bearer token + /v1/models with /models fallback.
149
+ */
150
+ async function validateOpenAI(baseUrl, apiKey, validationId) {
151
+ const base = baseUrl.replace(/\/+$/, "");
152
+ const headers = {
153
+ "Authorization": `Bearer ${apiKey}`,
154
+ };
155
+ for (const ep of ["/v1/models", "/models"]) {
156
+ const url = `${base}${ep}`;
157
+ try {
158
+ await ssrfCheck(url, validationId);
159
+ const resp = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
160
+ if (resp.ok) {
161
+ const body = await safeReadJson(resp);
162
+ const models = parseModelsResponse(body);
163
+ if (models.length === 0) {
164
+ return { status: "models_empty", message: "Provider returned empty model list" };
165
+ }
166
+ return { status: "ok", models };
167
+ }
168
+ // Non-OK: check if auth failure
169
+ if (resp.status === 401 || resp.status === 403) {
170
+ try {
171
+ await resp.body?.cancel();
172
+ }
173
+ catch { /* ignore */ }
174
+ return { status: statusFromHttpCode(resp.status), message: `HTTP ${resp.status}` };
175
+ }
176
+ // Other error (404, 5xx) — try next endpoint
177
+ try {
178
+ await resp.body?.cancel();
179
+ }
180
+ catch { /* ignore */ }
181
+ }
182
+ catch (err) {
183
+ const msg = String(err?.message || "");
184
+ if (msg.includes("private") || msg.includes("link-local")) {
185
+ return { status: "unreachable", message: msg };
186
+ }
187
+ if (err.name === "TimeoutError" || msg.includes("timeout")) {
188
+ return { status: "timeout", message: "Connection timed out" };
189
+ }
190
+ // Network error — try next endpoint
191
+ }
192
+ }
193
+ return { status: "unreachable", message: "Cannot reach provider" };
194
+ }
195
+ /**
196
+ * Anthropic dual-try strategy:
197
+ * 1. Try Bearer auth + /v1/models (gateways)
198
+ * 2. If 401/403 → retry with x-api-key (native Anthropic) + anthropic-version header
199
+ */
200
+ async function validateAnthropic(baseUrl, apiKey, validationId) {
201
+ const base = baseUrl.replace(/\/+$/, "");
202
+ const url = `${base}/v1/models`;
203
+ await ssrfCheck(url, validationId);
204
+ // Attempt 1: Bearer auth (works for gateways like MiniMax Intl)
205
+ try {
206
+ const resp = await fetch(url, {
207
+ headers: { "Authorization": `Bearer ${apiKey}` },
208
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
209
+ });
210
+ if (resp.ok) {
211
+ const body = await safeReadJson(resp);
212
+ const models = parseModelsResponse(body);
213
+ if (models.length === 0) {
214
+ return { status: "models_empty", message: "Provider returned empty model list" };
215
+ }
216
+ return { status: "ok", models };
217
+ }
218
+ try {
219
+ await resp.body?.cancel();
220
+ }
221
+ catch { /* ignore */ }
222
+ // If not auth failure, return the error (e.g. 500)
223
+ if (resp.status !== 401 && resp.status !== 403) {
224
+ return { status: statusFromHttpCode(resp.status), message: `HTTP ${resp.status}` };
225
+ }
226
+ }
227
+ catch (err) {
228
+ const msg = String(err?.message || "");
229
+ if (msg.includes("private") || msg.includes("link-local")) {
230
+ return { status: "unreachable", message: msg };
231
+ }
232
+ if (err.name === "TimeoutError" || msg.includes("timeout")) {
233
+ return { status: "timeout", message: "Connection timed out" };
234
+ }
235
+ // Network error on first try — still attempt native
236
+ }
237
+ // Attempt 2: Native Anthropic (x-api-key raw + anthropic-version)
238
+ try {
239
+ const resp = await fetch(url, {
240
+ headers: {
241
+ "x-api-key": apiKey,
242
+ "anthropic-version": "2023-06-01",
243
+ },
244
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
245
+ });
246
+ if (resp.ok) {
247
+ const body = await safeReadJson(resp);
248
+ const models = parseModelsResponse(body);
249
+ if (models.length === 0) {
250
+ return { status: "models_empty", message: "Provider returned empty model list" };
251
+ }
252
+ return { status: "ok", models };
253
+ }
254
+ try {
255
+ await resp.body?.cancel();
256
+ }
257
+ catch { /* ignore */ }
258
+ return { status: statusFromHttpCode(resp.status), message: `HTTP ${resp.status}` };
259
+ }
260
+ catch (err) {
261
+ const msg = String(err?.message || "");
262
+ if (err.name === "TimeoutError" || msg.includes("timeout")) {
263
+ return { status: "timeout", message: "Connection timed out" };
264
+ }
265
+ return { status: "unreachable", message: "Cannot reach provider" };
266
+ }
267
+ }
268
+ /**
269
+ * Google Generative AI: Bearer token + /v1beta/openai/models.
270
+ */
271
+ async function validateGoogle(baseUrl, apiKey, validationId) {
272
+ const base = baseUrl.replace(/\/+$/, "");
273
+ const url = `${base}/v1beta/openai/models`;
274
+ try {
275
+ await ssrfCheck(url, validationId);
276
+ const resp = await fetch(url, {
277
+ headers: { "Authorization": `Bearer ${apiKey}` },
278
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
279
+ });
280
+ if (resp.ok) {
281
+ const body = await safeReadJson(resp);
282
+ const models = parseModelsResponse(body);
283
+ if (models.length === 0) {
284
+ return { status: "models_empty", message: "Provider returned empty model list" };
285
+ }
286
+ return { status: "ok", models };
287
+ }
288
+ try {
289
+ await resp.body?.cancel();
290
+ }
291
+ catch { /* ignore */ }
292
+ if (resp.status === 401 || resp.status === 403) {
293
+ return { status: statusFromHttpCode(resp.status), message: `HTTP ${resp.status}` };
294
+ }
295
+ return { status: "unknown_error", message: `HTTP ${resp.status}` };
296
+ }
297
+ catch (err) {
298
+ const msg = String(err?.message || "");
299
+ if (msg.includes("private") || msg.includes("link-local")) {
300
+ return { status: "unreachable", message: msg };
301
+ }
302
+ if (err.name === "TimeoutError" || msg.includes("timeout")) {
303
+ return { status: "timeout", message: "Connection timed out" };
304
+ }
305
+ return { status: "unreachable", message: "Cannot reach provider" };
306
+ }
307
+ }
308
+ /**
309
+ * Ollama / local providers: No auth + /v1/models with /api/tags fallback.
310
+ */
311
+ async function validateOllama(baseUrl, validationId) {
312
+ const base = baseUrl.replace(/\/+$/, "");
313
+ for (const ep of ["/v1/models", "/api/tags"]) {
314
+ const url = `${base}${ep}`;
315
+ try {
316
+ await ssrfCheck(url, validationId);
317
+ const resp = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
318
+ if (resp.ok) {
319
+ const body = await safeReadJson(resp);
320
+ const models = parseModelsResponse(body);
321
+ if (models.length === 0) {
322
+ return { status: "models_empty", message: "No models available" };
323
+ }
324
+ return { status: "ok", models };
325
+ }
326
+ try {
327
+ await resp.body?.cancel();
328
+ }
329
+ catch { /* ignore */ }
330
+ // 404 on first endpoint → try fallback
331
+ if (resp.status === 404)
332
+ continue;
333
+ return { status: statusFromHttpCode(resp.status), message: `HTTP ${resp.status}` };
334
+ }
335
+ catch (err) {
336
+ const msg = String(err?.message || "");
337
+ if (msg.includes("private") || msg.includes("link-local")) {
338
+ return { status: "unreachable", message: msg };
339
+ }
340
+ if (err.name === "TimeoutError" || msg.includes("timeout")) {
341
+ return { status: "timeout", message: "Connection timed out" };
342
+ }
343
+ // Network error — try next endpoint
344
+ }
345
+ }
346
+ return { status: "unreachable", message: "Cannot reach provider" };
347
+ }
348
+ // ── Completion validation strategies ───────────────────────────────────────
349
+ const COMPLETION_BODY_BASE = {
350
+ messages: [{ role: "user", content: "hi" }],
351
+ max_tokens: 1,
352
+ stream: false,
353
+ };
354
+ /**
355
+ * Parse a provider error response to extract meaningful status.
356
+ * Only reads bounded JSON for classification — never returns content.
357
+ */
358
+ function classifyCompletionError(status, body) {
359
+ const code = String(body?.error?.code || body?.error?.type || "");
360
+ const msg = String(body?.error?.message || body?.message || "");
361
+ // Quota / billing errors
362
+ if (status === 402 || code === "insufficient_quota" || code === "billing_hard_limit_reached"
363
+ || msg.includes("quota") || msg.includes("billing") || msg.includes("credit")
364
+ || msg.includes("exceeded") || code === "rate_limit_exceeded") {
365
+ return { status: "quota_exceeded", message: msg || "Quota or credits exhausted" };
366
+ }
367
+ // Model not found
368
+ if (status === 404 || code === "model_not_found" || code === "not_found_error"
369
+ || msg.includes("does not exist") || msg.includes("not found")) {
370
+ return { status: "model_not_found", message: msg || "Model not found" };
371
+ }
372
+ // Auth failures
373
+ if (status === 401)
374
+ return { status: "auth_failed", message: msg || `HTTP ${status}` };
375
+ if (status === 403)
376
+ return { status: "forbidden", message: msg || `HTTP ${status}` };
377
+ // Rate limit from provider (different from our own rate limit).
378
+ // Distinguish temporary rate limits from persistent quota exhaustion.
379
+ if (status === 429) {
380
+ const isQuota = msg.includes("quota") || msg.includes("billing")
381
+ || msg.includes("credit") || msg.includes("exceeded")
382
+ || code === "insufficient_quota" || code === "billing_hard_limit_reached";
383
+ if (isQuota) {
384
+ return { status: "quota_exceeded", message: msg || "Quota exhausted" };
385
+ }
386
+ return { status: "quota_exceeded", message: msg || "Rate limited by provider (retry later)" };
387
+ }
388
+ return { status: "unknown_error", message: msg || `HTTP ${status}` };
389
+ }
390
+ /**
391
+ * OpenAI-compatible completion: POST {base}/chat/completions with path fallback.
392
+ */
393
+ async function deepValidateOpenAI(baseUrl, apiKey, modelId, validationId) {
394
+ const base = baseUrl.replace(/\/+$/, "");
395
+ const headers = {
396
+ "Authorization": `Bearer ${apiKey}`,
397
+ "Content-Type": "application/json",
398
+ };
399
+ const body = JSON.stringify({ ...COMPLETION_BODY_BASE, model: modelId });
400
+ // Try /v1/chat/completions then /chat/completions (avoid double /v1)
401
+ const endpoints = base.endsWith("/v1") || base.includes("/v1/")
402
+ ? ["/chat/completions"]
403
+ : ["/v1/chat/completions", "/chat/completions"];
404
+ for (const ep of endpoints) {
405
+ const url = `${base}${ep}`;
406
+ try {
407
+ await ssrfCheck(url, validationId);
408
+ const resp = await fetch(url, {
409
+ method: "POST",
410
+ headers,
411
+ body,
412
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
413
+ });
414
+ if (resp.ok) {
415
+ // Discard response content — we only care that it succeeded
416
+ try {
417
+ await resp.body?.cancel();
418
+ }
419
+ catch { /* ignore */ }
420
+ return { status: "ok", message: "Completion request succeeded" };
421
+ }
422
+ // Parse bounded error body for classification
423
+ let errBody;
424
+ errBody = await safeReadJson(resp) ?? {};
425
+ // On 404: only fallback to next endpoint if it's a generic "not found"
426
+ // (endpoint doesn't exist). If the error body indicates model-specific
427
+ // issues, return immediately.
428
+ if (resp.status === 404 && endpoints.length > 1) {
429
+ const code = String(errBody?.error?.code || errBody?.error?.type || "");
430
+ const msg = String(errBody?.error?.message || "");
431
+ if (code === "model_not_found" || code === "not_found_error"
432
+ || msg.includes("does not exist") || msg.includes("not found")) {
433
+ return classifyCompletionError(resp.status, errBody);
434
+ }
435
+ continue; // generic 404 — try fallback endpoint
436
+ }
437
+ return classifyCompletionError(resp.status, errBody);
438
+ }
439
+ catch (err) {
440
+ const msg = String(err?.message || "");
441
+ if (msg.includes("private") || msg.includes("link-local")) {
442
+ return { status: "unreachable", message: msg };
443
+ }
444
+ if (err.name === "TimeoutError" || msg.includes("timeout")) {
445
+ return { status: "timeout", message: "Connection timed out" };
446
+ }
447
+ }
448
+ }
449
+ return { status: "unreachable", message: "Cannot reach provider" };
450
+ }
451
+ /**
452
+ * Anthropic completion: POST {base}/v1/messages with dual auth strategy.
453
+ */
454
+ async function deepValidateAnthropic(baseUrl, apiKey, modelId, validationId) {
455
+ const base = baseUrl.replace(/\/+$/, "");
456
+ const endpoint = base.endsWith("/v1") || base.includes("/v1/")
457
+ ? "/messages"
458
+ : "/v1/messages";
459
+ const url = `${base}${endpoint}`;
460
+ const body = JSON.stringify({
461
+ model: modelId,
462
+ messages: [{ role: "user", content: "hi" }],
463
+ max_tokens: 1,
464
+ });
465
+ await ssrfCheck(url, validationId);
466
+ // Attempt 1: Bearer auth (gateways)
467
+ try {
468
+ const resp = await fetch(url, {
469
+ method: "POST",
470
+ headers: {
471
+ "Authorization": `Bearer ${apiKey}`,
472
+ "Content-Type": "application/json",
473
+ "anthropic-version": "2023-06-01",
474
+ },
475
+ body,
476
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
477
+ });
478
+ if (resp.ok) {
479
+ try {
480
+ await resp.body?.cancel();
481
+ }
482
+ catch { /* ignore */ }
483
+ return { status: "ok", message: "Completion request succeeded" };
484
+ }
485
+ let errBody;
486
+ errBody = await safeReadJson(resp) ?? {};
487
+ if (resp.status !== 401 && resp.status !== 403) {
488
+ return classifyCompletionError(resp.status, errBody);
489
+ }
490
+ }
491
+ catch (err) {
492
+ const msg = String(err?.message || "");
493
+ if (msg.includes("private") || msg.includes("link-local")) {
494
+ return { status: "unreachable", message: msg };
495
+ }
496
+ if (err.name === "TimeoutError" || msg.includes("timeout")) {
497
+ return { status: "timeout", message: "Connection timed out" };
498
+ }
499
+ }
500
+ // Attempt 2: Native x-api-key
501
+ try {
502
+ const resp = await fetch(url, {
503
+ method: "POST",
504
+ headers: {
505
+ "x-api-key": apiKey,
506
+ "Content-Type": "application/json",
507
+ "anthropic-version": "2023-06-01",
508
+ },
509
+ body,
510
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
511
+ });
512
+ if (resp.ok) {
513
+ try {
514
+ await resp.body?.cancel();
515
+ }
516
+ catch { /* ignore */ }
517
+ return { status: "ok", message: "Completion request succeeded" };
518
+ }
519
+ let errBody;
520
+ errBody = await safeReadJson(resp) ?? {};
521
+ return classifyCompletionError(resp.status, errBody);
522
+ }
523
+ catch (err) {
524
+ const msg = String(err?.message || "");
525
+ if (err.name === "TimeoutError" || msg.includes("timeout")) {
526
+ return { status: "timeout", message: "Connection timed out" };
527
+ }
528
+ return { status: "unreachable", message: "Cannot reach provider" };
529
+ }
530
+ }
531
+ /**
532
+ * Google Generative AI completion: POST {base}/v1beta/openai/chat/completions.
533
+ */
534
+ async function deepValidateGoogle(baseUrl, apiKey, modelId, validationId) {
535
+ const base = baseUrl.replace(/\/+$/, "");
536
+ const endpoint = base.includes("/v1beta/openai")
537
+ ? "/chat/completions"
538
+ : "/v1beta/openai/chat/completions";
539
+ const url = `${base}${endpoint}`;
540
+ try {
541
+ await ssrfCheck(url, validationId);
542
+ const resp = await fetch(url, {
543
+ method: "POST",
544
+ headers: {
545
+ "Authorization": `Bearer ${apiKey}`,
546
+ "Content-Type": "application/json",
547
+ },
548
+ body: JSON.stringify({ ...COMPLETION_BODY_BASE, model: modelId }),
549
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
550
+ });
551
+ if (resp.ok) {
552
+ try {
553
+ await resp.body?.cancel();
554
+ }
555
+ catch { /* ignore */ }
556
+ return { status: "ok", message: "Completion request succeeded" };
557
+ }
558
+ let errBody;
559
+ errBody = await safeReadJson(resp) ?? {};
560
+ return classifyCompletionError(resp.status, errBody);
561
+ }
562
+ catch (err) {
563
+ const msg = String(err?.message || "");
564
+ if (msg.includes("private") || msg.includes("link-local")) {
565
+ return { status: "unreachable", message: msg };
566
+ }
567
+ if (err.name === "TimeoutError" || msg.includes("timeout")) {
568
+ return { status: "timeout", message: "Connection timed out" };
569
+ }
570
+ return { status: "unreachable", message: "Cannot reach provider" };
571
+ }
572
+ }
573
+ /**
574
+ * Ollama completion: POST {base}/v1/chat/completions (no auth).
575
+ */
576
+ async function deepValidateOllama(baseUrl, modelId, validationId) {
577
+ const base = baseUrl.replace(/\/+$/, "");
578
+ const endpoints = base.endsWith("/v1") || base.includes("/v1/")
579
+ ? ["/chat/completions"]
580
+ : ["/v1/chat/completions", "/api/chat"];
581
+ for (const ep of endpoints) {
582
+ const url = `${base}${ep}`;
583
+ try {
584
+ await ssrfCheck(url, validationId);
585
+ const body = ep === "/api/chat"
586
+ ? JSON.stringify({ model: modelId, messages: [{ role: "user", content: "hi" }], stream: false })
587
+ : JSON.stringify({ ...COMPLETION_BODY_BASE, model: modelId });
588
+ const resp = await fetch(url, {
589
+ method: "POST",
590
+ headers: { "Content-Type": "application/json" },
591
+ body,
592
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
593
+ });
594
+ if (resp.ok) {
595
+ try {
596
+ await resp.body?.cancel();
597
+ }
598
+ catch { /* ignore */ }
599
+ return { status: "ok", message: "Completion request succeeded" };
600
+ }
601
+ let errBody;
602
+ errBody = await safeReadJson(resp) ?? {};
603
+ if (resp.status === 404 && endpoints.length > 1)
604
+ continue;
605
+ return classifyCompletionError(resp.status, errBody);
606
+ }
607
+ catch (err) {
608
+ const msg = String(err?.message || "");
609
+ if (msg.includes("private") || msg.includes("link-local")) {
610
+ return { status: "unreachable", message: msg };
611
+ }
612
+ if (err.name === "TimeoutError" || msg.includes("timeout")) {
613
+ return { status: "timeout", message: "Connection timed out" };
614
+ }
615
+ }
616
+ }
617
+ return { status: "unreachable", message: "Cannot reach provider" };
618
+ }
619
+ /**
620
+ * Validate an API key by probing the provider's models endpoint using the
621
+ * correct protocol-aware authentication strategy.
622
+ *
623
+ * When mode is "completion", sends a 1-token completion request to verify
624
+ * the key can actually perform inference (catches quota/credit issues).
625
+ */
626
+ export async function validateApiKey(params) {
627
+ const { baseUrl, apiKey, providerId, api, authHeader, mode = "models", modelId } = params;
628
+ if (!baseUrl) {
629
+ return { status: "unknown_error", message: "baseUrl is required" };
630
+ }
631
+ const base = baseUrl.replace(/\/+$/, "");
632
+ try {
633
+ const parsed = new URL(base);
634
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
635
+ return { status: "unknown_error", message: "URL must use http:// or https://" };
636
+ }
637
+ }
638
+ catch {
639
+ return { status: "unknown_error", message: "Invalid URL" };
640
+ }
641
+ const normalizedApi = normalizeApi(api);
642
+ const validationId = ssrfProviderId(providerId, normalizedApi);
643
+ // Completion mode: send a 1-token request
644
+ if (mode === "completion") {
645
+ if (!modelId) {
646
+ return { status: "unknown_error", message: "modelId is required for completion validation" };
647
+ }
648
+ switch (normalizedApi) {
649
+ case "anthropic-messages":
650
+ return deepValidateAnthropic(base, apiKey, modelId, validationId);
651
+ case "google-generative-ai":
652
+ return deepValidateGoogle(base, apiKey, modelId, validationId);
653
+ case "ollama":
654
+ return deepValidateOllama(base, modelId, validationId);
655
+ default:
656
+ return deepValidateOpenAI(base, apiKey, modelId, validationId);
657
+ }
658
+ }
659
+ // Models mode (default): probe /models endpoint
660
+ switch (normalizedApi) {
661
+ case "anthropic-messages":
662
+ return validateAnthropic(base, apiKey, validationId);
663
+ case "google-generative-ai":
664
+ return validateGoogle(base, apiKey, validationId);
665
+ case "ollama":
666
+ return validateOllama(base, validationId);
667
+ default:
668
+ // openai-completions and all unknown protocols → OpenAI-compat strategy
669
+ return validateOpenAI(base, apiKey, validationId);
670
+ }
671
+ }
672
+ //# sourceMappingURL=validate-key.js.map