little-coder 1.8.0 → 1.8.2
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/.pi/extensions/benchmark-profiles/index.ts +24 -0
- package/.pi/extensions/benchmark-profiles/profiles.test.ts +53 -0
- package/.pi/extensions/extra-tools/glob.test.ts +89 -0
- package/.pi/extensions/extra-tools/glob.ts +102 -0
- package/.pi/extensions/extra-tools/index.ts +8 -11
- package/.pi/extensions/llama-cpp-provider/config.test.ts +116 -0
- package/.pi/extensions/llama-cpp-provider/config.ts +33 -0
- package/CHANGELOG.md +21 -0
- package/package.json +1 -1
|
@@ -163,10 +163,29 @@ function toLittleCoderOptions(p: ModelProfile): Record<string, unknown> {
|
|
|
163
163
|
return out;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// Providers whose servers accept a `temperature` field on chat-completions.
|
|
167
|
+
// little-coder's temperature defaults are tuned for the local-server case;
|
|
168
|
+
// hosted reasoning models (Copilot's gpt-5.x, OpenAI o-series) hard-reject
|
|
169
|
+
// the parameter with a 400 (issue #33). The list is intentionally minimal:
|
|
170
|
+
// llama.cpp-style local servers. Override at runtime via
|
|
171
|
+
// LITTLE_CODER_TEMPERATURE_PROVIDERS=foo,bar to add your own local provider.
|
|
172
|
+
const DEFAULT_TEMPERATURE_PROVIDERS = ["llamacpp", "ollama", "lmstudio"] as const;
|
|
173
|
+
|
|
174
|
+
export function providerAcceptsTemperature(provider: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
175
|
+
const override = env.LITTLE_CODER_TEMPERATURE_PROVIDERS;
|
|
176
|
+
const list = override
|
|
177
|
+
? override.split(",").map((s) => s.trim()).filter(Boolean)
|
|
178
|
+
: (DEFAULT_TEMPERATURE_PROVIDERS as readonly string[]);
|
|
179
|
+
return list.includes(provider);
|
|
180
|
+
}
|
|
181
|
+
|
|
166
182
|
export default function (pi: ExtensionAPI) {
|
|
167
183
|
// Shared across handlers so before_provider_request can re-read the most
|
|
168
184
|
// recently resolved temperature without re-parsing settings every turn.
|
|
169
185
|
let resolvedTemperature: number | undefined;
|
|
186
|
+
// Provider-level guard: hosted reasoning models reject `temperature` (see
|
|
187
|
+
// DEFAULT_TEMPERATURE_PROVIDERS above).
|
|
188
|
+
let temperatureAccepted = false;
|
|
170
189
|
|
|
171
190
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
172
191
|
const model = ctx.model;
|
|
@@ -193,6 +212,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
193
212
|
opts.littleCoder.contextLimit = resolveContextLimit(profile.context_limit, modelWindow);
|
|
194
213
|
|
|
195
214
|
resolvedTemperature = opts.littleCoder.temperature;
|
|
215
|
+
temperatureAccepted = providerAcceptsTemperature(model.provider);
|
|
196
216
|
});
|
|
197
217
|
|
|
198
218
|
// Inject the profile's temperature onto the outgoing provider payload.
|
|
@@ -200,10 +220,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
200
220
|
// llama.cpp), which adds measurable stochastic variance on hard
|
|
201
221
|
// algorithmic exercises. Matches local-coder's profiles[].temperature=0.3.
|
|
202
222
|
//
|
|
223
|
+
// Skipped for providers whose servers reject `temperature` (Copilot's
|
|
224
|
+
// gpt-5.x, OpenAI's o-series) — see providerAcceptsTemperature.
|
|
225
|
+
//
|
|
203
226
|
// IMPORTANT: pi's runner passes payload by reference but only adopts
|
|
204
227
|
// *returned* values. Mutating in place is discarded between handlers, so
|
|
205
228
|
// we build a new payload object and return it explicitly.
|
|
206
229
|
pi.on("before_provider_request", async (event) => {
|
|
230
|
+
if (!temperatureAccepted) return;
|
|
207
231
|
if (resolvedTemperature === undefined) return;
|
|
208
232
|
const payload: any = (event as any).payload;
|
|
209
233
|
if (!payload || typeof payload !== "object") return;
|
|
@@ -7,6 +7,7 @@ import benchmarkProfiles, {
|
|
|
7
7
|
normKey,
|
|
8
8
|
resolveContextLimit,
|
|
9
9
|
CONTEXT_FALLBACK,
|
|
10
|
+
providerAcceptsTemperature,
|
|
10
11
|
} from "./index.ts";
|
|
11
12
|
|
|
12
13
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -148,3 +149,55 @@ describe("before_agent_start publishes a model-window contextLimit", () => {
|
|
|
148
149
|
expect(lc.contextLimit).toBe(65536);
|
|
149
150
|
});
|
|
150
151
|
});
|
|
152
|
+
|
|
153
|
+
describe("providerAcceptsTemperature (issue #33)", () => {
|
|
154
|
+
it("accepts the shipped local providers by default", () => {
|
|
155
|
+
expect(providerAcceptsTemperature("llamacpp", {})).toBe(true);
|
|
156
|
+
expect(providerAcceptsTemperature("ollama", {})).toBe(true);
|
|
157
|
+
expect(providerAcceptsTemperature("lmstudio", {})).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
it("rejects hosted reasoning providers that 400 on temperature", () => {
|
|
160
|
+
expect(providerAcceptsTemperature("copilot", {})).toBe(false);
|
|
161
|
+
expect(providerAcceptsTemperature("openai", {})).toBe(false);
|
|
162
|
+
expect(providerAcceptsTemperature("anthropic", {})).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
it("LITTLE_CODER_TEMPERATURE_PROVIDERS env replaces the default list", () => {
|
|
165
|
+
const env = { LITTLE_CODER_TEMPERATURE_PROVIDERS: "vllm, my-local" };
|
|
166
|
+
expect(providerAcceptsTemperature("vllm", env)).toBe(true);
|
|
167
|
+
expect(providerAcceptsTemperature("my-local", env)).toBe(true);
|
|
168
|
+
expect(providerAcceptsTemperature("llamacpp", env)).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("before_provider_request only injects temperature for accepting providers", () => {
|
|
173
|
+
async function runHandlers(model: any, payload: any) {
|
|
174
|
+
const handlers: Record<string, ((e: any, c: any) => any)[]> = {};
|
|
175
|
+
const pi = { on: (n: string, h: any) => ((handlers[n] ??= []).push(h)) };
|
|
176
|
+
benchmarkProfiles(pi as any);
|
|
177
|
+
const startEvent: any = { systemPromptOptions: {} };
|
|
178
|
+
const ctx: any = { model };
|
|
179
|
+
for (const h of handlers["before_agent_start"] ?? []) await h(startEvent, ctx);
|
|
180
|
+
const reqEvent: any = { payload };
|
|
181
|
+
let lastResult: any;
|
|
182
|
+
for (const h of handlers["before_provider_request"] ?? []) {
|
|
183
|
+
lastResult = await h(reqEvent, ctx);
|
|
184
|
+
}
|
|
185
|
+
return lastResult;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
it("injects temperature for a local llamacpp model", async () => {
|
|
189
|
+
const out = await runHandlers(
|
|
190
|
+
{ provider: "llamacpp", id: "qwen3.6-27b", contextWindow: 131072 },
|
|
191
|
+
{ messages: [] },
|
|
192
|
+
);
|
|
193
|
+
expect(out).toMatchObject({ temperature: 0.3 });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("does NOT inject temperature for copilot/gpt-5.x (issue #33)", async () => {
|
|
197
|
+
const out = await runHandlers(
|
|
198
|
+
{ provider: "copilot", id: "gpt-5.4", contextWindow: 131072 },
|
|
199
|
+
{ messages: [] },
|
|
200
|
+
);
|
|
201
|
+
expect(out).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { globFiles, renderGlobOutcome, DEFAULT_HEAVY_DIRS } from "./glob.ts";
|
|
6
|
+
|
|
7
|
+
let dir: string;
|
|
8
|
+
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
dir = mkdtempSync(join(tmpdir(), "glob-test-"));
|
|
11
|
+
// real source we want to find
|
|
12
|
+
mkdirSync(join(dir, "src", "sub"), { recursive: true });
|
|
13
|
+
writeFileSync(join(dir, "src", "a.py"), "");
|
|
14
|
+
writeFileSync(join(dir, "src", "sub", "b.py"), "");
|
|
15
|
+
writeFileSync(join(dir, "README.md"), "");
|
|
16
|
+
// heavy dirs that must be pruned (with files matching the pattern inside)
|
|
17
|
+
mkdirSync(join(dir, "node_modules", "pkg", "deep"), { recursive: true });
|
|
18
|
+
writeFileSync(join(dir, "node_modules", "pkg", "deep", "x.py"), "");
|
|
19
|
+
mkdirSync(join(dir, ".git", "objects"), { recursive: true });
|
|
20
|
+
writeFileSync(join(dir, ".git", "objects", "y.py"), "");
|
|
21
|
+
mkdirSync(join(dir, "dist"), { recursive: true });
|
|
22
|
+
writeFileSync(join(dir, "dist", "z.py"), "");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
26
|
+
|
|
27
|
+
describe("globFiles", () => {
|
|
28
|
+
it("matches real files and prunes heavy dirs (node_modules/.git/dist)", async () => {
|
|
29
|
+
const { matches, scanTruncated, matchTruncated } = await globFiles("**/*.py", { base: dir });
|
|
30
|
+
const rel = matches.map((m) => m.slice(dir.length + 1)).sort();
|
|
31
|
+
expect(rel).toEqual(["src/a.py", "src/sub/b.py"]);
|
|
32
|
+
expect(matches.some((m) => m.includes("node_modules"))).toBe(false);
|
|
33
|
+
expect(matches.some((m) => m.includes(".git"))).toBe(false);
|
|
34
|
+
expect(matches.some((m) => m.includes("/dist/"))).toBe(false);
|
|
35
|
+
expect(scanTruncated).toBe(false);
|
|
36
|
+
expect(matchTruncated).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("caps matches at maxMatches and flags matchTruncated", async () => {
|
|
40
|
+
const many = mkdtempSync(join(tmpdir(), "glob-many-"));
|
|
41
|
+
for (let i = 0; i < 50; i++) writeFileSync(join(many, `f${i}.txt`), "");
|
|
42
|
+
try {
|
|
43
|
+
const { matches, matchTruncated } = await globFiles("*.txt", { base: many, maxMatches: 10 });
|
|
44
|
+
expect(matches.length).toBe(10);
|
|
45
|
+
expect(matchTruncated).toBe(true);
|
|
46
|
+
} finally {
|
|
47
|
+
rmSync(many, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("stops the walk at maxScan and flags scanTruncated (memory bound)", async () => {
|
|
52
|
+
// A low budget must halt the walk regardless of how many entries exist.
|
|
53
|
+
const { scanned, scanTruncated } = await globFiles("**/*", { base: dir, maxScan: 3 });
|
|
54
|
+
expect(scanTruncated).toBe(true);
|
|
55
|
+
expect(scanned).toBeLessThanOrEqual(5); // a couple over the budget, not unbounded
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("the heavy-dir set covers the usual offenders", () => {
|
|
59
|
+
for (const d of ["node_modules", ".git", "dist", ".cache", "Library", "venv", "target"]) {
|
|
60
|
+
expect(DEFAULT_HEAVY_DIRS.has(d)).toBe(true);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("renderGlobOutcome", () => {
|
|
66
|
+
it("reports no matches plainly", () => {
|
|
67
|
+
expect(renderGlobOutcome({ matches: [], scanned: 5, scanTruncated: false, matchTruncated: false }))
|
|
68
|
+
.toBe("No files matched");
|
|
69
|
+
});
|
|
70
|
+
it("notes scan truncation when nothing matched", () => {
|
|
71
|
+
expect(renderGlobOutcome({ matches: [], scanned: 9, scanTruncated: true, matchTruncated: false }, 200000))
|
|
72
|
+
.toMatch(/stopped after scanning 200000 entries/);
|
|
73
|
+
});
|
|
74
|
+
it("appends a match-cap note", () => {
|
|
75
|
+
const text = renderGlobOutcome(
|
|
76
|
+
{ matches: ["/a", "/b"], scanned: 2, scanTruncated: false, matchTruncated: true },
|
|
77
|
+
200000,
|
|
78
|
+
500,
|
|
79
|
+
);
|
|
80
|
+
expect(text).toMatch(/stopped at 500 matches/);
|
|
81
|
+
});
|
|
82
|
+
it("appends a scan-cap note when there were partial matches", () => {
|
|
83
|
+
const text = renderGlobOutcome(
|
|
84
|
+
{ matches: ["/a"], scanned: 9, scanTruncated: true, matchTruncated: false },
|
|
85
|
+
200000,
|
|
86
|
+
);
|
|
87
|
+
expect(text).toMatch(/results may be incomplete/);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { glob as fsGlob } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
// Bounded file globbing. The naive `for await (…glob…) { if (len>=500) break }`
|
|
4
|
+
// only caps MATCHES — it does nothing about the WALK. Run from a huge root
|
|
5
|
+
// (e.g. a home directory with macOS Library / caches / node_modules), fs.glob
|
|
6
|
+
// recursively descends everything, and its internal traversal state grows until
|
|
7
|
+
// the Node process OOMs (heap, not the model's context) — long before 500
|
|
8
|
+
// matches are found if matches are sparse. fs.glob exposes no signal/abort and
|
|
9
|
+
// no depth/scan cap, so we bound it through the one hook it does call for every
|
|
10
|
+
// entry: `exclude`. We use it to (a) prune heavy/irrelevant directories so they
|
|
11
|
+
// are never descended, and (b) meter total entries scanned — once the budget is
|
|
12
|
+
// hit, exclude everything, which winds the walk down.
|
|
13
|
+
|
|
14
|
+
/** Directories never worth descending for a file search — pruned at the dir
|
|
15
|
+
* level (returning true from `exclude` on a directory stops descent), which is
|
|
16
|
+
* what keeps a home-directory glob from exhausting memory. */
|
|
17
|
+
export const DEFAULT_HEAVY_DIRS: ReadonlySet<string> = new Set([
|
|
18
|
+
// version control
|
|
19
|
+
".git", ".hg", ".svn",
|
|
20
|
+
// dependencies / language caches
|
|
21
|
+
"node_modules", ".venv", "venv", "__pycache__", ".tox", ".mypy_cache",
|
|
22
|
+
".pytest_cache", ".gradle", ".cargo", "vendor", "Pods",
|
|
23
|
+
// build output
|
|
24
|
+
"dist", "build", "out", "target", ".next", ".nuxt", ".output", ".svelte-kit",
|
|
25
|
+
// tool caches
|
|
26
|
+
".cache", ".npm", ".pnpm-store", ".yarn", ".turbo",
|
|
27
|
+
// macOS / system heavies that blow up a home-dir walk
|
|
28
|
+
"Library", "Applications", ".Trash", "Photos Library.photoslibrary",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export interface GlobOptions {
|
|
32
|
+
base: string;
|
|
33
|
+
maxScan?: number;
|
|
34
|
+
maxMatches?: number;
|
|
35
|
+
heavyDirs?: ReadonlySet<string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GlobOutcome {
|
|
39
|
+
matches: string[];
|
|
40
|
+
scanned: number;
|
|
41
|
+
/** the walk was cut short at maxScan entries (results may be incomplete) */
|
|
42
|
+
scanTruncated: boolean;
|
|
43
|
+
/** matches were capped at maxMatches */
|
|
44
|
+
matchTruncated: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_MAX_SCAN = 200_000;
|
|
48
|
+
export const DEFAULT_MAX_MATCHES = 500;
|
|
49
|
+
|
|
50
|
+
export async function globFiles(pattern: string, opts: GlobOptions): Promise<GlobOutcome> {
|
|
51
|
+
const maxScan = opts.maxScan ?? DEFAULT_MAX_SCAN;
|
|
52
|
+
const maxMatches = opts.maxMatches ?? DEFAULT_MAX_MATCHES;
|
|
53
|
+
const heavy = opts.heavyDirs ?? DEFAULT_HEAVY_DIRS;
|
|
54
|
+
|
|
55
|
+
const matches: string[] = [];
|
|
56
|
+
let scanned = 0;
|
|
57
|
+
let scanTruncated = false;
|
|
58
|
+
let matchTruncated = false;
|
|
59
|
+
|
|
60
|
+
// Called for every entry the walk visits (files AND directories). Pruning a
|
|
61
|
+
// directory here stops descent into it. Also our scan meter: once the budget
|
|
62
|
+
// is spent, exclude everything so fs.glob stops adding work and ends.
|
|
63
|
+
const exclude = (entry: unknown): boolean => {
|
|
64
|
+
scanned++;
|
|
65
|
+
if (scanned > maxScan) {
|
|
66
|
+
scanTruncated = true;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
const name = typeof entry === "string" ? entry : String((entry as { name?: string })?.name ?? entry);
|
|
70
|
+
return name.split(/[\\/]/).some((seg) => heavy.has(seg));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// `exclude` as a predicate isn't in every @types/node version's fs.glob
|
|
74
|
+
// signature, but it's supported at runtime (Node 22+); cast to pass it through.
|
|
75
|
+
for await (const m of fsGlob(pattern, { cwd: opts.base, exclude } as Parameters<typeof fsGlob>[1])) {
|
|
76
|
+
matches.push(`${opts.base}/${m}`);
|
|
77
|
+
if (matches.length >= maxMatches) {
|
|
78
|
+
matchTruncated = true;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
matches.sort();
|
|
84
|
+
return { matches, scanned, scanTruncated, matchTruncated };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Render a globFiles outcome as the tool's text output, with a one-line note
|
|
88
|
+
* when results were cut short so the model knows to narrow its search. */
|
|
89
|
+
export function renderGlobOutcome(o: GlobOutcome, maxScan = DEFAULT_MAX_SCAN, maxMatches = DEFAULT_MAX_MATCHES): string {
|
|
90
|
+
if (o.matches.length === 0) {
|
|
91
|
+
return o.scanTruncated
|
|
92
|
+
? `No files matched (search stopped after scanning ${maxScan} entries — narrow the base path; build/dependency/cache dirs are skipped automatically).`
|
|
93
|
+
: "No files matched";
|
|
94
|
+
}
|
|
95
|
+
let text = o.matches.join("\n");
|
|
96
|
+
if (o.matchTruncated) {
|
|
97
|
+
text += `\n… (stopped at ${maxMatches} matches — narrow the pattern for the rest)`;
|
|
98
|
+
} else if (o.scanTruncated) {
|
|
99
|
+
text += `\n… (search stopped after scanning ${maxScan} entries — results may be incomplete; narrow the base path)`;
|
|
100
|
+
}
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "@sinclair/typebox";
|
|
3
|
-
import {
|
|
3
|
+
import { globFiles, renderGlobOutcome } from "./glob.ts";
|
|
4
4
|
|
|
5
5
|
// Ports of tools.py::_glob, _webfetch, _websearch. Pi ships its own grep/find,
|
|
6
6
|
// so those are not re-registered here.
|
|
@@ -10,7 +10,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
10
10
|
name: "glob",
|
|
11
11
|
label: "Glob",
|
|
12
12
|
description:
|
|
13
|
-
"Find files matching a glob pattern. Returns a sorted list of matching paths (up to 500)."
|
|
13
|
+
"Find files matching a glob pattern. Returns a sorted list of matching paths (up to 500). " +
|
|
14
|
+
"Common dependency/build/cache dirs (node_modules, .git, dist, …) are skipped, and the walk " +
|
|
15
|
+
"is bounded — for a focused search, pass a `path` rather than globbing a whole home directory.",
|
|
14
16
|
parameters: Type.Object({
|
|
15
17
|
pattern: Type.String({ description: "Glob pattern e.g. **/*.py" }),
|
|
16
18
|
path: Type.Optional(Type.String({ description: "Base directory (default: cwd)" })),
|
|
@@ -18,16 +20,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
18
20
|
async execute(_id, { pattern, path }) {
|
|
19
21
|
try {
|
|
20
22
|
const base = path || process.cwd();
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
matches.push(`${base}/${m}`);
|
|
25
|
-
if (matches.length >= 500) break;
|
|
26
|
-
}
|
|
27
|
-
matches.sort();
|
|
28
|
-
const text = matches.length === 0 ? "No files matched" : matches.join("\n");
|
|
23
|
+
// Bounded walk: prunes heavy dirs and caps total entries scanned so a
|
|
24
|
+
// recursive glob from a huge root can't exhaust the process heap.
|
|
25
|
+
const outcome = await globFiles(pattern, { base });
|
|
29
26
|
return {
|
|
30
|
-
content: [{ type: "text", text }],
|
|
27
|
+
content: [{ type: "text", text: renderGlobOutcome(outcome) }],
|
|
31
28
|
details: {},
|
|
32
29
|
};
|
|
33
30
|
} catch (e) {
|
|
@@ -5,6 +5,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import {
|
|
7
7
|
applyEnvOverrides,
|
|
8
|
+
fillModelDefaults,
|
|
8
9
|
loadProviders,
|
|
9
10
|
mergeProviders,
|
|
10
11
|
resolveOverridePath,
|
|
@@ -195,6 +196,121 @@ describe("shipped models.json", () => {
|
|
|
195
196
|
});
|
|
196
197
|
});
|
|
197
198
|
|
|
199
|
+
describe("fillModelDefaults (issue #36)", () => {
|
|
200
|
+
// The crash was: a user models.json entry that omitted name/maxTokens/cost
|
|
201
|
+
// reached pi's registry as `model.cost === undefined`, which then exploded
|
|
202
|
+
// with "Cannot read properties of undefined (reading 'input')" deep in
|
|
203
|
+
// applyModelOverride. Filling the same defaults pi uses internally lets a
|
|
204
|
+
// minimal entry round-trip safely.
|
|
205
|
+
it("fills name/maxTokens/cost/input/contextWindow/reasoning when missing", () => {
|
|
206
|
+
const out = fillModelDefaults({ id: "foo.gguf" }, "llamacpp", 0);
|
|
207
|
+
expect(out).toMatchObject({
|
|
208
|
+
id: "foo.gguf",
|
|
209
|
+
name: "foo.gguf",
|
|
210
|
+
reasoning: false,
|
|
211
|
+
input: ["text"],
|
|
212
|
+
contextWindow: 32768,
|
|
213
|
+
maxTokens: 4096,
|
|
214
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("preserves user-supplied values over defaults", () => {
|
|
219
|
+
const out = fillModelDefaults(
|
|
220
|
+
{
|
|
221
|
+
id: "Qwen3.6-27B-Q4_K_M.gguf",
|
|
222
|
+
reasoning: true,
|
|
223
|
+
input: ["text", "image"],
|
|
224
|
+
contextWindow: 262144,
|
|
225
|
+
},
|
|
226
|
+
"llamacpp",
|
|
227
|
+
0,
|
|
228
|
+
);
|
|
229
|
+
expect(out.reasoning).toBe(true);
|
|
230
|
+
expect(out.input).toEqual(["text", "image"]);
|
|
231
|
+
expect(out.contextWindow).toBe(262144);
|
|
232
|
+
// Still defaulted:
|
|
233
|
+
expect(out.maxTokens).toBe(4096);
|
|
234
|
+
expect(out.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("preserves unknown extra fields (e.g. _launch)", () => {
|
|
238
|
+
const out: any = fillModelDefaults({ id: "x", _launch: true }, "llamacpp", 0);
|
|
239
|
+
expect(out._launch).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("throws with a precise pointer when id is missing", () => {
|
|
243
|
+
expect(() => fillModelDefaults({}, "llamacpp", 2)).toThrow(/provider 'llamacpp' model at index 2/);
|
|
244
|
+
expect(() => fillModelDefaults({ id: "" }, "llamacpp", 0)).toThrow(/missing or invalid "id"/);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("loadProviders with an under-specified user override (issue #36)", () => {
|
|
249
|
+
let dir: string;
|
|
250
|
+
beforeEach(() => {
|
|
251
|
+
dir = mkdtempSync(join(tmpdir(), "lc-providers36-"));
|
|
252
|
+
});
|
|
253
|
+
afterEach(() => {
|
|
254
|
+
rmSync(dir, { recursive: true, force: true });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("a minimal user model entry no longer leaves cost undefined", () => {
|
|
258
|
+
writeFileSync(join(dir, "models.json"), JSON.stringify({ providers: {} }));
|
|
259
|
+
const userPath = join(dir, "user.json");
|
|
260
|
+
writeFileSync(
|
|
261
|
+
userPath,
|
|
262
|
+
JSON.stringify({
|
|
263
|
+
providers: {
|
|
264
|
+
llamacpp: {
|
|
265
|
+
api: "openai-completions",
|
|
266
|
+
apiKey: "llama",
|
|
267
|
+
baseUrl: "http://127.0.0.1:8020/v1",
|
|
268
|
+
models: [
|
|
269
|
+
{
|
|
270
|
+
_launch: true,
|
|
271
|
+
contextWindow: 262144,
|
|
272
|
+
id: "Qwen3.6-27B-Q4_K_M.gguf",
|
|
273
|
+
input: ["text", "image"],
|
|
274
|
+
reasoning: true,
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
const result = loadProviders(dir, { LITTLE_CODER_MODELS_FILE: userPath });
|
|
282
|
+
const m = result.providers.llamacpp.models[0];
|
|
283
|
+
expect(m.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
|
284
|
+
expect(m.maxTokens).toBe(4096);
|
|
285
|
+
expect(m.name).toBe("Qwen3.6-27B-Q4_K_M.gguf");
|
|
286
|
+
// User-supplied values must win:
|
|
287
|
+
expect(m.contextWindow).toBe(262144);
|
|
288
|
+
expect(m.input).toEqual(["text", "image"]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("a model entry without an id is reported as invalid, not silently passed through", () => {
|
|
292
|
+
writeFileSync(join(dir, "models.json"), JSON.stringify({ providers: {} }));
|
|
293
|
+
const userPath = join(dir, "user.json");
|
|
294
|
+
writeFileSync(
|
|
295
|
+
userPath,
|
|
296
|
+
JSON.stringify({
|
|
297
|
+
providers: {
|
|
298
|
+
llamacpp: {
|
|
299
|
+
api: "openai-completions",
|
|
300
|
+
apiKey: "k",
|
|
301
|
+
baseUrl: "http://x/v1",
|
|
302
|
+
models: [{ reasoning: true }],
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
307
|
+
const result = loadProviders(dir, { LITTLE_CODER_MODELS_FILE: userPath });
|
|
308
|
+
const userSrc = result.sources.find((s) => s.path === userPath);
|
|
309
|
+
expect(userSrc?.status).toBe("invalid");
|
|
310
|
+
expect(userSrc?.error).toMatch(/missing or invalid "id"/);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
198
314
|
describe("propsUrlFor", () => {
|
|
199
315
|
it("strips a trailing /v1 and points at the server root /props", () => {
|
|
200
316
|
expect(propsUrlFor("http://127.0.0.1:8888/v1")).toBe("http://127.0.0.1:8888/props");
|
|
@@ -67,9 +67,42 @@ function parseModelsFile(raw: string): ModelsFile {
|
|
|
67
67
|
if (!parsed || typeof parsed !== "object" || !parsed.providers || typeof parsed.providers !== "object") {
|
|
68
68
|
throw new Error("expected top-level { providers: { ... } }");
|
|
69
69
|
}
|
|
70
|
+
const providers = parsed.providers as Record<string, ProviderEntry>;
|
|
71
|
+
for (const [name, entry] of Object.entries(providers)) {
|
|
72
|
+
if (!entry || typeof entry !== "object" || !Array.isArray(entry.models)) continue;
|
|
73
|
+
entry.models = entry.models.map((m, i) => fillModelDefaults(m, name, i));
|
|
74
|
+
}
|
|
70
75
|
return parsed as ModelsFile;
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Fill in defaults for optional model fields that pi requires downstream.
|
|
80
|
+
* pi's `registerProvider` path stores model entries verbatim, so a user
|
|
81
|
+
* override that omits e.g. `cost` ends up with `model.cost === undefined`,
|
|
82
|
+
* and the model registry's per-model override path crashes with
|
|
83
|
+
* "Cannot read properties of undefined (reading 'input')" (issue #36) when
|
|
84
|
+
* it tries to read `model.cost.input`. Filling the same defaults pi uses
|
|
85
|
+
* for built-in models means a minimal user entry — just an id — works.
|
|
86
|
+
*
|
|
87
|
+
* The `id` field is the only true requirement. We throw with a precise
|
|
88
|
+
* pointer when it's missing so the caller can route this to the source-list
|
|
89
|
+
* diagnostics rather than crashing pi.
|
|
90
|
+
*/
|
|
91
|
+
export function fillModelDefaults(m: any, providerName: string, index: number): ProviderModelEntry {
|
|
92
|
+
if (!m || typeof m !== "object" || typeof m.id !== "string" || m.id.length === 0) {
|
|
93
|
+
throw new Error(`provider '${providerName}' model at index ${index}: missing or invalid "id"`);
|
|
94
|
+
}
|
|
95
|
+
const defaults = {
|
|
96
|
+
name: m.id,
|
|
97
|
+
reasoning: false,
|
|
98
|
+
input: ["text"] as ("text" | "image")[],
|
|
99
|
+
contextWindow: 32768,
|
|
100
|
+
maxTokens: 4096,
|
|
101
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
102
|
+
};
|
|
103
|
+
return { ...defaults, ...m };
|
|
104
|
+
}
|
|
105
|
+
|
|
73
106
|
function readIfPresent(path: string): { kind: "ok"; data: ModelsFile } | { kind: "missing" } | { kind: "invalid"; error: string } {
|
|
74
107
|
if (!existsSync(path)) return { kind: "missing" };
|
|
75
108
|
try {
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to little-coder are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and little-coder's public interface (CLI, providers, tools, skills) follows semver starting at `v0.0.1` post-rename.
|
|
4
4
|
|
|
5
|
+
## [v1.8.2] — 2026-05-25
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Minimal user `models.json` entries no longer crash startup with `Cannot read properties of undefined (reading 'input')`** ([#36](https://github.com/itayinbarr/little-coder/issues/36)). The shipped `models.json` declares every field — `id`, `name`, `reasoning`, `input`, `contextWindow`, `maxTokens`, `cost` — but a user override that omitted e.g. `name`/`maxTokens`/`cost` was passed through unchanged to pi's registry, which then exploded deep in `applyModelOverride` when it tried to read `model.cost.input`. `llama-cpp-provider` now fills in the same defaults pi uses for built-in models (`name = id`, `reasoning = false`, `input = ["text"]`, `contextWindow = 32768`, `maxTokens = 4096`, zero-cost) so a minimal entry — just `id` plus the provider's `baseUrl`/`apiKey` — works. User-supplied values still win over defaults; unknown extra fields (e.g. `_launch`) are preserved. A model entry that omits `id` is now flagged with a precise error in the source diagnostics instead of crashing pi. New `fillModelDefaults` helper, plus regression tests using the exact entry shape from the issue report.
|
|
9
|
+
- **`temperature' is not supported with this model` against Copilot GPT-5.x / OpenAI o-series** ([#33](https://github.com/itayinbarr/little-coder/issues/33)). `benchmark-profiles` was injecting `temperature: 0.3` from `default_model_profile` into every outgoing chat-completions payload, but hosted reasoning models hard-reject the parameter with a 400. The temperature injection is now gated on the provider: it ships on for `llamacpp`, `ollama`, and `lmstudio` (the providers it was tuned for) and is skipped for everything else. New env var `LITTLE_CODER_TEMPERATURE_PROVIDERS=foo,bar` replaces the default list when you bring your own local provider (e.g. `vllm`). New exported, tested `providerAcceptsTemperature()`; end-to-end test fires `before_agent_start` + `before_provider_request` and asserts the copilot path returns no payload mutation.
|
|
10
|
+
|
|
11
|
+
### Notes for upgraders
|
|
12
|
+
- No CLI-flag or public-API changes. If you previously relied on temperature 0.3 reaching a non-local provider via the default profile (uncommon — most hosted providers reject it), add that provider name to `LITTLE_CODER_TEMPERATURE_PROVIDERS`.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## [v1.8.1] — 2026-05-23
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- **`glob` no longer exhausts memory on a recursive search from a huge root.** The tool capped *matches* at 500 but never bounded the *walk*: run from a home directory (or any tree with macOS `Library`, caches, or `node_modules`), `fs.glob` recursively descended everything and its internal traversal state grew until the Node **process** ran out of heap — a host-memory crash (`Ineffective mark-compacts near heap limit`), entirely distinct from the model's *context window* (the read-guard / window machinery operates on tool *results* in tokens; this died mid-walk, before any result existed). The walk is now bounded two ways: heavy/irrelevant directories (`node_modules`, `.git`, `dist`, `.cache`, `Library`, `venv`, `target`, …) are **pruned** — never descended — and a hard scan budget (200 000 entries) halts the walk through the one hook `fs.glob` calls per entry (`exclude`), since it exposes no signal/abort. When results are cut short the output says so, so the model narrows its search. New unit-tested `globFiles` / `renderGlobOutcome` helpers (`.pi/extensions/extra-tools/glob.ts`), verified to prune `node_modules` (0 descent) and to halt at the scan budget.
|
|
20
|
+
|
|
21
|
+
### Notes for upgraders
|
|
22
|
+
- For a focused search, pass a `path` (a project subdirectory) instead of globbing from a home directory. Hidden directories continue to be skipped by `fs.glob` as before.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
5
26
|
## [v1.8.0] — 2026-05-23
|
|
6
27
|
|
|
7
28
|
little-coder now **auto-detects the llama.cpp server's live context window** at startup and registers the model with it, so a `llama-server -c 131072` shows 128k instead of the declared default — no config edit. This completes [v1.7.0](#v170--2026-05-23): the budget already *followed* the registered window; now the registered window itself comes from the running server.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "little-coder",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.2",
|
|
4
4
|
"description": "A pi-based coding agent optimized for small local language models. Reproduces the whitepaper's scaffold-model-fit adaptations as pi extensions.",
|
|
5
5
|
"homepage": "https://github.com/itayinbarr/little-coder",
|
|
6
6
|
"repository": {
|