@synmux/claude-commit 1.0.4 → 1.1.0

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/src/models.ts DELETED
@@ -1,95 +0,0 @@
1
- /**
2
- * Model-string parsing: which provider serves a configured model name.
3
- *
4
- * A model string is either a Claude model (an alias like `sonnet`, or a full
5
- * `claude-*` id) or, with an `ollama:` prefix, a model on a local or
6
- * self-hosted Ollama server. Everything after the prefix is the Ollama model
7
- * name **verbatim**, which matters because Ollama names carry their own
8
- * colon: `ollama:ornith-1.5:35b` is the model `ornith-1.5:35b`, not
9
- * `ornith-1.5` with some tag `35b` cco is expected to reassemble. Only the
10
- * first `ollama:` is consumed.
11
- *
12
- * The prefix is matched case-insensitively - it is cco's own syntax, and
13
- * `Ollama:` is an easy thing to type - while the model name is passed
14
- * through with its case intact, because Ollama's registry is case-sensitive.
15
- *
16
- * This module is deliberately free of transport and SDK imports so that
17
- * anything needing to know *which* provider a name refers to (chunk sizing
18
- * in `src/tokens.ts`, for one) can ask without pulling in a backend.
19
- */
20
- import { ClaudeCommitError } from "./errors";
21
-
22
- /** Marks a model name as belonging to an Ollama server. Case-insensitive. */
23
- export const OLLAMA_PREFIX = "ollama:";
24
-
25
- /** Which backend serves a model. */
26
- export type ModelProvider = "claude" | "ollama";
27
-
28
- /** A model string resolved into a provider and the name that provider expects. */
29
- export interface ModelRef {
30
- provider: ModelProvider;
31
- /** The model name to send to the provider, with any cco prefix removed. */
32
- name: string;
33
- }
34
-
35
- /**
36
- * Default Ollama base URL, used when neither the config nor `$OLLAMA_HOST`
37
- * names one. This is Ollama's own default listen address.
38
- */
39
- export const DEFAULT_OLLAMA_HOST = "http://localhost:11434";
40
-
41
- /**
42
- * Default `ollama.context`: ask the server what window it would run the
43
- * model with on this machine, rather than guess (see `probeOllamaContext`
44
- * in `src/ollama.ts`). Ollama's choice is made from available VRAM and
45
- * capped at the model's trained maximum, so it is the largest window the
46
- * server believes it can actually load.
47
- */
48
- export const DEFAULT_OLLAMA_CONTEXT = "auto" as const;
49
-
50
- /**
51
- * The window assumed for an `ollama:` model when nothing better is known:
52
- * the synchronous fallback in `contextWindowTokens` for callers that size
53
- * chunks without first resolving the context. The pipeline never relies on
54
- * it - it resolves `"auto"` to a real number before sizing - so this only
55
- * matters to direct library use. 32768 is Ollama's middle VRAM tier.
56
- */
57
- export const DEFAULT_OLLAMA_CONTEXT_TOKENS = 32_768;
58
-
59
- /** Whether `model` names an Ollama model (i.e. carries the `ollama:` prefix). */
60
- export function isOllamaModel(model: string): boolean {
61
- return model.trim().toLowerCase().startsWith(OLLAMA_PREFIX);
62
- }
63
-
64
- /**
65
- * Resolve a configured model string into its provider and provider-side name.
66
- *
67
- * Throws {@link ClaudeCommitError} for a name that no provider could serve:
68
- * an empty string, or an `ollama:` prefix with nothing after it.
69
- */
70
- export function parseModelRef(model: string): ModelRef {
71
- const trimmed = model.trim();
72
- if (trimmed === "") {
73
- throw new ClaudeCommitError(
74
- "No model configured. Set a model name, or an Ollama model as " +
75
- `"${OLLAMA_PREFIX}<name>:<tag>".`,
76
- );
77
- }
78
- if (!isOllamaModel(trimmed)) {
79
- return { provider: "claude", name: trimmed };
80
- }
81
- const name = trimmed.slice(OLLAMA_PREFIX.length).trim();
82
- if (name === "") {
83
- throw new ClaudeCommitError(
84
- `"${model}" names no Ollama model. Write the model after the prefix, ` +
85
- `e.g. "${OLLAMA_PREFIX}ornith-1.5:35b".`,
86
- );
87
- }
88
- return { provider: "ollama", name };
89
- }
90
-
91
- /** A model string as it should appear in an error or a `--verbose` line. */
92
- export function describeModel(model: string): string {
93
- const trimmed = model.trim();
94
- return isOllamaModel(trimmed) ? `${trimmed} (Ollama)` : `${trimmed} (Claude)`;
95
- }
package/src/ollama.ts DELETED
@@ -1,502 +0,0 @@
1
- /**
2
- * The Ollama backend: one prompt to one local (or self-hosted) model.
3
- *
4
- * cco speaks Ollama's **native** `/api/chat`, not either of its compatibility
5
- * layers. The OpenAI layer has no field for the context length, and cco sizes
6
- * every diff chunk against a context window, so a dialect that cannot state
7
- * one is unusable here; the Anthropic layer exists to let Anthropic SDK
8
- * clients point at Ollama, and cco does not talk raw Anthropic - it talks
9
- * Agent SDK, which spawns its own binary. The native API gives
10
- * `options.num_ctx`, structured output via `format`, and the usage counts
11
- * that make a truncated prompt detectable.
12
- *
13
- * There is no SDK dependency: one `fetch` against one endpoint, so nothing
14
- * here assumes a particular JavaScript runtime.
15
- *
16
- * ## Two failure modes worth knowing about
17
- *
18
- * **A prompt over the context window is truncated silently.** Ollama drops
19
- * the oldest content, returns HTTP 200, and flags nothing. A summary written
20
- * from half a diff is worse than no summary, so every request pins
21
- * `options.num_ctx` to the same number the chunks were sized against, and
22
- * the response's `prompt_eval_count` is checked against it afterwards.
23
- * Where that number comes from is {@link resolveOllamaContext}: a configured
24
- * token count, or - by default - the window Ollama itself picks for the
25
- * model on this machine, read back from `/api/ps` after a preload.
26
- * Reaching the limit means content was dropped, and cco raises an error
27
- * whose text contains "prompt is too long" - the phrase
28
- * {@link isPromptTooLongError} matches - so the pipeline's existing
29
- * halve-and-re-split retry handles it exactly as it handles a Claude
30
- * overflow. Ollama has no rejection of its own to trigger that path, so this
31
- * synthesises one.
32
- *
33
- * **An error can arrive after HTTP 200.** In a streamed response it is a
34
- * plain NDJSON line `{"error": "..."}` partway through, long after the
35
- * status line said everything was fine, so every parsed line is checked for
36
- * it rather than trusting the status code.
37
- */
38
- import { ClaudeCommitError } from "./errors";
39
- import {
40
- DEFAULT_OLLAMA_CONTEXT,
41
- DEFAULT_OLLAMA_HOST,
42
- parseModelRef,
43
- } from "./models";
44
- import type { ModelResult, OllamaConfig, RunPromptOptions } from "./types";
45
-
46
- /** Ollama settings with every default filled in; the context may still be `"auto"`. */
47
- export interface ResolvedOllama {
48
- host: string;
49
- context: number | "auto";
50
- keepAlive: string | number | null;
51
- }
52
-
53
- /** {@link ResolvedOllama} after `"auto"` has been turned into a number. */
54
- export interface OllamaRequestSettings {
55
- host: string;
56
- contextTokens: number;
57
- keepAlive: string | number | null;
58
- }
59
-
60
- /**
61
- * Normalise a base URL: add a scheme to a bare `host:port` and drop any
62
- * trailing slash. Ollama's own `OLLAMA_HOST` convention allows the bare
63
- * form, so `127.0.0.1:11434` has to mean what a user expects it to.
64
- */
65
- export function normaliseOllamaHost(host: string): string {
66
- const trimmed = host.trim().replace(/\/+$/, "");
67
- if (trimmed === "") return DEFAULT_OLLAMA_HOST;
68
- return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
69
- ? trimmed
70
- : `http://${trimmed}`;
71
- }
72
-
73
- /**
74
- * The Ollama base URL for this run: the configured `ollama.host`, else
75
- * `$OLLAMA_HOST`, else {@link DEFAULT_OLLAMA_HOST}.
76
- */
77
- export function resolveOllamaHost(
78
- configured?: string,
79
- env: Record<string, string | undefined> = process.env,
80
- ): string {
81
- const candidate = configured?.trim() || env.OLLAMA_HOST?.trim() || "";
82
- return normaliseOllamaHost(candidate);
83
- }
84
-
85
- /**
86
- * Fill in defaults for any Ollama setting the config left out. A missing
87
- * or unusable `context` becomes `"auto"`; turning that into a number is
88
- * {@link resolveOllamaContext}'s job, because it takes a round trip.
89
- */
90
- export function resolveOllamaConfig(
91
- config: Partial<OllamaConfig> | undefined,
92
- env: Record<string, string | undefined> = process.env,
93
- ): ResolvedOllama {
94
- const context = config?.context;
95
- return {
96
- host: resolveOllamaHost(config?.host, env),
97
- context:
98
- typeof context === "number" && context > 0
99
- ? Math.floor(context)
100
- : DEFAULT_OLLAMA_CONTEXT,
101
- keepAlive: config?.keepAlive ?? null,
102
- };
103
- }
104
-
105
- /** The one field of a `/api/ps` entry cco reads, plus the names it matches on. */
106
- interface OllamaLoadedModel {
107
- name?: string;
108
- model?: string;
109
- context_length?: number;
110
- }
111
-
112
- /**
113
- * Ask Ollama what context window it would run `model` with on this machine.
114
- *
115
- * Two calls. The first is a chat request with no messages, which loads the
116
- * model (a no-op if it is already resident) *without* a `num_ctx` - so the
117
- * server applies its own choice, made from available VRAM (4k / 32k / 256k
118
- * tiers, capped at the model's trained maximum). The second reads that
119
- * choice back from `/api/ps`, which reports the window each loaded model is
120
- * actually running with. The load was going to happen on the first real
121
- * request anyway, so the only added cost is the `ps` round trip.
122
- *
123
- * This is deliberately not `/api/show`'s `context_length`, which is the
124
- * *trained* maximum regardless of hardware - 131072 for a model this
125
- * machine may only be able to run at 32768. The number the server picked
126
- * is the one it can actually load.
127
- */
128
- export async function probeOllamaContext(
129
- model: string,
130
- settings: { host: string; keepAlive: string | number | null },
131
- signal?: AbortSignal,
132
- ): Promise<number> {
133
- const { host, keepAlive } = settings;
134
- const preload = await ollamaFetch(
135
- `${host}/api/chat`,
136
- {
137
- method: "POST",
138
- headers: { "Content-Type": "application/json" },
139
- body: JSON.stringify({
140
- model,
141
- messages: [],
142
- stream: false,
143
- ...(keepAlive !== null ? { keep_alive: keepAlive } : {}),
144
- }),
145
- },
146
- host,
147
- signal,
148
- );
149
- if (!preload.ok) {
150
- throw new ClaudeCommitError(
151
- await describeHttpFailure(preload, host, model),
152
- );
153
- }
154
-
155
- const ps = await ollamaFetch(
156
- `${host}/api/ps`,
157
- { method: "GET" },
158
- host,
159
- signal,
160
- );
161
- if (!ps.ok) {
162
- throw new ClaudeCommitError(await describeHttpFailure(ps, host, model));
163
- }
164
- const body = (await ps.json()) as { models?: OllamaLoadedModel[] };
165
- const loaded = (body.models ?? []).find(
166
- (entry) => entry.name === model || entry.model === model,
167
- );
168
- const contextLength = loaded?.context_length;
169
- if (typeof contextLength !== "number" || contextLength <= 0) {
170
- throw new ClaudeCommitError(
171
- `Ollama loaded "${model}" but did not report its context window in ` +
172
- `/api/ps, so cco cannot size the diff for it. Set "ollama.context" ` +
173
- `to a token count to pin one.`,
174
- );
175
- }
176
- return Math.floor(contextLength);
177
- }
178
-
179
- /**
180
- * The context window to use for `model`: the configured number, or the
181
- * server's own choice when the config says `"auto"` (see
182
- * {@link probeOllamaContext}). Callers that make several requests to the
183
- * same model should resolve once and reuse the result.
184
- */
185
- export async function resolveOllamaContext(
186
- model: string,
187
- config: Partial<OllamaConfig> | undefined,
188
- signal?: AbortSignal,
189
- ): Promise<number> {
190
- const resolved = resolveOllamaConfig(config);
191
- if (resolved.context !== "auto") return resolved.context;
192
- const { name } = parseModelRef(model);
193
- return probeOllamaContext(name, resolved, signal);
194
- }
195
-
196
- /** `fetch` with transport failures and cancellation turned into cco errors. */
197
- async function ollamaFetch(
198
- url: string,
199
- init: RequestInit,
200
- host: string,
201
- signal?: AbortSignal,
202
- ): Promise<Response> {
203
- try {
204
- return await fetch(url, { ...init, ...(signal ? { signal } : {}) });
205
- } catch (error) {
206
- if (signal?.aborted) {
207
- throw new ClaudeCommitError("Generation was cancelled.");
208
- }
209
- throw new ClaudeCommitError(describeTransportFailure(error, host));
210
- }
211
- }
212
-
213
- /** The body of a native `/api/chat` request. */
214
- export interface OllamaChatRequest {
215
- model: string;
216
- messages: Array<{ role: "system" | "user"; content: string }>;
217
- stream: boolean;
218
- format?: Record<string, unknown>;
219
- keep_alive?: string | number;
220
- options: Record<string, unknown>;
221
- }
222
-
223
- /**
224
- * Build the `/api/chat` body for one prompt.
225
- *
226
- * Two things are deliberate. Sampling parameters go inside `options` - at the
227
- * top level Ollama accepts and silently ignores them, so a misplaced
228
- * `temperature` would look like a model that refuses to vary. And `think` is
229
- * never sent at all: models disagree on whether reasoning can be switched
230
- * off (gpt-oss cannot, and takes only a level; Granite uses its own field
231
- * entirely), so asking is a needless way to earn a 400. Any `thinking` that
232
- * comes back is dropped on the floor instead.
233
- */
234
- export function buildChatRequest(
235
- prompt: string,
236
- opts: RunPromptOptions,
237
- settings: OllamaRequestSettings,
238
- ): OllamaChatRequest {
239
- const { name } = parseModelRef(opts.model);
240
- const options: Record<string, unknown> = { num_ctx: settings.contextTokens };
241
- if (opts.temperature != null) options.temperature = opts.temperature;
242
-
243
- return {
244
- model: name,
245
- messages: [
246
- { role: "system", content: opts.system },
247
- { role: "user", content: prompt },
248
- ],
249
- // Stream only when someone is watching the text arrive. A single JSON
250
- // body is easier to get right, and is what Ollama's own guidance
251
- // recommends for structured output.
252
- stream: Boolean(opts.onText),
253
- ...(opts.outputFormat ? { format: opts.outputFormat.schema } : {}),
254
- ...(settings.keepAlive !== null ? { keep_alive: settings.keepAlive } : {}),
255
- options,
256
- };
257
- }
258
-
259
- /** The fields of a chat response cco actually reads. */
260
- interface OllamaChatChunk {
261
- model?: string;
262
- message?: { content?: string; thinking?: string };
263
- done?: boolean;
264
- done_reason?: string;
265
- prompt_eval_count?: number;
266
- prompt_eval_cached_count?: number;
267
- eval_count?: number;
268
- error?: string;
269
- }
270
-
271
- /** Turn a non-2xx response into a message that says what to do about it. */
272
- async function describeHttpFailure(
273
- response: Response,
274
- host: string,
275
- model: string,
276
- ): Promise<string> {
277
- let detail = "";
278
- try {
279
- const body: unknown = await response.json();
280
- if (body && typeof body === "object" && "error" in body) {
281
- detail = String((body as { error: unknown }).error);
282
- }
283
- } catch {
284
- /* a non-JSON error body tells us nothing extra */
285
- }
286
-
287
- switch (response.status) {
288
- case 404:
289
- return (
290
- `Ollama has no model "${model}" on ${host}. Pull it first with ` +
291
- `\`ollama pull ${model}\`, or check the exact name with \`ollama list\`.`
292
- );
293
- case 400:
294
- return (
295
- `Ollama rejected the request for "${model}"${detail ? `: ${detail}` : ""}. ` +
296
- `Check the model supports plain chat completion (\`ollama show ${model}\`).`
297
- );
298
- case 401:
299
- case 403:
300
- return `Ollama at ${host} refused the request as unauthorised${detail ? `: ${detail}` : ""}.`;
301
- case 429:
302
- return `Ollama at ${host} is rate limiting requests. Try again shortly.`;
303
- case 500:
304
- return (
305
- `Ollama failed to run "${model}"${detail ? `: ${detail}` : ""}. ` +
306
- `This is often the model runner running out of memory - set ` +
307
- `"ollama.context" to a smaller number or use a smaller model.`
308
- );
309
- case 503:
310
- return `Ollama at ${host} has a full request queue. Try again shortly.`;
311
- default:
312
- return (
313
- `Ollama at ${host} returned ${response.status} ${response.statusText}` +
314
- (detail ? `: ${detail}` : "") +
315
- "."
316
- );
317
- }
318
- }
319
-
320
- /** Turn a transport-level failure into a message that says what to do about it. */
321
- function describeTransportFailure(error: unknown, host: string): string {
322
- const message = error instanceof Error ? error.message : String(error);
323
- if (
324
- /econnrefused|failed to fetch|unable to connect|connection refused/i.test(
325
- message,
326
- )
327
- ) {
328
- return (
329
- `Cannot reach the Ollama server at ${host}. Start it with ` +
330
- `\`ollama serve\`, or set "ollama.host" in your claude-commit config.`
331
- );
332
- }
333
- return `Failed to call Ollama at ${host}: ${message}`;
334
- }
335
-
336
- /**
337
- * Read one NDJSON stream, forwarding text deltas and returning the final
338
- * chunk. Each line is checked for an `error` key: a mid-stream failure
339
- * arrives that way, after the 200 has already been sent, so a status check
340
- * alone would miss it.
341
- */
342
- async function consumeStream(
343
- response: Response,
344
- onText: ((delta: string) => void) | undefined,
345
- ): Promise<{ content: string; final: OllamaChatChunk }> {
346
- const body = response.body;
347
- if (!body) throw new ClaudeCommitError("Ollama returned an empty response.");
348
-
349
- const reader = body.getReader();
350
- const decoder = new TextDecoder();
351
- let buffer = "";
352
- let content = "";
353
- let final: OllamaChatChunk = {};
354
-
355
- const handleLine = (line: string) => {
356
- const trimmed = line.trim();
357
- if (trimmed === "") return;
358
- let chunk: OllamaChatChunk;
359
- try {
360
- chunk = JSON.parse(trimmed) as OllamaChatChunk;
361
- } catch {
362
- throw new ClaudeCommitError(
363
- `Ollama sent a malformed response line: ${trimmed.slice(0, 200)}`,
364
- );
365
- }
366
- if (chunk.error) throw new ClaudeCommitError(`Ollama: ${chunk.error}`);
367
- const delta = chunk.message?.content ?? "";
368
- if (delta !== "") {
369
- content += delta;
370
- onText?.(delta);
371
- }
372
- if (chunk.done) final = chunk;
373
- };
374
-
375
- for (;;) {
376
- const { value, done } = await reader.read();
377
- if (done) break;
378
- buffer += decoder.decode(value, { stream: true });
379
- let newline: number;
380
- while ((newline = buffer.indexOf("\n")) >= 0) {
381
- const line = buffer.slice(0, newline);
382
- buffer = buffer.slice(newline + 1);
383
- handleLine(line);
384
- }
385
- }
386
- buffer += decoder.decode();
387
- handleLine(buffer);
388
-
389
- if (!final.done) {
390
- throw new ClaudeCommitError(
391
- "Ollama's response ended before the model finished.",
392
- );
393
- }
394
- return { content, final };
395
- }
396
-
397
- /**
398
- * Tokens the server reported for the prompt. `prompt_eval_cached_count` is
399
- * documented as its own counter without saying whether cached tokens are
400
- * also inside `prompt_eval_count`, so take the larger: under either reading
401
- * that is the prompt's real size, and neither double-counts.
402
- */
403
- function promptTokensOf(final: OllamaChatChunk): number {
404
- return Math.max(
405
- final.prompt_eval_count ?? 0,
406
- final.prompt_eval_cached_count ?? 0,
407
- );
408
- }
409
-
410
- /**
411
- * Run a single prompt against an Ollama model and return its response.
412
- *
413
- * Throws {@link ClaudeCommitError} on any transport, model or truncation
414
- * failure. `costUsd` is always zero: local inference is not billed, so a
415
- * mixed-provider run's reported cost is exactly its Claude half.
416
- */
417
- export async function runOllamaPrompt(
418
- prompt: string,
419
- opts: RunPromptOptions,
420
- ): Promise<ModelResult> {
421
- const { name } = parseModelRef(opts.model);
422
- const signal = opts.abortController?.signal;
423
- // A caller that has already resolved `"auto"` (the pipeline does, once
424
- // per model) passes a number through and pays nothing here; a direct
425
- // caller with `"auto"` pays the probe on every call.
426
- const base = resolveOllamaConfig(opts.ollama);
427
- const resolved: OllamaRequestSettings = {
428
- host: base.host,
429
- keepAlive: base.keepAlive,
430
- contextTokens: await resolveOllamaContext(opts.model, opts.ollama, signal),
431
- };
432
- const request = buildChatRequest(prompt, opts, resolved);
433
-
434
- const response = await ollamaFetch(
435
- `${resolved.host}/api/chat`,
436
- {
437
- method: "POST",
438
- headers: { "Content-Type": "application/json" },
439
- body: JSON.stringify(request),
440
- },
441
- resolved.host,
442
- signal,
443
- );
444
- if (!response.ok) {
445
- throw new ClaudeCommitError(
446
- await describeHttpFailure(response, resolved.host, name),
447
- );
448
- }
449
-
450
- let content: string;
451
- let final: OllamaChatChunk;
452
- if (request.stream) {
453
- ({ content, final } = await consumeStream(response, opts.onText));
454
- } else {
455
- final = (await response.json()) as OllamaChatChunk;
456
- if (final.error) throw new ClaudeCommitError(`Ollama: ${final.error}`);
457
- content = final.message?.content ?? "";
458
- }
459
-
460
- // The prompt filled the window, which means Ollama dropped whatever did
461
- // not fit rather than complaining. Phrase it so the pipeline's overflow
462
- // retry recognises it and re-splits the chunk.
463
- const promptTokens = promptTokensOf(final);
464
- if (promptTokens > 0 && promptTokens >= resolved.contextTokens) {
465
- throw new ClaudeCommitError(
466
- `Ollama truncated the request to "${name}": the prompt is too long for ` +
467
- `the ${resolved.contextTokens}-token context window ("ollama.context").`,
468
- );
469
- }
470
-
471
- if (final.done_reason === "length") {
472
- throw new ClaudeCommitError(
473
- `Ollama's reply from "${name}" was cut off at the context limit. ` +
474
- `Raise "ollama.context" beyond ${resolved.contextTokens}, or use ` +
475
- `a model with more room.`,
476
- );
477
- }
478
-
479
- const text = content.trim();
480
- if (text === "") {
481
- throw new ClaudeCommitError(`Ollama model "${name}" returned no text.`);
482
- }
483
-
484
- let structured: unknown;
485
- if (opts.outputFormat) {
486
- try {
487
- structured = JSON.parse(text);
488
- } catch {
489
- // Leave `structured` unset: the caller's fallback chain drops to a
490
- // plain-text attempt, which is exactly the right response to a model
491
- // or server that could not honour the schema (Ollama Cloud, for one,
492
- // does not support `format` at all).
493
- }
494
- }
495
-
496
- return {
497
- text,
498
- costUsd: 0,
499
- ...(final.model ? { model: final.model } : {}),
500
- ...(structured !== undefined ? { structured } : {}),
501
- };
502
- }