codeshark-cli 0.1.1 → 0.1.4

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/models.js CHANGED
@@ -5,31 +5,31 @@ export const MODELS = [
5
5
  provider: "unorouter",
6
6
  model: "gpt-5.6-sol:free",
7
7
  context: "400K",
8
- notes: "OpenAI's frontier reasoning model.",
8
+ notes: "Frontier reasoning model when available.",
9
9
  },
10
10
  {
11
11
  id: "unorouter/deepseek-v4-flash",
12
12
  label: "DeepSeek-V4 Flash",
13
13
  provider: "unorouter",
14
- model: "deepseek-v4-flash-0731:free",
14
+ model: "deepseek-v4-flash:free",
15
15
  context: "256K",
16
- notes: "DeepSeek's fast flash model, strong at code.",
16
+ notes: "Fast coding model when available.",
17
17
  },
18
18
  {
19
- id: "unorouter/glm-5.3-flash-thinking",
20
- label: "GLM 5.3 Flash Thinking",
19
+ id: "unorouter/minimax-m3",
20
+ label: "MiniMax M3",
21
21
  provider: "unorouter",
22
- model: "glm-5.3-flash-thinking:free",
23
- context: "1M",
24
- notes: "Zhipu's reasoning coder with 1M context — the default.",
22
+ model: "minimax-m3:free",
23
+ context: "128K",
24
+ notes: "Fast general-purpose coding and reasoning.",
25
25
  },
26
26
  {
27
- id: "unorouter/kimi-k3",
28
- label: "Kimi-K3",
27
+ id: "unorouter/glm-5.3-flash-think-search",
28
+ label: "GLM 5.3 Flash Think Search",
29
29
  provider: "unorouter",
30
- model: "kimi-k3:free",
31
- context: "256K",
32
- notes: "Moonshot's flagship coding model.",
30
+ model: "glm-5.3-flash-think-search:free",
31
+ context: "1M",
32
+ notes: "Reasoning model with search-oriented thinking.",
33
33
  },
34
34
  {
35
35
  id: "unorouter/gemini-3.6-flash",
@@ -37,16 +37,49 @@ export const MODELS = [
37
37
  provider: "unorouter",
38
38
  model: "gemini-3.6-flash:free",
39
39
  context: "1M",
40
- notes: "Google's fast flash model with a huge 1M context.",
40
+ notes: "Fast long-context model for large codebases.",
41
+ },
42
+ {
43
+ id: "unorouter/sarvam-30b",
44
+ label: "Sarvam 30B",
45
+ provider: "unorouter",
46
+ model: "sarvam-30b:free",
47
+ context: "128K",
48
+ notes: "Open coding model from Sarvam AI.",
49
+ },
50
+ {
51
+ id: "unorouter/gpt-oss-120b",
52
+ label: "GPT-OSS 120B",
53
+ provider: "unorouter",
54
+ model: "gpt-oss-120b:free",
55
+ context: "128K",
56
+ notes: "Large open-weight model for demanding coding tasks.",
57
+ },
58
+ {
59
+ id: "unorouter/nemotron-3-ultra-550b-a55b",
60
+ label: "Nemotron 3 Ultra 550B A55B",
61
+ provider: "unorouter",
62
+ model: "nemotron-3-ultra-550b-a55b:free",
63
+ context: "256K",
64
+ notes: "Large-scale open model for deep reasoning.",
41
65
  },
42
66
  ];
43
- export const DEFAULT_MODEL_ID = "unorouter/glm-5.3-flash-thinking";
67
+ export const RETIRED_MODELS = [
68
+ { id: "gemini/gemini-3.8-flash", label: "Gemini 3.8 Flash", provider: "gemini", model: "gemini-3.8-flash", context: "-", notes: "Removed from the catalog.", available: false },
69
+ { id: "unorouter/glm-5.3-flash-thinking", label: "GLM 5.3 Flash Thinking", provider: "unorouter", model: "glm-5.3-flash-thinking:free", context: "-", notes: "Removed from UnoRouter.", available: false },
70
+ { id: "unorouter/kimi-k3", label: "Kimi-K3", provider: "unorouter", model: "kimi-k3:free", context: "-", notes: "Removed from UnoRouter.", available: false },
71
+ ];
72
+ export const DEFAULT_MODEL_ID = "unorouter/glm-5.3-flash-think-search";
44
73
  export function listModels() {
45
74
  return [...MODELS];
46
75
  }
76
+ export function isRetiredModel(idOrSlug) {
77
+ const s = idOrSlug.trim();
78
+ return RETIRED_MODELS.some((m) => m.id === s || m.model === s);
79
+ }
47
80
  export function findModel(idOrSlug) {
48
81
  const s = idOrSlug.trim();
49
- return MODELS.find((m) => m.id === s || m.model === s);
82
+ return [...MODELS, ...RETIRED_MODELS].find((m) => m.id === s || m.model === s);
50
83
  }
51
84
  /** Map a catalog id (or raw slug) to the raw slug the provider API expects. */
52
85
  export function toApiSlug(idOrSlug) {
package/dist/project.js CHANGED
@@ -1,5 +1,5 @@
1
- import { statSync } from "node:fs";
2
- import { isAbsolute, relative, resolve, sep } from "node:path";
1
+ import { statSync, lstatSync, realpathSync } from "node:fs";
2
+ import { isAbsolute, dirname, relative, resolve, sep } from "node:path";
3
3
  export class ProjectFolderError extends Error {
4
4
  constructor(message) {
5
5
  super(message);
@@ -22,10 +22,24 @@ export function requireProjectFolder(folder = process.cwd()) {
22
22
  export function resolveProjectPath(input, cwd) {
23
23
  const root = resolve(cwd);
24
24
  const target = isAbsolute(input) ? resolve(input) : resolve(root, input);
25
- const rel = relative(root, target);
26
- if (rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)))
27
- return target;
28
- throw new ProjectFolderError(`Path escapes the project folder: ${input}`);
25
+ const inside = (base, candidate) => {
26
+ const path = relative(base, candidate);
27
+ return path === "" || (path !== ".." && !path.startsWith(".." + sep) && !isAbsolute(path));
28
+ };
29
+ if (!inside(root, target))
30
+ throw new ProjectFolderError("Path escapes the project folder: " + input);
31
+ // Validate existing ancestors so links cannot redirect new writes outside the project.
32
+ let ancestor = target;
33
+ while (!lstatSync(ancestor, { throwIfNoEntry: false })) {
34
+ const parent = dirname(ancestor);
35
+ if (parent === ancestor)
36
+ throw new ProjectFolderError("Cannot resolve project path: " + input);
37
+ ancestor = parent;
38
+ }
39
+ if (!inside(realpathSync(root), realpathSync(ancestor))) {
40
+ throw new ProjectFolderError("Path escapes the project folder through a link: " + input);
41
+ }
42
+ return target;
29
43
  }
30
44
  /** Change into an explicitly selected project folder before starting CodeShark. */
31
45
  export function openProjectFolder(folder) {
@@ -1,10 +1,11 @@
1
- import { activeProvider, envApiKey } from "../config.js";
1
+ import { activeModelId, activeProvider, envApiKey } from "../config.js";
2
2
  import { createGatewayClient } from "./gateway.js";
3
3
  import { createGeminiClient } from "./gemini.js";
4
4
  import { createNvidiaClient } from "./nvidia.js";
5
5
  import { createOllamaClient } from "./ollama.js";
6
6
  import { createOpenRouterClient } from "./openrouter.js";
7
7
  import { createUnoRouterClient } from "./unorouter.js";
8
+ import { findModel } from "../models.js";
8
9
  /**
9
10
  * Build the ordered list of ChatClients for this machine:
10
11
  * the active model's provider first, then automatic free fallbacks
@@ -18,6 +19,7 @@ export function resolveClients(cfg, debug) {
18
19
  };
19
20
  const has = (provider) => clients.some((c) => c.provider === provider);
20
21
  const primary = activeProvider(cfg);
22
+ const selectedModel = activeModelId(cfg);
21
23
  switch (primary) {
22
24
  case "openrouter": {
23
25
  const key = cfg.openrouterApiKey ?? envApiKey("openrouter");
@@ -63,6 +65,10 @@ export function resolveClients(cfg, debug) {
63
65
  add(createGatewayClient(cfg));
64
66
  break;
65
67
  }
68
+ // UnoRouter catalog selections must not silently become a different model
69
+ // through another provider's fallback chain.
70
+ if (selectedModel.startsWith("unorouter/") || findModel(selectedModel))
71
+ return clients;
66
72
  // Automatic free fallbacks, deduped: any other provider you have a key for.
67
73
  if (!has("openrouter")) {
68
74
  const key = cfg.openrouterApiKey ?? envApiKey("openrouter");
@@ -3,7 +3,7 @@ import { effectiveModel } from "../config.js";
3
3
  import { toApiSlug } from "../models.js";
4
4
  export const DEFAULT_NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
5
5
  /**
6
- * NVIDIA NIM hosts open models (DeepSeek, Kimi, GLM, Nemotron, …) behind a
6
+ * NVIDIA NIM hosts open models behind a
7
7
  * free, OpenAI-compatible API. Get a key at https://build.nvidia.com —
8
8
  * no credit card, keys start with `nvapi-`.
9
9
  */
@@ -1,3 +1,4 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
1
2
  import { ProviderError, classifyStatus, errorMessage, } from "./types.js";
2
3
  function toOpenAIMessages(messages) {
3
4
  const out = [];
@@ -37,7 +38,7 @@ function safeParseArgs(raw) {
37
38
  return {};
38
39
  try {
39
40
  const parsed = JSON.parse(raw);
40
- return parsed && typeof parsed === "object" ? parsed : {};
41
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
41
42
  }
42
43
  catch {
43
44
  return {};
@@ -52,57 +53,78 @@ export function createOpenAICompatClient(opts) {
52
53
  model: opts.model,
53
54
  isFree: opts.isFree ?? true,
54
55
  async chat(messages, tools, events, signal) {
55
- const body = {
56
- model: opts.model,
57
- messages: toOpenAIMessages(messages),
58
- stream: true,
59
- temperature: 0.3,
60
- };
61
- if (tools.length)
62
- body.tools = toOpenAITools(tools);
63
- const headers = {
64
- "content-type": "application/json",
65
- ...(opts.apiKey ? { authorization: `Bearer ${opts.apiKey}` } : {}),
66
- ...opts.extraHeaders,
67
- };
68
- // Wait out rate limits with backoff before surfacing a 429 — the
69
- // queue lives client-side too, so shared gateway lanes feel smooth.
70
- const retryDelays = opts.rateLimitRetryDelays ?? [800, 1600];
71
- let res;
72
- for (let attempt = 0;; attempt++) {
73
- let candidate;
74
- try {
75
- candidate = await fetchImpl(endpoint, {
76
- method: "POST",
77
- headers,
78
- body: JSON.stringify(body),
79
- signal,
80
- });
56
+ signal?.throwIfAborted();
57
+ const controller = new AbortController();
58
+ const abort = () => controller.abort(signal?.reason);
59
+ signal?.addEventListener("abort", abort, { once: true });
60
+ const timeout = setTimeout(() => controller.abort(new Error("Provider request timed out.")), opts.timeoutMs ?? 120_000);
61
+ try {
62
+ const body = {
63
+ model: opts.model,
64
+ messages: toOpenAIMessages(messages),
65
+ stream: true,
66
+ temperature: 0.3,
67
+ };
68
+ if (tools.length)
69
+ body.tools = toOpenAITools(tools);
70
+ const headers = {
71
+ "content-type": "application/json",
72
+ ...(opts.apiKey ? { authorization: `Bearer ${opts.apiKey}` } : {}),
73
+ ...opts.extraHeaders,
74
+ };
75
+ // Wait out rate limits with backoff before surfacing a 429 — the
76
+ // queue lives client-side too, so shared gateway lanes feel smooth.
77
+ const retryDelays = opts.rateLimitRetryDelays ?? [800, 1600];
78
+ let res;
79
+ for (let attempt = 0;; attempt++) {
80
+ let candidate;
81
+ try {
82
+ candidate = await fetchImpl(endpoint, {
83
+ method: "POST",
84
+ headers,
85
+ body: JSON.stringify(body),
86
+ signal: controller.signal,
87
+ });
88
+ }
89
+ catch (e) {
90
+ throw new ProviderError(`Cannot reach ${opts.provider} at ${base}: ${errorMessage(e)}`, "network");
91
+ }
92
+ if (candidate.status !== 429 || attempt >= retryDelays.length) {
93
+ res = candidate;
94
+ break;
95
+ }
96
+ await candidate.body?.cancel();
97
+ const retryAfter = candidate.headers.get("retry-after");
98
+ const seconds = retryAfter === null ? NaN : Number(retryAfter);
99
+ const suggested = Number.isFinite(seconds) ? seconds * 1000 : retryAfter ? Date.parse(retryAfter) - Date.now() : NaN;
100
+ const wait = Number.isFinite(suggested) ? Math.min(30_000, Math.max(0, suggested)) : retryDelays[attempt];
101
+ await delay(wait, undefined, { signal: controller.signal });
81
102
  }
82
- catch (e) {
83
- throw new ProviderError(`Cannot reach ${opts.provider} at ${base}: ${errorMessage(e)}`, "network");
103
+ if (!res.ok) {
104
+ const raw = await res.text();
105
+ let detail = raw.slice(0, 300);
106
+ try {
107
+ const data = JSON.parse(raw);
108
+ if (typeof data?.error?.message === "string")
109
+ detail = data.error.message.slice(0, 300);
110
+ }
111
+ catch { /* Preserve plain-text errors from proxies. */ }
112
+ throw classifyStatus(res.status, opts.provider, detail);
84
113
  }
85
- if (candidate.status !== 429 || attempt >= retryDelays.length) {
86
- res = candidate;
87
- break;
114
+ if (!res.body) {
115
+ throw new ProviderError(`${opts.provider}: empty response body`, "unknown");
88
116
  }
89
- await new Promise((r) => setTimeout(r, retryDelays[attempt]));
117
+ return await parseOpenAIStream(res.body, opts.provider, events);
90
118
  }
91
- if (!res.ok) {
92
- let detail = "";
93
- try {
94
- const j = (await res.json());
95
- detail = j.error?.message ?? JSON.stringify(j).slice(0, 300);
96
- }
97
- catch {
98
- detail = (await res.text().catch(() => "")).slice(0, 300);
99
- }
100
- throw classifyStatus(res.status, opts.provider, detail);
119
+ catch (error) {
120
+ if (controller.signal.aborted)
121
+ throw controller.signal.reason;
122
+ throw error;
101
123
  }
102
- if (!res.body) {
103
- throw new ProviderError(`${opts.provider}: empty response body`, "unknown");
124
+ finally {
125
+ clearTimeout(timeout);
126
+ signal?.removeEventListener("abort", abort);
104
127
  }
105
- return parseOpenAIStream(res.body, opts.provider, events);
106
128
  },
107
129
  };
108
130
  }
@@ -111,17 +133,24 @@ async function parseOpenAIStream(body, provider, events) {
111
133
  const decoder = new TextDecoder();
112
134
  let buffer = "";
113
135
  let content = "";
136
+ let completed = false;
137
+ let doneMarker = false;
114
138
  const pending = new Map();
115
139
  const finalized = new Map();
116
140
  const handleData = (data) => {
117
- if (!data || data === "[DONE]")
141
+ if (!data)
118
142
  return;
143
+ if (data === "[DONE]") {
144
+ doneMarker = true;
145
+ completed = true;
146
+ return;
147
+ }
119
148
  let json;
120
149
  try {
121
150
  json = JSON.parse(data);
122
151
  }
123
152
  catch {
124
- return;
153
+ throw new ProviderError(provider + ": malformed stream event", "network");
125
154
  }
126
155
  if (json.error) {
127
156
  const err = json.error;
@@ -151,6 +180,9 @@ async function parseOpenAIStream(body, provider, events) {
151
180
  }
152
181
  }
153
182
  if (choice.finish_reason) {
183
+ completed = true;
184
+ if (choice.finish_reason === "length")
185
+ throw new ProviderError(provider + ": response exceeded the token limit; narrow the task.", "model");
154
186
  for (const [idx, tc] of pending)
155
187
  finalized.set(idx, tc);
156
188
  pending.clear();
@@ -165,12 +197,17 @@ async function parseOpenAIStream(body, provider, events) {
165
197
  const lines = buffer.split("\n");
166
198
  buffer = lines.pop() ?? "";
167
199
  for (const line of lines) {
200
+ if (doneMarker)
201
+ break;
168
202
  const trimmed = line.trim();
169
203
  if (trimmed.startsWith("data:"))
170
204
  handleData(trimmed.slice(5).trim());
171
205
  }
206
+ if (doneMarker)
207
+ break;
172
208
  }
173
- if (buffer.trim()) {
209
+ buffer += decoder.decode();
210
+ if (!doneMarker && buffer.trim()) {
174
211
  const trimmed = buffer.trim();
175
212
  if (trimmed.startsWith("data:"))
176
213
  handleData(trimmed.slice(5).trim());
@@ -181,12 +218,20 @@ async function parseOpenAIStream(body, provider, events) {
181
218
  throw e;
182
219
  throw new ProviderError(`${provider}: stream interrupted: ${errorMessage(e)}`, "network");
183
220
  }
221
+ finally {
222
+ await reader.cancel().catch(() => { });
223
+ reader.releaseLock();
224
+ }
225
+ if (!completed)
226
+ throw new ProviderError(provider + ": stream ended before completion; please retry.", "network");
227
+ for (const [index, call] of pending)
228
+ finalized.set(index, call);
184
229
  const toolCalls = [...finalized.values()]
185
230
  .filter((c) => c.name)
186
231
  .map((c) => ({
187
232
  id: c.id || `call_${Math.random().toString(36).slice(2, 10)}`,
188
233
  name: c.name,
189
- args: safeParseArgs(c.args),
234
+ args: parseToolArgs(c.args, provider),
190
235
  }));
191
236
  return {
192
237
  role: "assistant",
@@ -194,5 +239,14 @@ async function parseOpenAIStream(body, provider, events) {
194
239
  toolCalls: toolCalls.length ? toolCalls : undefined,
195
240
  };
196
241
  }
242
+ function parseToolArgs(raw, provider) {
243
+ try {
244
+ const parsed = JSON.parse(raw || "{}");
245
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
246
+ return parsed;
247
+ }
248
+ catch { /* Reject partial tool arguments instead of executing an empty object. */ }
249
+ throw new ProviderError(provider + ": invalid tool arguments; no action was executed.", "model");
250
+ }
197
251
  /** Exported for tests. */
198
252
  export { toOpenAIMessages, toOpenAITools, safeParseArgs };