@freesyntax/notch-cli 0.5.22 → 0.5.23

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.
@@ -50,6 +50,7 @@ import {
50
50
 
51
51
  // src/tools/index.ts
52
52
  import { tool } from "ai";
53
+ import { zodToJsonSchema } from "zod-to-json-schema";
53
54
 
54
55
  // src/tools/code-mode.ts
55
56
  import vm from "vm";
@@ -1696,6 +1697,7 @@ function mcpToolsToNotch(client, serverName) {
1696
1697
  name: `mcp_${serverName}_${toolDef.name}`,
1697
1698
  description: `[MCP/${serverName}] ${toolDef.description}`,
1698
1699
  parameters: params,
1700
+ inputSchema: toolDef.inputSchema,
1699
1701
  async execute(args, _ctx) {
1700
1702
  try {
1701
1703
  const result = await client.callTool(toolDef.name, args);
@@ -2318,8 +2320,7 @@ async function spawnSubagent(config) {
2318
2320
  ## Working Directory
2319
2321
  ${toolContext.cwd}
2320
2322
 
2321
- ## Available Tools
2322
- ${Object.keys(tools).map((n) => `- ${n}`).join("\n")}`;
2323
+ ${describeToolSchemas(subagentCtx, { includeNames: Object.keys(tools) })}`;
2323
2324
  const messages = [
2324
2325
  { role: "user", content: prompt }
2325
2326
  ];
@@ -2454,8 +2455,7 @@ async function spawnBuiltinAgent(config) {
2454
2455
  ## Working Directory
2455
2456
  ${toolContext.cwd}
2456
2457
 
2457
- ## Available Tools
2458
- ${Object.keys(tools).map((n) => `- ${n}`).join("\n")}`;
2458
+ ${describeToolSchemas(subagentCtx, { includeNames: Object.keys(tools) })}`;
2459
2459
  const messages = [{ role: "user", content: prompt }];
2460
2460
  let iterations = 0;
2461
2461
  let totalToolCalls = 0;
@@ -3081,11 +3081,50 @@ ${paramSummary}`
3081
3081
  function listToolNames(ctx) {
3082
3082
  return getAllTools(ctx).map((t) => t.name);
3083
3083
  }
3084
- function describeTools2(ctx) {
3084
+ function describeTools(ctx) {
3085
3085
  return getAllTools(ctx).map(
3086
3086
  (t) => `- **${t.name}**: ${t.description}`
3087
3087
  ).join("\n");
3088
3088
  }
3089
+ function describeToolSchemas(ctx, options = {}) {
3090
+ const include = options.includeNames ? new Set(options.includeNames) : null;
3091
+ const tools = getAllTools(ctx).filter((t) => !include || include.has(t.name));
3092
+ if (tools.length === 0) return "## Tool Schemas\nNo tools are available in this mode.";
3093
+ const lines = [
3094
+ "## Tool Schemas",
3095
+ "Native tool schemas are sent with every model request. They are repeated here so OpenAI-compatible routers and local models can reliably choose valid tools.",
3096
+ "When using a tool, call the native tool with exactly one listed name and a JSON object matching its parameters schema. Do not print tool-call JSON as normal text.",
3097
+ ""
3098
+ ];
3099
+ for (const t of tools) {
3100
+ const schema = toolPromptSchema(t);
3101
+ lines.push(`### ${t.name}`);
3102
+ lines.push(t.description);
3103
+ lines.push("Parameters JSON Schema:");
3104
+ lines.push("```json");
3105
+ lines.push(JSON.stringify(schema));
3106
+ lines.push("```");
3107
+ lines.push("");
3108
+ }
3109
+ return lines.join("\n").trimEnd();
3110
+ }
3111
+ function toolPromptSchema(t) {
3112
+ const schema = t.inputSchema ?? zodToJsonSchema(t.parameters, {
3113
+ target: "jsonSchema7",
3114
+ $refStrategy: "none"
3115
+ });
3116
+ return stripSchemaNoise(schema);
3117
+ }
3118
+ function stripSchemaNoise(value) {
3119
+ if (Array.isArray(value)) return value.map(stripSchemaNoise);
3120
+ if (!value || typeof value !== "object") return value;
3121
+ const out = {};
3122
+ for (const [key, raw] of Object.entries(value)) {
3123
+ if (key === "$schema" || key === "definitions" || key === "$defs") continue;
3124
+ out[key] = stripSchemaNoise(raw);
3125
+ }
3126
+ return out;
3127
+ }
3089
3128
  function mcpToolCount() {
3090
3129
  return mcpTools.length;
3091
3130
  }
@@ -3103,6 +3142,7 @@ export {
3103
3142
  disconnectMCPServers,
3104
3143
  buildToolMap,
3105
3144
  listToolNames,
3106
- describeTools2 as describeTools,
3145
+ describeTools,
3146
+ describeToolSchemas,
3107
3147
  mcpToolCount
3108
3148
  };
@@ -15,16 +15,17 @@ var BUILTIN_BYOK_PROVIDERS = [
15
15
  label: "OpenAI",
16
16
  baseUrl: "https://api.openai.com/v1",
17
17
  apiKeyEnv: "OPENAI_API_KEY",
18
- defaultModel: "gpt-4o",
18
+ defaultModel: "gpt-5",
19
19
  models: [
20
- "gpt-4o",
21
- "gpt-4o-mini",
22
- "gpt-4-turbo",
23
- "gpt-4.1",
24
- "gpt-4.1-mini",
25
20
  "gpt-5",
26
21
  "gpt-5-mini",
27
- "o3-mini",
22
+ "gpt-5-nano",
23
+ "gpt-5-chat-latest",
24
+ "gpt-4.1",
25
+ "gpt-4.1-mini",
26
+ "gpt-4o",
27
+ "gpt-4o-mini",
28
+ "o3",
28
29
  "o4-mini"
29
30
  ],
30
31
  compatibility: "strict"
@@ -63,6 +64,33 @@ var BUILTIN_BYOK_PROVIDERS = [
63
64
  "X-Title": "Notch CLI"
64
65
  }
65
66
  },
67
+ {
68
+ id: "google",
69
+ label: "Google Gemini",
70
+ baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
71
+ apiKeyEnv: "GEMINI_API_KEY",
72
+ defaultModel: "gemini-3.5-flash",
73
+ models: [
74
+ "gemini-3.5-flash",
75
+ "gemini-2.5-pro",
76
+ "gemini-2.5-flash"
77
+ ],
78
+ compatibility: "compatible"
79
+ },
80
+ {
81
+ id: "deepseek",
82
+ label: "DeepSeek",
83
+ baseUrl: "https://api.deepseek.com",
84
+ apiKeyEnv: "DEEPSEEK_API_KEY",
85
+ defaultModel: "deepseek-v4-flash",
86
+ models: [
87
+ "deepseek-v4-flash",
88
+ "deepseek-v4-pro",
89
+ "deepseek-chat",
90
+ "deepseek-reasoner"
91
+ ],
92
+ compatibility: "strict"
93
+ },
66
94
  {
67
95
  id: "together",
68
96
  label: "Together AI",
@@ -194,23 +222,35 @@ function findByokProvider(id) {
194
222
  function listByokProviders() {
195
223
  return BUILTIN_BYOK_PROVIDERS.filter((p) => p.id !== "__custom__");
196
224
  }
197
- function isByokRef(model) {
198
- return model.includes(":") && !isOllamaTagOnly(model);
225
+ function normalizeProviderId(id) {
226
+ return id === "custom" ? "__custom__" : id;
199
227
  }
200
- function isOllamaTagOnly(model) {
201
- const [head] = model.split(":");
202
- if (!head) return false;
203
- return !BYOK_BY_ID[head];
228
+ function providerExists(id) {
229
+ return Boolean(BYOK_BY_ID[normalizeProviderId(id)]);
230
+ }
231
+ function isByokRef(model) {
232
+ const slash = model.indexOf("/");
233
+ if (slash > 0 && providerExists(model.slice(0, slash))) return true;
234
+ const colon = model.indexOf(":");
235
+ if (colon > 0 && providerExists(model.slice(0, colon))) return true;
236
+ return false;
204
237
  }
205
238
  function parseByokRef(ref) {
239
+ const slash = ref.indexOf("/");
206
240
  const colon = ref.indexOf(":");
207
- if (colon < 0) {
208
- throw new Error(`BYOK ref "${ref}" is missing a colon separator.`);
241
+ if (slash > 0 && (colon < 0 || slash < colon) && providerExists(ref.slice(0, slash))) {
242
+ const rawProvider = ref.slice(0, slash);
243
+ const model = ref.slice(slash + 1);
244
+ return { provider: normalizeProviderId(rawProvider), model };
209
245
  }
210
- const rawProvider = ref.slice(0, colon);
211
- const model = ref.slice(colon + 1);
212
- const provider = rawProvider === "custom" ? "__custom__" : rawProvider;
213
- return { provider, model };
246
+ if (colon > 0 && providerExists(ref.slice(0, colon))) {
247
+ const rawProvider = ref.slice(0, colon);
248
+ const model = ref.slice(colon + 1);
249
+ return { provider: normalizeProviderId(rawProvider), model };
250
+ }
251
+ throw new Error(
252
+ `Provider model ref "${ref}" must be "<provider>/<model>" or "<provider>:<model>".`
253
+ );
214
254
  }
215
255
  var ByokMissingApiKeyError = class extends Error {
216
256
  constructor(provider) {
@@ -225,7 +265,7 @@ var ByokMissingApiKeyError = class extends Error {
225
265
  var ByokMissingBaseUrlError = class extends Error {
226
266
  constructor() {
227
267
  super(
228
- "Custom BYOK provider requires --base-url (and usually --api-key). Example: notch --provider custom --base-url https://my.endpoint/v1 --model my-model"
268
+ "Custom provider requires --base-url (and usually --api-key). Example: notch --provider custom --base-url https://my.endpoint/v1 --model my-model"
229
269
  );
230
270
  this.name = "ByokMissingBaseUrlError";
231
271
  }
@@ -234,7 +274,7 @@ function resolveByokModel(spec) {
234
274
  const providerInfo = findByokProvider(spec.provider);
235
275
  if (!providerInfo) {
236
276
  throw new Error(
237
- `Unknown BYOK provider "${spec.provider}". Available: ${listByokProviders().map((p) => p.id).join(", ")}, custom`
277
+ `Unknown provider "${spec.provider}". Available: ${listByokProviders().map((p) => p.id).join(", ")}, custom`
238
278
  );
239
279
  }
240
280
  if (providerInfo.id === "__custom__" && !spec.baseUrl) {
@@ -244,7 +284,7 @@ function resolveByokModel(spec) {
244
284
  const modelId = spec.model && spec.model.length > 0 ? spec.model : providerInfo.defaultModel;
245
285
  if (!modelId) {
246
286
  throw new Error(
247
- `BYOK provider "${providerInfo.id}" has no default model. Pass --model or set byok.model in .notch.json.`
287
+ `Provider "${providerInfo.id}" has no default model. Pass --model or set byok.model in .notch.json.`
248
288
  );
249
289
  }
250
290
  let apiKey = spec.apiKey;
@@ -303,7 +343,7 @@ function resolveByokModel(spec) {
303
343
  async function validateByokConfig(spec) {
304
344
  const providerInfo = findByokProvider(spec.provider);
305
345
  if (!providerInfo) {
306
- return { ok: false, error: `Unknown BYOK provider "${spec.provider}"` };
346
+ return { ok: false, error: `Unknown provider "${spec.provider}"` };
307
347
  }
308
348
  if (providerInfo.id === "__custom__" && !spec.baseUrl) {
309
349
  return { ok: false, error: "Custom provider requires --base-url" };
@@ -324,6 +364,156 @@ async function validateByokConfig(spec) {
324
364
 
325
365
  // src/providers/registry.ts
326
366
  import { createOpenAI as createOpenAI2 } from "@ai-sdk/openai";
367
+
368
+ // src/providers/pi-ai-adapter.ts
369
+ var PI_AI_PROVIDER_MAP = {
370
+ openai: "openai",
371
+ anthropic: "anthropic",
372
+ google: "google",
373
+ "google-vertex": "google-vertex",
374
+ mistral: "mistral",
375
+ bedrock: "amazon-bedrock",
376
+ azure: "azure-openai"
377
+ };
378
+ var UNSUPPORTED_BY_PI_AI = /* @__PURE__ */ new Set(["openrouter", "groq", "together"]);
379
+ function piAiMode() {
380
+ const enabled = process.env.NOTCH_USE_PI_AI;
381
+ if (!enabled || enabled === "0" || enabled.toLowerCase() === "false") return "off";
382
+ const mode = (process.env.NOTCH_PI_AI_MODE ?? "shadow").toLowerCase();
383
+ return mode === "swap" ? "swap" : "shadow";
384
+ }
385
+ function shouldRouteViaPiAi(config) {
386
+ if (piAiMode() === "off") return false;
387
+ const provider = extractProvider(config);
388
+ if (!provider) return false;
389
+ if (UNSUPPORTED_BY_PI_AI.has(provider)) return false;
390
+ return provider in PI_AI_PROVIDER_MAP;
391
+ }
392
+ var PiAiNotInstalledError = class extends Error {
393
+ constructor(originalError) {
394
+ super(
395
+ `NOTCH_USE_PI_AI is set but @mariozechner/pi-ai could not be loaded. Install with: pnpm --filter @freesyntax/notch-cli add @mariozechner/pi-ai@0.70.2. Original error: ${originalError instanceof Error ? originalError.message : String(originalError)}`
396
+ );
397
+ this.name = "PiAiNotInstalledError";
398
+ }
399
+ };
400
+ async function resolveModelViaPiAi(config) {
401
+ const { providerName, modelId } = extractProviderAndModel(config);
402
+ const piAiProvider = PI_AI_PROVIDER_MAP[providerName];
403
+ if (!piAiProvider) {
404
+ throw new Error(
405
+ `pi-ai does not support BYOK provider "${providerName}". Supported: ${Object.keys(PI_AI_PROVIDER_MAP).join(", ")}. Set NOTCH_USE_PI_AI=0 or use a different provider.`
406
+ );
407
+ }
408
+ if (!modelId) {
409
+ throw new Error(
410
+ `pi-ai resolution requires a non-empty model id. Config: ${JSON.stringify({ byokProvider: config.byokProvider, model: config.model })}`
411
+ );
412
+ }
413
+ const piAi = await loadPiAi();
414
+ const model = piAi.getModel(piAiProvider, modelId);
415
+ if (!model) {
416
+ throw new Error(
417
+ `pi-ai has no registered model "${piAiProvider}/${modelId}". pi-ai only lists tool-calling-capable models; if your model is text-only, keep it on the AI SDK path (UNSUPPORTED_BY_PI_AI) or clear NOTCH_USE_PI_AI.`
418
+ );
419
+ }
420
+ return { model, piAiProvider, modelId };
421
+ }
422
+ async function computePiAiCostUsd(config, usage) {
423
+ try {
424
+ const handle = await resolveModelViaPiAi(config);
425
+ const piAi = await loadPiAi();
426
+ const usageBucket = {
427
+ input: usage.input,
428
+ output: usage.output,
429
+ cacheRead: usage.cacheRead ?? 0,
430
+ cacheWrite: usage.cacheWrite ?? 0,
431
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
432
+ };
433
+ piAi.calculateCost(handle.model, usageBucket);
434
+ return usageBucket.cost.total;
435
+ } catch {
436
+ return null;
437
+ }
438
+ }
439
+ var shadowContexts = /* @__PURE__ */ new WeakMap();
440
+ function registerShadowContext(model, context) {
441
+ if (piAiMode() === "off") return;
442
+ shadowContexts.set(model, context);
443
+ }
444
+ async function recordShadowUsage(model, usage, opts) {
445
+ if (piAiMode() === "off") return;
446
+ const ctx = shadowContexts.get(model);
447
+ if (!ctx) return;
448
+ const inputTokens = usage.promptTokens ?? 0;
449
+ const outputTokens = usage.completionTokens ?? 0;
450
+ const piAiCostUsd = await computePiAiCostUsd(ctx.config, {
451
+ input: inputTokens,
452
+ output: outputTokens
453
+ });
454
+ await recordShadowCost({
455
+ requestId: opts?.requestId ?? `usage-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
456
+ provider: ctx.provider,
457
+ modelId: ctx.modelId,
458
+ inputTokens,
459
+ outputTokens,
460
+ piAiCostUsd,
461
+ stripeChargeUsd: opts?.stripeChargeUsd ?? null,
462
+ timestamp: Date.now()
463
+ });
464
+ }
465
+ async function recordShadowCost(event) {
466
+ if (piAiMode() === "off") return;
467
+ try {
468
+ const { homedir } = await import("os");
469
+ const { mkdir, appendFile } = await import("fs/promises");
470
+ const { join } = await import("path");
471
+ const dir = join(homedir(), ".notch");
472
+ await mkdir(dir, { recursive: true });
473
+ await appendFile(join(dir, "pi-ai-shadow.jsonl"), `${JSON.stringify(event)}
474
+ `, "utf8");
475
+ } catch {
476
+ }
477
+ }
478
+ var piAiModulePromise = null;
479
+ async function loadPiAi() {
480
+ if (!piAiModulePromise) {
481
+ piAiModulePromise = (async () => {
482
+ try {
483
+ const mod = await import("@mariozechner/pi-ai");
484
+ return mod;
485
+ } catch (err) {
486
+ piAiModulePromise = null;
487
+ throw new PiAiNotInstalledError(err);
488
+ }
489
+ })();
490
+ }
491
+ return piAiModulePromise;
492
+ }
493
+ function extractProvider(config) {
494
+ if (config.byokProvider) return config.byokProvider;
495
+ if (typeof config.model === "string" && isByokRef(config.model)) {
496
+ return parseByokRef(config.model).provider;
497
+ }
498
+ return void 0;
499
+ }
500
+ function extractProviderAndModel(config) {
501
+ if (config.byokProvider) {
502
+ return {
503
+ providerName: config.byokProvider,
504
+ modelId: typeof config.model === "string" ? config.model : ""
505
+ };
506
+ }
507
+ if (typeof config.model === "string" && isByokRef(config.model)) {
508
+ const { provider, model } = parseByokRef(config.model);
509
+ return { providerName: provider, modelId: model };
510
+ }
511
+ throw new Error(
512
+ "resolveModelViaPiAi called on a non-BYOK config. Notch-hosted Modal endpoints still use the AI SDK path."
513
+ );
514
+ }
515
+
516
+ // src/providers/registry.ts
327
517
  var MissingApiKeyError = class extends Error {
328
518
  /** Which flow caused this error (informs the onboarding message). */
329
519
  flow;
@@ -421,22 +611,40 @@ function isValidModel(id) {
421
611
  }
422
612
  function modelSupportsImages(modelId) {
423
613
  if (!modelId) return false;
614
+ const lowerModelId = modelId.toLowerCase();
424
615
  if (modelId in MODEL_CATALOG) return true;
425
- if (modelId.startsWith("notch-")) return true;
426
- const prefix = modelId.split(":", 1)[0]?.toLowerCase() ?? "";
616
+ if (lowerModelId.startsWith("notch-")) return true;
617
+ const ref = isByokRef(lowerModelId) ? parseByokRef(lowerModelId) : null;
618
+ const prefix = ref?.provider ?? "";
427
619
  switch (prefix) {
428
620
  case "openai":
429
621
  case "anthropic":
430
622
  case "google":
431
623
  case "together":
432
- case "openrouter":
433
624
  return true;
625
+ case "openrouter":
626
+ return openRouterModelSupportsImages(ref?.model ?? "");
434
627
  case "groq":
435
628
  return false;
436
629
  default:
437
630
  return false;
438
631
  }
439
632
  }
633
+ function openRouterModelSupportsImages(upstreamModelId) {
634
+ return [
635
+ "openai/gpt-5.3-codex",
636
+ "openai/gpt-5.2-codex",
637
+ "openai/gpt-5.1-codex",
638
+ "openai/gpt-5-codex",
639
+ "anthropic/claude-sonnet-4.6",
640
+ "anthropic/claude-sonnet-4.5",
641
+ "anthropic/claude-sonnet-4",
642
+ "moonshotai/kimi-k2.6",
643
+ "moonshotai/kimi-k2.5",
644
+ "~anthropic/claude-sonnet-latest",
645
+ "~moonshotai/kimi-latest"
646
+ ].some((id) => upstreamModelId === id || upstreamModelId.startsWith(`${id}:`));
647
+ }
440
648
  function resolveModel(config) {
441
649
  if (config.byokProvider) {
442
650
  const resolved = resolveByokModel({
@@ -447,6 +655,7 @@ function resolveModel(config) {
447
655
  headers: { ...config.headers, ...config.byokHeaders },
448
656
  apiShape: config.byokApiShape
449
657
  });
658
+ firePiAiShadowProbe(config, resolved.model);
450
659
  return resolved.model;
451
660
  }
452
661
  if (typeof config.model === "string" && isByokRef(config.model)) {
@@ -459,12 +668,13 @@ function resolveModel(config) {
459
668
  headers: { ...config.headers, ...config.byokHeaders },
460
669
  apiShape: config.byokApiShape
461
670
  });
671
+ firePiAiShadowProbe(config, resolved.model);
462
672
  return resolved.model;
463
673
  }
464
674
  const info = MODEL_CATALOG[config.model];
465
675
  if (!info) {
466
676
  throw new Error(
467
- `Unknown model "${config.model}". Notch models: ${MODEL_IDS.join(", ")}. For BYOK use "<provider>:<model>" (e.g. openrouter:anthropic/claude-sonnet-4-6).`
677
+ `Unknown model "${config.model}". Use provider/model, for example openrouter/anthropic/claude-sonnet-4-6.`
468
678
  );
469
679
  }
470
680
  const baseUrl = config.baseUrl ?? process.env.NOTCH_BASE_URL ?? info.baseUrl;
@@ -525,6 +735,47 @@ async function validateConfig(config) {
525
735
  return { ok: false, error: `Cannot reach Notch ${info.label} at ${baseUrl}` };
526
736
  }
527
737
  }
738
+ function firePiAiShadowProbe(config, resolvedModel) {
739
+ if (piAiMode() === "off") return;
740
+ if (!shouldRouteViaPiAi(config)) return;
741
+ const provider = extractByokProvider(config);
742
+ const modelId = typeof config.model === "string" ? config.model : "unknown";
743
+ if (provider) {
744
+ registerShadowContext(resolvedModel, {
745
+ provider,
746
+ modelId,
747
+ config
748
+ });
749
+ }
750
+ void (async () => {
751
+ let handle = null;
752
+ let error = null;
753
+ try {
754
+ handle = await resolveModelViaPiAi(config);
755
+ } catch (err) {
756
+ error = err instanceof Error ? err.message : String(err);
757
+ }
758
+ await recordShadowCost({
759
+ requestId: `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
760
+ provider: handle?.piAiProvider ?? provider ?? "unknown",
761
+ modelId: handle?.modelId ?? modelId,
762
+ inputTokens: 0,
763
+ // real counts come from loop.ts via recordShadowUsage
764
+ outputTokens: 0,
765
+ piAiCostUsd: null,
766
+ stripeChargeUsd: null,
767
+ timestamp: Date.now(),
768
+ ...error ? { error } : {}
769
+ });
770
+ })();
771
+ }
772
+ function extractByokProvider(config) {
773
+ if (config.byokProvider) return config.byokProvider;
774
+ if (typeof config.model === "string" && isByokRef(config.model)) {
775
+ return parseByokRef(config.model).provider;
776
+ }
777
+ return void 0;
778
+ }
528
779
 
529
780
  export {
530
781
  findByokProvider,
@@ -534,9 +785,9 @@ export {
534
785
  ByokMissingApiKeyError,
535
786
  ByokMissingBaseUrlError,
536
787
  resolveByokModel,
788
+ recordShadowUsage,
537
789
  MissingApiKeyError,
538
790
  MODEL_CATALOG,
539
- MODEL_IDS,
540
791
  isValidModel,
541
792
  modelSupportsImages,
542
793
  resolveModel,
@@ -4,11 +4,18 @@ import {
4
4
  import {
5
5
  findByokProvider,
6
6
  isByokRef,
7
- isValidModel
8
- } from "./chunk-JXQ4HZ47.js";
7
+ isValidModel,
8
+ listByokProviders,
9
+ parseByokRef
10
+ } from "./chunk-JVFOAPYV.js";
11
+ import {
12
+ OLLAMA_LOCAL_BASE_URL,
13
+ listModels
14
+ } from "./chunk-GFVLHUSS.js";
9
15
  import {
10
16
  init_auth,
11
- loadCredentials
17
+ loadCredentials,
18
+ loadSyncedByokKeys
12
19
  } from "./chunk-PPEBWOMJ.js";
13
20
 
14
21
  // src/config.ts
@@ -16,7 +23,7 @@ import fs from "fs/promises";
16
23
  import path from "path";
17
24
  init_auth();
18
25
  var DEFAULT_MODEL = {
19
- model: "notch-pyre",
26
+ model: "openrouter/anthropic/claude-sonnet-4-6",
20
27
  temperature: 0.3
21
28
  };
22
29
  var DEFAULTS = {
@@ -33,17 +40,20 @@ var DEFAULTS = {
33
40
  };
34
41
  async function loadConfig(overrides = {}) {
35
42
  const config = { ...DEFAULTS, models: { chat: { ...DEFAULT_MODEL } } };
43
+ let explicitModelConfig = false;
36
44
  const configPath = path.resolve(config.projectRoot, ".notch.json");
37
45
  try {
38
46
  const raw = await fs.readFile(configPath, "utf-8");
39
47
  const fileConfig = JSON.parse(raw);
40
48
  if (fileConfig.model && (isValidModel(fileConfig.model) || isByokRef(fileConfig.model))) {
41
49
  config.models.chat.model = fileConfig.model;
50
+ explicitModelConfig = true;
42
51
  }
43
52
  if (fileConfig.baseUrl) config.models.chat.baseUrl = fileConfig.baseUrl;
44
53
  if (fileConfig.apiKey) config.models.chat.apiKey = fileConfig.apiKey;
45
- if (fileConfig.byok && typeof fileConfig.byok === "object") {
46
- const byok = fileConfig.byok;
54
+ const providerBlock = fileConfig.provider && typeof fileConfig.provider === "object" ? fileConfig.provider : fileConfig.byok;
55
+ if (providerBlock && typeof providerBlock === "object") {
56
+ const byok = providerBlock;
47
57
  config.byok = { ...byok };
48
58
  if (byok.provider && findByokProvider(byok.provider === "custom" ? "__custom__" : byok.provider)) {
49
59
  config.models.chat.byokProvider = byok.provider === "custom" ? "__custom__" : byok.provider;
@@ -55,6 +65,7 @@ async function loadConfig(overrides = {}) {
55
65
  if (byok.apiShape === "openai" || byok.apiShape === "anthropic") {
56
66
  config.models.chat.byokApiShape = byok.apiShape;
57
67
  }
68
+ explicitModelConfig = true;
58
69
  }
59
70
  }
60
71
  if (fileConfig.hybrid && typeof fileConfig.hybrid === "object") {
@@ -72,7 +83,34 @@ async function loadConfig(overrides = {}) {
72
83
  if (fileConfig.theme) config.theme = fileConfig.theme;
73
84
  } catch {
74
85
  }
75
- const activeProviderId = config.models.chat.byokProvider ?? (typeof config.models.chat.model === "string" && isByokRef(config.models.chat.model) ? config.models.chat.model.split(":", 1)[0] : void 0);
86
+ if (process.env.NOTCH_PROVIDER) {
87
+ const providerId = process.env.NOTCH_PROVIDER === "custom" ? "__custom__" : process.env.NOTCH_PROVIDER;
88
+ const provider = findByokProvider(providerId);
89
+ if (provider) {
90
+ config.models.chat.byokProvider = providerId;
91
+ config.models.chat.model = process.env.NOTCH_MODEL || provider.defaultModel;
92
+ explicitModelConfig = true;
93
+ }
94
+ } else if (process.env.NOTCH_MODEL) {
95
+ const envModel = process.env.NOTCH_MODEL;
96
+ if (isValidModel(envModel)) {
97
+ config.models.chat.model = envModel;
98
+ config.models.chat.byokProvider = void 0;
99
+ explicitModelConfig = true;
100
+ } else if (isByokRef(envModel)) {
101
+ config.models.chat.model = envModel;
102
+ config.models.chat.byokProvider = void 0;
103
+ explicitModelConfig = true;
104
+ }
105
+ }
106
+ if (!explicitModelConfig) {
107
+ const inferred = await inferDefaultProviderConfig();
108
+ config.models.chat.model = inferred.modelRef;
109
+ config.models.chat.byokProvider = void 0;
110
+ if (inferred.baseUrl) config.models.chat.baseUrl = inferred.baseUrl;
111
+ if (inferred.apiShape) config.models.chat.byokApiShape = inferred.apiShape;
112
+ }
113
+ const activeProviderId = config.models.chat.byokProvider ?? (typeof config.models.chat.model === "string" && isByokRef(config.models.chat.model) ? parseByokRef(config.models.chat.model).provider : void 0);
76
114
  const isOllamaProvider = activeProviderId === "ollama" || activeProviderId === "ollama-cloud" || activeProviderId === "ollama-anthropic";
77
115
  if (isOllamaProvider) {
78
116
  if (!config.models.chat.apiKey && !process.env.OLLAMA_API_KEY) {
@@ -81,25 +119,16 @@ async function loadConfig(overrides = {}) {
81
119
  config.models.chat.apiKey = ollamaCreds.apiKey;
82
120
  }
83
121
  }
84
- } else {
122
+ } else if (!activeProviderId) {
85
123
  const creds = await loadCredentials();
86
124
  if (creds?.token) {
87
125
  config.models.chat.apiKey = creds.token;
88
126
  }
89
127
  }
90
- if (process.env.NOTCH_MODEL) {
91
- const envModel = process.env.NOTCH_MODEL;
92
- if (isValidModel(envModel)) {
93
- config.models.chat.model = envModel;
94
- } else if (isByokRef(envModel)) {
95
- config.models.chat.model = envModel;
96
- config.models.chat.byokProvider = void 0;
97
- }
98
- }
99
128
  if (process.env.NOTCH_BASE_URL) {
100
129
  config.models.chat.baseUrl = process.env.NOTCH_BASE_URL;
101
130
  }
102
- if (process.env.NOTCH_API_KEY) {
131
+ if (process.env.NOTCH_API_KEY && !activeProviderId) {
103
132
  config.models.chat.apiKey = process.env.NOTCH_API_KEY;
104
133
  }
105
134
  if (config.models.chat.temperature !== void 0) {
@@ -108,6 +137,46 @@ async function loadConfig(overrides = {}) {
108
137
  config.maxIterations = Math.max(1, Math.min(100, config.maxIterations));
109
138
  return { ...config, ...overrides };
110
139
  }
140
+ async function inferDefaultProviderConfig() {
141
+ const providers = listByokProviders();
142
+ const envMatch = providers.find((p) => p.apiKeyEnv && process.env[p.apiKeyEnv]);
143
+ if (envMatch) return { modelRef: `${envMatch.id}/${envMatch.defaultModel}` };
144
+ const synced = await loadSyncedByokKeys();
145
+ if (synced) {
146
+ const syncedMatch = providers.find((p) => {
147
+ const key = synced.keys[p.id];
148
+ return typeof key === "string" && key.length > 0;
149
+ });
150
+ if (syncedMatch) return { modelRef: `${syncedMatch.id}/${syncedMatch.defaultModel}` };
151
+ if (synced.localEndpoint) {
152
+ return {
153
+ modelRef: "custom/local-model",
154
+ baseUrl: synced.localEndpoint,
155
+ apiShape: "openai"
156
+ };
157
+ }
158
+ }
159
+ const localOllama = await inferLocalOllamaModel();
160
+ if (localOllama) return localOllama;
161
+ const openrouter = findByokProvider("openrouter");
162
+ if (openrouter) return { modelRef: `${openrouter.id}/${openrouter.defaultModel}` };
163
+ const first = providers[0];
164
+ return { modelRef: first ? `${first.id}/${first.defaultModel}` : DEFAULT_MODEL.model };
165
+ }
166
+ async function inferLocalOllamaModel() {
167
+ try {
168
+ const baseUrl = process.env.OLLAMA_HOST?.startsWith("http") ? process.env.OLLAMA_HOST : OLLAMA_LOCAL_BASE_URL;
169
+ const rows = await listModels(baseUrl, { timeoutMs: 300 });
170
+ const modelName = rows[0]?.name;
171
+ if (!modelName) return null;
172
+ return {
173
+ modelRef: `ollama/${modelName}`,
174
+ baseUrl: `${baseUrl.replace(/\/+$/, "")}/v1`
175
+ };
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
111
180
  async function persistConfigPatch(projectRoot, patch) {
112
181
  const configPath = path.resolve(projectRoot, ".notch.json");
113
182
  let current = {};