From fd6a704a17289cb1b60807ed32080a44e6fc2501 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 30 Jul 2026 22:03:21 -0500 Subject: [PATCH] fix(core): respect model input limits --- 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 | 7 +--- packages/core/src/session/compaction.ts | 6 ++- packages/core/test/model-resolver.test.ts | 6 ++- .../core/test/plugin/provider-openai.test.ts | 12 +++++- packages/core/test/session-compaction.test.ts | 41 ++++++++++++++++++- 9 files changed, 65 insertions(+), 15 deletions(-) diff --git a/packages/ai/src/provider-package.ts b/packages/ai/src/provider-package.ts index ed753ba954..149c5a85b6 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 df7c6544aa..bb77022c95 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 7aaa5ec6c9..da6b9a7e0e 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 a3bfd29e41..d397c5816d 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 ef0011c259..877c070015 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -220,12 +220,7 @@ 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 } - } + 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 569675cb45..7ea11bcb04 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -351,10 +351,14 @@ const make = (dependencies: Dependencies) => { ) if (!last) return false const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX) + const limit = Math.min( + input.model.route.defaults.limits?.input ?? Number.POSITIVE_INFINITY, + context - (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 >= limit } 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 b329288da2..dca9eddf66 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 37b71d3a21..9a73f5af93 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 aa781f40da..1b626e0ef4 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,43 @@ test("compaction prompt requires the checkpoint headings in order", () => { expect(prompt).toContain("Keep every section, even when empty.") }) +it.effect("auto compaction respects explicit model input limits", () => + 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) => ({ + session, + model: Model.make({ + id: "test-model", + provider: "test-provider", + route: OpenAIChat.route.with({ limits: { context: 1_000, input: 100, output: 100 } }), + }), + 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 }, + }), + ], + }) + + expect(compaction.required(input(99))).toBe(false) + expect(compaction.required(input(100))).toBe(true) + }), +) + it.effect("manual compaction summarizes short context instead of no-op", () => Effect.gen(function* () { requests = []