From 7129ed6e62643fe83e17a40257c32d8d2b47bf1d Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:41:36 -0500 Subject: [PATCH] fix(core): respect model input limits (#39797) --- packages/ai/src/provider-package.ts | 1 + packages/ai/src/schema/options.ts | 1 + packages/core/src/aisdk.ts | 2 +- packages/core/src/model-resolver.ts | 4 +- packages/core/src/plugin/provider/openai.ts | 8 +-- packages/core/src/session/compaction.ts | 22 ++++---- packages/core/test/model-resolver.test.ts | 6 ++- .../core/test/plugin/provider-openai.test.ts | 12 ++++- packages/core/test/session-compaction.test.ts | 50 ++++++++++++++++++- .../content/docs/(Configure)/compaction.mdx | 2 +- 10 files changed, 83 insertions(+), 25 deletions(-) diff --git a/packages/ai/src/provider-package.ts b/packages/ai/src/provider-package.ts index ed753ba954f..149c5a85b6e 100644 --- a/packages/ai/src/provider-package.ts +++ b/packages/ai/src/provider-package.ts @@ -6,6 +6,7 @@ export interface Settings extends Readonly> { readonly body?: Readonly> readonly limits?: { readonly context: number + readonly input?: number readonly output: number } } diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts index df7c6544aa4..bb77022c959 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -123,6 +123,7 @@ export const mergeGenerationOptions = (...items: ReadonlyArray("LLM.ModelLimits")({ context: Schema.optional(Schema.Number), + input: Schema.optional(Schema.Number), output: Schema.optional(Schema.Number), }) {} diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 7aaa5ec6c95..da6b9a7e0ee 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -332,7 +332,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) { body: projected.body === undefined ? undefined : { ...projected.body }, headers: info.headers, }, - limits: { context: info.limit.context, output: info.limit.output }, + limits: { context: info.limit.context, input: info.limit.input, output: info.limit.output }, providerOptions, }, body: { diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index a3bfd29e41e..d397c5816d1 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -81,7 +81,7 @@ const withDefaults = (model: Info, route: AnyRoute) => headers: providerHeaders(model), providerOptions: providerOptions(model), http: model.body === undefined ? undefined : { body: model.body }, - limits: { context: model.limit.context, output: model.limit.output }, + limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output }, }) const providerHeaders = (model: Info) => { @@ -204,7 +204,7 @@ export const fromCatalogModel = ( ...nativeCredentialSettings(specifier, credential), headers: resolved.headers, body: resolved.body, - limits: { context: resolved.limit.context, output: resolved.limit.output }, + limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output }, } return yield* Effect.try({ try: () => { diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index ef0011c259b..cbd101eacaf 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -220,12 +220,8 @@ export const OpenAIPlugin = define({ return } draft.cost = [] - if (draft.id.includes("gpt-5.5")) { - draft.limit = { context: 400_000, input: 272_000, output: 128_000 } - } - if (draft.id.includes("gpt-5.6")) { - draft.limit = { context: 500_000, input: 372_000, output: 128_000 } - } + // Match Codex CLI so context consumption and subscription usage stay consistent between clients. + draft.limit = { ...draft.limit, context: 272_000, input: 272_000 } }) } }) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 569675cb459..b36d997727e 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -152,14 +152,11 @@ const settings = (documents: readonly Config.Entry[]) => { const configured = documents .filter((entry): entry is Config.Document => entry.type === "document") .flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : [])) - return configured.reduce( - (result, current) => ({ - auto: current.auto ?? result.auto, - buffer: current.buffer ?? result.buffer, - tokens: current.keep?.tokens ?? result.tokens, - }), - { auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }, - ) + return { + auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true, + buffer: configured.findLast((value) => value.buffer !== undefined)?.buffer ?? DEFAULT_BUFFER, + tokens: configured.findLast((value) => value.keep?.tokens !== undefined)?.keep?.tokens ?? DEFAULT_KEEP_TOKENS, + } } const select = ( @@ -350,11 +347,16 @@ const make = (dependencies: Dependencies) => { message.type === "assistant" && message.tokens !== undefined, ) if (!last) return false - const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX) + const limits = input.model.route.defaults.limits + const output = Math.min(limits?.output ?? 0, OUTPUT_TOKEN_MAX) + const promptCeiling = Math.min( + limits?.input === undefined ? Number.POSITIVE_INFINITY : limits.input - config.buffer, + context - Math.max(output, config.buffer), + ) const used = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write if (used <= 0) return false - return used >= context - (output || config.buffer) + return used >= promptCeiling } const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) { const content = planContent(input.messages, config.tokens) diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 5cd0699844c..b7838b8c9ff 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -17,6 +17,7 @@ interface ModelOptions { readonly headers?: Info["headers"] readonly body?: Info["body"] readonly variants?: Info["variants"] + readonly limit?: Info["limit"] } const model = (packageName: string | undefined, options: ModelOptions = {}) => @@ -36,7 +37,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) => cost: [], status: "active", enabled: true, - limit: { context: 100, output: 20 }, + limit: options.limit ?? { context: 100, output: 20 }, }) describe("ModelResolver", () => { @@ -44,6 +45,7 @@ describe("ModelResolver", () => { Effect.gen(function* () { const catalog = model(Provider.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, + limit: { context: 100, input: 80, output: 20 }, }) const resolved = yield* ModelResolver.fromCatalogModel(catalog) @@ -55,7 +57,7 @@ describe("ModelResolver", () => { endpoint: { baseURL: "https://openai.example/v1" }, defaults: { headers: { "x-test": "header" }, - limits: { context: 100, output: 20 }, + limits: { context: 100, input: 80, output: 20 }, http: { body: { custom_extension: { enabled: true } } }, }, }) diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 37b71d3a212..9a73f5af939 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -74,6 +74,9 @@ describe("OpenAIPlugin", () => { ] }) catalog.model.update(item.id, Model.ID.make("gpt-5.5-pro"), () => {}) + catalog.model.update(item.id, Model.ID.make("gpt-5.4"), (model) => { + model.limit = { context: 1_050_000, input: 922_000, output: 64_000 } + }) catalog.model.update(item.id, Model.ID.make("gpt-5.4-pro"), (model) => { model.modelID = Model.ID.make("gpt-5.4") model.body = { reasoning: { mode: "pro" } } @@ -137,7 +140,7 @@ describe("OpenAIPlugin", () => { const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) expect(eligible.package).toBe("@opencode-ai/ai/providers/openai") expect(eligible.cost).toEqual([]) - expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) + expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 }) expect(eligible.enabled).toBe(true) expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe( false, @@ -145,10 +148,15 @@ describe("OpenAIPlugin", () => { expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe( false, ) + expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({ + context: 272_000, + input: 272_000, + output: 64_000, + }) expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false) const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"))) expect(gpt56.enabled).toBe(true) - expect(gpt56.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 }) + expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 }) expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false) }), ) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index aa781f40dae..b32f453285a 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -19,9 +19,11 @@ import { Session } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { App } from "@opencode-ai/core/app" +import { Agent } from "@opencode-ai/core/agent" +import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { Money } from "@opencode-ai/schema/money" -import { DateTime, Effect, Fiber, Layer, Stream } from "effect" +import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect" import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" @@ -130,6 +132,52 @@ test("compaction prompt requires the checkpoint headings in order", () => { expect(prompt).toContain("Keep every section, even when empty.") }) +it.effect("auto compaction reserves a buffer below the prompt ceiling", () => + Effect.gen(function* () { + const compaction = yield* SessionCompaction.Service + const session = Session.Info.make({ + id: Session.ID.make("ses_input_limit"), + projectID: Project.ID.global, + cost: Money.USD.zero, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }), + }) + const input = (tokens: number, limits: { context: number; input?: number; output: number }) => ({ + session, + model: Model.make({ + id: "test-model", + provider: "test-provider", + route: OpenAIChat.route.with({ limits }), + }), + cost: [], + messages: [ + Schema.decodeUnknownSync(SessionMessage.Assistant)({ + id: SessionMessage.ID.make("msg_assistant"), + type: "assistant", + agent: Agent.defaultID, + model: { id: "test-model", providerID: "test-provider" }, + content: [], + tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, completed: 0 }, + }), + ], + }) + + const inputLimited = { context: 400_000, input: 272_000, output: 128_000 } + expect(compaction.required(input(251_999, inputLimited))).toBe(false) + expect(compaction.required(input(252_000, inputLimited))).toBe(true) + + const contextLimited = { context: 100_000, output: 10_000 } + expect(compaction.required(input(79_999, contextLimited))).toBe(false) + expect(compaction.required(input(80_000, contextLimited))).toBe(true) + + const outputLimited = { context: 100_000, output: 30_000 } + expect(compaction.required(input(69_999, outputLimited))).toBe(false) + expect(compaction.required(input(70_000, outputLimited))).toBe(true) + }), +) + it.effect("manual compaction summarizes short context instead of no-op", () => Effect.gen(function* () { requests = [] diff --git a/packages/www/content/docs/(Configure)/compaction.mdx b/packages/www/content/docs/(Configure)/compaction.mdx index dd338e69291..00bcaf63a8b 100644 --- a/packages/www/content/docs/(Configure)/compaction.mdx +++ b/packages/www/content/docs/(Configure)/compaction.mdx @@ -95,7 +95,7 @@ Add `compaction` to any [OpenCode configuration file](/config): | `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. | | `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. | | `keep.tokens` | `8000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. | -| `buffer` | `20000` | Token reserve used by the automatic threshold. The requested model output allowance wins when it is larger. | +| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. | `keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens` preserves more recent detail but leaves less room for future work. Larger