@freesyntax/notch-cli 0.5.20 → 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.
- package/dist/chunk-443G6HCC.js +543 -0
- package/dist/chunk-GFVLHUSS.js +155 -0
- package/dist/chunk-MMBFNIKE.js +509 -0
- package/dist/chunk-OSWUX6TC.js +167 -0
- package/dist/{chunk-6M6CXXWR.js → chunk-PKZKVOAN.js} +209 -1
- package/dist/chunk-QKM27RHS.js +198 -0
- package/dist/{chunk-YBYF7L4A.js → chunk-TU465P2P.js} +1830 -1331
- package/dist/compression-SQAIQ2UU.js +32 -0
- package/dist/index.js +2346 -822
- package/dist/ollama-bench-QQHBIG2D.js +190 -0
- package/dist/ollama-launch-2ASVER3S.js +18 -0
- package/dist/ollama-usage-2WPCZJJI.js +69 -0
- package/dist/{server-W7FRCVRZ.js → server-7UQKCB2Z.js} +1 -1
- package/dist/session-index-SSGOOZXK.js +21 -0
- package/dist/{tools-Q7CDHB4K.js → tools-7WAWS6V4.js} +3 -1
- package/package.json +2 -1
- package/dist/compression-UTB2Y4BB.js +0 -16
|
@@ -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
|
+
};
|