From 219ecada681992eed914c61ac4fb321dd887fbef Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:18:33 -0500 Subject: [PATCH] refactor(core): move provider policies into settings (#49666) --- .../client/src/promise/generated/types.ts | 126 +++++++++--------- packages/core/src/aisdk.ts | 7 +- packages/core/src/config/plugin/provider.ts | 4 - packages/core/src/model-resolver.ts | 14 +- packages/core/src/model.ts | 2 - packages/core/src/plugin/provider/azure.ts | 4 +- packages/core/src/plugin/provider/openai.ts | 4 +- packages/core/src/plugin/provider/xai.ts | 4 +- packages/core/src/provider.ts | 12 +- packages/core/src/session/compaction.ts | 11 +- packages/core/test/aisdk.test.ts | 6 + packages/core/test/config/provider.test.ts | 41 +++--- .../core/test/plugin/provider-azure.test.ts | 4 +- .../core/test/plugin/provider-openai.test.ts | 4 +- .../core/test/plugin/provider-xai.test.ts | 2 +- packages/core/test/provider.test.ts | 12 +- packages/core/test/session-compaction.test.ts | 13 +- .../test/session-native-compaction.test.ts | 2 +- packages/core/test/session-runner.test.ts | 6 +- packages/core/test/shared-schema.test.ts | 3 + packages/protocol/openapi.json | 114 ++++++++++------ packages/schema/src/config/provider.ts | 22 +-- packages/schema/src/model.ts | 3 - packages/schema/src/provider.ts | 31 +++-- packages/schema/test/contract-hygiene.test.ts | 9 +- packages/schema/test/model.test.ts | 33 ++--- services/www/openapi.json | 114 ++++++++++------ services/www/public/openapi.json | 114 ++++++++++------ services/www/src/docs/content/compaction.mdx | 19 +-- services/www/src/docs/content/config.mdx | 11 +- services/www/src/docs/content/providers.mdx | 14 +- 31 files changed, 442 insertions(+), 323 deletions(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 10ab3744111..33e6387e97b 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -8,7 +8,9 @@ export type LocationPublicRef = { directory: string } export type ModelRef = { id: string; providerID: string; variant?: string } -export type ProviderSettings = { [x: string]: any } +export type ProviderCompaction = { type: "summary" } | { type: "native" } + +export type ProviderTransport = "http" | "websocket" export type AgentColor = string @@ -218,19 +220,8 @@ export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens" -export type ProviderCompaction = { mode: "local" } | { mode: "provider"; threshold?: number } - -export type ProviderTransport = "http" | "websocket" - export type ModelCapabilities = { tools: boolean; input: Array; output: Array } -export type ModelVariant = { - id: string - settings?: { [x: string]: any } - headers?: { [x: string]: string } - body?: { [x: string]: any } -} - export type MoneyUSDPerMillionTokens = number export type GenerateTextResponse = { data: { text: string } } @@ -465,11 +456,19 @@ export type V2EventServerConnected = { data: {} } -export type ProviderRequest = { - settings: ProviderSettings - headers: { [x: string]: string } - body: { [x: string]: any } -} +export type ProviderSettings = { + timeout?: number | false + chunkTimeout?: number + compaction?: ProviderCompaction + transport?: ProviderTransport +} & { [x: string]: any } + +export type ConfigProviderSettings = { + timeout?: number | false + chunkTimeout?: number + compaction?: ProviderCompaction + transport?: ProviderTransport +} & { [x: string]: JsonValue | null } export type PermissionRule = { action: string; resource: string; effect: PermissionEffect } @@ -1443,20 +1442,6 @@ export type ModelCompatibility = { supportsPromptCacheKey?: boolean } -export type ProviderInfo = { - id: string - canonical?: string - integrationID?: string - name: string - activation: "auto" | "enabled" | "disabled" - package: string - compaction?: ProviderCompaction - transport?: ProviderTransport - settings?: { [x: string]: any } - headers?: { [x: string]: string } - body?: { [x: string]: any } -} - export type ModelCost = { tier?: { type: "context"; size: number } input: MoneyUSDPerMillionTokens @@ -1671,6 +1656,31 @@ export type SessionInboxMove = { payload: SessionInboxMovePayload } +export type ProviderRequest = { + settings: ProviderSettings + headers: { [x: string]: string } + body: { [x: string]: any } +} + +export type ModelVariant = { + id: string + settings?: ProviderSettings + headers?: { [x: string]: string } + body?: { [x: string]: any } +} + +export type ProviderInfo = { + id: string + canonical?: string + integrationID?: string + name: string + activation: "auto" | "enabled" | "disabled" + package: string + settings?: ProviderSettings + headers?: { [x: string]: string } + body?: { [x: string]: any } +} + export type PermissionRuleset = Array export type SessionRevertStaged = { @@ -1854,29 +1864,6 @@ export type FormField = export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" } -export type ModelInfo = { - id: string - modelID: string - providerID: string - canonical?: string - family?: string - name: string - compatibility?: ModelCompatibility - package?: string - compaction?: ProviderCompaction - transport?: ProviderTransport - settings?: { [x: string]: any } - headers?: { [x: string]: string } - body?: { [x: string]: any } - capabilities: ModelCapabilities - variants: Array - time: { released: number } - cost: Array - status: "alpha" | "beta" | "deprecated" | "active" - enabled: boolean - limit: { context: number; input?: number; output: number } -} - export type FormField1 = | FormStringField1 | FormNumberField1 @@ -1902,6 +1889,27 @@ export type ReferenceInfo = { source: ReferenceSource } +export type ModelInfo = { + id: string + modelID: string + providerID: string + canonical?: string + family?: string + name: string + compatibility?: ModelCompatibility + package?: string + settings?: ProviderSettings + headers?: { [x: string]: string } + body?: { [x: string]: any } + capabilities: ModelCapabilities + variants: Array + time: { released: number } + cost: Array + status: "alpha" | "beta" | "deprecated" | "active" + enabled: boolean + limit: { context: number; input?: number; output: number } +} + export type AgentInfo = { id: string name: string @@ -2085,31 +2093,27 @@ export type ConfigEntry = warming?: boolean | { prompt?: string; interval?: string; duration?: string } providers?: { [x: string]: { - compaction?: ProviderCompaction - transport?: ProviderTransport canonical?: string name?: string env?: Array package?: string - settings?: { [x: string]: JsonValue } + settings?: ConfigProviderSettings headers?: { [x: string]: string } body?: { [x: string]: JsonValue } models?: { [x: string]: { - compaction?: ProviderCompaction - transport?: ProviderTransport modelID?: string family?: string name?: string compatibility?: ModelCompatibility package?: string - settings?: { [x: string]: JsonValue } + settings?: ConfigProviderSettings headers?: { [x: string]: string } body?: { [x: string]: JsonValue } capabilities?: ModelCapabilities variants?: Array<{ id: string - settings?: { [x: string]: JsonValue } + settings?: ConfigProviderSettings headers?: { [x: string]: string } body?: { [x: string]: JsonValue } }> diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 28e2cfef8fa..4184a7a5cd0 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -128,6 +128,8 @@ function prepareOptions(model: Info, pkg: string) { const customFetch = options.fetch const chunkTimeout = options.chunkTimeout delete options.chunkTimeout + delete options.compaction + delete options.transport options.fetch = async (input: Parameters[0], init?: RequestInit) => { const opts = { ...(init ?? {}) } const signals = [ @@ -388,7 +390,10 @@ function requestSettings(settings: Readonly> | undefined if (settings === undefined) return undefined const result = Object.fromEntries( Object.entries(settings).filter( - ([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key), + ([key]) => + !["apiKey", "authToken", "baseURL", "chunkTimeout", "compaction", "fetch", "timeout", "transport"].includes( + key, + ), ), ) return Object.keys(result).length === 0 ? undefined : result diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 4eb08871e50..41a7f80d4cd 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -68,8 +68,6 @@ export const Plugin = define({ if (item.canonical !== undefined) provider.canonical = item.canonical if (item.name !== undefined) provider.name = item.name if (item.package !== undefined) provider.package = item.package - if (item.compaction !== undefined) provider.compaction = { ...item.compaction } - if (item.transport !== undefined) provider.transport = item.transport if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings) if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers) if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body) @@ -116,8 +114,6 @@ export const Plugin = define({ if (config.compatibility !== undefined) model.compatibility = { ...model.compatibility, ...config.compatibility } if (config.package !== undefined) model.package = config.package - if (config.compaction !== undefined) model.compaction = { ...config.compaction } - if (config.transport !== undefined) model.transport = config.transport if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings) if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers) if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body) diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 8945f495a08..483ccf0176c 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -118,9 +118,9 @@ export interface Resolved { /** Catalog token limits used by Core for context management. */ readonly limit: Info["limit"] /** Model policy overrides the provider policy; omitted means local compaction. */ - readonly compaction?: Info["compaction"] + readonly compaction?: Provider.Compaction /** Model transport overrides the provider transport; omitted means HTTP. */ - readonly transport?: Info["transport"] + readonly transport?: Provider.Transport } export interface Interface { @@ -178,7 +178,11 @@ export const fromCatalogModel = ( Effect.flatMap((resolved) => validateProviderVariables(model, resolved)), Effect.flatMap((resolved) => { // Reject provider compaction policies up front so the misconfiguration surfaces before any step runs. - if (model.compaction?.mode !== "provider" || resolved.route.compact?.trigger || resolved.route.compact?.endpoint) + if ( + model.settings?.compaction?.type !== "native" || + resolved.route.compact?.trigger || + resolved.route.compact?.endpoint + ) return Effect.succeed(resolved) return Effect.fail( new UnsupportedCompactionError({ providerID: model.providerID, modelID: model.id, route: resolved.route.id }), @@ -377,8 +381,8 @@ export const layer = Layer.effect( capabilities: selected.capabilities, cost: selected.cost, limit: selected.limit, - compaction: selected.compaction, - transport: selected.transport, + compaction: runtimeInfo.settings?.compaction, + transport: runtimeInfo.settings?.transport, } }) return Service.of({ diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 7f5328a57c8..bb27e9f9768 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -188,8 +188,6 @@ const layer = Layer.effect( ...model, ...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }), package: model.package ?? provider?.package, - compaction: model.compaction ?? provider?.compaction, - transport: model.transport ?? provider?.transport, settings: Provider.mergeOverlay(provider?.settings, model.settings), headers: Provider.mergeHeaders(provider?.headers, model.headers), body: Provider.mergeOverlay(provider?.body, model.body), diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 312ccbe8a76..06e351003a1 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -168,7 +168,9 @@ export const AzurePlugin = define({ resolveResourceName(draft.settings, resourceName) ?? resourceName, ) if (responsesWebSocketCapable(item.provider, draft)) - draft.transport = item.provider.transport ?? "websocket" + draft.settings = Provider.mergeOverlay(draft.settings, { + transport: item.provider.settings?.transport ?? "websocket", + }) }) } } diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 4ee7df7c5e2..469e7adfe23 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -270,7 +270,9 @@ export const OpenAIPlugin = define({ // ChatGPT-plan tokens only authorize codex-eligible models, and the // subscription covers usage, so hide the rest and zero the cost. models.update(model.providerID, model.id, (draft) => { - draft.transport = models.provider.get(model.providerID)?.provider.transport ?? "websocket" + draft.settings = Provider.mergeOverlay(draft.settings, { + transport: models.provider.get(model.providerID)?.provider.settings?.transport ?? "websocket", + }) if (!chatgpt) return if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) { draft.enabled = false diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 91586424eed..ff066961553 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -98,7 +98,9 @@ export const XAIPlugin = define({ yield* ctx.model.transform((models) => { for (const model of models.list(providerID)) { models.update(providerID, model.id, (draft) => { - draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket" + draft.settings = Provider.mergeOverlay(draft.settings, { + transport: models.provider.get(providerID)?.provider.settings?.transport ?? "websocket", + }) }) } }) diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index f309ca4171c..7999a2ee2ce 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -132,11 +132,11 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s return yield* importPackage(specifier, entrypoint) }) -/** opencode transport settings consumed in aisdk.ts; native packages never receive them. */ -const TRANSPORT_KEYS = ["chunkTimeout", "fetch", "timeout"] as const +/** opencode settings consumed in Core; native packages never receive them. */ +const CORE_KEYS = ["chunkTimeout", "compaction", "fetch", "timeout", "transport"] as const export function nativeSettings(settings: Settings): Settings { - return Struct.omit(settings, TRANSPORT_KEYS) + return Struct.omit(settings, CORE_KEYS) } export function mergeOverlay( @@ -177,6 +177,12 @@ export function mergeHeaders( export const Request = Provider.Request export type Request = Provider.Request +export const Compaction = Provider.Compaction +export type Compaction = Provider.Compaction + +export const Transport = Provider.Transport +export type Transport = Provider.Transport + export const Settings = Provider.Settings export type Settings = Provider.Settings diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index a495da6fa5a..db9a19fb760 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -736,7 +736,7 @@ export const layer = Layer.effect( const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput): Effect.fn.Return { const request = { ...input, reason: "auto" as const } if (input.overflow) return yield* recoverLocally(request) - if (input.context.model.compaction?.mode !== "provider") return yield* execute(request) + if (input.context.model.compaction?.type !== "native") return yield* execute(request) return yield* executeProvider(request) }) const required = (input: RequiredInput) => { @@ -759,12 +759,7 @@ export const layer = Layer.effect( limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer, context - Math.max(output, config.buffer), ) - const policy = input.resolved.compaction - const threshold = - policy?.mode === "provider" && policy.threshold !== undefined - ? Math.min(policy.threshold, promptCeiling) - : promptCeiling - return estimateTokens(input) >= threshold + return estimateTokens(input) >= promptCeiling } const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) { if (findTailStart(input.messages, state.get().tokens) === undefined) @@ -792,7 +787,7 @@ export const layer = Layer.effect( inputID: input.inputID, started: input.started, } - return context.model.compaction?.mode === "provider" ? executeProvider(request) : execute(request) + return context.model.compaction?.type === "native" ? executeProvider(request) : execute(request) }, }), ) diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 07695f046eb..3e5e33e2edc 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -155,14 +155,18 @@ it.effect("projects request settings, headers, and body overlays", () => Effect.gen(function* () { const aisdk = yield* AISDK.Service let body: unknown + let options: Record | undefined yield* aisdk.hook.sdk((event) => { body = event.options.body + options = event.options event.sdk = { languageModel: () => ({ provider: event.model.providerID }) } }) const input = model("@ai-sdk/google", { apiKey: "secret", thinkingConfig: { thinkingBudget: 1024 }, + compaction: { type: "native" }, + transport: "websocket", }) const resolved = yield* aisdk.model({ ...input, @@ -185,6 +189,8 @@ it.effect("projects request settings, headers, and body overlays", () => }) expect(prepared.body.headers).toEqual({ "x-test": "header" }) expect(body).toEqual({ safety_setting: "strict" }) + expect(options).not.toHaveProperty("compaction") + expect(options).not.toHaveProperty("transport") }), ) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 53b6c57dadc..12811fb7593 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -34,7 +34,7 @@ function required(value: T | undefined): T { const decode = Schema.decodeUnknownSync(Info) describe("ConfigProviderPlugin.Plugin", () => { - it.effect("inherits provider compaction policy with model overrides and rejects unsupported routes", () => + it.effect("inherits the provider compaction setting with model overrides and rejects unsupported routes", () => Effect.gen(function* () { const models = yield* Model.Service yield* addPlugin([ @@ -44,12 +44,10 @@ describe("ConfigProviderPlugin.Plugin", () => { providers: { custom: { package: "@opencode/ai/providers/openai/responses", - compaction: { mode: "provider", threshold: 120_000 }, + settings: { compaction: { type: "native" } }, models: { native: {}, - reset: { compaction: { mode: "provider" } }, - threshold: { compaction: { mode: "provider", threshold: 90_000 } }, - local: { compaction: { mode: "local" }, package: "@opencode/ai/providers/openai/chat" }, + local: { settings: { compaction: { type: "summary" } }, package: "@opencode/ai/providers/openai/chat" }, unsupported: { package: "@opencode/ai/providers/openai/chat" }, }, }, @@ -62,16 +60,9 @@ describe("ConfigProviderPlugin.Plugin", () => { const local = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("local"))) const unsupported = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("unsupported"))) const defaultModel = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("chat"))) - expect(native.compaction).toEqual({ mode: "provider", threshold: 120_000 }) - expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("reset")))?.compaction).toEqual({ - mode: "provider", - }) - expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("threshold")))?.compaction).toEqual({ - mode: "provider", - threshold: 90_000, - }) - expect(local.compaction).toEqual({ mode: "local" }) - expect(defaultModel.compaction).toBeUndefined() + expect(native.settings?.compaction).toEqual({ type: "native" }) + expect(local.settings?.compaction).toEqual({ type: "summary" }) + expect(defaultModel.settings?.compaction).toBeUndefined() yield* ModelResolver.fromCatalogModel(native) yield* ModelResolver.fromCatalogModel(local) yield* ModelResolver.fromCatalogModel(defaultModel) @@ -92,8 +83,8 @@ describe("ConfigProviderPlugin.Plugin", () => { providers: { custom: { package: "@opencode/ai/providers/openai/responses", - transport: "http", - models: { inherited: {}, override: { transport: "websocket" } }, + settings: { transport: "http" }, + models: { inherited: {}, override: { settings: { transport: "websocket" } } }, }, default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } }, }, @@ -103,9 +94,9 @@ describe("ConfigProviderPlugin.Plugin", () => { const inherited = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("inherited"))) const override = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("override"))) const untouched = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("untouched"))) - expect(inherited.transport).toBe("http") - expect(override.transport).toBe("websocket") - expect(untouched.transport).toBeUndefined() + expect(inherited.settings?.transport).toBe("http") + expect(override.settings?.transport).toBe("websocket") + expect(untouched.settings?.transport).toBeUndefined() }), ) @@ -131,7 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => { editor.models.update(providerID, modelID, () => {}) }) yield* builtin.plugin.effect(host) - expect((yield* models.get(providerID, modelID))?.transport).toBe("websocket") + expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("websocket") yield* addPlugin([ new Document({ @@ -139,16 +130,16 @@ describe("ConfigProviderPlugin.Plugin", () => { info: decode({ providers: { [builtin.id]: { - transport: "http", - models: { override: { modelID: builtin.model, transport: "websocket" } }, + settings: { transport: "http" }, + models: { override: { modelID: builtin.model, settings: { transport: "websocket" } } }, }, }, }), }), ]) - expect((yield* models.get(providerID, modelID))?.transport).toBe("http") - expect((yield* models.get(providerID, Model.ID.make("override")))?.transport).toBe("websocket") + expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("http") + expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBe("websocket") }), ) } diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 8b85f236266..8a9f04103d2 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -473,10 +473,10 @@ describe("AzurePlugin", () => { yield* addPlugin() const responses = required(yield* service.get(Provider.ID.azure, models.responses)) - expect(responses.transport).toBe("websocket") + expect(responses.settings?.transport).toBe("websocket") for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) { const model = required(yield* service.get(Provider.ID.azure, modelID)) - expect(model.transport).toBeUndefined() + expect(model.settings?.transport).toBeUndefined() } }), ), diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 5d9e870b1f5..dd182586f31 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -205,7 +205,7 @@ describe("OpenAIPlugin", () => { expect(model.package).toBe("@opencode/ai/providers/openai") expect(model.enabled).toBe(true) expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) - expect(model.transport).toBe("websocket") + expect(model.settings?.transport).toBe("websocket") expect(direct.headers).not.toHaveProperty("originator") expect(direct.baseURL).toBe("https://api.openai.com/v1") expect(provider.headers).not.toHaveProperty("x-codex-beta-features") @@ -236,7 +236,7 @@ describe("OpenAIPlugin", () => { id: "deployment-responses", provider: Provider.ID.azure, }) - const prepare = (preference?: Model.Info["transport"]) => + const prepare = (preference?: Provider.Transport) => Effect.gen(function* () { const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), { capabilities: { tools: true, input: ["text"], output: ["text"] }, diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index 2820e89790c..d0799c13810 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -84,7 +84,7 @@ describe("XAIPlugin", () => { yield* addPlugin() const model = yield* models.get(providerID, Model.ID.make("grok-4.6")) - expect(model?.transport).toBe("websocket") + expect(model?.settings?.transport).toBe("websocket") }), ) }) diff --git a/packages/core/test/provider.test.ts b/packages/core/test/provider.test.ts index b984a40638d..8bb6dfc8156 100644 --- a/packages/core/test/provider.test.ts +++ b/packages/core/test/provider.test.ts @@ -28,8 +28,16 @@ describe("Provider", () => { } }) - test("passes flat settings to native packages without opencode transport keys", () => { - expect(Provider.nativeSettings({ apiKey: "secret", reasoningEffort: "high", chunkTimeout: 1000 })).toEqual({ + test("passes flat settings to native packages without Core settings", () => { + expect( + Provider.nativeSettings({ + apiKey: "secret", + reasoningEffort: "high", + chunkTimeout: 1000, + compaction: { type: "native" }, + transport: "websocket", + }), + ).toEqual({ apiKey: "secret", reasoningEffort: "high", }) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index ea958f0261f..e98bda151d5 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -207,20 +207,13 @@ it.effect("auto compaction estimates current content against the buffered prompt 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 native = ( - tokens: number, - limit: { context: number; input?: number; output: number } = inputLimited, - threshold?: number, - ) => { + const native = (tokens: number, limit: { context: number; input?: number; output: number } = inputLimited) => { const selected = input(tokens, limit) - return { ...selected, resolved: { ...selected.resolved, compaction: { mode: "provider" as const, threshold } } } + return { ...selected, resolved: { ...selected.resolved, compaction: { type: "native" as const } } } } expect(compaction.required(native(251_999))).toBe(false) expect(compaction.required(native(252_000))).toBe(true) - expect(compaction.required(native(99_999, inputLimited, 100_000))).toBe(false) - expect(compaction.required(native(100_000, inputLimited, 100_000))).toBe(true) - expect(compaction.required(native(252_000, inputLimited, 500_000))).toBe(true) - expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }, 100_000))).toBe(false) + expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }))).toBe(false) const contextLimited = { context: 100_000, output: 10_000 } expect(compaction.required(input(79_999, contextLimited))).toBe(false) diff --git a/packages/core/test/session-native-compaction.test.ts b/packages/core/test/session-native-compaction.test.ts index a7c7ee37538..502f88e7ce7 100644 --- a/packages/core/test/session-native-compaction.test.ts +++ b/packages/core/test/session-native-compaction.test.ts @@ -164,7 +164,7 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, cost: [], limit: { context: 200_000, output: 32_000 }, - compaction: { mode: "provider" }, + compaction: { type: "native" }, }, ) const sessionID = SessionSchema.ID.create() diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 9b8a92e5637..24dd448af91 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -2863,7 +2863,8 @@ describe("SessionRunnerLLM", () => { scenario("automatically persists native windows, retains earlier users, and waits for fresh usage", function* (s) { s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route }) - s.compaction = { mode: "provider", threshold: 10_000 } + modelLimits.set("native", { context: 42_000, output: 32_000 }) + s.compaction = { type: "native" } const agents = yield* Agent.Service yield* agents.transform((editor) => editor.update(Agent.defaultID, (agent) => { @@ -2914,7 +2915,8 @@ describe("SessionRunnerLLM", () => { scenario("recovers an overflowing native window locally from original durable history", function* (s) { s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route }) - s.compaction = { mode: "provider", threshold: 10_000 } + modelLimits.set("native", { context: 42_000, output: 32_000 }) + s.compaction = { type: "native" } yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000)) yield* s.runPrompt("Original durable request") yield* s.llm.push( diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index 9279d2082d0..d384fc6ab3f 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -106,6 +106,9 @@ test("Core reuses the canonical shared schemas", async () => { [coreModel.Info, Model.Info], [coreProvider.ID, Provider.ID], [coreProvider.Request, Provider.Request], + [coreProvider.Compaction, Provider.Compaction], + [coreProvider.Transport, Provider.Transport], + [coreProvider.Settings, Provider.Settings], [coreProvider.Info, Provider.Info], [corePermission.Effect, Permission.Effect], [corePermission.Rule, Permission.Rule], diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index a08d054c560..538f9d3af1b 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -13161,7 +13161,7 @@ "properties": { "action": { "type": "string", - "enum": ["provider.use"] + "enum": ["provider.use", "permission"] }, "resource": { "type": "string" @@ -13254,12 +13254,6 @@ "Config.ModelEncoded": { "type": "object", "properties": { - "compaction": { - "$ref": "#/components/schemas/Provider.Compaction" - }, - "transport": { - "$ref": "#/components/schemas/Provider.Transport" - }, "modelID": { "type": "string" }, @@ -13276,7 +13270,7 @@ "type": "string" }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Config.Provider.Settings" }, "headers": { "type": "object", @@ -13299,7 +13293,7 @@ "type": "string" }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Config.Provider.Settings" }, "headers": { "type": "object", @@ -13379,15 +13373,47 @@ "required": ["package"], "additionalProperties": false }, - "Config.ProviderEncoded": { + "Config.Provider.Settings": { "type": "object", "properties": { + "timeout": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "boolean", + "enum": [false] + } + ] + }, + "chunkTimeout": { + "type": "number" + }, "compaction": { "$ref": "#/components/schemas/Provider.Compaction" }, "transport": { "$ref": "#/components/schemas/Provider.Transport" - }, + } + }, + "allOf": [ + { + "type": "object", + "additionalProperties": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + } + ] + }, + "Config.ProviderEncoded": { + "type": "object", + "properties": { "canonical": { "type": "string" }, @@ -13404,7 +13430,7 @@ "type": "string" }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Config.Provider.Settings" }, "headers": { "type": "object", @@ -15481,14 +15507,8 @@ "package": { "type": "string" }, - "compaction": { - "$ref": "#/components/schemas/Provider.Compaction" - }, - "transport": { - "$ref": "#/components/schemas/Provider.Transport" - }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Provider.Settings" }, "headers": { "type": "object", @@ -15601,7 +15621,7 @@ "type": "string" }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Provider.Settings" }, "headers": { "type": "object", @@ -16418,27 +16438,23 @@ { "type": "object", "properties": { - "mode": { + "type": { "type": "string", - "enum": ["local"] + "enum": ["summary"] } }, - "required": ["mode"], + "required": ["type"], "additionalProperties": false }, { "type": "object", "properties": { - "mode": { + "type": { "type": "string", - "enum": ["provider"] - }, - "threshold": { - "type": "integer", - "exclusiveMinimum": 0 + "enum": ["native"] } }, - "required": ["mode"], + "required": ["type"], "additionalProperties": false } ] @@ -16465,14 +16481,8 @@ "package": { "type": "string" }, - "compaction": { - "$ref": "#/components/schemas/Provider.Compaction" - }, - "transport": { - "$ref": "#/components/schemas/Provider.Transport" - }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Provider.Settings" }, "headers": { "type": "object", @@ -16507,7 +16517,35 @@ "additionalProperties": false }, "Provider.Settings": { - "type": "object" + "type": "object", + "properties": { + "timeout": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "boolean", + "enum": [false] + } + ] + }, + "chunkTimeout": { + "type": "number" + }, + "compaction": { + "$ref": "#/components/schemas/Provider.Compaction" + }, + "transport": { + "$ref": "#/components/schemas/Provider.Transport" + } + }, + "allOf": [ + { + "type": "object", + "additionalProperties": {} + } + ] }, "Provider.Transport": { "type": "string", diff --git a/packages/schema/src/config/provider.ts b/packages/schema/src/config/provider.ts index 4b4900719a7..4782411a771 100644 --- a/packages/schema/src/config/provider.ts +++ b/packages/schema/src/config/provider.ts @@ -6,10 +6,21 @@ import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js" import { Provider } from "../provider.js" import { optional } from "../schema.js" +export const Settings = Schema.StructWithRest( + Schema.Struct({ + timeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional), + chunkTimeout: Schema.Finite.pipe(optional), + compaction: Provider.Compaction.pipe(optional), + transport: Provider.Transport.pipe(optional), + }), + [Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Json))], +).annotate({ identifier: "Config.Provider.Settings" }) +export type Settings = typeof Settings.Type + const JsonRecord = Schema.Record(Schema.String, Schema.Json) export const Overlays = { - settings: JsonRecord.pipe(optional), + settings: Settings.pipe(optional), headers: Schema.Record(Schema.String, Schema.String).pipe(optional), body: JsonRecord.pipe(optional), } @@ -41,10 +52,6 @@ class Limit extends Schema.Class("Config.Model.Limit")({ }) {} class Model extends Schema.Class("Config.Model")({ - compaction: Provider.Compaction.pipe(optional), - transport: Provider.Transport.pipe(optional).annotate({ - description: "Session transport for this model. Defaults to the provider transport.", - }), modelID: ID.pipe(optional), family: Family.pipe(optional), name: Schema.String.pipe(optional), @@ -62,11 +69,6 @@ class Model extends Schema.Class("Config.Model")({ }) {} export class Info extends Schema.Class("Config.Provider")({ - compaction: Provider.Compaction.pipe(optional), - transport: Provider.Transport.pipe(optional).annotate({ - description: - "Session transport for this provider's models. Defaults to the built-in policy; \"websocket\" on a route without a WebSocket channel warns and falls back to HTTP.", - }), canonical: Provider.ID.pipe(optional), name: Schema.String.pipe(optional), env: Schema.String.pipe(Schema.Array, optional), diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 49045ac6169..f5b418e1c72 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -110,9 +110,6 @@ export const Info = Schema.Struct({ name: Schema.String, compatibility: Compatibility.pipe(optional), package: Provider.Package.pipe(optional), - compaction: Provider.Compaction.pipe(optional), - /** Session transport; omitted inherits the provider transport, then defaults to HTTP. */ - transport: Provider.Transport.pipe(optional), ...Provider.Overlays, capabilities: Capabilities, variants: Schema.Array(Variant), diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index e6062533ffe..a8493801d9b 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -2,7 +2,7 @@ export * as Provider from "./provider.js" import { Effect, Schema } from "effect" import { Integration } from "./integration.js" -import { optional, PositiveInt, statics } from "./schema.js" +import { optional, statics } from "./schema.js" import { ephemeral, inventory } from "./event.js" export const ID = Schema.String.pipe( @@ -32,25 +32,35 @@ export type Package = typeof Package.Type export const Activation = Schema.Literals(["auto", "enabled", "disabled"]) export type Activation = typeof Activation.Type -export type Compaction = typeof Compaction.Type export const Compaction = Schema.Union([ - Schema.Struct({ mode: Schema.Literal("local") }), - Schema.Struct({ mode: Schema.Literal("provider"), threshold: PositiveInt.pipe(optional) }), -]).annotate({ identifier: "Provider.Compaction" }) + Schema.Struct({ type: Schema.Literal("summary") }), + Schema.Struct({ type: Schema.Literal("native") }), +]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Provider.Compaction" }) +export type Compaction = typeof Compaction.Type /** "websocket" on a route without a WebSocket channel warns and falls back to HTTP. */ export const Transport = Schema.Literals(["http", "websocket"]).annotate({ identifier: "Provider.Transport" }) export type Transport = typeof Transport.Type +export const Settings = Schema.StructWithRest( + Schema.Struct({ + timeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional), + chunkTimeout: Schema.Finite.pipe(optional), + compaction: Compaction.pipe(optional), + transport: Transport.pipe(optional), + }), + [Schema.Record(Schema.String, Schema.Any)], +).annotate({ identifier: "Provider.Settings" }) +export type Settings = typeof Settings.Type + export const Overlays = { - settings: Schema.Record(Schema.String, Schema.Any).pipe(optional), + settings: Settings.pipe(optional), headers: Schema.Record(Schema.String, Schema.String).pipe(optional), body: Schema.Record(Schema.String, Schema.Any).pipe(optional), } -export const Settings = Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "Provider.Settings" }) -export type Settings = typeof Settings.Type - export interface Request extends Schema.Schema.Type {} export const Request = Schema.Struct({ settings: Settings.pipe(Schema.withConstructorDefault(Effect.succeed({}))), @@ -66,9 +76,6 @@ export const Info = Schema.Struct({ name: Schema.String, activation: Activation, package: Package, - compaction: Compaction.pipe(optional), - /** Session transport for this provider's models; omitted means HTTP. */ - transport: Transport.pipe(optional), ...Overlays, }) .annotate({ identifier: "Provider.Info" }) diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index 179568180d9..1510ec33f3b 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { DateTime, Schema } from "effect" import { Agent } from "../src/agent.js" import { ConfigAgent } from "../src/config/agent.js" +import { ConfigProvider } from "../src/config/provider.js" import { FileSystem } from "../src/filesystem.js" import { Form } from "../src/form.js" import { Mcp } from "../src/mcp.js" @@ -168,6 +169,7 @@ describe("contract hygiene", () => { test("reusable public identifiers are stable and unique", () => { const identifiers = [ Agent.Color, + ConfigProvider.Settings, FileSystem.Submatch, Form.Field, Form.Fields, @@ -226,7 +228,7 @@ describe("contract hygiene", () => { } }) - test("current source limits Any to provider options and avoids mutable contract wrappers", async () => { + test("current source limits Any to reviewed boundaries and avoids mutable contract wrappers", async () => { const files = [...new Bun.Glob("*.ts").scanSync(new URL("../src", import.meta.url).pathname)].filter( (file) => !file.endsWith("-v1.ts"), ) @@ -237,11 +239,12 @@ describe("contract hygiene", () => { expect( sources - .filter((item) => item.file !== "provider.ts") + .filter((item) => item.file !== "provider.ts" && item.file !== "integration.ts") .map((item) => item.source) .join("\n"), ).not.toContain("Schema.Any") - expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(4) + expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(3) + expect(sources.find((item) => item.file === "integration.ts")?.source.match(/Schema\.Any/g)).toHaveLength(2) expect(source).not.toContain("Schema.mutable") }) diff --git a/packages/schema/test/model.test.ts b/packages/schema/test/model.test.ts index 405f181c1d3..34422eb8ce1 100644 --- a/packages/schema/test/model.test.ts +++ b/packages/schema/test/model.test.ts @@ -56,23 +56,16 @@ describe("Model.Compatibility", () => { }) describe("Model.Info", () => { - test("provider compaction policy is optional and uses the canonical closed schema", () => { + test("provider compaction policy is a typed setting", () => { const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini")) - expect(Schema.encodeSync(Model.Info)({ ...model, compaction: undefined })).not.toHaveProperty("compaction") - expect(Schema.decodeUnknownSync(Model.Info)({ ...model, compaction: { mode: "provider" } }).compaction).toEqual({ - mode: "provider", + expect(Schema.encodeSync(Model.Info)({ ...model, settings: { compaction: undefined } }).settings).toEqual({}) + expect( + Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { compaction: { type: "native" } } }).settings, + ).toEqual({ + compaction: { type: "native" }, }) - expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "local" })).toEqual({ mode: "local" }) - expect(Schema.encodeSync(Provider.Compaction)({ mode: "provider", threshold: undefined })).toEqual({ - mode: "provider", - }) - expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold: 120_000 })).toEqual({ - mode: "provider", - threshold: 120_000, - }) - for (const threshold of [0, -1, 1.5]) - expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold })).toThrow() - expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "automatic" })).toThrow() + expect(Schema.decodeUnknownSync(Provider.Compaction)({ type: "summary" })).toEqual({ type: "summary" }) + expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ type: "automatic" })).toThrow() }) test("uses practical token limits for unknown models", () => { @@ -83,10 +76,12 @@ describe("Model.Info", () => { }) describe("Model.Capabilities", () => { - test("decodes the optional transport preference", () => { + test("decodes the optional transport setting", () => { const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini")) - expect(Schema.encodeSync(Model.Info)({ ...model, transport: undefined })).not.toHaveProperty("transport") - expect(Schema.decodeUnknownSync(Model.Info)({ ...model, transport: "websocket" }).transport).toBe("websocket") - expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, transport: "sse" })).toThrow() + expect(Schema.encodeSync(Model.Info)({ ...model, settings: { transport: undefined } }).settings).toEqual({}) + expect(Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "websocket" } }).settings).toEqual({ + transport: "websocket", + }) + expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "sse" } })).toThrow() }) }) diff --git a/services/www/openapi.json b/services/www/openapi.json index a08d054c560..538f9d3af1b 100644 --- a/services/www/openapi.json +++ b/services/www/openapi.json @@ -13161,7 +13161,7 @@ "properties": { "action": { "type": "string", - "enum": ["provider.use"] + "enum": ["provider.use", "permission"] }, "resource": { "type": "string" @@ -13254,12 +13254,6 @@ "Config.ModelEncoded": { "type": "object", "properties": { - "compaction": { - "$ref": "#/components/schemas/Provider.Compaction" - }, - "transport": { - "$ref": "#/components/schemas/Provider.Transport" - }, "modelID": { "type": "string" }, @@ -13276,7 +13270,7 @@ "type": "string" }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Config.Provider.Settings" }, "headers": { "type": "object", @@ -13299,7 +13293,7 @@ "type": "string" }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Config.Provider.Settings" }, "headers": { "type": "object", @@ -13379,15 +13373,47 @@ "required": ["package"], "additionalProperties": false }, - "Config.ProviderEncoded": { + "Config.Provider.Settings": { "type": "object", "properties": { + "timeout": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "boolean", + "enum": [false] + } + ] + }, + "chunkTimeout": { + "type": "number" + }, "compaction": { "$ref": "#/components/schemas/Provider.Compaction" }, "transport": { "$ref": "#/components/schemas/Provider.Transport" - }, + } + }, + "allOf": [ + { + "type": "object", + "additionalProperties": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + } + ] + }, + "Config.ProviderEncoded": { + "type": "object", + "properties": { "canonical": { "type": "string" }, @@ -13404,7 +13430,7 @@ "type": "string" }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Config.Provider.Settings" }, "headers": { "type": "object", @@ -15481,14 +15507,8 @@ "package": { "type": "string" }, - "compaction": { - "$ref": "#/components/schemas/Provider.Compaction" - }, - "transport": { - "$ref": "#/components/schemas/Provider.Transport" - }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Provider.Settings" }, "headers": { "type": "object", @@ -15601,7 +15621,7 @@ "type": "string" }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Provider.Settings" }, "headers": { "type": "object", @@ -16418,27 +16438,23 @@ { "type": "object", "properties": { - "mode": { + "type": { "type": "string", - "enum": ["local"] + "enum": ["summary"] } }, - "required": ["mode"], + "required": ["type"], "additionalProperties": false }, { "type": "object", "properties": { - "mode": { + "type": { "type": "string", - "enum": ["provider"] - }, - "threshold": { - "type": "integer", - "exclusiveMinimum": 0 + "enum": ["native"] } }, - "required": ["mode"], + "required": ["type"], "additionalProperties": false } ] @@ -16465,14 +16481,8 @@ "package": { "type": "string" }, - "compaction": { - "$ref": "#/components/schemas/Provider.Compaction" - }, - "transport": { - "$ref": "#/components/schemas/Provider.Transport" - }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Provider.Settings" }, "headers": { "type": "object", @@ -16507,7 +16517,35 @@ "additionalProperties": false }, "Provider.Settings": { - "type": "object" + "type": "object", + "properties": { + "timeout": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "boolean", + "enum": [false] + } + ] + }, + "chunkTimeout": { + "type": "number" + }, + "compaction": { + "$ref": "#/components/schemas/Provider.Compaction" + }, + "transport": { + "$ref": "#/components/schemas/Provider.Transport" + } + }, + "allOf": [ + { + "type": "object", + "additionalProperties": {} + } + ] }, "Provider.Transport": { "type": "string", diff --git a/services/www/public/openapi.json b/services/www/public/openapi.json index a08d054c560..538f9d3af1b 100644 --- a/services/www/public/openapi.json +++ b/services/www/public/openapi.json @@ -13161,7 +13161,7 @@ "properties": { "action": { "type": "string", - "enum": ["provider.use"] + "enum": ["provider.use", "permission"] }, "resource": { "type": "string" @@ -13254,12 +13254,6 @@ "Config.ModelEncoded": { "type": "object", "properties": { - "compaction": { - "$ref": "#/components/schemas/Provider.Compaction" - }, - "transport": { - "$ref": "#/components/schemas/Provider.Transport" - }, "modelID": { "type": "string" }, @@ -13276,7 +13270,7 @@ "type": "string" }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Config.Provider.Settings" }, "headers": { "type": "object", @@ -13299,7 +13293,7 @@ "type": "string" }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Config.Provider.Settings" }, "headers": { "type": "object", @@ -13379,15 +13373,47 @@ "required": ["package"], "additionalProperties": false }, - "Config.ProviderEncoded": { + "Config.Provider.Settings": { "type": "object", "properties": { + "timeout": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "boolean", + "enum": [false] + } + ] + }, + "chunkTimeout": { + "type": "number" + }, "compaction": { "$ref": "#/components/schemas/Provider.Compaction" }, "transport": { "$ref": "#/components/schemas/Provider.Transport" - }, + } + }, + "allOf": [ + { + "type": "object", + "additionalProperties": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + } + ] + }, + "Config.ProviderEncoded": { + "type": "object", + "properties": { "canonical": { "type": "string" }, @@ -13404,7 +13430,7 @@ "type": "string" }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Config.Provider.Settings" }, "headers": { "type": "object", @@ -15481,14 +15507,8 @@ "package": { "type": "string" }, - "compaction": { - "$ref": "#/components/schemas/Provider.Compaction" - }, - "transport": { - "$ref": "#/components/schemas/Provider.Transport" - }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Provider.Settings" }, "headers": { "type": "object", @@ -15601,7 +15621,7 @@ "type": "string" }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Provider.Settings" }, "headers": { "type": "object", @@ -16418,27 +16438,23 @@ { "type": "object", "properties": { - "mode": { + "type": { "type": "string", - "enum": ["local"] + "enum": ["summary"] } }, - "required": ["mode"], + "required": ["type"], "additionalProperties": false }, { "type": "object", "properties": { - "mode": { + "type": { "type": "string", - "enum": ["provider"] - }, - "threshold": { - "type": "integer", - "exclusiveMinimum": 0 + "enum": ["native"] } }, - "required": ["mode"], + "required": ["type"], "additionalProperties": false } ] @@ -16465,14 +16481,8 @@ "package": { "type": "string" }, - "compaction": { - "$ref": "#/components/schemas/Provider.Compaction" - }, - "transport": { - "$ref": "#/components/schemas/Provider.Transport" - }, "settings": { - "type": "object" + "$ref": "#/components/schemas/Provider.Settings" }, "headers": { "type": "object", @@ -16507,7 +16517,35 @@ "additionalProperties": false }, "Provider.Settings": { - "type": "object" + "type": "object", + "properties": { + "timeout": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "boolean", + "enum": [false] + } + ] + }, + "chunkTimeout": { + "type": "number" + }, + "compaction": { + "$ref": "#/components/schemas/Provider.Compaction" + }, + "transport": { + "$ref": "#/components/schemas/Provider.Transport" + } + }, + "allOf": [ + { + "type": "object", + "additionalProperties": {} + } + ] }, "Provider.Transport": { "type": "string", diff --git a/services/www/src/docs/content/compaction.mdx b/services/www/src/docs/content/compaction.mdx index 29ef20f0860..dfca69e83e8 100644 --- a/services/www/src/docs/content/compaction.mdx +++ b/services/www/src/docs/content/compaction.mdx @@ -148,23 +148,20 @@ selected provider's native compaction for both automatic and manual requests: "$schema": "https://opencode.ai/config.json", "providers": { "openai": { - "compaction": { "mode": "provider", "threshold": 120000 }, + "settings": { "compaction": { "type": "native" } }, "models": { - "gpt-5.4-mini": { "compaction": { "mode": "provider" } }, - "gpt-4.1": { "compaction": { "mode": "local" } }, + "gpt-4.1": { "settings": { "compaction": { "type": "summary" } } }, }, }, }, } ``` -A model policy replaces the entire provider policy. In this example, -`gpt-5.4-mini` therefore uses its own usable input ceiling rather than inheriting -the provider's `120000` threshold. +A model setting overrides the provider setting. | Topic | Provider behavior | | --- | --- | -| Threshold | `threshold` is an optional positive integer in provider mode. When omitted, OpenCode uses the model's usable input ceiling; when larger than that ceiling, it is clamped. | +| Threshold | OpenCode uses the model's usable input ceiling configured by its context, input, and output limits plus the global compaction buffer. | | Scheduling | Checkpoints run at normal safe step boundaries. `compaction.auto: false` disables all new automatic work. | | Usage | After a native checkpoint is installed, automatic checks wait for fresh model usage because encrypted checkpoint bytes cannot provide a meaningful token count. | | OpenAI | Responses routes use a streamed compaction trigger when supported; endpoint-only routes use the standalone compaction endpoint. Deployment and model support vary. | @@ -172,14 +169,6 @@ the provider's `120000` threshold. | Routes | Unsupported routes fail during model resolution. Configure custom endpoints with provider or model `settings.baseURL`. Native compaction rejects endpoint rewrites made by a `model.request` hook. | | Replay | Native checkpoints are reused only with the same provider, model, protocol, and endpoint. An incompatible route falls back to an earlier compatible checkpoint or retained transcript. | -Choose a threshold comfortably above `keep.tokens` plus the system prompt and -tools. For example, avoid a 20,000-token threshold when the retained input alone -is 15,000 tokens and the prompt and tools require another 8,000: - -```text -15,000 retained + 8,000 prompt and tools > 20,000 threshold -``` - Otherwise each fresh usage report can immediately trigger another checkpoint. Disabling automatic compaction does not remove a checkpoint already installed. diff --git a/services/www/src/docs/content/config.mdx b/services/www/src/docs/content/config.mdx index 63f6afdb542..db80b989560 100644 --- a/services/www/src/docs/content/config.mdx +++ b/services/www/src/docs/content/config.mdx @@ -328,18 +328,17 @@ automatic and manual requests with a provider or model policy: { "providers": { "openai": { - "compaction": { "mode": "provider", "threshold": 120000 }, + "settings": { "compaction": { "type": "native" } }, "models": { - "gpt-4.1": { "compaction": { "mode": "local" } }, + "gpt-4.1": { "settings": { "compaction": { "type": "summary" } } }, }, }, }, } ``` -A model policy replaces the whole provider policy. The optional positive integer -`threshold` defaults to the selected model's usable input budget and cannot exceed -its safe ceiling. Provider checkpoints keep recent user messages within the same +A model setting overrides the provider setting. Automatic compaction uses the +selected model's usable input budget. Provider checkpoints keep recent user messages within the same `compaction.tokens` budget that local summaries use for their retained tail. Top-level `compaction.auto: false` disables new automatic compaction without discarding installed checkpoints. See the [compaction guide](/compaction) for @@ -507,7 +506,7 @@ headers, and model variants. } ``` -`transport: "websocket"` on a provider or model selects its session +`settings.transport: "websocket"` on a provider or model selects its session WebSocket; a model value overrides the provider value. See the [providers guide](/providers) for credentials, custom endpoints, provider packages, the WebSocket transport, and model configuration. diff --git a/services/www/src/docs/content/providers.mdx b/services/www/src/docs/content/providers.mdx index ed670f3d12c..0da3b38cae5 100644 --- a/services/www/src/docs/content/providers.mdx +++ b/services/www/src/docs/content/providers.mdx @@ -61,12 +61,10 @@ The `providers` object is keyed by the provider ID used in model references, suc | `env` | Ordered environment variable names that can provide the credential. | | `package` | Runtime provider package. | | `canonical` | Built-in provider ID whose catalog defaults this provider inherits. | -| `settings` | JSON options passed to the runtime package. | +| `settings` | Typed OpenCode controls and JSON options passed to the runtime package. | | `headers` | String-valued HTTP headers added to requests. | | `body` | JSON fields merged into request bodies. | | `models` | Models to add or override, keyed by the OpenCode model ID. | -| `transport` | Provider-level session transport, `"http"` or `"websocket"`. | -| `compaction` | Local or provider [compaction](/compaction) policy. | ## Endpoint @@ -222,7 +220,7 @@ ID sent to the provider. | `name` | Display name. | | `family` | Model family used for grouping related models. | | `package` | Runtime override for this model. | -| `settings` | Package-specific JSON settings. | +| `settings` | Model-level OpenCode controls and package-specific JSON settings. | | `headers` | Additional string-valued request headers. | | `body` | Additional JSON request body fields. | | `capabilities` | Tool support plus accepted input and output media types. | @@ -231,8 +229,6 @@ ID sent to the provider. | `cost` | Input, output, and optional cache pricing per million tokens. | | `limit` | Context, input, and output token limits. | | `disabled` | Removes the model from selection when `true`. | -| `transport` | Model-level session transport; overrides the provider value. | -| `compaction` | Model-level [compaction](/compaction) policy; overrides the provider policy. | See [Models](/models) for selection, defaults, capabilities, limits, costs, and variants. @@ -561,9 +557,9 @@ sessions. "$schema": "https://opencode.ai/config.json", "providers": { "openai": { - "transport": "http", + "settings": { "transport": "http" }, "models": { - "gpt-5.5": { "transport": "websocket" }, + "gpt-5.5": { "settings": { "transport": "websocket" } }, }, }, }, @@ -573,7 +569,7 @@ sessions. WebSocket behavior follows these rules: - Built-in providers opt supported models in according to their own policy. -- `transport: "websocket"` enables a provider or model; `"http"` disables it. +- `settings.transport: "websocket"` enables a provider or model; `"http"` disables it. - A model value overrides its provider value. - `"websocket"` on a route without a WebSocket channel logs a warning and falls back to HTTP. - OpenAI provider compaction uses the same connection.