@cortexkit/opencode-magic-context 0.22.1 → 0.22.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.
Files changed (70) hide show
  1. package/dist/config/agent-disable.d.ts +0 -9
  2. package/dist/config/agent-disable.d.ts.map +1 -1
  3. package/dist/config/schema/agent-overrides.d.ts +0 -3
  4. package/dist/config/schema/agent-overrides.d.ts.map +1 -1
  5. package/dist/features/builtin-commands/types.d.ts +0 -2
  6. package/dist/features/builtin-commands/types.d.ts.map +1 -1
  7. package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
  8. package/dist/features/magic-context/dreamer/scheduler.d.ts +0 -4
  9. package/dist/features/magic-context/dreamer/scheduler.d.ts.map +1 -1
  10. package/dist/features/magic-context/git-commits/git-log-reader.d.ts +8 -0
  11. package/dist/features/magic-context/git-commits/git-log-reader.d.ts.map +1 -1
  12. package/dist/features/magic-context/git-commits/index.d.ts +1 -0
  13. package/dist/features/magic-context/git-commits/index.d.ts.map +1 -1
  14. package/dist/features/magic-context/git-commits/indexer.d.ts.map +1 -1
  15. package/dist/features/magic-context/git-commits/storage-git-commits.d.ts.map +1 -1
  16. package/dist/features/magic-context/git-commits/sweep-coordinator.d.ts +48 -0
  17. package/dist/features/magic-context/git-commits/sweep-coordinator.d.ts.map +1 -0
  18. package/dist/features/magic-context/key-files/storage-key-files.d.ts +0 -5
  19. package/dist/features/magic-context/key-files/storage-key-files.d.ts.map +1 -1
  20. package/dist/features/magic-context/literal-probes.d.ts +24 -0
  21. package/dist/features/magic-context/literal-probes.d.ts.map +1 -0
  22. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  23. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  24. package/dist/features/magic-context/search.d.ts +7 -0
  25. package/dist/features/magic-context/search.d.ts.map +1 -1
  26. package/dist/features/magic-context/storage-db.d.ts +1 -1
  27. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  28. package/dist/features/magic-context/storage-notes.d.ts +8 -0
  29. package/dist/features/magic-context/storage-notes.d.ts.map +1 -1
  30. package/dist/hooks/magic-context/compartment-runner-types.d.ts +14 -1
  31. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  32. package/dist/hooks/magic-context/derive-budgets.d.ts +3 -3
  33. package/dist/hooks/magic-context/event-handler.d.ts +7 -0
  34. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  35. package/dist/hooks/magic-context/event-payloads.d.ts +7 -0
  36. package/dist/hooks/magic-context/event-payloads.d.ts.map +1 -1
  37. package/dist/hooks/magic-context/event-resolvers.d.ts +1 -0
  38. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  39. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  40. package/dist/hooks/magic-context/live-session-state.d.ts +12 -0
  41. package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
  42. package/dist/hooks/magic-context/recomp-orchestrator.d.ts +7 -2
  43. package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
  44. package/dist/hooks/magic-context/system-prompt-hash.d.ts +9 -0
  45. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  46. package/dist/hooks/magic-context/tag-content-primitives.d.ts +23 -0
  47. package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
  48. package/dist/hooks/magic-context/temporal-awareness.d.ts.map +1 -1
  49. package/dist/hooks/magic-context/text-complete.d.ts +23 -0
  50. package/dist/hooks/magic-context/text-complete.d.ts.map +1 -1
  51. package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
  52. package/dist/hooks/magic-context/transform.d.ts +9 -0
  53. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +561 -190
  56. package/dist/plugin/dream-timer.d.ts.map +1 -1
  57. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  58. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  59. package/dist/shared/models-dev-cache.d.ts +54 -27
  60. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  61. package/dist/shared/rpc-types.d.ts +3 -1
  62. package/dist/shared/rpc-types.d.ts.map +1 -1
  63. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  64. package/dist/tools/ctx-search/tools.d.ts.map +1 -1
  65. package/package.json +1 -1
  66. package/src/shared/models-dev-cache.test.ts +192 -360
  67. package/src/shared/models-dev-cache.ts +162 -193
  68. package/src/shared/rpc-types.ts +3 -1
  69. package/src/tui/index.tsx +17 -8
  70. package/src/tui/slots/sidebar-content.tsx +20 -10
@@ -1,44 +1,40 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
6
6
  clearModelsDevCache,
7
7
  getModelsDevCacheState,
8
- getModelsDevContextLimit,
8
+ getSdkContextLimit,
9
9
  refreshModelLimitsFromApi,
10
10
  } from "./models-dev-cache";
11
11
 
12
- describe("models-dev-cache", () => {
12
+ /**
13
+ * Model context limits resolve from OpenCode's SDK only (`config.providers()`),
14
+ * bounded to a sane [20k, 3M] range, with a persisted last-known-good cache for
15
+ * cold start. We no longer read OpenCode's `models.json` file ourselves (a torn
16
+ * read produced impossible limits and a stale copy out-voted the live cap).
17
+ */
18
+ describe("models-dev-cache (SDK-only)", () => {
13
19
  let tempDir: string;
14
- let originalEnv: Record<string, string | undefined>;
20
+ let originalXdgData: string | undefined;
21
+
22
+ function makeClient(providers: Array<unknown>) {
23
+ return { config: { providers: async () => ({ data: { providers } }) } };
24
+ }
15
25
 
16
26
  beforeEach(() => {
17
27
  tempDir = mkdtempSync(join(tmpdir(), "mc-models-dev-"));
18
- originalEnv = {
19
- OPENCODE_MODELS_PATH: process.env.OPENCODE_MODELS_PATH,
20
- OPENCODE_MODELS_URL: process.env.OPENCODE_MODELS_URL,
21
- XDG_CACHE_HOME: process.env.XDG_CACHE_HOME,
22
- OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
23
- };
24
- // Isolate from user environment — including user's ~/.config/opencode/opencode.jsonc
25
- // which may have custom provider limits that would override models.json entries.
26
- delete process.env.OPENCODE_MODELS_PATH;
27
- delete process.env.OPENCODE_MODELS_URL;
28
- process.env.XDG_CACHE_HOME = tempDir;
29
- // Point at an empty directory so no opencode.json{c} is read unless the test writes one.
30
- const emptyConfigDir = join(tempDir, "config", "opencode");
31
- mkdirSync(emptyConfigDir, { recursive: true });
32
- process.env.OPENCODE_CONFIG_DIR = emptyConfigDir;
28
+ // Isolate the persisted-cache file under a temp data dir so tests never
29
+ // touch the real ~/.local/share/cortexkit/magic-context cache.
30
+ originalXdgData = process.env.XDG_DATA_HOME;
31
+ process.env.XDG_DATA_HOME = tempDir;
33
32
  clearModelsDevCache();
34
33
  });
35
34
 
36
35
  afterEach(() => {
37
- // Restore env.
38
- for (const [k, v] of Object.entries(originalEnv)) {
39
- if (v === undefined) delete process.env[k];
40
- else process.env[k] = v;
41
- }
36
+ if (originalXdgData === undefined) delete process.env.XDG_DATA_HOME;
37
+ else process.env.XDG_DATA_HOME = originalXdgData;
42
38
  try {
43
39
  rmSync(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
44
40
  } catch {
@@ -47,66 +43,46 @@ describe("models-dev-cache", () => {
47
43
  clearModelsDevCache();
48
44
  });
49
45
 
50
- test("reads context limits from models.json under XDG_CACHE_HOME", () => {
51
- const opencodeDir = join(tempDir, "opencode");
52
- mkdirSync(opencodeDir, { recursive: true });
53
- writeFileSync(
54
- join(opencodeDir, "models.json"),
55
- JSON.stringify({
56
- anthropic: {
46
+ test("resolves from the SDK and prefers limit.input over limit.context", async () => {
47
+ // github-copilot / codex shape: input is the max prompt, context the total
48
+ // window. Pressure math must use the input cap (OpenCode's own overflow.ts
49
+ // does the same), so a 400k-context / 272k-input model resolves to 272k.
50
+ await refreshModelLimitsFromApi(
51
+ makeClient([
52
+ {
53
+ id: "github-copilot",
57
54
  models: {
58
- "claude-sonnet-4-6": { limit: { context: 200000 } },
59
- },
60
- },
61
- "github-copilot": {
62
- models: {
63
- "gpt-5.3-codex": { limit: { context: 400000 } },
55
+ "gpt-5.3-codex": { limit: { context: 400000, input: 272000 } },
56
+ "legacy-only-context": { limit: { context: 100000 } },
64
57
  },
65
58
  },
66
- }),
59
+ ]),
67
60
  );
68
61
 
69
- expect(getModelsDevContextLimit("anthropic", "claude-sonnet-4-6")).toBe(200000);
70
- expect(getModelsDevContextLimit("github-copilot", "gpt-5.3-codex")).toBe(400000);
71
- expect(getModelsDevContextLimit("unknown", "unknown")).toBeUndefined();
62
+ expect(getSdkContextLimit("github-copilot", "gpt-5.3-codex")).toBe(272000);
63
+ expect(getSdkContextLimit("github-copilot", "legacy-only-context")).toBe(100000);
64
+ expect(getSdkContextLimit("unknown", "unknown")).toBeUndefined();
72
65
  });
73
66
 
74
- test("prefers limit.input over limit.context when both are present", () => {
75
- //#given GitHub Copilot shape: input is max prompt, context is total window.
76
- // Matches real-world github-copilot/gpt-5.3-codex which has
77
- // limit.context = 400000 (total), limit.input = 272000 (max prompt).
78
- // Our pressure math must use the input cap; sending a 400K prompt gets rejected.
79
- // OpenCode's own session/overflow.ts follows the same rule.
80
- const opencodeDir = join(tempDir, "opencode");
81
- mkdirSync(opencodeDir, { recursive: true });
82
- writeFileSync(
83
- join(opencodeDir, "models.json"),
84
- JSON.stringify({
85
- "github-copilot": {
86
- models: {
87
- "gpt-5.3-codex": { limit: { context: 400000, input: 272000 } },
88
- "claude-opus-4.6": { limit: { context: 144000, input: 128000 } },
89
- // Context-only model (no input) falls back to context.
90
- "legacy-only-context": { limit: { context: 100000 } },
91
- },
67
+ test("Codex-OAuth cap is honored: a 400k/272k gpt-5.5 resolves to 272k (not the stale 922k)", async () => {
68
+ // The bug we're fixing: the SDK reports the auth-resolved cap; nothing may
69
+ // out-vote it with a larger stale value.
70
+ await refreshModelLimitsFromApi(
71
+ makeClient([
72
+ {
73
+ id: "openai",
74
+ models: { "gpt-5.5": { limit: { context: 400000, input: 272000 } } },
92
75
  },
93
- }),
76
+ ]),
94
77
  );
95
-
96
- //#then
97
- expect(getModelsDevContextLimit("github-copilot", "gpt-5.3-codex")).toBe(272000);
98
- expect(getModelsDevContextLimit("github-copilot", "claude-opus-4.6")).toBe(128000);
99
- expect(getModelsDevContextLimit("github-copilot", "legacy-only-context")).toBe(100000);
78
+ expect(getSdkContextLimit("openai", "gpt-5.5")).toBe(272000);
100
79
  });
101
80
 
102
- test("derived experimental.modes inherit the effective (input) limit", () => {
103
- //#given — parent has input < context; derived modes should inherit input, not context
104
- const opencodeDir = join(tempDir, "opencode");
105
- mkdirSync(opencodeDir, { recursive: true });
106
- writeFileSync(
107
- join(opencodeDir, "models.json"),
108
- JSON.stringify({
109
- openai: {
81
+ test("derived experimental.modes inherit the effective (input) limit", async () => {
82
+ await refreshModelLimitsFromApi(
83
+ makeClient([
84
+ {
85
+ id: "openai",
110
86
  models: {
111
87
  "gpt-5.4": {
112
88
  limit: { context: 1050000, input: 922000 },
@@ -114,249 +90,169 @@ describe("models-dev-cache", () => {
114
90
  },
115
91
  },
116
92
  },
117
- }),
93
+ ]),
118
94
  );
119
-
120
- //#then
121
- expect(getModelsDevContextLimit("openai", "gpt-5.4")).toBe(922000);
122
- expect(getModelsDevContextLimit("openai", "gpt-5.4-fast")).toBe(922000);
123
- expect(getModelsDevContextLimit("openai", "gpt-5.4-mini")).toBe(922000);
95
+ expect(getSdkContextLimit("openai", "gpt-5.4")).toBe(922000);
96
+ expect(getSdkContextLimit("openai", "gpt-5.4-fast")).toBe(922000);
97
+ expect(getSdkContextLimit("openai", "gpt-5.4-mini")).toBe(922000);
124
98
  });
125
99
 
126
- test("custom opencode.json provider overlay uses limit.input preferentially", () => {
127
- //#given — user defines a proxy provider in opencode.json with input < context
128
- const opencodeDir = join(tempDir, "opencode");
129
- mkdirSync(opencodeDir, { recursive: true });
130
- const configDir = join(tempDir, "config", "opencode");
131
- mkdirSync(configDir, { recursive: true });
132
- writeFileSync(
133
- join(configDir, "opencode.json"),
134
- JSON.stringify({
135
- provider: {
136
- "my-proxy": {
137
- models: {
138
- "split-model": { limit: { context: 400000, input: 200000 } },
139
- },
100
+ test("matches a tagged ollama model against its tag-less SDK entry", async () => {
101
+ await refreshModelLimitsFromApi(
102
+ makeClient([
103
+ {
104
+ id: "ollama-cloud",
105
+ models: {
106
+ "deepseek-v4-pro": { limit: { context: 1048576 } },
107
+ "gemma3:27b": { limit: { context: 131072 } },
140
108
  },
141
109
  },
142
- }),
110
+ ]),
143
111
  );
144
- process.env.OPENCODE_CONFIG_DIR = configDir;
145
- clearModelsDevCache();
146
-
147
- //#then
148
- expect(getModelsDevContextLimit("my-proxy", "split-model")).toBe(200000);
149
-
150
- // Cleanup: restore env (afterEach also handles this, but we added a new var)
151
- delete process.env.OPENCODE_CONFIG_DIR;
112
+ // Tagged invocation falls back to the tag-less entry.
113
+ expect(getSdkContextLimit("ollama-cloud", "deepseek-v4-pro:cloud")).toBe(1048576);
114
+ // Exact tagged match still wins (no wrongful collapse).
115
+ expect(getSdkContextLimit("ollama-cloud", "gemma3:27b")).toBe(131072);
116
+ // Unknown tagged model with no tag-less base stays undefined.
117
+ expect(getSdkContextLimit("ollama-cloud", "nonexistent:cloud")).toBeUndefined();
152
118
  });
153
119
 
154
- test("API cache uses limit.input preferentially", async () => {
155
- //#given API response shape mirrors file layer
156
- const mockClient = {
157
- config: {
158
- providers: async () => ({
159
- data: {
160
- providers: [
161
- {
162
- id: "github-copilot",
163
- models: {
164
- "gpt-5.3-codex": {
165
- limit: { context: 400000, input: 272000 },
166
- },
167
- },
168
- },
169
- ],
120
+ describe("sanity bounds [20k, 3M]", () => {
121
+ test("rejects an implausibly small limit (torn-read garbage like 6748)", async () => {
122
+ await refreshModelLimitsFromApi(
123
+ makeClient([
124
+ {
125
+ id: "ollama-cloud",
126
+ // 6748 is smaller than a single system prompt — impossible
127
+ // as a real limit; must be rejected, not trusted.
128
+ models: { "deepseek-v4-pro": { limit: { context: 6748 } } },
170
129
  },
171
- }),
172
- },
173
- };
174
- await refreshModelLimitsFromApi(mockClient);
130
+ ]),
131
+ );
132
+ expect(getSdkContextLimit("ollama-cloud", "deepseek-v4-pro")).toBeUndefined();
133
+ });
175
134
 
176
- //#then
177
- expect(getModelsDevContextLimit("github-copilot", "gpt-5.3-codex")).toBe(272000);
178
- });
135
+ test("rejects a below-floor 8192 num_ctx default", async () => {
136
+ await refreshModelLimitsFromApi(
137
+ makeClient([{ id: "p", models: { m: { limit: { context: 8192 } } } }]),
138
+ );
139
+ expect(getSdkContextLimit("p", "m")).toBeUndefined();
140
+ });
179
141
 
180
- test("expands experimental.modes into derived model IDs with parent context", () => {
181
- const opencodeDir = join(tempDir, "opencode");
182
- mkdirSync(opencodeDir, { recursive: true });
183
- writeFileSync(
184
- join(opencodeDir, "models.json"),
185
- JSON.stringify({
186
- "github-copilot": {
187
- models: {
188
- "gpt-5.4": {
189
- limit: { context: 400000 },
190
- experimental: { modes: { fast: {}, high: {} } },
142
+ test("rejects an impossibly large limit (> 3M)", async () => {
143
+ await refreshModelLimitsFromApi(
144
+ makeClient([{ id: "p", models: { m: { limit: { context: 5_000_000 } } } }]),
145
+ );
146
+ expect(getSdkContextLimit("p", "m")).toBeUndefined();
147
+ });
148
+
149
+ test("accepts values exactly on the bounds", async () => {
150
+ await refreshModelLimitsFromApi(
151
+ makeClient([
152
+ {
153
+ id: "p",
154
+ models: {
155
+ lo: { limit: { context: 20000 } },
156
+ hi: { limit: { context: 3000000 } },
191
157
  },
192
158
  },
193
- },
194
- }),
195
- );
196
-
197
- // Parent ID works.
198
- expect(getModelsDevContextLimit("github-copilot", "gpt-5.4")).toBe(400000);
199
- // Derived mode IDs inherit parent context.
200
- expect(getModelsDevContextLimit("github-copilot", "gpt-5.4-fast")).toBe(400000);
201
- expect(getModelsDevContextLimit("github-copilot", "gpt-5.4-high")).toBe(400000);
202
- });
203
-
204
- test("OPENCODE_MODELS_PATH env overrides default path", () => {
205
- // Write real file somewhere unexpected.
206
- const customPath = join(tempDir, "elsewhere", "my-models.json");
207
- mkdirSync(join(tempDir, "elsewhere"), { recursive: true });
208
- writeFileSync(
209
- customPath,
210
- JSON.stringify({
211
- anthropic: { models: { "claude-4": { limit: { context: 1000000 } } } },
212
- }),
213
- );
214
- process.env.OPENCODE_MODELS_PATH = customPath;
215
- clearModelsDevCache();
216
-
217
- expect(getModelsDevContextLimit("anthropic", "claude-4")).toBe(1000000);
159
+ ]),
160
+ );
161
+ expect(getSdkContextLimit("p", "lo")).toBe(20000);
162
+ expect(getSdkContextLimit("p", "hi")).toBe(3000000);
163
+ });
218
164
  });
219
165
 
220
- test("OPENCODE_MODELS_URL (non-default) selects hashed filename", () => {
221
- // We can't easily verify the exact hash without duplicating the hash logic,
222
- // but we can confirm that setting OPENCODE_MODELS_URL prevents reading
223
- // the default models.json when that file exists with different data.
224
- const opencodeDir = join(tempDir, "opencode");
225
- mkdirSync(opencodeDir, { recursive: true });
226
- writeFileSync(
227
- join(opencodeDir, "models.json"),
228
- JSON.stringify({
229
- anthropic: { models: { "claude-4": { limit: { context: 500000 } } } },
230
- }),
231
- );
232
-
233
- process.env.OPENCODE_MODELS_URL = "https://custom.example.com/models";
234
- clearModelsDevCache();
166
+ describe("persisted cache (cold start)", () => {
167
+ test("seeds from the persisted file after a clear (restart simulation)", async () => {
168
+ // First run: warm + persist.
169
+ await refreshModelLimitsFromApi(
170
+ makeClient([{ id: "openai", models: { "gpt-5.5": { limit: { input: 272000 } } } }]),
171
+ );
172
+ expect(getSdkContextLimit("openai", "gpt-5.5")).toBe(272000);
173
+
174
+ // Simulate a restart: in-memory cache gone, but the persisted file
175
+ // remains under XDG_DATA_HOME. The next lookup seeds from disk.
176
+ clearModelsDevCache();
177
+ expect(getModelsDevCacheState().apiLoaded).toBe(false);
178
+ expect(getSdkContextLimit("openai", "gpt-5.5")).toBe(272000);
179
+ // Seeding populated the in-memory cache.
180
+ expect(getModelsDevCacheState().apiLoaded).toBe(true);
181
+ });
235
182
 
236
- // Should NOT find claude-4 because we're looking at a hashed filename now,
237
- // not models.json.
238
- expect(getModelsDevContextLimit("anthropic", "claude-4")).toBeUndefined();
183
+ test("does not persist or seed insane values", async () => {
184
+ await refreshModelLimitsFromApi(
185
+ makeClient([
186
+ {
187
+ id: "p",
188
+ models: {
189
+ good: { limit: { context: 200000 } },
190
+ bad: { limit: { context: 6748 } },
191
+ },
192
+ },
193
+ ]),
194
+ );
195
+ clearModelsDevCache();
196
+ expect(getSdkContextLimit("p", "good")).toBe(200000);
197
+ expect(getSdkContextLimit("p", "bad")).toBeUndefined();
198
+ });
239
199
  });
240
200
 
241
- test("takes the larger limit when both layers know the model (API larger)", async () => {
242
- // Seed file layer with one value.
243
- const opencodeDir = join(tempDir, "opencode");
244
- mkdirSync(opencodeDir, { recursive: true });
245
- writeFileSync(
246
- join(opencodeDir, "models.json"),
247
- JSON.stringify({
248
- anthropic: { models: { "claude-4": { limit: { context: 100000 } } } },
249
- }),
250
- );
251
-
252
- // Sanity: file layer returns 100000 before API refresh.
253
- expect(getModelsDevContextLimit("anthropic", "claude-4")).toBe(100000);
254
-
255
- // Mock client providing a LARGER value via API.
256
- const mockClient = {
257
- config: {
258
- providers: async () => ({
259
- data: {
260
- providers: [
261
- {
262
- id: "anthropic",
263
- models: {
264
- "claude-4": { limit: { context: 1000000 } },
265
- },
201
+ describe("startup retry", () => {
202
+ test("retries when the provider payload is empty, then succeeds", async () => {
203
+ let calls = 0;
204
+ const client = {
205
+ config: {
206
+ providers: async () => {
207
+ calls++;
208
+ if (calls === 1) return { data: { providers: [] } };
209
+ return {
210
+ data: {
211
+ providers: [
212
+ { id: "p", models: { m: { limit: { context: 200000 } } } },
213
+ ],
266
214
  },
267
- ],
215
+ };
268
216
  },
269
- }),
270
- },
271
- };
272
- await refreshModelLimitsFromApi(mockClient);
273
-
274
- // Larger (API) value wins.
275
- expect(getModelsDevContextLimit("anthropic", "claude-4")).toBe(1000000);
276
-
277
- const state = getModelsDevCacheState();
278
- expect(state.apiLoaded).toBe(true);
279
- expect(state.apiCount).toBe(1);
280
- });
281
-
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
217
  },
294
- }),
295
- );
218
+ };
219
+ await refreshModelLimitsFromApi(client, { retries: 2, retryDelayMs: 1 });
220
+ expect(calls).toBe(2);
221
+ expect(getSdkContextLimit("p", "m")).toBe(200000);
222
+ });
296
223
 
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
- },
224
+ test("stops early on first successful load (no wasted retries)", async () => {
225
+ let calls = 0;
226
+ const client = {
227
+ config: {
228
+ providers: async () => {
229
+ calls++;
230
+ return {
231
+ data: {
232
+ providers: [
233
+ { id: "p", models: { m: { limit: { context: 200000 } } } },
234
+ ],
308
235
  },
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 } },
236
+ };
333
237
  },
334
238
  },
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();
239
+ };
240
+ await refreshModelLimitsFromApi(client, { retries: 3, retryDelayMs: 1 });
241
+ expect(calls).toBe(1);
242
+ });
344
243
  });
345
244
 
346
- test("refreshModelLimitsFromApi tolerates empty/malformed responses", async () => {
347
- // Undefined data.
245
+ test("tolerates empty / malformed / thrown responses without populating", async () => {
348
246
  await refreshModelLimitsFromApi({
349
247
  config: { providers: async () => ({ data: undefined }) },
350
248
  });
351
249
  expect(getModelsDevCacheState().apiLoaded).toBe(false);
352
250
 
353
- // Non-array providers.
354
251
  await refreshModelLimitsFromApi({
355
252
  config: { providers: async () => ({ data: { providers: "not an array" } }) },
356
253
  });
357
254
  expect(getModelsDevCacheState().apiLoaded).toBe(false);
358
255
 
359
- // Thrown error.
360
256
  await refreshModelLimitsFromApi({
361
257
  config: {
362
258
  providers: async () => {
@@ -367,96 +263,32 @@ describe("models-dev-cache", () => {
367
263
  expect(getModelsDevCacheState().apiLoaded).toBe(false);
368
264
  });
369
265
 
370
- test("repeated manual API refreshes replace cache state without corruption", async () => {
371
- // Simulates the issue #77 recovery path manually retrying provider metadata
372
- // after a bad cache value. Normal startup no longer schedules periodic
373
- // refreshes, but explicit refresh calls should still replace cache state
374
- // cleanly even when provider counts alternate.
375
- const sizeA = {
376
- data: {
377
- providers: [
378
- {
379
- id: "p",
380
- models: {
381
- m1: { limit: { context: 100 } },
382
- m2: { limit: { context: 100 } },
383
- m3: { limit: { context: 100 } },
384
- },
385
- },
386
- ],
266
+ test("repeated refreshes replace cache state without corruption", async () => {
267
+ const clientA = makeClient([
268
+ {
269
+ id: "p",
270
+ models: {
271
+ m1: { limit: { context: 200000 } },
272
+ m2: { limit: { context: 200000 } },
273
+ m3: { limit: { context: 200000 } },
274
+ },
387
275
  },
388
- };
389
- const sizeB = {
390
- data: {
391
- providers: [
392
- {
393
- id: "p",
394
- models: {
395
- m1: { limit: { context: 100 } },
396
- m2: { limit: { context: 100 } },
397
- },
398
- },
399
- ],
276
+ ]);
277
+ const clientB = makeClient([
278
+ {
279
+ id: "p",
280
+ models: { m1: { limit: { context: 200000 } }, m2: { limit: { context: 200000 } } },
400
281
  },
401
- };
402
-
403
- const clientA = { config: { providers: async () => sizeA } };
404
- const clientB = { config: { providers: async () => sizeB } };
405
-
406
- await refreshModelLimitsFromApi(clientA);
407
- expect(getModelsDevCacheState().apiCount).toBe(3);
408
-
409
- await refreshModelLimitsFromApi(clientB);
410
- expect(getModelsDevCacheState().apiCount).toBe(2);
282
+ ]);
411
283
 
412
284
  await refreshModelLimitsFromApi(clientA);
413
285
  expect(getModelsDevCacheState().apiCount).toBe(3);
414
-
415
286
  await refreshModelLimitsFromApi(clientB);
416
287
  expect(getModelsDevCacheState().apiCount).toBe(2);
417
-
418
288
  await refreshModelLimitsFromApi(clientA);
419
289
  expect(getModelsDevCacheState().apiCount).toBe(3);
420
290
 
421
- // The cache itself still updates on every call (model contents are correct
422
- // for whichever provider response just arrived). The suppression is purely
423
- // a logging concern. Last call was clientA → all three models present.
424
- expect(getModelsDevContextLimit("p", "m1")).toBe(100);
425
- expect(getModelsDevContextLimit("p", "m2")).toBe(100);
426
- expect(getModelsDevContextLimit("p", "m3")).toBe(100);
427
- });
428
-
429
- test("falls back to file layer when API provider/model key is missing", async () => {
430
- const opencodeDir = join(tempDir, "opencode");
431
- mkdirSync(opencodeDir, { recursive: true });
432
- writeFileSync(
433
- join(opencodeDir, "models.json"),
434
- JSON.stringify({
435
- anthropic: { models: { "claude-only-in-file": { limit: { context: 777777 } } } },
436
- }),
437
- );
438
-
439
- const mockClient = {
440
- config: {
441
- providers: async () => ({
442
- data: {
443
- providers: [
444
- {
445
- id: "anthropic",
446
- models: {
447
- "claude-only-in-api": { limit: { context: 888888 } },
448
- },
449
- },
450
- ],
451
- },
452
- }),
453
- },
454
- };
455
- await refreshModelLimitsFromApi(mockClient);
456
-
457
- // API-only key comes from API.
458
- expect(getModelsDevContextLimit("anthropic", "claude-only-in-api")).toBe(888888);
459
- // File-only key falls through to file layer.
460
- expect(getModelsDevContextLimit("anthropic", "claude-only-in-file")).toBe(777777);
291
+ expect(getSdkContextLimit("p", "m1")).toBe(200000);
292
+ expect(getSdkContextLimit("p", "m3")).toBe(200000);
461
293
  });
462
294
  });