little-coder 1.8.1 → 1.8.3

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.
@@ -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
+ });
@@ -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,
@@ -36,10 +37,13 @@ describe("resolveOverridePath", () => {
36
37
  expect(resolveOverridePath({ LITTLE_CODER_MODELS_FILE: "/explicit.json", HOME: "/h" })).toBe("/explicit.json");
37
38
  });
38
39
  it("falls back to XDG_CONFIG_HOME", () => {
39
- expect(resolveOverridePath({ XDG_CONFIG_HOME: "/xdg", HOME: "/h" })).toBe("/xdg/little-coder/models.json");
40
+ expect(resolveOverridePath({ XDG_CONFIG_HOME: "/xdg", HOME: "/h" })).toBe(join("/xdg", "little-coder", "models.json"),);
40
41
  });
41
42
  it("falls back to HOME/.config", () => {
42
- expect(resolveOverridePath({ HOME: "/h" })).toBe("/h/.config/little-coder/models.json");
43
+ expect(resolveOverridePath({ HOME: "/h" })).toBe(join("/h", ".config", "little-coder", "models.json"),);
44
+ });
45
+ it("falls back to USERPROFILE/.config when HOME is absent", () => {
46
+ expect(resolveOverridePath({ USERPROFILE: "/profile" })).toBe(join("/profile", ".config", "little-coder", "models.json"),);
43
47
  });
44
48
  it("returns undefined when neither is set", () => {
45
49
  expect(resolveOverridePath({})).toBeUndefined();
@@ -195,6 +199,121 @@ describe("shipped models.json", () => {
195
199
  });
196
200
  });
197
201
 
202
+ describe("fillModelDefaults (issue #36)", () => {
203
+ // The crash was: a user models.json entry that omitted name/maxTokens/cost
204
+ // reached pi's registry as `model.cost === undefined`, which then exploded
205
+ // with "Cannot read properties of undefined (reading 'input')" deep in
206
+ // applyModelOverride. Filling the same defaults pi uses internally lets a
207
+ // minimal entry round-trip safely.
208
+ it("fills name/maxTokens/cost/input/contextWindow/reasoning when missing", () => {
209
+ const out = fillModelDefaults({ id: "foo.gguf" }, "llamacpp", 0);
210
+ expect(out).toMatchObject({
211
+ id: "foo.gguf",
212
+ name: "foo.gguf",
213
+ reasoning: false,
214
+ input: ["text"],
215
+ contextWindow: 32768,
216
+ maxTokens: 4096,
217
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
218
+ });
219
+ });
220
+
221
+ it("preserves user-supplied values over defaults", () => {
222
+ const out = fillModelDefaults(
223
+ {
224
+ id: "Qwen3.6-27B-Q4_K_M.gguf",
225
+ reasoning: true,
226
+ input: ["text", "image"],
227
+ contextWindow: 262144,
228
+ },
229
+ "llamacpp",
230
+ 0,
231
+ );
232
+ expect(out.reasoning).toBe(true);
233
+ expect(out.input).toEqual(["text", "image"]);
234
+ expect(out.contextWindow).toBe(262144);
235
+ // Still defaulted:
236
+ expect(out.maxTokens).toBe(4096);
237
+ expect(out.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
238
+ });
239
+
240
+ it("preserves unknown extra fields (e.g. _launch)", () => {
241
+ const out: any = fillModelDefaults({ id: "x", _launch: true }, "llamacpp", 0);
242
+ expect(out._launch).toBe(true);
243
+ });
244
+
245
+ it("throws with a precise pointer when id is missing", () => {
246
+ expect(() => fillModelDefaults({}, "llamacpp", 2)).toThrow(/provider 'llamacpp' model at index 2/);
247
+ expect(() => fillModelDefaults({ id: "" }, "llamacpp", 0)).toThrow(/missing or invalid "id"/);
248
+ });
249
+ });
250
+
251
+ describe("loadProviders with an under-specified user override (issue #36)", () => {
252
+ let dir: string;
253
+ beforeEach(() => {
254
+ dir = mkdtempSync(join(tmpdir(), "lc-providers36-"));
255
+ });
256
+ afterEach(() => {
257
+ rmSync(dir, { recursive: true, force: true });
258
+ });
259
+
260
+ it("a minimal user model entry no longer leaves cost undefined", () => {
261
+ writeFileSync(join(dir, "models.json"), JSON.stringify({ providers: {} }));
262
+ const userPath = join(dir, "user.json");
263
+ writeFileSync(
264
+ userPath,
265
+ JSON.stringify({
266
+ providers: {
267
+ llamacpp: {
268
+ api: "openai-completions",
269
+ apiKey: "llama",
270
+ baseUrl: "http://127.0.0.1:8020/v1",
271
+ models: [
272
+ {
273
+ _launch: true,
274
+ contextWindow: 262144,
275
+ id: "Qwen3.6-27B-Q4_K_M.gguf",
276
+ input: ["text", "image"],
277
+ reasoning: true,
278
+ },
279
+ ],
280
+ },
281
+ },
282
+ }),
283
+ );
284
+ const result = loadProviders(dir, { LITTLE_CODER_MODELS_FILE: userPath });
285
+ const m = result.providers.llamacpp.models[0];
286
+ expect(m.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
287
+ expect(m.maxTokens).toBe(4096);
288
+ expect(m.name).toBe("Qwen3.6-27B-Q4_K_M.gguf");
289
+ // User-supplied values must win:
290
+ expect(m.contextWindow).toBe(262144);
291
+ expect(m.input).toEqual(["text", "image"]);
292
+ });
293
+
294
+ it("a model entry without an id is reported as invalid, not silently passed through", () => {
295
+ writeFileSync(join(dir, "models.json"), JSON.stringify({ providers: {} }));
296
+ const userPath = join(dir, "user.json");
297
+ writeFileSync(
298
+ userPath,
299
+ JSON.stringify({
300
+ providers: {
301
+ llamacpp: {
302
+ api: "openai-completions",
303
+ apiKey: "k",
304
+ baseUrl: "http://x/v1",
305
+ models: [{ reasoning: true }],
306
+ },
307
+ },
308
+ }),
309
+ );
310
+ const result = loadProviders(dir, { LITTLE_CODER_MODELS_FILE: userPath });
311
+ const userSrc = result.sources.find((s) => s.path === userPath);
312
+ expect(userSrc?.status).toBe("invalid");
313
+ expect(userSrc?.error).toMatch(/missing or invalid "id"/);
314
+ });
315
+ });
316
+
198
317
  describe("propsUrlFor", () => {
199
318
  it("strips a trailing /v1 and points at the server root /props", () => {
200
319
  expect(propsUrlFor("http://127.0.0.1:8888/v1")).toBe("http://127.0.0.1:8888/props");
@@ -58,7 +58,8 @@ export function resolveOverridePath(env: NodeJS.ProcessEnv = process.env): strin
58
58
  if (env.LITTLE_CODER_MODELS_FILE) return env.LITTLE_CODER_MODELS_FILE;
59
59
  const xdg = env.XDG_CONFIG_HOME;
60
60
  if (xdg) return join(xdg, "little-coder", "models.json");
61
- if (env.HOME) return join(env.HOME, ".config", "little-coder", "models.json");
61
+ const home = env.HOME || env.USERPROFILE;
62
+ if (home) return join(home, ".config", "little-coder", "models.json");
62
63
  return undefined;
63
64
  }
64
65
 
@@ -67,9 +68,42 @@ function parseModelsFile(raw: string): ModelsFile {
67
68
  if (!parsed || typeof parsed !== "object" || !parsed.providers || typeof parsed.providers !== "object") {
68
69
  throw new Error("expected top-level { providers: { ... } }");
69
70
  }
71
+ const providers = parsed.providers as Record<string, ProviderEntry>;
72
+ for (const [name, entry] of Object.entries(providers)) {
73
+ if (!entry || typeof entry !== "object" || !Array.isArray(entry.models)) continue;
74
+ entry.models = entry.models.map((m, i) => fillModelDefaults(m, name, i));
75
+ }
70
76
  return parsed as ModelsFile;
71
77
  }
72
78
 
79
+ /**
80
+ * Fill in defaults for optional model fields that pi requires downstream.
81
+ * pi's `registerProvider` path stores model entries verbatim, so a user
82
+ * override that omits e.g. `cost` ends up with `model.cost === undefined`,
83
+ * and the model registry's per-model override path crashes with
84
+ * "Cannot read properties of undefined (reading 'input')" (issue #36) when
85
+ * it tries to read `model.cost.input`. Filling the same defaults pi uses
86
+ * for built-in models means a minimal user entry — just an id — works.
87
+ *
88
+ * The `id` field is the only true requirement. We throw with a precise
89
+ * pointer when it's missing so the caller can route this to the source-list
90
+ * diagnostics rather than crashing pi.
91
+ */
92
+ export function fillModelDefaults(m: any, providerName: string, index: number): ProviderModelEntry {
93
+ if (!m || typeof m !== "object" || typeof m.id !== "string" || m.id.length === 0) {
94
+ throw new Error(`provider '${providerName}' model at index ${index}: missing or invalid "id"`);
95
+ }
96
+ const defaults = {
97
+ name: m.id,
98
+ reasoning: false,
99
+ input: ["text"] as ("text" | "image")[],
100
+ contextWindow: 32768,
101
+ maxTokens: 4096,
102
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
103
+ };
104
+ return { ...defaults, ...m };
105
+ }
106
+
73
107
  function readIfPresent(path: string): { kind: "ok"; data: ModelsFile } | { kind: "missing" } | { kind: "invalid"; error: string } {
74
108
  if (!existsSync(path)) return { kind: "missing" };
75
109
  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.3] — 2026-06-08
6
+
7
+ ### Fixed
8
+ - **User `models.json` is now found on Windows when `HOME` is unset** ([#43](https://github.com/itayinbarr/little-coder/pull/43), thanks [@A-M-D-R-3-W](https://github.com/A-M-D-R-3-W)). Windows doesn't guarantee `HOME`, but it does set `USERPROFILE`. The documented fallback `~/.config/little-coder/models.json` was therefore skipped on Windows and user-defined models never registered. `resolveOverridePath()` now falls back to `USERPROFILE` when `HOME` is absent (resolution order is unchanged where `HOME` exists: `$LITTLE_CODER_MODELS_FILE` → `$XDG_CONFIG_HOME` → `$HOME`/`$USERPROFILE` `/.config`). Path-resolution tests are now platform-neutral via `path.join`.
9
+
10
+ ### Documentation
11
+ - **Added an "Any OpenAI-compatible server (e.g. MLX / omlx)" section** to the model-configuration docs ([#40](https://github.com/itayinbarr/little-coder/issues/40)). little-coder registers providers from `models.json` rather than from pi's standalone picker extensions, so an omlx/MLX server is added by declaring a provider entry (any OpenAI-compatible `/v1` endpoint works the same way), not by installing its pi picker. The README now shows the exact `~/.config/little-coder/models.json` block.
12
+
13
+ ---
14
+
15
+ ## [v1.8.2] — 2026-05-25
16
+
17
+ ### Fixed
18
+ - **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.
19
+ - **`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.
20
+
21
+ ### Notes for upgraders
22
+ - 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`.
23
+
24
+ ---
25
+
5
26
  ## [v1.8.1] — 2026-05-23
6
27
 
7
28
  ### Fixed
package/README.md CHANGED
@@ -188,6 +188,35 @@ Then verify with `little-coder --list-models` — you should see your overridden
188
188
 
189
189
  `LLAMACPP_BASE_URL`, `OLLAMA_BASE_URL`, and `LMSTUDIO_BASE_URL` env vars still beat both files for those three providers.
190
190
 
191
+ ### Any OpenAI-compatible server (e.g. MLX / omlx)
192
+
193
+ little-coder registers providers from `models.json` — it doesn't pick up pi's standalone "picker" extensions. So a server isn't added by installing its pi picker; you add it by declaring a provider. Any OpenAI-compatible endpoint works this way, including Apple's MLX server (`mlx_lm.server`, often surfaced as **omlx**). Drop this into `~/.config/little-coder/models.json` and pick it with `little-coder --model omlx/<id>`:
194
+
195
+ ```json
196
+ {
197
+ "providers": {
198
+ "omlx": {
199
+ "api": "openai-completions",
200
+ "baseUrl": "http://127.0.0.1:8000/v1",
201
+ "apiKey": "IGNORED",
202
+ "models": [
203
+ {
204
+ "id": "Qwen3-32B-4bit",
205
+ "name": "Qwen3.6-35B-A3B (local omlx, 150K)",
206
+ "reasoning": true,
207
+ "input": ["text"],
208
+ "contextWindow": 150000,
209
+ "maxTokens": 4096,
210
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
211
+ }
212
+ ]
213
+ }
214
+ }
215
+ }
216
+ ```
217
+
218
+ Set `id` to whatever model your server reports, and `baseUrl` to its `/v1` endpoint. Verify with `little-coder --list-models`.
219
+
191
220
  `.pi/settings.json` is a separate concern: it controls per-model **profiles** (context_limit, thinking_budget, temperature, benchmark_overrides) referenced by the `<provider>/<id>` key. Profiles don't register or describe models — they only tune how little-coder runs against models that are already registered.
192
221
 
193
222
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "little-coder",
3
- "version": "1.8.1",
3
+ "version": "1.8.3",
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": {