@freesyntax/notch-cli 0.5.19 → 0.5.21

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 (42) hide show
  1. package/dist/{apply-patch-EBZ5VLO7.js → apply-patch-D5PDUXUC.js} +0 -1
  2. package/dist/{auth-S3FIB42I.js → auth-JQX6MHJG.js} +0 -1
  3. package/dist/builtins/archimedes.toml +18 -0
  4. package/dist/builtins/awaiter.toml +18 -0
  5. package/dist/builtins/euclid.toml +18 -0
  6. package/dist/builtins/hypatia.toml +18 -0
  7. package/dist/builtins/kepler.toml +18 -0
  8. package/dist/builtins/plato.toml +18 -0
  9. package/dist/builtins/ptolemy.toml +18 -0
  10. package/dist/builtins/pythagoras.toml +18 -0
  11. package/dist/chunk-443G6HCC.js +543 -0
  12. package/dist/chunk-GFVLHUSS.js +155 -0
  13. package/dist/chunk-MMBFNIKE.js +509 -0
  14. package/dist/chunk-OSWUX6TC.js +167 -0
  15. package/dist/{chunk-6M6CXXWR.js → chunk-PKZKVOAN.js} +209 -1
  16. package/dist/chunk-QKM27RHS.js +198 -0
  17. package/dist/chunk-TU465P2P.js +3106 -0
  18. package/dist/compression-SQAIQ2UU.js +32 -0
  19. package/dist/{edit-FXWXOFAF.js → edit-JEFEK43H.js} +0 -1
  20. package/dist/{git-XVWI2BT7.js → git-5T5TSQTX.js} +0 -1
  21. package/dist/{github-DOZ2MVQE.js → github-DWRGWX6U.js} +0 -1
  22. package/dist/{glob-XSBN4MDB.js → glob-BI3P4C7Q.js} +0 -1
  23. package/dist/{grep-2A42QPWM.js → grep-VZ3I5GNW.js} +0 -1
  24. package/dist/index.js +5049 -1113
  25. package/dist/{lsp-WUEGBQ3F.js → lsp-UPY6I3L7.js} +0 -1
  26. package/dist/{notebook-5U6PAF6M.js → notebook-FXJBTSPA.js} +0 -1
  27. package/dist/ollama-bench-QQHBIG2D.js +190 -0
  28. package/dist/ollama-launch-2ASVER3S.js +18 -0
  29. package/dist/ollama-usage-2WPCZJJI.js +69 -0
  30. package/dist/{plugins-GJIUZCJ5.js → plugins-OG2P75K5.js} +0 -1
  31. package/dist/{read-LY2VGCZY.js → read-OVJG2XKW.js} +0 -1
  32. package/dist/{server-4JRQH3DT.js → server-7UQKCB2Z.js} +15 -17
  33. package/dist/session-index-SSGOOZXK.js +21 -0
  34. package/dist/{shell-RGXMLRLH.js → shell-4X545EVN.js} +0 -1
  35. package/dist/{task-VIJ3N5EB.js → task-OS3E5F3X.js} +0 -1
  36. package/dist/{tools-XKVTMNR5.js → tools-7WAWS6V4.js} +3 -2
  37. package/dist/{web-fetch-XOH5PUCP.js → web-fetch-KNIV3Z3W.js} +0 -1
  38. package/dist/{write-DOLDW7HM.js → write-NNHLOTYK.js} +0 -1
  39. package/package.json +6 -4
  40. package/dist/chunk-3RG5ZIWI.js +0 -10
  41. package/dist/chunk-75K7DQVI.js +0 -630
  42. package/dist/compression-LPFNGAV6.js +0 -17
@@ -0,0 +1,543 @@
1
+ // src/providers/byok.ts
2
+ import { createOpenAI } from "@ai-sdk/openai";
3
+ import { createAnthropic } from "@ai-sdk/anthropic";
4
+ var BUILTIN_BYOK_PROVIDERS = [
5
+ {
6
+ id: "openai",
7
+ label: "OpenAI",
8
+ baseUrl: "https://api.openai.com/v1",
9
+ apiKeyEnv: "OPENAI_API_KEY",
10
+ defaultModel: "gpt-4o",
11
+ models: [
12
+ "gpt-4o",
13
+ "gpt-4o-mini",
14
+ "gpt-4-turbo",
15
+ "gpt-4.1",
16
+ "gpt-4.1-mini",
17
+ "gpt-5",
18
+ "gpt-5-mini",
19
+ "o3-mini",
20
+ "o4-mini"
21
+ ],
22
+ compatibility: "strict"
23
+ },
24
+ {
25
+ id: "anthropic",
26
+ label: "Anthropic (Claude)",
27
+ // Anthropic's OpenAI compat lives at /v1/ (root), not /v1/openai.
28
+ // See https://platform.claude.com/docs/en/api/openai-sdk
29
+ baseUrl: "https://api.anthropic.com/v1",
30
+ apiKeyEnv: "ANTHROPIC_API_KEY",
31
+ defaultModel: "claude-sonnet-4-6",
32
+ models: [
33
+ "claude-opus-4-7",
34
+ "claude-sonnet-4-6",
35
+ "claude-haiku-4-5"
36
+ ],
37
+ compatibility: "compatible",
38
+ // anthropic-version is optional on the OpenAI compat layer, but we
39
+ // pin it so behaviour is deterministic across CLI releases.
40
+ headers: {
41
+ "anthropic-version": "2023-06-01"
42
+ }
43
+ },
44
+ {
45
+ id: "openrouter",
46
+ label: "OpenRouter",
47
+ baseUrl: "https://openrouter.ai/api/v1",
48
+ apiKeyEnv: "OPENROUTER_API_KEY",
49
+ defaultModel: "anthropic/claude-sonnet-4-6",
50
+ // OpenRouter exposes hundreds of models — leave undefined and let
51
+ // users discover via openrouter.ai/models.
52
+ compatibility: "strict",
53
+ headers: {
54
+ "HTTP-Referer": "https://driftrail.com/notch",
55
+ "X-Title": "Notch CLI"
56
+ }
57
+ },
58
+ {
59
+ id: "together",
60
+ label: "Together AI",
61
+ baseUrl: "https://api.together.xyz/v1",
62
+ apiKeyEnv: "TOGETHER_API_KEY",
63
+ defaultModel: "meta-llama/Llama-4-70B-Instruct",
64
+ models: [
65
+ "meta-llama/Llama-4-70B-Instruct",
66
+ "meta-llama/Llama-4-8B-Instruct",
67
+ "meta-llama/Llama-3.3-70B-Instruct-Turbo",
68
+ "Qwen/Qwen2.5-72B-Instruct-Turbo",
69
+ "Qwen/QwQ-32B-Preview",
70
+ "deepseek-ai/DeepSeek-V3",
71
+ "mistralai/Mixtral-8x22B-Instruct-v0.1"
72
+ ],
73
+ compatibility: "strict"
74
+ },
75
+ {
76
+ id: "fireworks",
77
+ label: "Fireworks AI",
78
+ baseUrl: "https://api.fireworks.ai/inference/v1",
79
+ apiKeyEnv: "FIREWORKS_API_KEY",
80
+ defaultModel: "accounts/fireworks/models/llama-v4-70b-instruct",
81
+ models: [
82
+ "accounts/fireworks/models/llama-v4-70b-instruct",
83
+ "accounts/fireworks/models/llama-v3p3-70b-instruct",
84
+ "accounts/fireworks/models/qwen2p5-72b-instruct",
85
+ "accounts/fireworks/models/deepseek-v3",
86
+ "accounts/fireworks/models/mixtral-8x22b-instruct"
87
+ ],
88
+ compatibility: "strict"
89
+ },
90
+ {
91
+ id: "groq",
92
+ label: "Groq",
93
+ baseUrl: "https://api.groq.com/openai/v1",
94
+ apiKeyEnv: "GROQ_API_KEY",
95
+ defaultModel: "llama-4-70b-8192",
96
+ models: [
97
+ "llama-4-70b-8192",
98
+ "llama-3.3-70b-versatile",
99
+ "llama-3.1-8b-instant",
100
+ "mixtral-8x7b-32768",
101
+ "gemma2-9b-it",
102
+ "qwen-qwq-32b"
103
+ ],
104
+ compatibility: "strict"
105
+ },
106
+ {
107
+ id: "ollama",
108
+ label: "Ollama (local)",
109
+ baseUrl: "http://localhost:11434/v1",
110
+ apiKeyEnv: "OLLAMA_API_KEY",
111
+ defaultModel: "llama3.2:latest",
112
+ // Ollama ignores the api key but openai-compat clients require a
113
+ // non-empty string. Default to the literal "ollama" per their docs.
114
+ fallbackApiKey: "ollama",
115
+ compatibility: "compatible",
116
+ apiShape: "openai"
117
+ },
118
+ {
119
+ // Same daemon, Anthropic Messages API shape. Ollama v0.14+ serves
120
+ // /v1/messages at the root (no /v1 suffix on the baseUrl because the
121
+ // Anthropic SDK owns the path). Higher tool-calling fidelity for
122
+ // Claude-style agents.
123
+ id: "ollama-anthropic",
124
+ label: "Ollama (local, Anthropic-compat)",
125
+ baseUrl: "http://localhost:11434",
126
+ apiKeyEnv: "OLLAMA_API_KEY",
127
+ defaultModel: "qwen3-coder:30b",
128
+ fallbackApiKey: "ollama",
129
+ apiShape: "anthropic"
130
+ },
131
+ {
132
+ // Ollama Cloud: same endpoint surface as local, hosted on ollama.com.
133
+ // Requires OLLAMA_API_KEY from https://ollama.com/settings/keys.
134
+ id: "ollama-cloud",
135
+ label: "Ollama Cloud",
136
+ baseUrl: "https://ollama.com/v1",
137
+ apiKeyEnv: "OLLAMA_API_KEY",
138
+ defaultModel: "gpt-oss:120b",
139
+ models: [
140
+ "gpt-oss:120b",
141
+ "gpt-oss:20b",
142
+ "kimi-k2.5:cloud",
143
+ "glm-5:cloud",
144
+ "qwen3.5:cloud",
145
+ "deepseek-v3.1:671b",
146
+ "minimax-m2.7:cloud"
147
+ ],
148
+ compatibility: "compatible",
149
+ apiShape: "openai"
150
+ },
151
+ {
152
+ id: "lmstudio",
153
+ label: "LM Studio (local)",
154
+ baseUrl: "http://localhost:1234/v1",
155
+ apiKeyEnv: "",
156
+ defaultModel: "local-model",
157
+ fallbackApiKey: "lm-studio",
158
+ compatibility: "compatible"
159
+ },
160
+ {
161
+ id: "vllm",
162
+ label: "vLLM (local)",
163
+ baseUrl: "http://localhost:8000/v1",
164
+ apiKeyEnv: "",
165
+ defaultModel: "local-vllm",
166
+ fallbackApiKey: "EMPTY",
167
+ compatibility: "strict"
168
+ },
169
+ {
170
+ id: "__custom__",
171
+ label: "Custom (user-supplied)",
172
+ // User MUST supply --base-url. These defaults are placeholders that
173
+ // will fail fast if the user forgets the flag.
174
+ baseUrl: "",
175
+ apiKeyEnv: "",
176
+ defaultModel: "",
177
+ compatibility: "compatible"
178
+ }
179
+ ];
180
+ var BYOK_BY_ID = Object.fromEntries(
181
+ BUILTIN_BYOK_PROVIDERS.map((p) => [p.id, p])
182
+ );
183
+ function findByokProvider(id) {
184
+ return BYOK_BY_ID[id];
185
+ }
186
+ function listByokProviders() {
187
+ return BUILTIN_BYOK_PROVIDERS.filter((p) => p.id !== "__custom__");
188
+ }
189
+ function isByokRef(model) {
190
+ return model.includes(":") && !isOllamaTagOnly(model);
191
+ }
192
+ function isOllamaTagOnly(model) {
193
+ const [head] = model.split(":");
194
+ if (!head) return false;
195
+ return !BYOK_BY_ID[head];
196
+ }
197
+ function parseByokRef(ref) {
198
+ const colon = ref.indexOf(":");
199
+ if (colon < 0) {
200
+ throw new Error(`BYOK ref "${ref}" is missing a colon separator.`);
201
+ }
202
+ const rawProvider = ref.slice(0, colon);
203
+ const model = ref.slice(colon + 1);
204
+ const provider = rawProvider === "custom" ? "__custom__" : rawProvider;
205
+ return { provider, model };
206
+ }
207
+ var ByokMissingApiKeyError = class extends Error {
208
+ constructor(provider) {
209
+ super(
210
+ provider.apiKeyEnv ? `Missing API key for ${provider.label}. Set ${provider.apiKeyEnv} or pass --api-key.` : `Missing API key for ${provider.label}. Pass --api-key.`
211
+ );
212
+ this.provider = provider;
213
+ this.name = "ByokMissingApiKeyError";
214
+ }
215
+ provider;
216
+ };
217
+ var ByokMissingBaseUrlError = class extends Error {
218
+ constructor() {
219
+ super(
220
+ "Custom BYOK provider requires --base-url (and usually --api-key). Example: notch --provider custom --base-url https://my.endpoint/v1 --model my-model"
221
+ );
222
+ this.name = "ByokMissingBaseUrlError";
223
+ }
224
+ };
225
+ function resolveByokModel(spec) {
226
+ const providerInfo = findByokProvider(spec.provider);
227
+ if (!providerInfo) {
228
+ throw new Error(
229
+ `Unknown BYOK provider "${spec.provider}". Available: ${listByokProviders().map((p) => p.id).join(", ")}, custom`
230
+ );
231
+ }
232
+ if (providerInfo.id === "__custom__" && !spec.baseUrl) {
233
+ throw new ByokMissingBaseUrlError();
234
+ }
235
+ const baseUrl = spec.baseUrl ?? providerInfo.baseUrl;
236
+ const modelId = spec.model && spec.model.length > 0 ? spec.model : providerInfo.defaultModel;
237
+ if (!modelId) {
238
+ throw new Error(
239
+ `BYOK provider "${providerInfo.id}" has no default model. Pass --model or set byok.model in .notch.json.`
240
+ );
241
+ }
242
+ let apiKey = spec.apiKey;
243
+ if (!apiKey && providerInfo.apiKeyEnv) {
244
+ apiKey = process.env[providerInfo.apiKeyEnv];
245
+ }
246
+ if (!apiKey && providerInfo.fallbackApiKey) {
247
+ apiKey = providerInfo.fallbackApiKey;
248
+ }
249
+ if (!apiKey && !providerInfo.apiKeyEnv) {
250
+ apiKey = "not-needed";
251
+ }
252
+ if (!apiKey) {
253
+ throw new ByokMissingApiKeyError(providerInfo);
254
+ }
255
+ const headers = {
256
+ ...providerInfo.headers ?? {},
257
+ ...spec.headers ?? {}
258
+ };
259
+ const shape = spec.apiShape ?? providerInfo.apiShape ?? "openai";
260
+ if (shape === "anthropic") {
261
+ const anthropic = createAnthropic({
262
+ apiKey,
263
+ baseURL: baseUrl,
264
+ headers
265
+ });
266
+ return {
267
+ model: anthropic(modelId),
268
+ providerInfo,
269
+ modelId
270
+ };
271
+ }
272
+ const provider = createOpenAI({
273
+ apiKey,
274
+ baseURL: baseUrl,
275
+ headers,
276
+ // Anthropic's compat layer and some shims reject unknown fields —
277
+ // `compatibility: 'compatible'` tells @ai-sdk/openai to stick to the
278
+ // lowest-common-denominator feature set.
279
+ compatibility: providerInfo.compatibility ?? "compatible"
280
+ });
281
+ return {
282
+ model: provider(modelId),
283
+ providerInfo,
284
+ modelId
285
+ };
286
+ }
287
+ async function validateByokConfig(spec) {
288
+ const providerInfo = findByokProvider(spec.provider);
289
+ if (!providerInfo) {
290
+ return { ok: false, error: `Unknown BYOK provider "${spec.provider}"` };
291
+ }
292
+ if (providerInfo.id === "__custom__" && !spec.baseUrl) {
293
+ return { ok: false, error: "Custom provider requires --base-url" };
294
+ }
295
+ const baseUrl = spec.baseUrl ?? providerInfo.baseUrl;
296
+ if (!baseUrl) {
297
+ return { ok: false, error: `No base URL for ${providerInfo.label}` };
298
+ }
299
+ const apiKey = spec.apiKey ?? (providerInfo.apiKeyEnv ? process.env[providerInfo.apiKeyEnv] : void 0) ?? providerInfo.fallbackApiKey;
300
+ if (providerInfo.apiKeyEnv && !apiKey) {
301
+ return {
302
+ ok: false,
303
+ error: `${providerInfo.label}: set ${providerInfo.apiKeyEnv} or pass --api-key`
304
+ };
305
+ }
306
+ return { ok: true };
307
+ }
308
+
309
+ // src/providers/registry.ts
310
+ import { createOpenAI as createOpenAI2 } from "@ai-sdk/openai";
311
+ var MissingApiKeyError = class extends Error {
312
+ /** Which flow caused this error (informs the onboarding message). */
313
+ flow;
314
+ /** Env var name the user can set to fix it. */
315
+ envVar;
316
+ /** Human-friendly provider label (e.g. "OpenRouter"). */
317
+ providerLabel;
318
+ constructor(opts) {
319
+ const flow = opts?.flow ?? "notch";
320
+ const envVar = opts?.envVar ?? "NOTCH_API_KEY";
321
+ const providerLabel = opts?.providerLabel ?? "Notch";
322
+ super(`${envVar} is not set (${providerLabel})`);
323
+ this.name = "MissingApiKeyError";
324
+ this.flow = flow;
325
+ this.envVar = envVar;
326
+ this.providerLabel = providerLabel;
327
+ }
328
+ };
329
+ var MODEL_CATALOG = {
330
+ "notch-pyre": {
331
+ id: "notch-pyre",
332
+ label: "Pyre",
333
+ size: "9B",
334
+ gpu: "L40S",
335
+ contextWindow: 131072,
336
+ maxOutputTokens: 16384,
337
+ baseUrl: "https://acemagnifique--notch-serve-pyre-notchpyreserver-serve.modal.run/v1"
338
+ },
339
+ "notch-ignis": {
340
+ id: "notch-ignis",
341
+ label: "Ignis",
342
+ size: "27B",
343
+ gpu: "A100-80GB",
344
+ contextWindow: 131072,
345
+ maxOutputTokens: 16384,
346
+ baseUrl: "https://acemagnifique--notch-serve-ignis-notchignisserver-serve.modal.run/v1"
347
+ },
348
+ "notch-solace": {
349
+ id: "notch-solace",
350
+ label: "Solace",
351
+ size: "31B",
352
+ gpu: "A100-80GB",
353
+ contextWindow: 131072,
354
+ maxOutputTokens: 16384,
355
+ baseUrl: "https://acemagnifique--notch-serve-solace-notchsolaceserver-serve.modal.run/v1"
356
+ },
357
+ "notch-solace-lite": {
358
+ id: "notch-solace-lite",
359
+ label: "Solace Lite",
360
+ size: "E4B",
361
+ gpu: "L4",
362
+ contextWindow: 65536,
363
+ maxOutputTokens: 8192,
364
+ baseUrl: "https://acemagnifique--notch-serve-solace-lite-notchsolacelitese-0e4da6.modal.run/v1"
365
+ }
366
+ };
367
+ var MODEL_IDS = Object.keys(MODEL_CATALOG);
368
+ function isValidModel(id) {
369
+ return id in MODEL_CATALOG;
370
+ }
371
+ function modelSupportsImages(modelId) {
372
+ if (!modelId) return false;
373
+ if (modelId in MODEL_CATALOG) return true;
374
+ if (modelId.startsWith("notch-")) return true;
375
+ const prefix = modelId.split(":", 1)[0]?.toLowerCase() ?? "";
376
+ switch (prefix) {
377
+ case "openai":
378
+ case "anthropic":
379
+ case "google":
380
+ case "together":
381
+ case "openrouter":
382
+ return true;
383
+ case "groq":
384
+ return false;
385
+ default:
386
+ return false;
387
+ }
388
+ }
389
+ function resolveModel(config) {
390
+ if (config.byokProvider) {
391
+ const resolved = resolveByokModel({
392
+ provider: config.byokProvider,
393
+ model: typeof config.model === "string" && config.model.length > 0 ? config.model : void 0,
394
+ apiKey: config.apiKey,
395
+ baseUrl: config.baseUrl,
396
+ headers: { ...config.headers, ...config.byokHeaders },
397
+ apiShape: config.byokApiShape
398
+ });
399
+ return resolved.model;
400
+ }
401
+ if (typeof config.model === "string" && isByokRef(config.model)) {
402
+ const { provider: provider2, model } = parseByokRef(config.model);
403
+ const resolved = resolveByokModel({
404
+ provider: provider2,
405
+ model,
406
+ apiKey: config.apiKey,
407
+ baseUrl: config.baseUrl,
408
+ headers: { ...config.headers, ...config.byokHeaders },
409
+ apiShape: config.byokApiShape
410
+ });
411
+ return resolved.model;
412
+ }
413
+ const info = MODEL_CATALOG[config.model];
414
+ if (!info) {
415
+ throw new Error(
416
+ `Unknown model "${config.model}". Notch models: ${MODEL_IDS.join(", ")}. For BYOK use "<provider>:<model>" (e.g. openrouter:anthropic/claude-sonnet-4-6).`
417
+ );
418
+ }
419
+ const baseUrl = config.baseUrl ?? process.env.NOTCH_BASE_URL ?? info.baseUrl;
420
+ const apiKey = config.apiKey ?? process.env.NOTCH_API_KEY;
421
+ if (!apiKey) {
422
+ throw new MissingApiKeyError({ flow: "notch", envVar: "NOTCH_API_KEY", providerLabel: "Notch" });
423
+ }
424
+ const proxyKey = process.env.MODAL_PROXY_KEY;
425
+ const proxySecret = process.env.MODAL_PROXY_SECRET;
426
+ const modalProxyHeaders = proxyKey && proxySecret ? { "Modal-Key": proxyKey, "Modal-Secret": proxySecret } : {};
427
+ const provider = createOpenAI2({
428
+ apiKey,
429
+ baseURL: baseUrl,
430
+ headers: { ...modalProxyHeaders, ...config.headers }
431
+ });
432
+ return provider(config.model);
433
+ }
434
+ async function validateConfig(config) {
435
+ if (config.byokProvider) {
436
+ return validateByokConfig({
437
+ provider: config.byokProvider,
438
+ model: typeof config.model === "string" && config.model.length > 0 ? config.model : void 0,
439
+ apiKey: config.apiKey,
440
+ baseUrl: config.baseUrl,
441
+ headers: { ...config.headers, ...config.byokHeaders },
442
+ apiShape: config.byokApiShape
443
+ });
444
+ }
445
+ if (typeof config.model === "string" && isByokRef(config.model)) {
446
+ const { provider, model } = parseByokRef(config.model);
447
+ return validateByokConfig({
448
+ provider,
449
+ model,
450
+ apiKey: config.apiKey,
451
+ baseUrl: config.baseUrl,
452
+ headers: { ...config.headers, ...config.byokHeaders },
453
+ apiShape: config.byokApiShape
454
+ });
455
+ }
456
+ const info = MODEL_CATALOG[config.model];
457
+ if (!info) {
458
+ return { ok: false, error: `Unknown model "${config.model}". Available: ${MODEL_IDS.join(", ")}` };
459
+ }
460
+ const baseUrl = config.baseUrl ?? process.env.NOTCH_BASE_URL ?? info.baseUrl;
461
+ const proxyKey = process.env.MODAL_PROXY_KEY;
462
+ const proxySecret = process.env.MODAL_PROXY_SECRET;
463
+ const proxyHeaders = proxyKey && proxySecret ? { "Modal-Key": proxyKey, "Modal-Secret": proxySecret } : {};
464
+ try {
465
+ const res = await fetch(`${baseUrl.replace(/\/v1$/, "")}/health`, {
466
+ signal: AbortSignal.timeout(5e3),
467
+ headers: proxyHeaders
468
+ });
469
+ if (!res.ok) {
470
+ return { ok: false, error: `Notch ${info.label} returned ${res.status} at ${baseUrl}` };
471
+ }
472
+ return { ok: true };
473
+ } catch {
474
+ return { ok: false, error: `Cannot reach Notch ${info.label} at ${baseUrl}` };
475
+ }
476
+ }
477
+
478
+ // src/providers/ollama-credentials.ts
479
+ import fs from "fs/promises";
480
+ import path from "path";
481
+ function notchConfigDir() {
482
+ const xdg = process.env.XDG_CONFIG_HOME;
483
+ if (xdg) return path.join(xdg, "notch");
484
+ const appdata = process.env.APPDATA;
485
+ if (appdata && process.platform === "win32") return path.join(appdata, "notch");
486
+ const home = process.env.HOME ?? process.env.USERPROFILE;
487
+ if (!home) {
488
+ throw new Error("Cannot determine home directory for credential storage.");
489
+ }
490
+ return path.join(home, ".config", "notch");
491
+ }
492
+ function ollamaCredsPath() {
493
+ return path.join(notchConfigDir(), "ollama.json");
494
+ }
495
+ async function readOllamaCreds() {
496
+ try {
497
+ const raw = await fs.readFile(ollamaCredsPath(), "utf-8");
498
+ const parsed = JSON.parse(raw);
499
+ if (typeof parsed.apiKey !== "string" || typeof parsed.endpoint !== "string") return null;
500
+ return {
501
+ apiKey: parsed.apiKey,
502
+ endpoint: parsed.endpoint,
503
+ createdAt: typeof parsed.createdAt === "number" ? parsed.createdAt : Date.now()
504
+ };
505
+ } catch {
506
+ return null;
507
+ }
508
+ }
509
+ async function writeOllamaCreds(creds) {
510
+ const dir = notchConfigDir();
511
+ await fs.mkdir(dir, { recursive: true, mode: 448 });
512
+ const file = ollamaCredsPath();
513
+ await fs.writeFile(file, JSON.stringify(creds, null, 2) + "\n", { mode: 384 });
514
+ }
515
+ async function clearOllamaCreds() {
516
+ try {
517
+ await fs.unlink(ollamaCredsPath());
518
+ } catch (err) {
519
+ const code = err.code;
520
+ if (code !== "ENOENT") throw err;
521
+ }
522
+ }
523
+
524
+ export {
525
+ findByokProvider,
526
+ listByokProviders,
527
+ isByokRef,
528
+ parseByokRef,
529
+ ByokMissingApiKeyError,
530
+ ByokMissingBaseUrlError,
531
+ resolveByokModel,
532
+ MissingApiKeyError,
533
+ MODEL_CATALOG,
534
+ MODEL_IDS,
535
+ isValidModel,
536
+ modelSupportsImages,
537
+ resolveModel,
538
+ validateConfig,
539
+ ollamaCredsPath,
540
+ readOllamaCreds,
541
+ writeOllamaCreds,
542
+ clearOllamaCreds
543
+ };
@@ -0,0 +1,155 @@
1
+ // src/providers/ollama.ts
2
+ var OLLAMA_LOCAL_BASE_URL = "http://localhost:11434";
3
+ var OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
4
+ var DEFAULT_TIMEOUT_MS = 5e3;
5
+ function authHeaders(apiKey) {
6
+ return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
7
+ }
8
+ function joinUrl(baseUrl, path) {
9
+ const root = baseUrl.replace(/\/+$/, "");
10
+ const suffix = path.startsWith("/") ? path : `/${path}`;
11
+ return `${root}${suffix}`;
12
+ }
13
+ async function detectDaemon(baseUrl = OLLAMA_LOCAL_BASE_URL, opts = {}) {
14
+ try {
15
+ const res = await fetch(joinUrl(baseUrl, "/api/tags"), {
16
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
17
+ headers: authHeaders(opts.apiKey)
18
+ });
19
+ return res.ok;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+ async function listModels(baseUrl = OLLAMA_LOCAL_BASE_URL, opts = {}) {
25
+ const res = await fetch(joinUrl(baseUrl, "/api/tags"), {
26
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
27
+ headers: authHeaders(opts.apiKey)
28
+ });
29
+ if (!res.ok) {
30
+ throw new Error(`Ollama ${baseUrl} returned ${res.status} for /api/tags`);
31
+ }
32
+ const body = await res.json();
33
+ const models = body.models ?? [];
34
+ return models.map((m) => ({
35
+ name: m.name,
36
+ parameterSize: m.details?.parameter_size ?? "\u2014",
37
+ quantization: m.details?.quantization_level ?? "\u2014",
38
+ sizeBytes: m.size ?? 0,
39
+ modifiedAt: m.modified_at
40
+ }));
41
+ }
42
+ async function showModel(modelName, baseUrl = OLLAMA_LOCAL_BASE_URL, opts = {}) {
43
+ const res = await fetch(joinUrl(baseUrl, "/api/show"), {
44
+ method: "POST",
45
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
46
+ headers: { "Content-Type": "application/json", ...authHeaders(opts.apiKey) },
47
+ body: JSON.stringify({ name: modelName })
48
+ });
49
+ if (!res.ok) {
50
+ throw new Error(`Ollama /api/show(${modelName}) returned ${res.status}`);
51
+ }
52
+ const body = await res.json();
53
+ const info = body.model_info ?? {};
54
+ let contextLength;
55
+ for (const [key, value] of Object.entries(info)) {
56
+ if (key.endsWith(".context_length") && typeof value === "number") {
57
+ contextLength = value;
58
+ break;
59
+ }
60
+ }
61
+ return {
62
+ contextLength,
63
+ parameterSize: body.details?.parameter_size,
64
+ family: body.details?.family,
65
+ modelInfo: info
66
+ };
67
+ }
68
+ async function* pullModel(modelName, baseUrl = OLLAMA_LOCAL_BASE_URL, opts = {}) {
69
+ const res = await fetch(joinUrl(baseUrl, "/api/pull"), {
70
+ method: "POST",
71
+ signal: opts.signal,
72
+ headers: { "Content-Type": "application/json", ...authHeaders(opts.apiKey) },
73
+ body: JSON.stringify({ name: modelName, stream: true })
74
+ });
75
+ if (!res.ok || !res.body) {
76
+ const text = await res.text().catch(() => "");
77
+ throw new Error(`Ollama /api/pull returned ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`);
78
+ }
79
+ const reader = res.body.getReader();
80
+ const decoder = new TextDecoder();
81
+ let buf = "";
82
+ let done = false;
83
+ while (!done) {
84
+ const { value, done: streamDone } = await reader.read();
85
+ if (streamDone) break;
86
+ buf += decoder.decode(value, { stream: true });
87
+ let nl = buf.indexOf("\n");
88
+ while (nl >= 0) {
89
+ const line = buf.slice(0, nl).trim();
90
+ buf = buf.slice(nl + 1);
91
+ nl = buf.indexOf("\n");
92
+ if (!line) continue;
93
+ let frame;
94
+ try {
95
+ frame = JSON.parse(line);
96
+ } catch {
97
+ continue;
98
+ }
99
+ if (frame.error) {
100
+ throw new Error(`Ollama pull error: ${frame.error}`);
101
+ }
102
+ const status = frame.status ?? "";
103
+ if (status === "success") {
104
+ done = true;
105
+ break;
106
+ }
107
+ if (typeof frame.total === "number" && typeof frame.completed === "number" && frame.total > 0) {
108
+ yield {
109
+ kind: "progress",
110
+ message: status,
111
+ completed: frame.completed,
112
+ total: frame.total,
113
+ percent: Math.min(100, frame.completed / frame.total * 100)
114
+ };
115
+ } else if (status) {
116
+ yield { kind: "status", message: status };
117
+ }
118
+ }
119
+ }
120
+ yield { kind: "done" };
121
+ }
122
+ function isCloudBaseUrl(baseUrl) {
123
+ try {
124
+ const host = new URL(baseUrl).hostname;
125
+ return host === "ollama.com" || host.endsWith(".ollama.com");
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+ function isCloudModel(modelName) {
131
+ return /(^|[:\-])cloud$/i.test(modelName) || modelName.endsWith(":cloud");
132
+ }
133
+ function formatBytes(n) {
134
+ if (!n || n <= 0) return " \u2014";
135
+ const units = ["B", "KB", "MB", "GB", "TB"];
136
+ let v = n;
137
+ let i = 0;
138
+ while (v >= 1024 && i < units.length - 1) {
139
+ v /= 1024;
140
+ i++;
141
+ }
142
+ return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`.padStart(8);
143
+ }
144
+
145
+ export {
146
+ OLLAMA_LOCAL_BASE_URL,
147
+ OLLAMA_CLOUD_BASE_URL,
148
+ detectDaemon,
149
+ listModels,
150
+ showModel,
151
+ pullModel,
152
+ isCloudBaseUrl,
153
+ isCloudModel,
154
+ formatBytes
155
+ };