fix(ai): typecheck explicit compaction capability

This commit is contained in:
Shoubhit Dash 2026-08-31 21:08:20 +05:30
parent cd7447e834
commit dbe2998fce
12 changed files with 213 additions and 40 deletions

View file

@ -323,6 +323,8 @@ The corresponding package entrypoint is `@opencode-ai/ai/providers/amazon-bedroc
`LLMClient.compact(request)` performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` containing replacement `messages` and usage, not a normal generation response.
The selected model carries explicit-compaction capability through request construction and updates. Calls using unsupported routes fail type checking. When the model is selected dynamically, narrow the request with `LLMClient.canCompact(request)` before calling `LLMClient.compact`; a model or route switch does not inherit the old capability. Runtime validation still rejects unsupported calls from untyped consumers. Capability describes the route's API, not whether every model or custom deployment supports the operation.
```ts
const compacted = yield * LLMClient.compact(request)
const next = LLMRequest.update(request, {

View file

@ -1,4 +1,5 @@
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
import type { CompactOperation } from "./route/client.js"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
@ -9,8 +10,9 @@ export interface Settings extends Readonly<Record<string, unknown>> {
export interface Definition<
ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
}
export * as ProviderPackage from "./provider-package.js"

View file

@ -1,7 +1,7 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
@ -102,7 +102,11 @@ const auth = (input: Config) => {
)
}
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) =>
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
modelID: string | ModelID,
) =>
route.with({
auth: auth(input),
endpoint: endpoint(input, modelID),
@ -161,10 +165,11 @@ const config = (settings: Settings): Config => {
throw new Error("Azure requires resourceName or baseURL")
}
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const responsesModel: ProviderPackage.Definition<
Settings,
OpenAIProviderOptionsInput,
CompactOperation
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,

View file

@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput } from "../route/client.js"
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
@ -73,7 +73,10 @@ const defaults = (input: Config) => {
return rest
}
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
) =>
route.with({
auth: auth(input),
endpoint: { baseURL: input.baseURL, query: input.queryParams },
@ -129,7 +132,10 @@ const config = (settings: Settings): Config => {
}
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) => {
return configure(config(settings)).responses(modelID)
}

View file

@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Route, type RouteDefaultsInput, type CompactOperation } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
@ -103,7 +103,10 @@ export const configure = (input: LanguageModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,

View file

@ -35,8 +35,12 @@ export interface RouteBody<Body> {
readonly from: (request: LLMRequest) => Effect.Effect<Body, AIError>
}
export interface Route<Body, Prepared = unknown> {
readonly compact?: CompactOperation
export interface Route<
Body,
Prepared = unknown,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
readonly compact: Compact
readonly id: string
readonly provider?: ProviderID
/** ProviderMetadata namespace emitted and consumed by this route. */
@ -49,10 +53,10 @@ export interface Route<Body, Prepared = unknown> {
readonly transport: Transport<Body, Prepared, unknown>
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
readonly model: <Options extends ProviderOptions = ProviderOptions>(
input: RouteMappedLanguageModelInput,
) => LanguageModel<Options>
) => LanguageModel<Options, Compact>
readonly prepareTransport: (
body: Body,
request: LLMRequest,
@ -70,7 +74,11 @@ export interface Route<Body, Prepared = unknown> {
// Normal call sites use `OpenAIChat.route`; callers only need body types
// when preparing a request with a protocol-specific type assertion.
// oxlint-disable-next-line typescript-eslint/no-explicit-any
export type AnyRoute = Route<any, any>
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
any,
any,
Compact
>
export type HttpOptionsInput = HttpOptions.Input
@ -103,15 +111,15 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
const makeRouteLanguageModel = <Options extends ProviderOptions = ProviderOptions>(
route: AnyRoute,
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
route: AnyRoute<Compact>,
mapped: RouteMappedLanguageModelInput,
) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return LanguageModel.make<Options>({
return LanguageModel.make<Options, Compact>({
...mapped,
provider,
route,
@ -155,7 +163,7 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
export interface Interface {
readonly compact: (
request: LLMRequest,
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
readonly stream: StreamMethod
@ -181,6 +189,11 @@ export type CompactOperation = (
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
export type CompactionRequest = LLMRequest<LanguageModel<ProviderOptions, CompactOperation>>
export const canCompact = (request: LLMRequest): request is CompactionRequest =>
request.model.route.compact !== undefined
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
const resolveRequestOptions = (request: LLMRequest) => {
@ -328,7 +341,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
})
},
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
makeRouteLanguageModel<Options>(route, input),
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
prepareTransport: (body, request, options) =>
routeInput.transport.prepare({
body,
@ -425,6 +438,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
}
export function make<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: CompactOperation },
): Route<Body, Prepared, CompactOperation>
export function make<Body, Frame, Event, State>(
input: MakeInput<Body, Frame, Event, State> & { readonly compact: CompactOperation },
): Route<Body, HttpTransport.HttpPrepared<Frame>, CompactOperation>
export function make<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
): Route<Body, Prepared>
@ -537,7 +556,7 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
}
export const compact = (
request: LLMRequest,
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
): Effect.Effect<CompactionResponse, AIError, Service> =>
Effect.gen(function* () {
@ -576,6 +595,7 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
export const Route = { make } as const
export const LLMClient = {
canCompact,
compact,
Service,
layer,

View file

@ -7,6 +7,7 @@ import {
HttpOptions,
JsonSchema,
LanguageModelSchema,
type LanguageModel,
ProviderOptions,
} from "./options.js"
import { ProviderID } from "./ids.js"
@ -306,7 +307,7 @@ export namespace ToolChoice {
}
}
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
const requestSchema = Schema.Struct({
id: Schema.optional(Schema.String),
model: LanguageModelSchema,
system: Schema.Array(SystemPart),
@ -320,12 +321,26 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
// Stable cache affinity for protocols that support provider-managed prompt caching.
promptCacheKey: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
})
export class LLMRequest<Model extends LanguageModel = LanguageModel> extends Schema.Class<LLMRequest>("LLM.Request")(
requestSchema.fields,
) {
declare readonly model: Model
// Preserve model inference instead of inheriting the schema's erased constructor signature.
// oxlint-disable-next-line no-useless-constructor
constructor(input: LLMRequest.Input<Model>) {
super(input)
}
}
export namespace LLMRequest {
export type Input = ConstructorParameters<typeof LLMRequest>[0]
export type Input<Model extends LanguageModel = LanguageModel> = Omit<typeof requestSchema.Type, "model"> & {
readonly model: Model
}
export const input = (request: LLMRequest): Input => ({
export const input = <Model extends LanguageModel>(request: LLMRequest<Model>): Input<Model> => ({
id: request.id,
model: request.model,
system: request.system,
@ -340,7 +355,16 @@ export namespace LLMRequest {
metadata: request.metadata,
})
export const update = (request: LLMRequest, patch: Partial<Input>) => {
export function update<Model extends LanguageModel>(
request: LLMRequest,
patch: Partial<Input<Model>> & { readonly model: Model },
): LLMRequest<Model>
export function update<Model extends LanguageModel>(
request: LLMRequest<Model>,
patch: Partial<Omit<Input, "model">> & { readonly model?: undefined },
): LLMRequest<Model>
export function update(request: LLMRequest, patch: Partial<Input>): LLMRequest
export function update(request: LLMRequest, patch: Partial<Input>) {
if (Object.keys(patch).length === 0) return request
return new LLMRequest({
...input(request),

View file

@ -1,6 +1,6 @@
import { Schema } from "effect"
import { ModelID, ProviderID } from "./ids.js"
import type { AnyRoute } from "../route/client.js"
import type { AnyRoute, CompactOperation } from "../route/client.js"
import { isRecord } from "../utils/record.js"
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
@ -173,15 +173,18 @@ export namespace LanguageModelCompatibility {
input instanceof LanguageModelCompatibility ? input : new LanguageModelCompatibility(input)
}
export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
export class LanguageModel<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
declare protected readonly _ProviderOptions: Options
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
readonly route: AnyRoute<Compact>
readonly defaults?: LanguageModelDefaults
readonly compatibility?: LanguageModelCompatibility
constructor(input: LanguageModel.ConstructorInput) {
constructor(input: LanguageModel.ConstructorInput<Compact>) {
this.id = input.id
this.provider = input.provider
this.route = input.route
@ -189,8 +192,11 @@ export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
this.compatibility = input.compatibility
}
static make<Options extends ProviderOptions = ProviderOptions>(input: LanguageModel.Input) {
return new LanguageModel<Options>({
static make<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
>(input: LanguageModel.Input<Compact>) {
return new LanguageModel<Options, Compact>({
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: input.route,
@ -200,7 +206,9 @@ export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
})
}
static input<Options extends ProviderOptions>(model: LanguageModel<Options>): LanguageModel.ConstructorInput {
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
model: LanguageModel<Options, Compact>,
): LanguageModel.ConstructorInput<Compact> {
return {
id: model.id,
provider: model.provider,
@ -210,25 +218,41 @@ export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
}
}
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
model: LanguageModel<Options>,
patch: Partial<LanguageModel.Input<Compact>> & { readonly route: AnyRoute<Compact> },
): LanguageModel<Options, Compact>
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
model: LanguageModel<Options, Compact>,
patch: Partial<Omit<LanguageModel.Input, "route">> & { readonly route?: undefined },
): LanguageModel<Options, Compact>
static update<Options extends ProviderOptions>(
model: LanguageModel<Options>,
patch: Partial<LanguageModel.Input>,
): LanguageModel<Options>
static update<Options extends ProviderOptions>(model: LanguageModel<Options>, patch: Partial<LanguageModel.Input>) {
if (Object.keys(patch).length === 0) return model
return LanguageModel.make<Options>({
...LanguageModel.input(model),
...patch,
route: patch.route ?? model.route,
})
}
}
export namespace LanguageModel {
export type ConstructorInput = {
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute
readonly route: AnyRoute<Compact>
readonly defaults?: LanguageModelDefaults
readonly compatibility?: LanguageModelCompatibility
}
export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & {
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
ConstructorInput<Compact>,
"id" | "provider" | "defaults" | "compatibility"
> & {
readonly id: string | ModelID
readonly provider: string | ProviderID
readonly defaults?: LanguageModelDefaults.Input

View file

@ -1,6 +1,21 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { CompactionPart, LLMEvent, LLMResponse, Message, ProviderID } from "../src/schema/index.js"
import { LLM, LLMClient, LLMRequest, LanguageModel } from "../src/index.js"
import { OpenAI, Anthropic } from "../src/providers.js"
test("runtime capability checks follow model and route updates", () => {
const supported = OpenAI.configure({ apiKey: "test" }).responses("fixture")
const unsupported = Anthropic.configure({ apiKey: "test" }).model("fixture")
const request = LLM.request({ model: supported, prompt: "hello" })
expect(LLMClient.canCompact(request)).toBe(true)
expect(LLMClient.canCompact(LLMRequest.update(request, { messages: [] }))).toBe(true)
expect(LLMClient.canCompact(LLMRequest.update(request, { model: unsupported }))).toBe(false)
expect(
LLMClient.canCompact(LLM.request({ model: LanguageModel.update(supported, { route: unsupported.route }) })),
).toBe(false)
expect(LLMClient.canCompact(LLM.request({ model: LanguageModel.update(supported, { route: undefined }) }))).toBe(true)
})
test("compaction survives event assembly and message serialization without becoming text", () => {
const part = CompactionPart.make({

View file

@ -1,6 +1,23 @@
import { Effect } from "effect"
import { CompactionPart, LLM, LLMClient, LLMEvent, Message, ProviderID } from "../../src/index.js"
import { OpenAI, Anthropic, AmazonBedrock } from "../../src/providers.js"
import {
CompactionPart,
LanguageModel,
LLM,
LLMClient,
LLMEvent,
LLMRequest,
Message,
ProviderID,
} from "../../src/index.js"
import {
OpenAI,
Azure,
XAI,
Anthropic,
AmazonBedrock,
AmazonBedrockMantle,
OpenAICompatibleResponses,
} from "../../src/providers.js"
const openai = OpenAI.configure({
apiKey: "test",
@ -8,6 +25,59 @@ const openai = OpenAI.configure({
}).responses("gpt-5.3-codex")
LLMClient.compact(LLM.request({ model: openai, prompt: "hello" }))
for (const model of [
OpenAI.configure().responses("fixture"),
Azure.configure({ resourceName: "test" }).responses("fixture"),
XAI.configure().responses("fixture"),
OpenAI.model("fixture", {}),
Azure.responsesModel("fixture", { resourceName: "test" }),
XAI.model("fixture", {}),
openai.route.with({ headers: { "x-test": "test" } }).model({ id: "fixture" }),
LanguageModel.update(openai, { defaults: { generation: { maxTokens: 100 } } }),
LanguageModel.make(LanguageModel.input(openai)),
]) {
LLMClient.compact(LLM.request({ model, prompt: "hello" }))
}
for (const model of [
Anthropic.configure().model("fixture"),
OpenAI.configure().chat("fixture"),
Azure.configure({ resourceName: "test" }).chat("fixture"),
XAI.configure().chat("fixture"),
AmazonBedrock.configure().model("fixture"),
AmazonBedrock.configure().messages("fixture"),
AmazonBedrockMantle.configure().responses("fixture"),
OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"),
]) {
// @ts-expect-error This route does not guarantee an explicit compaction endpoint.
LLMClient.compact(LLM.request({ model, prompt: "hello" }))
LLMClient.Service.use((client) => {
// @ts-expect-error The service enforces the same capability as the convenience function.
return client.compact(LLM.request({ model, prompt: "hello" }))
})
}
const request = LLM.request({ model: openai, prompt: "hello" })
LLMClient.compact(LLMRequest.update(request, { messages: [Message.user("continue")] }))
LLMClient.compact(new LLMRequest(LLMRequest.input(request)))
const switched = LLMRequest.update(request, { model: Anthropic.configure().model("fixture") })
// @ts-expect-error Switching models replaces, rather than inherits, the capability.
LLMClient.compact(switched)
LLMClient.compact(LLMRequest.update(switched, { model: openai }))
LLMClient.compact(
// @ts-expect-error Replacing the route also replaces compaction capability.
LLM.request({ model: LanguageModel.update(openai, { route: Anthropic.configure().model("fixture").route }) }),
)
declare const dynamicModel: LanguageModel
declare const dynamicPatch: Partial<LLMRequest.Input>
const dynamicRequest = LLM.request({ model: dynamicModel, prompt: "hello" })
// @ts-expect-error A dynamically selected model must be narrowed first.
LLMClient.compact(dynamicRequest)
if (LLMClient.canCompact(dynamicRequest)) LLMClient.compact(dynamicRequest)
// @ts-expect-error An optional model override cannot retain the old capability statically.
LLMClient.compact(LLMRequest.update(request, dynamicPatch))
const checkpoint = CompactionPart.make({ provider: ProviderID.make("openai"), id: "cmp_1", encrypted: "opaque" })
const provider = ProviderID.make("anthropic")
CompactionPart.make({ provider, text: "summary" })

View file

@ -357,6 +357,7 @@ for (const model of [
`${model.route.id} does not inherit an unsupported compact endpoint`,
() =>
Effect.gen(function* () {
// @ts-expect-error Untyped callers must still receive the runtime capability error.
const error = yield* LLMClient.compact(LLM.request({ model, prompt: "hello" })).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
}),

View file

@ -303,6 +303,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
const projected = mapBodyToProviderOptions(info, packageName)
const optionKey = providerOptionKey(packageName, info.providerID)
const route: AnyRoute = {
compact: undefined,
id: `ai-sdk:${packageName}`,
provider: ProviderID.make(info.providerID),
providerMetadataKey: optionKey,