Skip to content

Commit 85e130d

Browse files
fix(context-limit): tag-suffix fallback + take-larger across cache layers (issue #117)
Reported as >1000% context for ollama-cloud models. Two real root causes, both in getModelsDevContextLimit: 1. Tag-suffix mismatch (deterministic): ollama invokes cloud models with a tag (deepseek-v4-pro:cloud) while models.dev stores them tag-less (deepseek-v4-pro). Exact-only match missed → fell to the 128k default → wrong pressure denominator. Now: exact match first (never collapses a legitimately tagged model like gemma3:27b), then retry with the last :tag stripped. 2. Wrong live-API value overriding the correct file value (intermittent): the apiCache (from /config/providers) had absolute priority, so ollama reporting its tiny default num_ctx (e.g. 8k) for a 1M-window cloud model overrode the correct models.dev value. Now: when BOTH layers know the model, take the LARGER limit. Providers never under-report their real window; a genuinely smaller real limit is still captured via the overflow-detection path (detectedContextLimit), not here. Verified against live models.json: ollama-cloud/deepseek-v4-pro is stored tag-less at context 1048576. +4 tests. Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent ec49f3c commit 85e130d

2 files changed

Lines changed: 114 additions & 11 deletions

File tree

packages/plugin/src/shared/models-dev-cache.test.ts

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ describe("models-dev-cache", () => {
238238
expect(getModelsDevContextLimit("anthropic", "claude-4")).toBeUndefined();
239239
});
240240

241-
test("API cache takes priority over file cache", async () => {
241+
test("takes the larger limit when both layers know the model (API larger)", async () => {
242242
// Seed file layer with one value.
243243
const opencodeDir = join(tempDir, "opencode");
244244
mkdirSync(opencodeDir, { recursive: true });
@@ -252,7 +252,7 @@ describe("models-dev-cache", () => {
252252
// Sanity: file layer returns 100000 before API refresh.
253253
expect(getModelsDevContextLimit("anthropic", "claude-4")).toBe(100000);
254254

255-
// Mock client providing DIFFERENT value via API.
255+
// Mock client providing a LARGER value via API.
256256
const mockClient = {
257257
config: {
258258
providers: async () => ({
@@ -271,14 +271,78 @@ describe("models-dev-cache", () => {
271271
};
272272
await refreshModelLimitsFromApi(mockClient);
273273

274-
// API value wins.
274+
// Larger (API) value wins.
275275
expect(getModelsDevContextLimit("anthropic", "claude-4")).toBe(1000000);
276276

277277
const state = getModelsDevCacheState();
278278
expect(state.apiLoaded).toBe(true);
279279
expect(state.apiCount).toBe(1);
280280
});
281281

282+
test("file value wins when the live API reports a smaller (wrong) limit (issue #117)", async () => {
283+
// The ollama-cloud scenario: models.dev has the correct large window, but
284+
// ollama reports its tiny default num_ctx via the live /config/providers
285+
// API. The larger, correct file value must win so pressure isn't bogus.
286+
const opencodeDir = join(tempDir, "opencode");
287+
mkdirSync(opencodeDir, { recursive: true });
288+
writeFileSync(
289+
join(opencodeDir, "models.json"),
290+
JSON.stringify({
291+
"ollama-cloud": {
292+
models: { "deepseek-v4-pro": { limit: { context: 1048576 } } },
293+
},
294+
}),
295+
);
296+
297+
const mockClient = {
298+
config: {
299+
providers: async () => ({
300+
data: {
301+
providers: [
302+
{
303+
id: "ollama-cloud",
304+
models: {
305+
// Bogus tiny default num_ctx from ollama.
306+
"deepseek-v4-pro": { limit: { context: 8192 } },
307+
},
308+
},
309+
],
310+
},
311+
}),
312+
},
313+
};
314+
await refreshModelLimitsFromApi(mockClient);
315+
316+
// Larger (file/models.dev) value wins, not the tiny live-API value.
317+
expect(getModelsDevContextLimit("ollama-cloud", "deepseek-v4-pro")).toBe(1048576);
318+
});
319+
320+
test("matches a tagged ollama model against its tag-less models.dev entry (issue #117)", () => {
321+
// ollama invokes cloud models with a tag (deepseek-v4-pro:cloud) while
322+
// models.dev stores them tag-less (deepseek-v4-pro).
323+
const opencodeDir = join(tempDir, "opencode");
324+
mkdirSync(opencodeDir, { recursive: true });
325+
writeFileSync(
326+
join(opencodeDir, "models.json"),
327+
JSON.stringify({
328+
"ollama-cloud": {
329+
models: {
330+
"deepseek-v4-pro": { limit: { context: 1048576 } },
331+
// A legitimately-tagged model must still match exactly.
332+
"gemma3:27b": { limit: { context: 131072 } },
333+
},
334+
},
335+
}),
336+
);
337+
338+
// Tagged invocation falls back to the tag-less entry.
339+
expect(getModelsDevContextLimit("ollama-cloud", "deepseek-v4-pro:cloud")).toBe(1048576);
340+
// Exact tagged match still wins (no wrongful collapse).
341+
expect(getModelsDevContextLimit("ollama-cloud", "gemma3:27b")).toBe(131072);
342+
// Unknown tagged model with no tag-less base stays undefined.
343+
expect(getModelsDevContextLimit("ollama-cloud", "nonexistent:cloud")).toBeUndefined();
344+
});
345+
282346
test("refreshModelLimitsFromApi tolerates empty/malformed responses", async () => {
283347
// Undefined data.
284348
await refreshModelLimitsFromApi({

packages/plugin/src/shared/models-dev-cache.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -298,19 +298,58 @@ export async function refreshModelLimitsFromApi(client: OpencodeClientLike): Pro
298298
* Returns `undefined` if neither layer knows the model.
299299
*/
300300
export function getModelsDevContextLimit(providerID: string, modelID: string): number | undefined {
301-
const key = `${providerID}/${modelID}`;
302-
303-
if (apiCache) {
304-
const fromApi = apiCache.get(key)?.limit;
305-
if (typeof fromApi === "number") return fromApi;
306-
}
307-
308301
const now = Date.now();
309302
if (!fileCache || now - fileLastAttempt > RELOAD_INTERVAL_MS) {
310303
fileLastAttempt = now;
311304
fileCache = loadModelsDevMetadataFromFile();
312305
}
313-
return fileCache.get(key)?.limit;
306+
307+
const fromApi = lookupLimitWithTagFallback(apiCache, providerID, modelID);
308+
const fromFile = lookupLimitWithTagFallback(fileCache, providerID, modelID);
309+
310+
// When BOTH layers know the model, take the LARGER limit. Providers never
311+
// under-report their real window, so a suspiciously small value — e.g.
312+
// ollama reporting its default `num_ctx` (4k/8k) for a cloud model via the
313+
// live `/config/providers` API — must not override the correct, larger
314+
// models.dev value. A genuinely smaller real limit (provider actually
315+
// rejects at N) is captured separately via the overflow-detection path
316+
// (detectedContextLimit), not here. (issue #117)
317+
if (typeof fromApi === "number" && typeof fromFile === "number") {
318+
return Math.max(fromApi, fromFile);
319+
}
320+
return fromApi ?? fromFile;
321+
}
322+
323+
/**
324+
* Look up a model's limit in one cache layer, with an ollama-style tag-suffix
325+
* fallback.
326+
*
327+
* models.dev stores some models WITH a colon tag (e.g. `gemma3:27b`,
328+
* `deepseek-v3.1:671b`) and ollama-cloud base models WITHOUT one
329+
* (`deepseek-v4-pro`). But ollama invokes cloud models with a tag at runtime
330+
* (`deepseek-v4-pro:cloud`), so OpenCode reports the tagged id. An exact-only
331+
* match therefore misses → falls back to the 128k default → wrong pressure
332+
* denominator (issue #117).
333+
*
334+
* Strategy: exact match first (never collapses a legitimately-tagged model),
335+
* then retry once with the last `:tag` segment stripped.
336+
*/
337+
function lookupLimitWithTagFallback(
338+
cache: Map<string, CachedModelMetadata> | null,
339+
providerID: string,
340+
modelID: string,
341+
): number | undefined {
342+
if (!cache) return undefined;
343+
const exact = cache.get(`${providerID}/${modelID}`)?.limit;
344+
if (typeof exact === "number") return exact;
345+
346+
const colonIdx = modelID.lastIndexOf(":");
347+
if (colonIdx > 0) {
348+
const baseModel = modelID.slice(0, colonIdx);
349+
const fallback = cache.get(`${providerID}/${baseModel}`)?.limit;
350+
if (typeof fallback === "number") return fallback;
351+
}
352+
return undefined;
314353
}
315354

316355
/** Clear in-memory caches (for testing). */

0 commit comments

Comments
 (0)