Skip to content

Commit 4669e1a

Browse files
committed
fix: per-model threshold lookup and input-aware context limits
Three related correctness fixes around per-model execute_threshold_percentage and context-limit resolution, addressing user-reported issues with GitHub Copilot models and experimental-mode model variants. - transform.ts: populate liveModelBySession from message history when empty, so the scheduler can resolve per-model thresholds immediately instead of waiting for the next message.updated event. Previously, after a plugin restart or on the first transform of a session with prior history, the map stayed empty and every transform in that window silently used the default threshold. - event-resolvers.ts: progressive key lookup in resolveExecuteThreshold. OpenCode's experimental.modes generates derived model IDs like 'openai/gpt-5.4-fast' from base 'openai/gpt-5.4'. Users may now write either form in config; most-specific match wins. Also matches bare model IDs (without provider prefix) through the same cascade. +10 tests. - models-dev-cache.ts: prefer limit.input over limit.context across all three resolution sites (models.json file layer, opencode.json custom provider overlay, SDK API refresh path). Matches OpenCode's session/ overflow.ts behavior. Affects 182+ models including every GitHub Copilot entry and most gpt-5.x derivations through proxies where limit.input is the enforced prompt cap and limit.context is the total window including output. Previously we used the larger context number as the denominator, so pressure math underestimated real usage. gpt-5.3-codex for example now correctly uses 272K instead of 400K as the context limit. - models-dev-cache.test.ts: test isolation fix — OPENCODE_CONFIG_DIR now points at an empty directory per test so user's real opencode.jsonc (which may have custom provider limit overrides) does not leak into test expectations. +4 tests for the input/context preference logic.
1 parent dc6ed12 commit 4669e1a

5 files changed

Lines changed: 314 additions & 26 deletions

File tree

packages/plugin/src/hooks/magic-context/event-resolvers.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
22
import {
33
resolveCacheTtl,
44
resolveContextLimit,
5+
resolveExecuteThreshold,
56
resolveModelKey,
67
resolveSessionId,
78
} from "./event-resolvers";
@@ -66,6 +67,124 @@ describe("event-resolvers", () => {
6667
});
6768
});
6869

70+
describe("resolveExecuteThreshold", () => {
71+
it("returns direct number config unchanged (after max cap)", () => {
72+
expect(resolveExecuteThreshold(50, "openai/gpt-5.4-fast", 65)).toBe(50);
73+
expect(resolveExecuteThreshold(50, undefined, 65)).toBe(50);
74+
});
75+
76+
it("caps any resolved value at 80%", () => {
77+
expect(resolveExecuteThreshold(95, "openai/gpt-4o", 65)).toBe(80);
78+
expect(
79+
resolveExecuteThreshold({ default: 95, "openai/gpt-4o": 90 }, "openai/gpt-4o", 65),
80+
).toBe(80);
81+
});
82+
83+
it("prefers exact provider/model key when present", () => {
84+
//#given — user wrote the derived key
85+
const config = { default: 65, "openai/gpt-5.4-fast": 25 };
86+
87+
//#when
88+
const result = resolveExecuteThreshold(config, "openai/gpt-5.4-fast", 65);
89+
90+
//#then
91+
expect(result).toBe(25);
92+
});
93+
94+
it("falls back to base model key when user wrote base (no derived)", () => {
95+
//#given — user wrote base key, runtime is derived (e.g., -fast variant)
96+
const config = { default: 65, "openai/gpt-5.4": 25 };
97+
98+
//#when — modelKey is the derived form
99+
const result = resolveExecuteThreshold(config, "openai/gpt-5.4-fast", 65);
100+
101+
//#then — should match "openai/gpt-5.4" after suffix strip
102+
expect(result).toBe(25);
103+
});
104+
105+
it("prefers most-specific match when both derived and base configured", () => {
106+
//#given — user wrote BOTH keys, want derived to win
107+
const config = {
108+
default: 65,
109+
"openai/gpt-5.4-fast": 20,
110+
"openai/gpt-5.4": 40,
111+
};
112+
113+
//#when
114+
const derived = resolveExecuteThreshold(config, "openai/gpt-5.4-fast", 65);
115+
const base = resolveExecuteThreshold(config, "openai/gpt-5.4", 65);
116+
117+
//#then
118+
expect(derived).toBe(20);
119+
expect(base).toBe(40);
120+
});
121+
122+
it("matches bare model id (no provider prefix) in config", () => {
123+
//#given — user wrote just the model id without provider
124+
const config = { default: 65, "gpt-5.4-fast": 25 };
125+
126+
//#when
127+
const result = resolveExecuteThreshold(config, "openai/gpt-5.4-fast", 65);
128+
129+
//#then
130+
expect(result).toBe(25);
131+
});
132+
133+
it("matches bare base model id for derived runtime model", () => {
134+
//#given
135+
const config = { default: 65, "gpt-5.4": 30 };
136+
137+
//#when
138+
const result = resolveExecuteThreshold(config, "openai/gpt-5.4-fast", 65);
139+
140+
//#then
141+
expect(result).toBe(30);
142+
});
143+
144+
it("returns config.default when no keys match", () => {
145+
//#given
146+
const config = { default: 55, "anthropic/claude-opus-4-6": 40 };
147+
148+
//#when
149+
const result = resolveExecuteThreshold(config, "openai/gpt-4o", 65);
150+
151+
//#then
152+
expect(result).toBe(55);
153+
});
154+
155+
it("returns fallback when config.default absent and no match", () => {
156+
//#given
157+
const config = { default: 0, "anthropic/claude-opus-4-6": 40 } as unknown as {
158+
default: number;
159+
[key: string]: number;
160+
};
161+
// Simulate missing default by deleting
162+
// biome-ignore lint/performance/noDelete: test setup requires actual missing key
163+
delete (config as Record<string, unknown>).default;
164+
165+
//#when
166+
const result = resolveExecuteThreshold(
167+
config as { default: number; [key: string]: number },
168+
"openai/gpt-4o",
169+
65,
170+
);
171+
172+
//#then
173+
expect(result).toBe(65);
174+
});
175+
176+
it("returns config.default when modelKey is undefined", () => {
177+
//#given
178+
const config = { default: 42, "openai/gpt-5.4-fast": 25 };
179+
180+
//#when
181+
const result = resolveExecuteThreshold(config, undefined, 65);
182+
183+
//#then — undefined modelKey hits the no-model branch, not the per-model lookup
184+
expect(result).toBe(42);
185+
});
186+
});
187+
69188
describe("resolveModelKey", () => {
70189
it("returns provider/model when both parts exist", () => {
71190
expect(resolveModelKey("openai", "gpt-4o")).toBe("openai/gpt-4o");

packages/plugin/src/hooks/magic-context/event-resolvers.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,35 @@ export function resolveCacheTtl(cacheTtl: CacheTtlConfig, modelKey: string | und
4747

4848
type ExecuteThresholdConfig = number | { default: number; [modelKey: string]: number };
4949

50+
/**
51+
* Yield progressively-less-specific lookup keys for a given `provider/model`.
52+
*
53+
* OpenCode's `experimental.modes` feature derives model IDs like
54+
* `gpt-5.4-fast` from a base model `gpt-5.4`. Users may put EITHER the
55+
* derived key OR the base key in their per-model config. This generator
56+
* returns keys in specificity order so we pick the most specific match
57+
* the user actually wrote:
58+
*
59+
* "openai/gpt-5.4-fast" (exact)
60+
* "gpt-5.4-fast" (bare, derived)
61+
* "openai/gpt-5.4" (base, with provider)
62+
* "gpt-5.4" (base, bare)
63+
* ...etc. stripping one "-segment" at a time
64+
*/
65+
function* modelKeyLookupOrder(modelKey: string): Generator<string> {
66+
const slash = modelKey.indexOf("/");
67+
const provider = slash >= 0 ? modelKey.slice(0, slash) : "";
68+
let modelId = slash >= 0 ? modelKey.slice(slash + 1) : modelKey;
69+
70+
while (modelId.length > 0) {
71+
if (provider) yield `${provider}/${modelId}`;
72+
yield modelId;
73+
const lastDash = modelId.lastIndexOf("-");
74+
if (lastDash <= 0) break;
75+
modelId = modelId.slice(0, lastDash);
76+
}
77+
}
78+
5079
export function resolveExecuteThreshold(
5180
config: ExecuteThresholdConfig,
5281
modelKey: string | undefined,
@@ -57,15 +86,15 @@ export function resolveExecuteThreshold(
5786

5887
if (typeof config === "number") {
5988
resolved = config;
60-
} else if (modelKey && typeof config[modelKey] === "number") {
61-
resolved = config[modelKey];
6289
} else if (modelKey) {
63-
const bareModelId = modelKey.split("/").slice(1).join("/");
64-
if (bareModelId && typeof config[bareModelId] === "number") {
65-
resolved = config[bareModelId];
66-
} else {
67-
resolved = config.default ?? fallback;
90+
let matched: number | undefined;
91+
for (const candidate of modelKeyLookupOrder(modelKey)) {
92+
if (typeof config[candidate] === "number") {
93+
matched = config[candidate];
94+
break;
95+
}
6896
}
97+
resolved = matched ?? config.default ?? fallback;
6998
} else {
7099
resolved = config.default ?? fallback;
71100
}

packages/plugin/src/hooks/magic-context/transform.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,10 +228,17 @@ export function createTransform(deps: TransformDeps) {
228228
const lastAssistantModel = findLastAssistantModel(messages);
229229
if (lastAssistantModel) {
230230
const knownModel = deps.liveModelBySession.get(sessionId);
231-
if (
232-
knownModel &&
233-
(knownModel.providerID !== lastAssistantModel.providerID ||
234-
knownModel.modelID !== lastAssistantModel.modelID)
231+
if (!knownModel) {
232+
// No known model yet — populate from message history so the
233+
// scheduler can resolve per-model execute_threshold_percentage
234+
// immediately. Without this, after a plugin restart the map
235+
// stays empty until a new message.updated event fires, and
236+
// any transform in between uses the default threshold even
237+
// for sessions with explicit per-model config.
238+
deps.liveModelBySession.set(sessionId, lastAssistantModel);
239+
} else if (
240+
knownModel.providerID !== lastAssistantModel.providerID ||
241+
knownModel.modelID !== lastAssistantModel.modelID
235242
) {
236243
sessionLog(
237244
sessionId,

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

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,17 @@ describe("models-dev-cache", () => {
1919
OPENCODE_MODELS_PATH: process.env.OPENCODE_MODELS_PATH,
2020
OPENCODE_MODELS_URL: process.env.OPENCODE_MODELS_URL,
2121
XDG_CACHE_HOME: process.env.XDG_CACHE_HOME,
22+
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
2223
};
23-
// Isolate from user environment.
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.
2426
delete process.env.OPENCODE_MODELS_PATH;
2527
delete process.env.OPENCODE_MODELS_URL;
2628
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;
2733
clearModelsDevCache();
2834
});
2935

@@ -61,6 +67,113 @@ describe("models-dev-cache", () => {
6167
expect(getModelsDevContextLimit("unknown", "unknown")).toBeUndefined();
6268
});
6369

70+
test("prefers limit.input over limit.context when both are present", () => {
71+
//#given — GitHub Copilot shape: input is max prompt, context is total window.
72+
// Matches real-world github-copilot/gpt-5.3-codex which has
73+
// limit.context = 400000 (total), limit.input = 272000 (max prompt).
74+
// Our pressure math must use the input cap; sending a 400K prompt gets rejected.
75+
// OpenCode's own session/overflow.ts follows the same rule.
76+
const opencodeDir = join(tempDir, "opencode");
77+
mkdirSync(opencodeDir, { recursive: true });
78+
writeFileSync(
79+
join(opencodeDir, "models.json"),
80+
JSON.stringify({
81+
"github-copilot": {
82+
models: {
83+
"gpt-5.3-codex": { limit: { context: 400000, input: 272000 } },
84+
"claude-opus-4.6": { limit: { context: 144000, input: 128000 } },
85+
// Context-only model (no input) falls back to context.
86+
"legacy-only-context": { limit: { context: 100000 } },
87+
},
88+
},
89+
}),
90+
);
91+
92+
//#then
93+
expect(getModelsDevContextLimit("github-copilot", "gpt-5.3-codex")).toBe(272000);
94+
expect(getModelsDevContextLimit("github-copilot", "claude-opus-4.6")).toBe(128000);
95+
expect(getModelsDevContextLimit("github-copilot", "legacy-only-context")).toBe(100000);
96+
});
97+
98+
test("derived experimental.modes inherit the effective (input) limit", () => {
99+
//#given — parent has input < context; derived modes should inherit input, not context
100+
const opencodeDir = join(tempDir, "opencode");
101+
mkdirSync(opencodeDir, { recursive: true });
102+
writeFileSync(
103+
join(opencodeDir, "models.json"),
104+
JSON.stringify({
105+
openai: {
106+
models: {
107+
"gpt-5.4": {
108+
limit: { context: 1050000, input: 922000 },
109+
experimental: { modes: { fast: {}, mini: {} } },
110+
},
111+
},
112+
},
113+
}),
114+
);
115+
116+
//#then
117+
expect(getModelsDevContextLimit("openai", "gpt-5.4")).toBe(922000);
118+
expect(getModelsDevContextLimit("openai", "gpt-5.4-fast")).toBe(922000);
119+
expect(getModelsDevContextLimit("openai", "gpt-5.4-mini")).toBe(922000);
120+
});
121+
122+
test("custom opencode.json provider overlay uses limit.input preferentially", () => {
123+
//#given — user defines a proxy provider in opencode.json with input < context
124+
const opencodeDir = join(tempDir, "opencode");
125+
mkdirSync(opencodeDir, { recursive: true });
126+
const configDir = join(tempDir, "config", "opencode");
127+
mkdirSync(configDir, { recursive: true });
128+
writeFileSync(
129+
join(configDir, "opencode.json"),
130+
JSON.stringify({
131+
provider: {
132+
"my-proxy": {
133+
models: {
134+
"split-model": { limit: { context: 400000, input: 200000 } },
135+
},
136+
},
137+
},
138+
}),
139+
);
140+
process.env.OPENCODE_CONFIG_DIR = configDir;
141+
clearModelsDevCache();
142+
143+
//#then
144+
expect(getModelsDevContextLimit("my-proxy", "split-model")).toBe(200000);
145+
146+
// Cleanup: restore env (afterEach also handles this, but we added a new var)
147+
delete process.env.OPENCODE_CONFIG_DIR;
148+
});
149+
150+
test("API cache uses limit.input preferentially", async () => {
151+
//#given — API response shape mirrors file layer
152+
const mockClient = {
153+
config: {
154+
providers: async () => ({
155+
data: {
156+
providers: [
157+
{
158+
id: "github-copilot",
159+
models: {
160+
"gpt-5.3-codex": {
161+
limit: { context: 400000, input: 272000 },
162+
},
163+
},
164+
},
165+
],
166+
},
167+
}),
168+
},
169+
};
170+
// @ts-expect-error mock narrow shape
171+
await refreshModelLimitsFromApi(mockClient);
172+
173+
//#then
174+
expect(getModelsDevContextLimit("github-copilot", "gpt-5.3-codex")).toBe(272000);
175+
});
176+
64177
test("expands experimental.modes into derived model IDs with parent context", () => {
65178
const opencodeDir = join(tempDir, "opencode");
66179
mkdirSync(opencodeDir, { recursive: true });

0 commit comments

Comments
 (0)