fix: prefer explicit config contextWindow/maxTokens over auto-discovered values

Problem:
When a model is found in the pi-ai registry (auto-discovered from Ollama),
the explicit config in clawdbot.json was completely ignored. This caused
models to use the registry's contextWindow (e.g., 8192 from Ollama's
/api/show) instead of the user's configured value (e.g., 131072).

Error seen:
  low context window: ollama/glm-4.7:cloud ctx=8192 (warn<32000) source=model
  blocked model (context window too small): ollama/glm-4.7:cloud ctx=8192

Fix:
When a model exists in both the registry AND explicit config, merge the
config's contextWindow/maxTokens into the registry model. Only positive
numbers are accepted as overrides.

Logic:
- Model not in registry → use config (existing behavior)
- Model in registry + valid config contextWindow/maxTokens → merge config values
- Model in registry + no valid overrides → use registry (existing behavior)

test: add tests for config contextWindow/maxTokens override behavior

Tests cover:
- Registry model used when no config override
- Config contextWindow/maxTokens override registry values
- Invalid values (0, negative) are ignored, registry used instead
- Config model used when not in registry
- Error returned when model not found anywhere
This commit is contained in:
ParthSareen 2026-01-25 16:15:48 -08:00
parent 6859e1e6a6
commit 62c3f1c100
2 changed files with 164 additions and 22 deletions

View File

@ -1,15 +1,45 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildInlineProviderModels } from "./model.js";
vi.mock("@mariozechner/pi-coding-agent", () => ({
discoverAuthStorage: () => ({ profiles: {} }),
discoverModels: () => ({
find: (provider: string, modelId: string) => {
if (provider === "ollama" && modelId === "test-model") {
return {
id: "test-model",
name: "test-model",
provider: "ollama",
api: "openai-completions",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 4096,
};
}
return null;
},
}),
}));
const makeModel = (id: string) => ({
vi.mock("../agent-paths.js", () => ({
resolveClawdbotAgentDir: () => "/tmp/test-agent",
}));
vi.mock("../model-compat.js", () => ({
normalizeModelCompat: <T>(model: T) => model,
}));
import { buildInlineProviderModels, resolveModel } from "./model.js";
const makeModel = (id: string, overrides?: { contextWindow?: number; maxTokens?: number }) => ({
id,
name: id,
reasoning: false,
input: ["text"] as const,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1,
maxTokens: 1,
contextWindow: overrides?.contextWindow ?? 1,
maxTokens: overrides?.maxTokens ?? 1,
});
describe("buildInlineProviderModels", () => {
@ -27,3 +57,91 @@ describe("buildInlineProviderModels", () => {
]);
});
});
describe("resolveModel", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses registry model when no config override exists", () => {
const result = resolveModel("ollama", "test-model");
expect(result.model).toBeDefined();
expect(result.model?.contextWindow).toBe(8192);
expect(result.model?.maxTokens).toBe(4096);
});
it("overrides registry contextWindow with valid config value", () => {
const cfg = {
models: {
providers: {
ollama: {
models: [makeModel("test-model", { contextWindow: 131072, maxTokens: 32000 })],
},
},
},
};
const result = resolveModel("ollama", "test-model", undefined, cfg);
expect(result.model).toBeDefined();
expect(result.model?.contextWindow).toBe(131072);
expect(result.model?.maxTokens).toBe(32000);
});
it("ignores config contextWindow of 0", () => {
const cfg = {
models: {
providers: {
ollama: {
models: [makeModel("test-model", { contextWindow: 0 })],
},
},
},
};
const result = resolveModel("ollama", "test-model", undefined, cfg);
expect(result.model?.contextWindow).toBe(8192); // registry value
});
it("ignores config contextWindow of negative number", () => {
const cfg = {
models: {
providers: {
ollama: {
models: [makeModel("test-model", { contextWindow: -1 })],
},
},
},
};
const result = resolveModel("ollama", "test-model", undefined, cfg);
expect(result.model?.contextWindow).toBe(8192); // registry value
});
it("uses config model when not in registry", () => {
const cfg = {
models: {
providers: {
ollama: {
models: [makeModel("not-in-registry", { contextWindow: 65536 })],
},
},
},
};
const result = resolveModel("ollama", "not-in-registry", undefined, cfg);
expect(result.model).toBeDefined();
expect(result.model?.contextWindow).toBe(65536);
});
it("returns error when model not in registry and no config", () => {
const result = resolveModel("ollama", "unknown-model");
expect(result.error).toContain("Unknown model");
expect(result.model).toBeUndefined();
});
});

View File

@ -35,6 +35,20 @@ export function buildModelAliasLines(cfg?: ClawdbotConfig) {
.map((entry) => `- ${entry.alias}: ${entry.model}`);
}
/** Find explicit model config matching provider/modelId */
function findInlineModelConfig(
cfg: ClawdbotConfig | undefined,
provider: string,
modelId: string,
): InlineModelEntry | undefined {
const providers = cfg?.models?.providers ?? {};
const inlineModels = buildInlineProviderModels(providers);
const normalizedProvider = normalizeProviderId(provider);
return inlineModels.find(
(entry) => normalizeProviderId(entry.provider) === normalizedProvider && entry.id === modelId,
);
}
export function resolveModel(
provider: string,
modelId: string,
@ -49,22 +63,19 @@ export function resolveModel(
const resolvedAgentDir = agentDir ?? resolveClawdbotAgentDir();
const authStorage = discoverAuthStorage(resolvedAgentDir);
const modelRegistry = discoverModels(authStorage, resolvedAgentDir);
const model = modelRegistry.find(provider, modelId) as Model<Api> | null;
if (!model) {
const providers = cfg?.models?.providers ?? {};
const inlineModels = buildInlineProviderModels(providers);
const normalizedProvider = normalizeProviderId(provider);
const inlineMatch = inlineModels.find(
(entry) => normalizeProviderId(entry.provider) === normalizedProvider && entry.id === modelId,
);
if (inlineMatch) {
const normalized = normalizeModelCompat(inlineMatch as Model<Api>);
const registryModel = modelRegistry.find(provider, modelId) as Model<Api> | null;
const inlineConfig = findInlineModelConfig(cfg, provider, modelId);
// Case 1: Model not in registry - use inline config or fallback
if (!registryModel) {
if (inlineConfig) {
return {
model: normalized,
model: normalizeModelCompat(inlineConfig as Model<Api>),
authStorage,
modelRegistry,
};
}
const providers = cfg?.models?.providers ?? {};
const providerCfg = providers[provider];
if (providerCfg || modelId.startsWith("mock-")) {
const fallbackModel: Model<Api> = normalizeModelCompat({
@ -80,11 +91,24 @@ export function resolveModel(
} as Model<Api>);
return { model: fallbackModel, authStorage, modelRegistry };
}
return {
error: `Unknown model: ${provider}/${modelId}`,
authStorage,
modelRegistry,
};
return { error: `Unknown model: ${provider}/${modelId}`, authStorage, modelRegistry };
}
return { model: normalizeModelCompat(model), authStorage, modelRegistry };
// Case 2: Model in registry - override contextWindow/maxTokens from config if valid
if (inlineConfig) {
const ctx = inlineConfig.contextWindow;
const max = inlineConfig.maxTokens;
const useCtx = typeof ctx === "number" && ctx > 0;
const useMax = typeof max === "number" && max > 0;
if (useCtx || useMax) {
const model: Model<Api> = {
...registryModel,
...(useCtx && { contextWindow: ctx }),
...(useMax && { maxTokens: max }),
};
return { model: normalizeModelCompat(model), authStorage, modelRegistry };
}
}
return { model: normalizeModelCompat(registryModel), authStorage, modelRegistry };
}