@cortexkit/opencode-magic-context 0.9.0 → 0.10.0

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 (34) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/doctor.d.ts.map +1 -1
  3. package/dist/cli.js +6 -3
  4. package/dist/config/schema/magic-context.d.ts +0 -4
  5. package/dist/config/schema/magic-context.d.ts.map +1 -1
  6. package/dist/hooks/magic-context/compartment-runner-types.d.ts +6 -1
  7. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  8. package/dist/hooks/magic-context/compartment-trigger.d.ts +1 -1
  9. package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
  10. package/dist/hooks/magic-context/derive-budgets.d.ts +57 -0
  11. package/dist/hooks/magic-context/derive-budgets.d.ts.map +1 -0
  12. package/dist/hooks/magic-context/event-handler.d.ts +0 -1
  13. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  14. package/dist/hooks/magic-context/event-resolvers.d.ts +1 -3
  15. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  16. package/dist/hooks/magic-context/hook.d.ts +3 -2
  17. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  18. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  19. package/dist/hooks/magic-context/nudge-injection.d.ts.map +1 -1
  20. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +1 -1
  21. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  22. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  23. package/dist/hooks/magic-context/transform.d.ts +7 -1
  24. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +293 -142
  27. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  28. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  29. package/dist/plugin/tui-action-consumer.d.ts.map +1 -1
  30. package/dist/shared/models-dev-cache.d.ts +52 -4
  31. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/shared/models-dev-cache.test.ts +228 -0
  34. package/src/shared/models-dev-cache.ts +146 -28
@@ -1 +1 @@
1
- {"version":3,"file":"create-session-hooks.d.ts","sourceRoot":"","sources":["../../../src/plugin/hooks/create-session-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AAU7D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,8CAA8C,CAAC;AACrF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAE9C,wBAAgB,kBAAkB,CAAC,IAAI,EAAE;IACrC,GAAG,EAAE,aAAa,CAAC;IACnB,YAAY,EAAE,wBAAwB,CAAC;IACvC,gBAAgB,EAAE,gBAAgB,CAAC;CACtC;;;;;;qBA8CgrB,CAAC;;;;;;;;;;;;qBAR/pB,CAAC;mBAAiB,CAAC;iBACtB,CAAP;;;;;0BAOkza,CAAC;;;;;;EAD3za"}
1
+ {"version":3,"file":"create-session-hooks.d.ts","sourceRoot":"","sources":["../../../src/plugin/hooks/create-session-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AAU7D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,8CAA8C,CAAC;AACrF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAE9C,wBAAgB,kBAAkB,CAAC,IAAI,EAAE;IACrC,GAAG,EAAE,aAAa,CAAC;IACnB,YAAY,EAAE,wBAAwB,CAAC;IACvC,gBAAgB,EAAE,gBAAgB,CAAC;CACtC;;;;;;qBA8C8sB,CAAC;;;;;;;;;;;;qBAPhsB,CAAX;mBAAiB,CAAC;iBAAe,CAAC;;;;;0BAO60b,CAAC;;;;;;EADp3b"}
@@ -1 +1 @@
1
- {"version":3,"file":"rpc-handlers.d.ts","sourceRoot":"","sources":["../../src/plugin/rpc-handlers.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AAIzE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2CAA2C,CAAC;AAGlF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAmZlE;;GAEG;AACH,wBAAgB,mBAAmB,CAC/B,SAAS,EAAE,qBAAqB,EAChC,IAAI,EAAE;IACF,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,kBAAkB,CAAC;IAC3B,MAAM,EAAE,OAAO,CAAC;IAChB,gBAAgB,EAAE,gBAAgB,CAAC;CACtC,GACF,IAAI,CA+FN"}
1
+ {"version":3,"file":"rpc-handlers.d.ts","sourceRoot":"","sources":["../../src/plugin/rpc-handlers.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AAIzE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2CAA2C,CAAC;AAGlF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAmZlE;;GAEG;AACH,wBAAgB,mBAAmB,CAC/B,SAAS,EAAE,qBAAqB,EAChC,IAAI,EAAE;IACF,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,kBAAkB,CAAC;IAC3B,MAAM,EAAE,OAAO,CAAC;IAChB,gBAAgB,EAAE,gBAAgB,CAAC;CACtC,GACF,IAAI,CAqGN"}
@@ -1 +1 @@
1
- {"version":3,"file":"tui-action-consumer.d.ts","sourceRoot":"","sources":["../../src/plugin/tui-action-consumer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AAKzE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2CAA2C,CAAC;AAGlF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAQ7C;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE;IACzC,MAAM,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,kBAAkB,CAAC;IAC3B,gBAAgB,EAAE,gBAAgB,CAAC;CACtC,GAAG,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CA2E3B"}
1
+ {"version":3,"file":"tui-action-consumer.d.ts","sourceRoot":"","sources":["../../src/plugin/tui-action-consumer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AASzE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2CAA2C,CAAC;AAGlF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAO7C;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE;IACzC,MAAM,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,kBAAkB,CAAC;IAC3B,gBAAgB,EAAE,gBAAgB,CAAC;CACtC,GAAG,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAiF3B"}
@@ -1,9 +1,57 @@
1
1
  /**
2
- * Get the context limit for a specific provider/model from OpenCode's models.dev cache.
3
- * Returns undefined if the model is not found or the cache is unavailable.
4
- * Results are cached in memory and refreshed every 5 minutes.
2
+ * Resolve per-model context limits to match whatever OpenCode itself sees.
3
+ *
4
+ * Two layers:
5
+ *
6
+ * 1. API cache (primary): populated asynchronously via
7
+ * `client.config.providers()`. OpenCode's own provider service merges
8
+ * the live models.dev cache file, its compiled-in snapshot fallback,
9
+ * opencode.json custom provider overrides, and derived experimental
10
+ * modes. Whatever OpenCode reports is the source of truth.
11
+ *
12
+ * 2. File cache (fallback): read-from-disk parse of OpenCode's
13
+ * `models.json` plus `opencode.json(c)` custom provider entries.
14
+ * Used during cold starts before the API cache warms up and in any
15
+ * code path that cannot reach the SDK client.
16
+ *
17
+ * The public `getModelsDevContextLimit()` getter is synchronous: it checks
18
+ * the API cache first, then the file cache. The plugin warms and refreshes
19
+ * the API cache from `src/index.ts` at startup and on a timer.
20
+ */
21
+ import type { createOpencodeClient } from "@opencode-ai/sdk";
22
+ type OpencodeClient = ReturnType<typeof createOpencodeClient>;
23
+ /**
24
+ * Asynchronously refresh the API-layer cache from OpenCode's SDK.
25
+ *
26
+ * Call this at plugin startup and periodically (e.g. every 5 minutes) from
27
+ * `src/index.ts`. OpenCode's `/config/providers` endpoint returns every
28
+ * provider with full model metadata — including `limit.context` — resolved
29
+ * through the same path OpenCode itself uses (live cache + compiled-in
30
+ * snapshot + opencode.json overrides + derived experimental modes).
31
+ *
32
+ * Safe to call concurrently; only overwrites the cache on success.
33
+ */
34
+ export declare function refreshModelLimitsFromApi(client: OpencodeClient): Promise<void>;
35
+ /**
36
+ * Returns the context limit for a provider/model.
37
+ *
38
+ * Lookup order:
39
+ * 1. API cache (populated by {@link refreshModelLimitsFromApi}). Matches
40
+ * what OpenCode sees exactly, including snapshot-only models.
41
+ * 2. File cache (parsed from models.json + opencode.json overrides).
42
+ * Used before the API cache warms and as a last resort.
43
+ *
44
+ * Returns `undefined` if neither layer knows the model.
5
45
  */
6
46
  export declare function getModelsDevContextLimit(providerID: string, modelID: string): number | undefined;
7
- /** Clear the in-memory cache (for testing) */
47
+ /** Clear in-memory caches (for testing). */
8
48
  export declare function clearModelsDevCache(): void;
49
+ /** Inspection helpers (for logging / debugging). */
50
+ export declare function getModelsDevCacheState(): {
51
+ apiLoaded: boolean;
52
+ apiCount: number;
53
+ apiAgeMs: number;
54
+ fileCount: number;
55
+ };
56
+ export {};
9
57
  //# sourceMappingURL=models-dev-cache.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"models-dev-cache.d.ts","sourceRoot":"","sources":["../../src/shared/models-dev-cache.ts"],"names":[],"mappings":"AA6IA;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAShG;AAED,8CAA8C;AAC9C,wBAAgB,mBAAmB,IAAI,IAAI,CAG1C"}
1
+ {"version":3,"file":"models-dev-cache.d.ts","sourceRoot":"","sources":["../../src/shared/models-dev-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAMH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAG7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAC;AA2J9D;;;;;;;;;;GAUG;AACH,wBAAsB,yBAAyB,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAmCrF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,wBAAwB,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAchG;AAED,4CAA4C;AAC5C,wBAAgB,mBAAmB,IAAI,IAAI,CAK1C;AAED,oDAAoD;AACpD,wBAAgB,sBAAsB,IAAI;IACtC,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACrB,CAOA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/opencode-magic-context",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Magic Context — cross-session memory and context management",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,228 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ clearModelsDevCache,
7
+ getModelsDevCacheState,
8
+ getModelsDevContextLimit,
9
+ refreshModelLimitsFromApi,
10
+ } from "./models-dev-cache";
11
+
12
+ describe("models-dev-cache", () => {
13
+ let tempDir: string;
14
+ let originalEnv: Record<string, string | undefined>;
15
+
16
+ beforeEach(() => {
17
+ 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
+ };
23
+ // Isolate from user environment.
24
+ delete process.env.OPENCODE_MODELS_PATH;
25
+ delete process.env.OPENCODE_MODELS_URL;
26
+ process.env.XDG_CACHE_HOME = tempDir;
27
+ clearModelsDevCache();
28
+ });
29
+
30
+ afterEach(() => {
31
+ // Restore env.
32
+ for (const [k, v] of Object.entries(originalEnv)) {
33
+ if (v === undefined) delete process.env[k];
34
+ else process.env[k] = v;
35
+ }
36
+ rmSync(tempDir, { recursive: true, force: true });
37
+ clearModelsDevCache();
38
+ });
39
+
40
+ test("reads context limits from models.json under XDG_CACHE_HOME", () => {
41
+ const opencodeDir = join(tempDir, "opencode");
42
+ mkdirSync(opencodeDir, { recursive: true });
43
+ writeFileSync(
44
+ join(opencodeDir, "models.json"),
45
+ JSON.stringify({
46
+ anthropic: {
47
+ models: {
48
+ "claude-sonnet-4-6": { limit: { context: 200000 } },
49
+ },
50
+ },
51
+ "github-copilot": {
52
+ models: {
53
+ "gpt-5.3-codex": { limit: { context: 400000 } },
54
+ },
55
+ },
56
+ }),
57
+ );
58
+
59
+ expect(getModelsDevContextLimit("anthropic", "claude-sonnet-4-6")).toBe(200000);
60
+ expect(getModelsDevContextLimit("github-copilot", "gpt-5.3-codex")).toBe(400000);
61
+ expect(getModelsDevContextLimit("unknown", "unknown")).toBeUndefined();
62
+ });
63
+
64
+ test("expands experimental.modes into derived model IDs with parent context", () => {
65
+ const opencodeDir = join(tempDir, "opencode");
66
+ mkdirSync(opencodeDir, { recursive: true });
67
+ writeFileSync(
68
+ join(opencodeDir, "models.json"),
69
+ JSON.stringify({
70
+ "github-copilot": {
71
+ models: {
72
+ "gpt-5.4": {
73
+ limit: { context: 400000 },
74
+ experimental: { modes: { fast: {}, high: {} } },
75
+ },
76
+ },
77
+ },
78
+ }),
79
+ );
80
+
81
+ // Parent ID works.
82
+ expect(getModelsDevContextLimit("github-copilot", "gpt-5.4")).toBe(400000);
83
+ // Derived mode IDs inherit parent context.
84
+ expect(getModelsDevContextLimit("github-copilot", "gpt-5.4-fast")).toBe(400000);
85
+ expect(getModelsDevContextLimit("github-copilot", "gpt-5.4-high")).toBe(400000);
86
+ });
87
+
88
+ test("OPENCODE_MODELS_PATH env overrides default path", () => {
89
+ // Write real file somewhere unexpected.
90
+ const customPath = join(tempDir, "elsewhere", "my-models.json");
91
+ mkdirSync(join(tempDir, "elsewhere"), { recursive: true });
92
+ writeFileSync(
93
+ customPath,
94
+ JSON.stringify({
95
+ anthropic: { models: { "claude-4": { limit: { context: 1000000 } } } },
96
+ }),
97
+ );
98
+ process.env.OPENCODE_MODELS_PATH = customPath;
99
+ clearModelsDevCache();
100
+
101
+ expect(getModelsDevContextLimit("anthropic", "claude-4")).toBe(1000000);
102
+ });
103
+
104
+ test("OPENCODE_MODELS_URL (non-default) selects hashed filename", () => {
105
+ // We can't easily verify the exact hash without duplicating the hash logic,
106
+ // but we can confirm that setting OPENCODE_MODELS_URL prevents reading
107
+ // the default models.json when that file exists with different data.
108
+ const opencodeDir = join(tempDir, "opencode");
109
+ mkdirSync(opencodeDir, { recursive: true });
110
+ writeFileSync(
111
+ join(opencodeDir, "models.json"),
112
+ JSON.stringify({
113
+ anthropic: { models: { "claude-4": { limit: { context: 500000 } } } },
114
+ }),
115
+ );
116
+
117
+ process.env.OPENCODE_MODELS_URL = "https://custom.example.com/models";
118
+ clearModelsDevCache();
119
+
120
+ // Should NOT find claude-4 because we're looking at a hashed filename now,
121
+ // not models.json.
122
+ expect(getModelsDevContextLimit("anthropic", "claude-4")).toBeUndefined();
123
+ });
124
+
125
+ test("API cache takes priority over file cache", async () => {
126
+ // Seed file layer with one value.
127
+ const opencodeDir = join(tempDir, "opencode");
128
+ mkdirSync(opencodeDir, { recursive: true });
129
+ writeFileSync(
130
+ join(opencodeDir, "models.json"),
131
+ JSON.stringify({
132
+ anthropic: { models: { "claude-4": { limit: { context: 100000 } } } },
133
+ }),
134
+ );
135
+
136
+ // Sanity: file layer returns 100000 before API refresh.
137
+ expect(getModelsDevContextLimit("anthropic", "claude-4")).toBe(100000);
138
+
139
+ // Mock client providing DIFFERENT value via API.
140
+ const mockClient = {
141
+ config: {
142
+ providers: async () => ({
143
+ data: {
144
+ providers: [
145
+ {
146
+ id: "anthropic",
147
+ models: {
148
+ "claude-4": { limit: { context: 1000000 } },
149
+ },
150
+ },
151
+ ],
152
+ },
153
+ }),
154
+ },
155
+ };
156
+ // @ts-expect-error mock narrow shape
157
+ await refreshModelLimitsFromApi(mockClient);
158
+
159
+ // API value wins.
160
+ expect(getModelsDevContextLimit("anthropic", "claude-4")).toBe(1000000);
161
+
162
+ const state = getModelsDevCacheState();
163
+ expect(state.apiLoaded).toBe(true);
164
+ expect(state.apiCount).toBe(1);
165
+ });
166
+
167
+ test("refreshModelLimitsFromApi tolerates empty/malformed responses", async () => {
168
+ // Undefined data.
169
+ // @ts-expect-error mock narrow shape
170
+ await refreshModelLimitsFromApi({
171
+ config: { providers: async () => ({ data: undefined }) },
172
+ });
173
+ expect(getModelsDevCacheState().apiLoaded).toBe(false);
174
+
175
+ // Non-array providers.
176
+ // @ts-expect-error mock narrow shape
177
+ await refreshModelLimitsFromApi({
178
+ config: { providers: async () => ({ data: { providers: "not an array" } }) },
179
+ });
180
+ expect(getModelsDevCacheState().apiLoaded).toBe(false);
181
+
182
+ // Thrown error.
183
+ // @ts-expect-error mock narrow shape
184
+ await refreshModelLimitsFromApi({
185
+ config: {
186
+ providers: async () => {
187
+ throw new Error("network error");
188
+ },
189
+ },
190
+ });
191
+ expect(getModelsDevCacheState().apiLoaded).toBe(false);
192
+ });
193
+
194
+ test("falls back to file layer when API provider/model key is missing", async () => {
195
+ const opencodeDir = join(tempDir, "opencode");
196
+ mkdirSync(opencodeDir, { recursive: true });
197
+ writeFileSync(
198
+ join(opencodeDir, "models.json"),
199
+ JSON.stringify({
200
+ anthropic: { models: { "claude-only-in-file": { limit: { context: 777777 } } } },
201
+ }),
202
+ );
203
+
204
+ const mockClient = {
205
+ config: {
206
+ providers: async () => ({
207
+ data: {
208
+ providers: [
209
+ {
210
+ id: "anthropic",
211
+ models: {
212
+ "claude-only-in-api": { limit: { context: 888888 } },
213
+ },
214
+ },
215
+ ],
216
+ },
217
+ }),
218
+ },
219
+ };
220
+ // @ts-expect-error mock narrow shape
221
+ await refreshModelLimitsFromApi(mockClient);
222
+
223
+ // API-only key comes from API.
224
+ expect(getModelsDevContextLimit("anthropic", "claude-only-in-api")).toBe(888888);
225
+ // File-only key falls through to file layer.
226
+ expect(getModelsDevContextLimit("anthropic", "claude-only-in-file")).toBe(777777);
227
+ });
228
+ });
@@ -1,27 +1,55 @@
1
1
  /**
2
- * Read model context limits from OpenCode's models.dev cache file.
2
+ * Resolve per-model context limits to match whatever OpenCode itself sees.
3
3
  *
4
- * OpenCode fetches model metadata from models.dev and caches it at:
5
- * <xdg_cache>/opencode/models.json
4
+ * Two layers:
6
5
  *
7
- * This file contains per-provider, per-model data including `limit.context`.
8
- * We read it lazily and refresh periodically to get accurate context limits
9
- * without requiring user configuration.
6
+ * 1. API cache (primary): populated asynchronously via
7
+ * `client.config.providers()`. OpenCode's own provider service merges
8
+ * the live models.dev cache file, its compiled-in snapshot fallback,
9
+ * opencode.json custom provider overrides, and derived experimental
10
+ * modes. Whatever OpenCode reports is the source of truth.
11
+ *
12
+ * 2. File cache (fallback): read-from-disk parse of OpenCode's
13
+ * `models.json` plus `opencode.json(c)` custom provider entries.
14
+ * Used during cold starts before the API cache warms up and in any
15
+ * code path that cannot reach the SDK client.
16
+ *
17
+ * The public `getModelsDevContextLimit()` getter is synchronous: it checks
18
+ * the API cache first, then the file cache. The plugin warms and refreshes
19
+ * the API cache from `src/index.ts` at startup and on a timer.
10
20
  */
21
+
22
+ import { createHash } from "node:crypto";
11
23
  import { existsSync, readFileSync } from "node:fs";
12
24
  import { homedir, platform } from "node:os";
13
25
  import { join } from "node:path";
26
+ import type { createOpencodeClient } from "@opencode-ai/sdk";
14
27
  import { sessionLog } from "./logger";
15
28
 
16
- /** Resolved context limits keyed by "providerID/modelID" */
17
- let cachedLimits: Map<string, number> | null = null;
18
- let lastLoadAttempt = 0;
29
+ type OpencodeClient = ReturnType<typeof createOpencodeClient>;
30
+
19
31
  const RELOAD_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes, matches OpenCode's TTL
20
32
 
33
+ /** Populated async from OpenCode SDK. Primary source of truth when available. */
34
+ let apiCache: Map<string, number> | null = null;
35
+ let apiLoadedAt = 0;
36
+
37
+ /** Populated sync from disk as fallback. */
38
+ let fileCache: Map<string, number> | null = null;
39
+ let fileLastAttempt = 0;
40
+
41
+ function hashFast(input: string): string {
42
+ // Matches OpenCode's Hash.fast() (packages/shared/src/util/hash.ts).
43
+ return createHash("sha1").update(input).digest("hex");
44
+ }
45
+
21
46
  function getModelsJsonPath(): string {
47
+ // 1. Explicit path override (OpenCode's OPENCODE_MODELS_PATH takes highest priority).
48
+ const explicit = process.env.OPENCODE_MODELS_PATH?.trim();
49
+ if (explicit) return explicit;
50
+
22
51
  const xdgCache = process.env.XDG_CACHE_HOME;
23
52
  const os = platform();
24
-
25
53
  let cacheBase: string;
26
54
  if (xdgCache) {
27
55
  cacheBase = xdgCache;
@@ -31,7 +59,15 @@ function getModelsJsonPath(): string {
31
59
  cacheBase = join(homedir(), ".cache");
32
60
  }
33
61
 
34
- return join(cacheBase, "opencode", "models.json");
62
+ // 2. Custom models source → hashed filename (matches OpenCode).
63
+ // source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`
64
+ const source = process.env.OPENCODE_MODELS_URL?.trim();
65
+ const filename =
66
+ source && source !== "https://models.dev"
67
+ ? `models-${hashFast(source)}.json`
68
+ : "models.json";
69
+
70
+ return join(cacheBase, "opencode", filename);
35
71
  }
36
72
 
37
73
  function getOpencodeConfigPath(): string | null {
@@ -42,7 +78,7 @@ function getOpencodeConfigPath(): string | null {
42
78
  ? join(homedir(), ".config", "opencode")
43
79
  : join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode");
44
80
 
45
- // Check jsonc first, then json (matches OpenCode's own lookup order)
81
+ // Check jsonc first, then json (matches OpenCode's own lookup order).
46
82
  const jsonc = join(configDir, "opencode.jsonc");
47
83
  if (existsSync(jsonc)) return jsonc;
48
84
  const json = join(configDir, "opencode.json");
@@ -50,13 +86,15 @@ function getOpencodeConfigPath(): string | null {
50
86
  return null;
51
87
  }
52
88
 
53
- function loadModelsDevLimits(): Map<string, number> {
89
+ function loadModelsDevLimitsFromFile(): Map<string, number> {
54
90
  const limits = new Map<string, number>();
55
91
 
56
- // 1. Load from OpenCode's models.dev cache (base layer — all known public models)
92
+ // 1. Read OpenCode's models.dev cache file (base layer).
57
93
  const modelsJsonPath = getModelsJsonPath();
94
+ let fileFound = false;
58
95
  try {
59
96
  if (existsSync(modelsJsonPath)) {
97
+ fileFound = true;
60
98
  const raw = readFileSync(modelsJsonPath, "utf-8");
61
99
  const data = JSON.parse(raw) as Record<
62
100
  string,
@@ -78,7 +116,7 @@ function loadModelsDevLimits(): Map<string, number> {
78
116
  if (typeof context === "number" && context > 0) {
79
117
  limits.set(`${providerId}/${modelId}`, context);
80
118
  // OpenCode creates derived model IDs from experimental.modes
81
- // e.g. gpt-5.4 + modes.fast → gpt-5.4-fast (inherits parent limit)
119
+ // e.g. gpt-5.4 + modes.fast → gpt-5.4-fast (inherits parent limit).
82
120
  const modes = model?.experimental?.modes;
83
121
  if (modes && typeof modes === "object") {
84
122
  for (const mode of Object.keys(modes)) {
@@ -92,7 +130,7 @@ function loadModelsDevLimits(): Map<string, number> {
92
130
  } catch (error) {
93
131
  sessionLog(
94
132
  "global",
95
- "models-dev-cache: failed to read models.json:",
133
+ `models-dev-cache: failed to read models.json at ${modelsJsonPath}:`,
96
134
  error instanceof Error ? error.message : String(error),
97
135
  );
98
136
  }
@@ -105,7 +143,6 @@ function loadModelsDevLimits(): Map<string, number> {
105
143
  if (configPath && existsSync(configPath)) {
106
144
  let raw = readFileSync(configPath, "utf-8");
107
145
  // Strip JSONC single-line comments while preserving // inside strings.
108
- // Match strings first (to skip them), then match comments outside strings.
109
146
  raw = raw.replace(/"(?:[^"\\]|\\.)*"|\/\/.*$/gm, (match) =>
110
147
  match.startsWith('"') ? match : "",
111
148
  );
@@ -136,27 +173,108 @@ function loadModelsDevLimits(): Map<string, number> {
136
173
  );
137
174
  }
138
175
 
176
+ sessionLog(
177
+ "global",
178
+ `models-dev-cache: file-layer loaded ${limits.size} model limits (modelsJsonPath=${modelsJsonPath}, found=${fileFound})`,
179
+ );
180
+
139
181
  return limits;
140
182
  }
141
183
 
142
184
  /**
143
- * Get the context limit for a specific provider/model from OpenCode's models.dev cache.
144
- * Returns undefined if the model is not found or the cache is unavailable.
145
- * Results are cached in memory and refreshed every 5 minutes.
185
+ * Asynchronously refresh the API-layer cache from OpenCode's SDK.
186
+ *
187
+ * Call this at plugin startup and periodically (e.g. every 5 minutes) from
188
+ * `src/index.ts`. OpenCode's `/config/providers` endpoint returns every
189
+ * provider with full model metadata — including `limit.context` — resolved
190
+ * through the same path OpenCode itself uses (live cache + compiled-in
191
+ * snapshot + opencode.json overrides + derived experimental modes).
192
+ *
193
+ * Safe to call concurrently; only overwrites the cache on success.
194
+ */
195
+ export async function refreshModelLimitsFromApi(client: OpencodeClient): Promise<void> {
196
+ try {
197
+ const result = await client.config.providers();
198
+ const data = (result as { data?: { providers?: Array<unknown> } }).data;
199
+ const providers = data?.providers;
200
+ if (!Array.isArray(providers)) {
201
+ sessionLog("global", "models-dev-cache: API refresh returned no providers payload");
202
+ return;
203
+ }
204
+
205
+ const map = new Map<string, number>();
206
+ for (const entry of providers) {
207
+ const p = entry as {
208
+ id?: string;
209
+ models?: Record<string, { limit?: { context?: number } }>;
210
+ };
211
+ if (!p?.id || !p.models || typeof p.models !== "object") continue;
212
+ for (const [modelId, model] of Object.entries(p.models)) {
213
+ const context = model?.limit?.context;
214
+ if (typeof context === "number" && context > 0) {
215
+ map.set(`${p.id}/${modelId}`, context);
216
+ }
217
+ }
218
+ }
219
+
220
+ apiCache = map;
221
+ apiLoadedAt = Date.now();
222
+ sessionLog("global", `models-dev-cache: API layer loaded ${map.size} model limits`);
223
+ } catch (error) {
224
+ sessionLog(
225
+ "global",
226
+ "models-dev-cache: API refresh failed:",
227
+ error instanceof Error ? error.message : String(error),
228
+ );
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Returns the context limit for a provider/model.
234
+ *
235
+ * Lookup order:
236
+ * 1. API cache (populated by {@link refreshModelLimitsFromApi}). Matches
237
+ * what OpenCode sees exactly, including snapshot-only models.
238
+ * 2. File cache (parsed from models.json + opencode.json overrides).
239
+ * Used before the API cache warms and as a last resort.
240
+ *
241
+ * Returns `undefined` if neither layer knows the model.
146
242
  */
147
243
  export function getModelsDevContextLimit(providerID: string, modelID: string): number | undefined {
148
- const now = Date.now();
244
+ const key = `${providerID}/${modelID}`;
149
245
 
150
- if (!cachedLimits || now - lastLoadAttempt > RELOAD_INTERVAL_MS) {
151
- lastLoadAttempt = now;
152
- cachedLimits = loadModelsDevLimits();
246
+ if (apiCache) {
247
+ const fromApi = apiCache.get(key);
248
+ if (typeof fromApi === "number") return fromApi;
153
249
  }
154
250
 
155
- return cachedLimits.get(`${providerID}/${modelID}`);
251
+ const now = Date.now();
252
+ if (!fileCache || now - fileLastAttempt > RELOAD_INTERVAL_MS) {
253
+ fileLastAttempt = now;
254
+ fileCache = loadModelsDevLimitsFromFile();
255
+ }
256
+ return fileCache.get(key);
156
257
  }
157
258
 
158
- /** Clear the in-memory cache (for testing) */
259
+ /** Clear in-memory caches (for testing). */
159
260
  export function clearModelsDevCache(): void {
160
- cachedLimits = null;
161
- lastLoadAttempt = 0;
261
+ apiCache = null;
262
+ apiLoadedAt = 0;
263
+ fileCache = null;
264
+ fileLastAttempt = 0;
265
+ }
266
+
267
+ /** Inspection helpers (for logging / debugging). */
268
+ export function getModelsDevCacheState(): {
269
+ apiLoaded: boolean;
270
+ apiCount: number;
271
+ apiAgeMs: number;
272
+ fileCount: number;
273
+ } {
274
+ return {
275
+ apiLoaded: apiCache !== null,
276
+ apiCount: apiCache?.size ?? 0,
277
+ apiAgeMs: apiLoadedAt > 0 ? Date.now() - apiLoadedAt : -1,
278
+ fileCount: fileCache?.size ?? 0,
279
+ };
162
280
  }