diff --git a/CONTEXT.md b/CONTEXT.md index b5de38067deb..7ec54424da18 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -39,6 +39,13 @@ An expected temporary inability to observe a **Context Source** value; the runti **Safe Provider-Turn Boundary**: The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically. +**Model Request Options**: +Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request. +_Avoid_: Request body, wire options + +**Generation Controls**: +Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog. + ## Relationships - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. @@ -84,6 +91,8 @@ The point immediately before a provider call, after durable input promotion and - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. - Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache. - A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history. +- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. +- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. - When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply. - Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible. diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index b4d3e040389e..d1ac7ef8c4e4 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -3,6 +3,7 @@ export * as Catalog from "./catalog" import { Context, Effect, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect" import { castDraft, enableMapSet, type Draft } from "immer" import { ModelV2 } from "./model" +import { ModelRequest } from "./model-request" import { PluginV2 } from "./plugin" import { ProviderV2 } from "./provider" import { Location } from "./location" @@ -106,14 +107,7 @@ export const layer = Layer.effect( ? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } } : model.api const request = { - headers: { - ...provider.request.headers, - ...model.request.headers, - }, - body: { - ...provider.request.body, - ...model.request.body, - }, + ...ModelRequest.merge({ ...provider.request, generation: {}, options: {} }, model.request), variant: model.request.variant, } return new ModelV2.Info({ diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 00f93544cd72..0d31b32d08fd 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -4,6 +4,7 @@ import { Effect } from "effect" import { Catalog } from "../../catalog" import { Config } from "../../config" import { ModelV2 } from "../../model" +import { ModelRequest } from "../../model-request" import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" @@ -31,16 +32,19 @@ export const Plugin = PluginV2.define({ provider.enabled = { via: "custom", data: {} } if (item.api !== undefined) provider.api = { ...item.api } if (item.request !== undefined) { - Object.assign(provider.request.headers, item.request.headers ?? {}) - Object.assign(provider.request.body, item.request.body ?? {}) + Object.assign(provider.request.headers, item.request.headers) + Object.assign(provider.request.body, item.request.body) } }) + const providerApi = catalog.provider.get(providerID)?.provider.api + const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined for (const [id, config] of Object.entries(item.models ?? {})) { catalog.model.update(providerID, ModelV2.ID.make(id), (model) => { if (config.family !== undefined) model.family = config.family if (config.name !== undefined) model.name = config.name if (config.api !== undefined) model.api = { ...model.api, ...config.api } + const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage if (config.capabilities !== undefined) { model.capabilities = { tools: config.capabilities.tools, @@ -49,8 +53,10 @@ export const Plugin = PluginV2.define({ } } if (config.request !== undefined) { - Object.assign(model.request.headers, config.request.headers ?? {}) - Object.assign(model.request.body, config.request.body ?? {}) + ModelRequest.assign(model.request, { + headers: config.request.headers, + ...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}), + }) if (config.request.variant !== undefined) model.request.variant = config.request.variant } if (config.variants !== undefined) { @@ -61,11 +67,15 @@ export const Plugin = PluginV2.define({ id: variant.id, headers: {}, body: {}, + generation: {}, + options: {}, } model.variants.push(existing) } - Object.assign(existing.headers, variant.headers ?? {}) - Object.assign(existing.body, variant.body ?? {}) + ModelRequest.assign(existing, { + headers: variant.headers, + ...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}), + }) } } if (config.cost !== undefined) { diff --git a/packages/core/src/model-request.ts b/packages/core/src/model-request.ts new file mode 100644 index 000000000000..f9f4f56936dd --- /dev/null +++ b/packages/core/src/model-request.ts @@ -0,0 +1,124 @@ +export * as ModelRequest from "./model-request" + +import { Effect, Schema } from "effect" + +export const Generation = Schema.Struct({ + maxTokens: Schema.Number.pipe(Schema.optional), + temperature: Schema.Number.pipe(Schema.optional), + topP: Schema.Number.pipe(Schema.optional), + topK: Schema.Number.pipe(Schema.optional), + frequencyPenalty: Schema.Number.pipe(Schema.optional), + presencePenalty: Schema.Number.pipe(Schema.optional), + seed: Schema.Number.pipe(Schema.optional), + stop: Schema.String.pipe(Schema.Array, Schema.mutable, Schema.optional), +}) +export type Generation = typeof Generation.Type + +export const Request = Schema.Struct({ + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Record(Schema.String, Schema.Any), + generation: Generation.pipe( + Schema.optionalKey, + Schema.withConstructorDefault(Effect.succeed({})), + Schema.withDecodingDefaultKey(Effect.succeed({})), + ), + options: Schema.Record(Schema.String, Schema.Any).pipe( + Schema.optionalKey, + Schema.withConstructorDefault(Effect.succeed({})), + Schema.withDecodingDefaultKey(Effect.succeed({})), + ), +}) +export type Request = typeof Request.Type + +interface MutableRequest { + headers: Record + body: Record + generation?: Generation + options?: Record +} + +const generationKeys = new Map([ + ["maxOutputTokens", "maxTokens"], + ["maxTokens", "maxTokens"], + ["temperature", "temperature"], + ["topP", "topP"], + ["topK", "topK"], + ["frequencyPenalty", "frequencyPenalty"], + ["presencePenalty", "presencePenalty"], + ["seed", "seed"], + ["stopSequences", "stop"], + ["stop", "stop"], +]) + +interface Profile { + readonly namespace: string + readonly semantics: ReadonlyMap +} + +const profiles = new Map([ + [ + "@ai-sdk/openai", + { + namespace: "openai", + semantics: new Map([ + ["store", "store"], + ["promptCacheKey", "promptCacheKey"], + ["reasoningEffort", "reasoningEffort"], + ["reasoningSummary", "reasoningSummary"], + ["include", "include"], + ["textVerbosity", "textVerbosity"], + ["serviceTier", "serviceTier"], + ["service_tier", "serviceTier"], + ]), + }, + ], + [ + "@ai-sdk/openai-compatible", + { + namespace: "openai", + semantics: new Map([ + ["store", "store"], + ["promptCacheKey", "promptCacheKey"], + ["reasoningEffort", "reasoningEffort"], + ["reasoning_effort", "reasoningEffort"], + ]), + }, + ], + ["@ai-sdk/anthropic", { namespace: "anthropic", semantics: new Map([["thinking", "thinking"]]) }], +]) + +export const namespace = (packageName: string) => profiles.get(packageName)?.namespace + +export const merge = (base: Request, override: Partial) => ({ + headers: { ...base.headers, ...override.headers }, + body: { ...base.body, ...override.body }, + generation: { ...base.generation, ...override.generation }, + options: { ...base.options, ...override.options }, +}) + +export const assign = (target: MutableRequest, override: Partial) => { + Object.assign(target.headers, override.headers) + Object.assign(target.body, override.body) + Object.assign((target.generation ??= {}), override.generation) + Object.assign((target.options ??= {}), override.options) +} + +/** Partitions AI-SDK-shaped request options before they enter the Catalog. */ +export function normalizeAiSdkOptions(packageName: string | undefined, input: Readonly>) { + const generation: Record> = {} + const options: Record = {} + const body: Record = {} + const semantics = profiles.get(packageName ?? "")?.semantics + + for (const [key, value] of Object.entries(input)) { + const generationKey = generationKeys.get(key) + if (generationKey === "stop" && Array.isArray(value) && value.every((item) => typeof item === "string")) + generation[generationKey] = value + else if (generationKey !== undefined && generationKey !== "stop" && typeof value === "number") + generation[generationKey] = value + else if (semantics?.has(key)) options[semantics.get(key)!] = value + else body[key] = value + } + + return { generation, options, body } +} diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index a57647c4da0c..3b0beece55fe 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -1,6 +1,7 @@ import { DateTime, Schema } from "effect" import { DateTimeUtcFromMillis } from "effect/Schema" import { ProviderV2 } from "./provider" +import { ModelRequest } from "./model-request" export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID")) export type ID = typeof ID.Type @@ -60,12 +61,12 @@ export class Info extends Schema.Class("ModelV2.Info")({ api: Api, capabilities: Capabilities, request: Schema.Struct({ - ...ProviderV2.Request.fields, + ...ModelRequest.Request.fields, variant: Schema.String.pipe(Schema.optional), }), variants: Schema.Struct({ id: VariantID, - ...ProviderV2.Request.fields, + ...ModelRequest.Request.fields, }).pipe(Schema.Array), time: Schema.Struct({ released: DateTimeUtcFromMillis, @@ -97,6 +98,8 @@ export class Info extends Schema.Class("ModelV2.Info")({ request: { headers: {}, body: {}, + generation: {}, + options: {}, }, variants: [], time: { diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index d223e59f65b2..6424c6b6ab7f 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -2,6 +2,7 @@ import { DateTime, Effect, Scope, Stream } from "effect" import { Catalog } from "../catalog" import { EventV2 } from "../event" import { ModelV2 } from "../model" +import { ModelRequest } from "../model-request" import { ModelsDev } from "../models-dev" import { PluginV2 } from "../plugin" import { ProviderV2 } from "../provider" @@ -38,12 +39,15 @@ function cost(input: ModelsDev.Model["cost"]) { ] } -function variants(model: ModelsDev.Model) { - return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => ({ - id: ModelV2.VariantID.make(id), - headers: { ...(item.provider?.headers ?? {}) }, - body: { ...(item.provider?.body ?? {}) }, - })) +function variants(model: ModelsDev.Model, packageName?: string) { + return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => { + const request = ModelRequest.normalizeAiSdkOptions(packageName, item.provider?.body ?? {}) + return { + id: ModelV2.VariantID.make(id), + headers: { ...(item.provider?.headers ?? {}) }, + ...request, + } + }) } export const ModelsDevPlugin = PluginV2.define({ @@ -98,7 +102,7 @@ export const ModelsDevPlugin = PluginV2.define({ input: [...(model.modalities?.input ?? [])], output: [...(model.modalities?.output ?? [])], } - draft.variants = variants(model) + draft.variants = variants(model, model.provider?.npm ?? item.npm) draft.time.released = released(model.release_date) draft.cost = cost(model.cost) draft.status = model.status ?? "active" diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 3e525adf28f8..27bd15ec8d40 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -9,6 +9,7 @@ import { Context, Effect, Layer, Option, Schema } from "effect" import { produce } from "immer" import { Catalog } from "../../catalog" import { ModelV2 } from "../../model" +import { ModelRequest } from "../../model-request" import { PluginBoot } from "../../plugin/boot" import { ProviderV2 } from "../../provider" import { SessionSchema } from "../schema" @@ -50,24 +51,30 @@ const apiKey = (model: ModelV2.Info, provider?: ProviderV2.Info) => { return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined } -const withDefaults = (model: ModelV2.Info, route: AnyRoute) => - route.with({ +const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { + const options = model.request.options ?? {} + const namespace = model.api.type === "aisdk" ? ModelRequest.namespace(model.api.package) : undefined + const body = model.request.body + const httpBody = Object.hasOwn(body, "apiKey") + ? Object.fromEntries(Object.entries(body).filter(([key]) => key !== "apiKey")) + : body + return route.with({ provider: model.providerID, endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url }, headers: model.request.headers, - http: { - body: Object.fromEntries(Object.entries(model.request.body).filter(([key]) => key !== "apiKey")), - }, + generation: model.request.generation, + providerOptions: namespace && Object.keys(options).length > 0 ? { [namespace]: options } : undefined, + http: { body: httpBody }, limits: { context: model.limit.context, output: model.limit.output }, }) +} const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => { const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID const variant = model.variants.find((item) => item.id === id) if (!variant) return model return produce(model, (draft) => { - Object.assign(draft.request.headers, variant.headers) - Object.assign(draft.request.body, variant.body) + ModelRequest.assign(draft.request, variant) }) } diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 617fe21d954d..3530458fc324 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -6,6 +6,7 @@ import { ConfigMCPV1 } from "./mcp" import { ConfigPermissionV1 } from "./permission" import { ConfigProviderV1 } from "./provider" import { ConfigProviderOptionsV1 } from "./provider-options" +import { ModelRequest } from "../../model-request" const keys = new Set([ "logLevel", @@ -183,6 +184,13 @@ function migrateProvider(info: ConfigProviderV1.Info) { } function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: string) { + const packageID = info.provider?.npm ?? packageName + const lowerer = ConfigProviderOptionsV1.get(packageID) + const ingest = (options: Readonly>) => { + const request = ModelRequest.normalizeAiSdkOptions(packageID, options) + return { ...lowerer.request(request.body), ...request.generation, ...request.options } + } + const request = info.options && ingest(info.options) const costs = info.cost && [ { input: info.cost.input, @@ -204,7 +212,6 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st info.tool_call !== undefined || info.modalities?.input !== undefined || info.modalities?.output !== undefined ? { tools: info.tool_call ?? false, input: info.modalities?.input ?? [], output: info.modalities?.output ?? [] } : undefined - const lowerer = ConfigProviderOptionsV1.get(info.provider?.npm ?? packageName) return { family: info.family, name: info.name, @@ -220,12 +227,16 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st ? undefined : { id: info.id }, capabilities, - request: (info.headers || info.options) && { + request: (info.headers || request) && { headers: info.headers, - body: info.options && lowerer.request(info.options), + body: request, }, variants: - info.variants && Object.entries(info.variants).map(([id, options]) => ({ id, body: lowerer.request(options) })), + info.variants && + Object.entries(info.variants).map(([id, options]) => ({ + id, + body: ingest(options), + })), cost: costs, disabled: info.status === "deprecated" ? true : undefined, limit: info.limit && { diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index ed2bd42c4072..a291af8d675f 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -219,12 +219,16 @@ describe("CatalogV2", () => { model.request.headers.shared = "model" model.request.body.model = true model.request.body.request = true + const options = (model.request.options ??= {}) + options.shared = "model" + options.model = true }) }) const model = yield* catalog.model.get(providerID, modelID) expect(model.request.headers).toEqual({ provider: "provider", shared: "model", model: "model" }) expect(model.request.body).toEqual({ provider: true, model: true, request: true }) + expect(model.request.options).toEqual({ shared: "model", model: true }) }), ) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index f2f9bc621c2a..a458258e545d 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -479,7 +479,22 @@ describe("Config", () => { npm: "@ai-sdk/openai", options: { apiKey: "secret", organization: "org" }, models: { - model: { options: { reasoningEffort: "high", serviceTier: "priority" } }, + model: { + options: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" }, + variants: { high: { reasoningEffort: "high", reasoningSummary: "auto" } }, + }, + }, + }, + anthropic: { + npm: "@ai-sdk/anthropic", + models: { + model: { + options: { + effort: "high", + taskBudget: 4096, + metadata: { userId: "user-1" }, + }, + }, }, }, }, @@ -537,7 +552,26 @@ describe("Config", () => { expect(documents[0]?.info.providers?.openai).toMatchObject({ api: { settings: {} }, request: { headers: { Authorization: "Bearer secret", "OpenAI-Organization": "org" } }, - models: { model: { request: { body: { reasoning_effort: "high", service_tier: "priority" } } } }, + models: { + model: { + request: { + body: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" }, + }, + variants: [{ id: "high", body: { reasoningEffort: "high", reasoningSummary: "auto" } }], + }, + }, + }) + expect(documents[0]?.info.providers?.anthropic).toMatchObject({ + models: { + model: { + request: { + body: { + output_config: { effort: "high", task_budget: 4096 }, + metadata: { user_id: "user-1" }, + }, + }, + }, + }, }) expect(documents[0]?.info.compaction).toEqual({ auto: true, diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 947d84113d0a..6a2510bab8c9 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -18,6 +18,118 @@ function request(headers: Record, variant?: string) { const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigProviderPlugin.Plugin", () => { + it.effect("partitions existing model variant bodies without changing config shape", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const plugin = yield* PluginV2.Service + const providerID = ProviderV2.ID.opencode + const modelID = ModelV2.ID.make("alpha-gpt-next") + const config = Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ + providers: { + opencode: { + api: { type: "aisdk", package: "@ai-sdk/openai", url: "https://opencode.test/v1" }, + models: { + "alpha-gpt-next": { + variants: [ + { + id: "high", + body: { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }, + ], + }, + }, + }, + }, + }), + }), + ]), + }) + + yield* plugin.add({ + ...ConfigProviderPlugin.Plugin, + effect: ConfigProviderPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(Catalog.Service, catalog), + ), + }) + + const model = yield* catalog.model.get(providerID, modelID) + expect(model.variants).toMatchObject([ + { + id: "high", + body: {}, + options: { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }, + ]) + }), + ) + + it.effect("uses the effective provider package across layered config", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const plugin = yield* PluginV2.Service + const providerID = ProviderV2.ID.opencode + const modelID = ModelV2.ID.make("alpha-gpt-next") + const config = Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ + providers: { + opencode: { + api: { type: "aisdk", package: "@ai-sdk/openai", url: "https://opencode.test/v1" }, + }, + }, + }), + }), + new Config.Document({ + type: "document", + info: decode({ + providers: { + opencode: { + models: { + "alpha-gpt-next": { + variants: [{ id: "high", body: { reasoningEffort: "high" } }], + }, + }, + }, + }, + }), + }), + ]), + }) + + yield* plugin.add({ + ...ConfigProviderPlugin.Plugin, + effect: ConfigProviderPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(Catalog.Service, catalog), + ), + }) + + const model = yield* catalog.model.get(providerID, modelID) + expect(model.variants[0]).toMatchObject({ + id: "high", + body: {}, + options: { reasoningEffort: "high" }, + }) + }), + ) + it.effect("loads configured providers and applies later model overrides", () => Effect.gen(function* () { const catalog = yield* Catalog.Service diff --git a/packages/core/test/model-request.test.ts b/packages/core/test/model-request.test.ts new file mode 100644 index 000000000000..c8ac12d47088 --- /dev/null +++ b/packages/core/test/model-request.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import { ModelRequest } from "@opencode-ai/core/model-request" + +describe("ModelRequest", () => { + test("partitions AI SDK model and models.dev mode options", () => { + expect( + ModelRequest.normalizeAiSdkOptions("@ai-sdk/openai", { + maxOutputTokens: 4096, + temperature: 0.2, + reasoningEffort: "high", + serviceTier: "priority", + custom_extension: { enabled: true }, + }), + ).toEqual({ + generation: { maxTokens: 4096, temperature: 0.2 }, + options: { reasoningEffort: "high", serviceTier: "priority" }, + body: { custom_extension: { enabled: true } }, + }) + }) + + test("keeps unknown-provider options as compatibility fields", () => { + expect(ModelRequest.normalizeAiSdkOptions(undefined, { temperature: 0.2, reasoningEffort: "high" })).toEqual({ + generation: { temperature: 0.2 }, + options: {}, + body: { reasoningEffort: "high" }, + }) + }) + + test("does not consult inherited package-name properties", () => { + expect(ModelRequest.normalizeAiSdkOptions("__proto__", { reasoningEffort: "high" })).toEqual({ + generation: {}, + options: {}, + body: { reasoningEffort: "high" }, + }) + }) + + test("normalizes models.dev wire aliases owned by native protocols", () => { + expect(ModelRequest.normalizeAiSdkOptions("@ai-sdk/openai", { service_tier: "priority" })).toEqual({ + generation: {}, + options: { serviceTier: "priority" }, + body: {}, + }) + }) +}) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index fbb7b65f7e7b..c50e2f855af9 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -29,7 +29,9 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) => capabilities: { tools: true, input: ["text"], output: ["text"] }, request: { headers: { "x-test": "header" }, - body: { store: false, apiKey: "secret" }, + body: { apiKey: "secret", custom_extension: { enabled: true } }, + generation: { temperature: 0.7 }, + options: { store: false, serviceTier: "priority" }, }, variants, time: { released: DateTime.makeUnsafe(0) }, @@ -63,7 +65,9 @@ describe("SessionRunnerModel", () => { defaults: { headers: { "x-test": "header" }, limits: { context: 100, output: 20 }, - http: { body: { store: false } }, + generation: { temperature: 0.7 }, + providerOptions: { openai: { store: false, serviceTier: "priority" } }, + http: { body: { custom_extension: { enabled: true } } }, }, }) }), @@ -91,7 +95,7 @@ describe("SessionRunnerModel", () => { url: "https://compatible.example/v1", settings: { apiKey: "settings-secret", compatibility: "strict" }, }), - request: { headers: {}, body: {} }, + request: { headers: {}, body: {}, generation: {}, options: {} }, }), ) const request = LLM.request({ model: resolved, prompt: "Hello" }) @@ -108,15 +112,21 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("applies the selected Session variant to request options", () => + it.effect("lowers selected OpenAI Session variants into Responses options", () => Effect.gen(function* () { - const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [ + const base = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [ { id: ModelV2.VariantID.make("high"), headers: { "x-variant": "high" }, - body: { reasoningEffort: "high" }, + body: {}, + generation: { temperature: 0.2 }, + options: { reasoningEffort: "high" }, }, ]) + const catalog = new ModelV2.Info({ + ...base, + request: { ...base.request, options: { ...base.request.options, reasoningEffort: "medium" } }, + }) const session = SessionV2.Info.make({ id: SessionV2.ID.make("ses_model_variant"), projectID: ProjectV2.ID.global, @@ -133,11 +143,87 @@ describe("SessionRunnerModel", () => { }) const resolved = yield* SessionRunnerModel.resolve(session, catalog) + const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" })) + + expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" }) + expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } }) + expect(prepared.body).toMatchObject({ + store: false, + service_tier: "priority", + temperature: 0.2, + reasoning: { effort: "high" }, + }) + expect(prepared.body).not.toHaveProperty("reasoningEffort") + }), + ) + + it.effect("lowers selected OpenAI-compatible Session variants into Chat options", () => + Effect.gen(function* () { + const catalog = model( + { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://compatible.example/v1" }, + [ + { + id: ModelV2.VariantID.make("high"), + headers: {}, + body: {}, + generation: {}, + options: { reasoningEffort: "high" }, + }, + ], + ) + const session = SessionV2.Info.make({ + id: SessionV2.ID.make("ses_compatible_variant"), + projectID: ProjectV2.ID.global, + title: "test", + model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: { directory: AbsolutePath.make("/project") }, + }) + + const resolved = yield* SessionRunnerModel.resolve(session, catalog) + const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" })) + + expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } }) + expect(prepared.body).toMatchObject({ + store: false, + reasoning_effort: "high", + }) + expect(prepared.body).not.toHaveProperty("reasoningEffort") + }), + ) + + it.effect("lowers selected Anthropic Session variants into Messages options", () => + Effect.gen(function* () { + const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [ + { + id: ModelV2.VariantID.make("high"), + headers: {}, + body: {}, + generation: {}, + options: { thinking: { type: "enabled", budgetTokens: 12000 } }, + }, + ]) + const session = SessionV2.Info.make({ + id: SessionV2.ID.make("ses_anthropic_variant"), + projectID: ProjectV2.ID.global, + title: "test", + model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: { directory: AbsolutePath.make("/project") }, + }) + + const resolved = yield* SessionRunnerModel.resolve(session, catalog) + const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" })) - expect(resolved.route.defaults).toMatchObject({ - headers: { "x-test": "header", "x-variant": "high" }, - http: { body: { store: false, reasoningEffort: "high" } }, + expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } }) + expect(prepared.body).toMatchObject({ + thinking: { type: "enabled", budget_tokens: 12000 }, }) + expect(JSON.stringify(prepared.body)).not.toContain("budgetTokens") }), ) @@ -159,7 +245,7 @@ describe("SessionRunnerModel", () => { const resolved = yield* SessionRunnerModel.fromCatalogModel( new ModelV2.Info({ ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), - request: { headers: {}, body: {} }, + request: { headers: {}, body: {}, generation: {}, options: {} }, }), provider({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), ) diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 06d07f8ccbdb..9bfdee44d8ac 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -126,6 +126,7 @@ const OpenAIResponsesCoreFields = { tools: optionalArray(OpenAIResponsesTool), tool_choice: Schema.optional(OpenAIResponsesToolChoice), store: Schema.optional(Schema.Boolean), + service_tier: Schema.optional(OpenAIOptions.OpenAIServiceTier), prompt_cache_key: Schema.optional(Schema.String), include: optionalArray(OpenAIOptions.OpenAIResponseIncludable), reasoning: Schema.optional( @@ -447,6 +448,7 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques const include = OpenAIOptions.include(request) const verbosity = OpenAIOptions.textVerbosity(request) const instructions = OpenAIOptions.instructions(request) + const serviceTier = OpenAIOptions.serviceTier(request) return { ...(instructions ? { instructions } : {}), ...(store !== undefined ? { store } : {}), @@ -454,6 +456,7 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques ...(include ? { include } : {}), ...(effort || summary ? { reasoning: { effort, summary } } : {}), ...(verbosity ? { text: { verbosity } } : {}), + ...(serviceTier ? { service_tier: serviceTier } : {}), } }) diff --git a/packages/llm/src/protocols/utils/openai-options.ts b/packages/llm/src/protocols/utils/openai-options.ts index 53c834be88d8..51e56ae21642 100644 --- a/packages/llm/src/protocols/utils/openai-options.ts +++ b/packages/llm/src/protocols/utils/openai-options.ts @@ -20,15 +20,19 @@ export const OpenAIResponseIncludables = [ "message.output_text.logprobs", ] as const export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number] +export const OpenAIServiceTiers = ["auto", "default", "flex", "priority"] as const +export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] const REASONING_EFFORTS = new Set(ReasoningEfforts) const OPENAI_REASONING_EFFORTS = new Set(OpenAIReasoningEfforts) const TEXT_VERBOSITY = new Set(["low", "medium", "high"]) const INCLUDABLES = new Set(OpenAIResponseIncludables) +const SERVICE_TIERS = new Set(OpenAIServiceTiers) export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts) export const OpenAITextVerbosity = TextVerbosity export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables) +export const OpenAIServiceTier = Schema.Literals(OpenAIServiceTiers) const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort => typeof effort === "string" && REASONING_EFFORTS.has(effort) @@ -76,6 +80,11 @@ export const textVerbosity = (request: LLMRequest) => { return isTextVerbosity(value) ? value : undefined } +export const serviceTier = (request: LLMRequest) => { + const value = options(request)?.serviceTier + return typeof value === "string" && SERVICE_TIERS.has(value) ? (value as OpenAIServiceTier) : undefined +} + export const instructions = (request: LLMRequest) => { const value = options(request)?.instructions return typeof value === "string" ? value : undefined diff --git a/packages/llm/src/providers/openai-options.ts b/packages/llm/src/providers/openai-options.ts index 329db08d786e..fb548dd79726 100644 --- a/packages/llm/src/providers/openai-options.ts +++ b/packages/llm/src/providers/openai-options.ts @@ -1,8 +1,8 @@ import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema" import { mergeProviderOptions } from "../schema" -import type { OpenAIResponseIncludable } from "../protocols/utils/openai-options" +import type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options" -export type { OpenAIResponseIncludable } from "../protocols/utils/openai-options" +export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options" export interface OpenAIOptionsInput { readonly [key: string]: unknown @@ -15,6 +15,7 @@ export interface OpenAIOptionsInput { // native-SDK callers share one shape and no translation is required. readonly include?: ReadonlyArray readonly textVerbosity?: TextVerbosity + readonly serviceTier?: OpenAIServiceTier } export type OpenAIProviderOptionsInput = ProviderOptions & { @@ -33,6 +34,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide reasoningSummary: options?.reasoningSummary, include: options?.include, textVerbosity: options?.textVerbosity, + serviceTier: options?.serviceTier, }), ) if (Object.keys(openai).length === 0) return undefined diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index aee6454fd6f8..fa0855ff3112 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -57,6 +57,27 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("lowers semantic service tier options", () => + Effect.gen(function* () { + const input = LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "priority" } } }) + expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } }) + const prepared = yield* LLMClient.prepare(input) + + expect(prepared.body).toMatchObject({ service_tier: "priority" }) + expect(prepared.body).not.toHaveProperty("serviceTier") + }), + ) + + it.effect("omits unsupported semantic service tiers", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }), + ) + + expect(prepared.body).not.toHaveProperty("service_tier") + }), + ) + it.effect("flattens top-level object unions in function schemas", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare(