diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index c9d000071f6..dbce9649054 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -33,7 +33,7 @@ import { import { Auth, Endpoint, RequestExecutor, type AnyRoute, type HttpMiddleware } from "@opencode/ai/route" import { ProviderShared } from "@opencode/ai/protocols/shared" import { isRecord } from "@opencode/ai/utils/record" -import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect" +import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream, Struct } from "effect" import { makeParser } from "effect/encoding/Sse" import { HttpClientRequest, HttpClientResponse } from "effect/http" import { AsyncLocalStorage } from "node:async_hooks" @@ -122,20 +122,16 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) { function prepareOptions(model: RuntimeInfo, pkg: string) { const projected = mapBodyToProviderOptions(model, pkg) + const settings = model.settings ?? {} const options: Record = { name: model.canonical ?? model.providerID, - ...(model.settings ?? {}), + ...Provider.nativeSettings(settings), headers: model.headers, body: projected.body, } - const customFetch = options.fetch - const timeouts = Provider.timeouts(options) - delete options.headerTimeout - delete options.chunkTimeout - delete options.compaction - delete options.cache - delete options.transport + const customFetch = settings.fetch + const timeouts = Provider.timeouts(settings) options.fetch = async (input: Parameters[0], init?: RequestInit) => { const opts = { ...(init ?? {}) } // A plain AbortSignal.timeout would keep running into the body stream, so the header limit uses @@ -454,25 +450,16 @@ function providerOptionKey(packageName: string | undefined, providerID: Provider return providerID } -function requestSettings(settings: Readonly> | undefined) { +function requestSettings(settings: Provider.Settings | undefined) { if (settings === undefined) return undefined - const result = Object.fromEntries( - Object.entries(settings).filter( - ([key]) => - ![ - "apiKey", - "authToken", - "baseURL", - "cache", - "chunkTimeout", - "compaction", - "fetch", - "headerTimeout", - "timeout", - "transport", - ].includes(key), - ), - ) + // These configure the SDK provider when it is created, not each request. + const result = Struct.omit(Provider.nativeSettings(settings), [ + "accessToken", + "apiKey", + "authToken", + "baseURL", + "fetch", + ]) return Object.keys(result).length === 0 ? undefined : result } diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index d5c73134361..9c4847e83d4 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -183,25 +183,17 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s return yield* importPackage(specifier, entrypoint) }) -/** opencode settings consumed in Core; native packages never receive them. */ -const CORE_KEYS = [ - "cache", - "chunkTimeout", - "compaction", - "encoding", - "fetch", - "headerTimeout", - "timeout", - "transport", -] as const -const PROVIDER_ONLY_KEYS = ["chunkTimeout", "headerTimeout", "timeout", "transport"] as const +const settingsKeys = Struct.keys(Provider.Settings.schema.fields) +const providerOnlyKeys = settingsKeys.filter((key) => !(key in Model.Settings.schema.fields)) -export function nativeSettings(settings: Settings): Settings { - return Struct.omit(settings, CORE_KEYS) +/** The provider options: every settings key OpenCode doesn't read itself. */ +export function nativeSettings(settings: Settings) { + return Struct.omit(settings, settingsKeys) } +/** Settings a model inherits from its provider. */ export function modelSettings(settings: Settings | undefined) { - return settings && Struct.omit(settings, PROVIDER_ONLY_KEYS) + return settings && Struct.omit(settings, providerOnlyKeys) } const decodeTimeout = Schema.decodeUnknownOption(HttpTimeout) diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 9f103b75720..53bc3467adc 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -170,6 +170,9 @@ it.effect("projects request settings, headers, and body overlays", () => apiKey: "secret", thinkingConfig: { thinkingBudget: 1024 }, compaction: { type: "native" }, + cache: { ttl: "1h" }, + encoding: "gzip", + timeout: 600_000, transport: "websocket", }) const resolved = yield* aisdk.model({ @@ -193,8 +196,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") + for (const key of ["compaction", "cache", "encoding", "timeout", "transport"]) + expect(options).not.toHaveProperty(key) }), ) diff --git a/packages/core/test/provider.test.ts b/packages/core/test/provider.test.ts index 6c7376ae2ae..ef655fe8ad5 100644 --- a/packages/core/test/provider.test.ts +++ b/packages/core/test/provider.test.ts @@ -31,6 +31,8 @@ describe("Provider", () => { reasoningEffort: "high", chunkTimeout: 1000, compaction: { type: "native" }, + cache: { ttl: "1h" }, + encoding: "gzip", transport: "websocket", }), ).toEqual({ diff --git a/packages/schema/src/config/provider.ts b/packages/schema/src/config/provider.ts index a0973653bd9..e84cdd68a11 100644 --- a/packages/schema/src/config/provider.ts +++ b/packages/schema/src/config/provider.ts @@ -2,33 +2,19 @@ export * as ConfigProvider from "./provider.js" import { Schema } from "effect" import { Money } from "../money.js" -import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js" +import { Capabilities, Compatibility, Family, ID, Model, VariantID } from "../model.js" import { ProviderCompatibility } from "../model-compatibility.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), - headerTimeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional), - chunkTimeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional), - compaction: Provider.Compaction.pipe(optional), - cache: Provider.Cache.pipe(optional), - transport: Provider.Transport.pipe(optional), - encoding: Provider.Encoding.pipe(optional), - }), - [Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Json))], -).annotate({ identifier: "Config.Provider.Settings" }) +export const Settings = Schema.StructWithRest(Schema.Struct(Provider.Settings.schema.fields), [ + Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Json)), +]).annotate({ identifier: "Config.Provider.Settings" }) export type Settings = typeof Settings.Type -export const ModelSettings = Schema.StructWithRest( - Schema.Struct({ - compaction: Provider.Compaction.pipe(optional), - cache: Provider.Cache.pipe(optional), - encoding: Provider.Encoding.pipe(optional), - }), - [Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Json))], -).annotate({ identifier: "Config.Model.Settings" }) +export const ModelSettings = Schema.StructWithRest(Schema.Struct(Model.Settings.schema.fields), [ + Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Json)), +]).annotate({ identifier: "Config.Model.Settings" }) export type ModelSettings = typeof ModelSettings.Type const JsonRecord = Schema.Record(Schema.String, Schema.Json) @@ -86,7 +72,7 @@ export const Reasoning = Schema.Union([ ]).annotate({ identifier: "Config.Model.Reasoning" }) export type Reasoning = typeof Reasoning.Type -class Model extends Schema.Class("Config.Model")({ +class ModelConfig extends Schema.Class("Config.Model")({ modelID: ID.pipe(optional), family: Family.pipe(optional), name: Schema.String.pipe(optional), @@ -123,5 +109,5 @@ export class Info extends Schema.Class("Config.Provider")({ // Defaults for every model of this provider; model compatibility overrides individual fields. compatibility: ProviderCompatibility.pipe(optional), ...Overlays, - models: Schema.Record(Schema.String, Model).pipe(optional), + models: Schema.Record(Schema.String, ModelConfig).pipe(optional), }) {} diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index e02f380ccf1..ebfe803254c 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -1,6 +1,6 @@ export * as Model from "./model.js" -import { Schema } from "effect" +import { Schema, Struct } from "effect" import { optional, statics } from "./schema.js" import { Provider } from "./provider.js" import { Compatibility, MaxTokensField, ReasoningField } from "./model-compatibility.js" @@ -49,11 +49,7 @@ export const Family = Schema.String.pipe(Schema.brand("Model.Family")) export type Family = typeof Family.Type export const Settings = Schema.StructWithRest( - Schema.Struct({ - compaction: Provider.Compaction.pipe(optional), - cache: Provider.Cache.pipe(optional), - encoding: Provider.Encoding.pipe(optional), - }), + Schema.Struct(Struct.pick(Provider.Settings.schema.fields, ["compaction", "cache", "encoding"])), // Provider packages may define arbitrary model-level options beyond OpenCode's shared compaction policy. [Schema.Record(Schema.String, Schema.Any)], ).annotate({ identifier: "Model.Settings" }) diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index ea74d144517..d158c5cff8c 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -62,11 +62,14 @@ export type Cache = typeof Cache.Type export const Encoding = Schema.Literals(["zstd", "gzip", "none"]).annotate({ identifier: "Provider.Encoding" }) export type Encoding = typeof Encoding.Type +const Timeout = Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional) + +// The named fields are the settings OpenCode reads itself; every other key is a provider option for the provider package. export const Settings = Schema.StructWithRest( Schema.Struct({ - timeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional), - headerTimeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional), - chunkTimeout: Schema.Union([Schema.Finite, Schema.Literal(false)]).pipe(optional), + timeout: Timeout, + headerTimeout: Timeout, + chunkTimeout: Timeout, compaction: Compaction.pipe(optional), cache: Cache.pipe(optional), transport: Transport.pipe(optional),