@cortexkit/opencode-magic-context 0.9.1 → 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.
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli.js +6 -3
- package/dist/config/schema/magic-context.d.ts +0 -4
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-types.d.ts +6 -1
- package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-trigger.d.ts +1 -1
- package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
- package/dist/hooks/magic-context/derive-budgets.d.ts +57 -0
- package/dist/hooks/magic-context/derive-budgets.d.ts.map +1 -0
- package/dist/hooks/magic-context/event-handler.d.ts +0 -1
- package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/event-resolvers.d.ts +1 -3
- package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts +3 -2
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/nudge-injection.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts +1 -1
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +7 -1
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +266 -136
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/plugin/tui-action-consumer.d.ts.map +1 -1
- package/dist/shared/models-dev-cache.d.ts +52 -4
- package/dist/shared/models-dev-cache.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/shared/models-dev-cache.test.ts +228 -0
- 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;;;;;;
|
|
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,
|
|
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;
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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":"
|
|
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
|
@@ -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
|
-
*
|
|
2
|
+
* Resolve per-model context limits to match whatever OpenCode itself sees.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* <xdg_cache>/opencode/models.json
|
|
4
|
+
* Two layers:
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
89
|
+
function loadModelsDevLimitsFromFile(): Map<string, number> {
|
|
54
90
|
const limits = new Map<string, number>();
|
|
55
91
|
|
|
56
|
-
// 1.
|
|
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
|
-
|
|
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
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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
|
|
244
|
+
const key = `${providerID}/${modelID}`;
|
|
149
245
|
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
246
|
+
if (apiCache) {
|
|
247
|
+
const fromApi = apiCache.get(key);
|
|
248
|
+
if (typeof fromApi === "number") return fromApi;
|
|
153
249
|
}
|
|
154
250
|
|
|
155
|
-
|
|
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
|
|
259
|
+
/** Clear in-memory caches (for testing). */
|
|
159
260
|
export function clearModelsDevCache(): void {
|
|
160
|
-
|
|
161
|
-
|
|
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
|
}
|