Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ai/src/schema/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions

export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({
context: Schema.optional(Schema.Number),
input: Schema.optional(Schema.Number),
output: Schema.optional(Schema.Number),
}) {}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/aisdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ function modelFromLanguage(info: ModelV2.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: {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/model-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const withDefaults = (model: ModelV2.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: ModelV2.Info) => {
Expand Down Expand Up @@ -206,7 +206,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: () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/plugin/provider/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ export const OpenAIPlugin = define({
})
yield* load()
yield* ctx.catalog.transform((evt) => {
const codex = evt.provider.get(ProviderV2.ID.make("openai-codex"))
if (codex)
for (const model of codex.models.values())
evt.model.update(codex.provider.id, model.id, (draft) => {
draft.enabled = false
})
for (const item of evt.provider.list()) {
if (!ProviderV2.isAISDK(item.provider.package)) continue
if (ProviderV2.packageName(item.provider.package) !== "@ai-sdk/openai") continue
Expand All @@ -205,6 +211,8 @@ export const OpenAIPlugin = define({
draft.enabled = false
return
}
const route = codex?.models.get(draft.id)
if (route) draft.limit = { ...route.limit }
draft.cost = []
})
}
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,10 +351,12 @@ const make = (dependencies: Dependencies) => {
)
if (!last) return false
const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX)
const inputLimit = input.model.route.defaults.limits?.input
const limit = inputLimit === undefined ? context - (output || config.buffer) : Math.max(0, inputLimit - 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)
Expand Down
6 changes: 4 additions & 2 deletions packages/core/test/model-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface ModelOptions {
readonly headers?: ModelV2.Info["headers"]
readonly body?: ModelV2.Info["body"]
readonly variants?: ModelV2.Info["variants"]
readonly limit?: ModelV2.Info["limit"]
}

const model = (packageName: string | undefined, options: ModelOptions = {}) =>
Expand All @@ -36,14 +37,15 @@ 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", () => {
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
limit: { context: 100, input: 70, output: 20 },
})
const resolved = yield* ModelResolver.fromCatalogModel(catalog)

Expand All @@ -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: 70, output: 20 },
http: { body: { custom_extension: { enabled: true } } },
},
})
Expand Down
42 changes: 36 additions & 6 deletions packages/core/test/plugin/provider-openai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@ describe("OpenAIPlugin", () => {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
yield* catalog.transform((catalog) => {
const codex = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.make("openai-codex")),
package: ProviderV2.aisdk("@ai-sdk/openai"),
})
catalog.provider.update(codex.id, (draft) => {
draft.package = codex.package
})
catalog.model.update(codex.id, ModelV2.ID.make("gpt-5.6-sol"), (model) => {
model.limit = { context: 400_000, input: 272_000, output: 128_000 }
})
const item = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.openai),
package: ProviderV2.aisdk("@ai-sdk/openai"),
Expand All @@ -189,7 +199,9 @@ describe("OpenAIPlugin", () => {
model.body = { reasoning: { mode: "pro" } }
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.6"), () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.6-sol"), () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.6-sol"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
})
yield* credentials.create({
Expand All @@ -215,9 +227,13 @@ describe("OpenAIPlugin", () => {
false,
)
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.6"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.6-sol"))).enabled).toBe(
true,
)
const sol = required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.6-sol")))
expect(sol.enabled).toBe(true)
expect(sol.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(
required(yield* catalog.model.get(ProviderV2.ID.make("openai-codex"), ModelV2.ID.make("gpt-5.6-sol")))
.enabled,
).toBe(false)
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(false)
}),
)
Expand All @@ -227,14 +243,26 @@ describe("OpenAIPlugin", () => {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
yield* catalog.transform((catalog) => {
const codex = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.make("openai-codex")),
package: ProviderV2.aisdk("@ai-sdk/openai"),
})
catalog.provider.update(codex.id, (draft) => {
draft.package = codex.package
})
catalog.model.update(codex.id, ModelV2.ID.make("gpt-5.5"), (model) => {
model.limit = { context: 400_000, input: 272_000, output: 128_000 }
})
const item = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.openai),
package: ProviderV2.aisdk("@ai-sdk/openai"),
})
catalog.provider.update(item.id, (draft) => {
draft.package = item.package
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
})
yield* credentials.create({
Expand All @@ -243,7 +271,9 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()

expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5"))).enabled).toBe(true)
const model = required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5")))
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
Expand Down
38 changes: 38 additions & 0 deletions packages/core/test/session-compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionV2 } from "@opencode-ai/core/session"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
Expand Down Expand Up @@ -131,6 +135,40 @@ test("compaction prompt requires the checkpoint headings in order", () => {
expect(prompt).toContain("Keep every section, even when empty.")
})

it.effect("auto compaction uses the model input limit", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_input_limit"),
projectID: Project.ID.global,
title: "Input limit",
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("/project") }),
})
const route = Model.make({
id: "codex-model",
provider: "openai",
route: OpenAIChat.route.with({ limits: { context: 400_000, input: 272_000, output: 128_000 } }),
})
const message = (input: number) =>
SessionMessage.Assistant.make({
id: SessionMessage.ID.create(),
type: "assistant",
agent: AgentV2.defaultID,
model: { id: ModelV2.ID.make("codex-model"), providerID: ProviderV2.ID.openai },
content: [],
cost: Money.USD.zero,
tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0) },
})

expect(compaction.required({ session, model: route, cost: [], messages: [message(251_999)] })).toBe(false)
expect(compaction.required({ session, model: route, cost: [], messages: [message(252_000)] })).toBe(true)
}),
)

it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () {
requests = []
Expand Down
3 changes: 2 additions & 1 deletion packages/tui/src/util/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ export function contextUsage(
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (tokens <= 0) return
const model = models?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
const limit = model?.limit.input ?? model?.limit.context
return {
tokens,
percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : undefined,
percent: limit ? Math.round((tokens / limit) * 100) : undefined,
}
}

Expand Down
19 changes: 17 additions & 2 deletions packages/tui/test/util/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client"
import { isDefaultTitle, lastAssistantWithUsage } from "../../src/util/session"
import type { ModelInfo, SessionMessageInfo } from "@opencode-ai/client"
import { contextUsage, isDefaultTitle, lastAssistantWithUsage } from "../../src/util/session"

const assistant = (id: string, input: number): SessionMessageInfo => ({
id,
Expand Down Expand Up @@ -45,4 +45,19 @@ describe("util.session", () => {
messages.push(assistant("msg_after", 5))
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(5)
})

test("reports usage against the effective input window", () => {
const models = [
{
id: "model",
providerID: "provider",
limit: { context: 400_000, input: 272_000, output: 128_000 },
} as unknown as ModelInfo,
]

expect(contextUsage([assistant("msg_input_limit", 252_000)], models)).toEqual({
tokens: 252_000,
percent: 93,
})
})
})
Loading