Selaa lähdekoodia

fix(core): preserve model request semantics (#30990)

Kit Langton 3 kuukautta sitten
vanhempi
commit
0bdd9aa494

+ 9 - 0
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.

+ 2 - 8
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({

+ 16 - 6
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) {

+ 124 - 0
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<string, string>
+  body: Record<string, unknown>
+  generation?: Generation
+  options?: Record<string, unknown>
+}
+
+const generationKeys = new Map<string, keyof Generation>([
+  ["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<string, string>
+}
+
+const profiles = new Map<string, Profile>([
+  [
+    "@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<Request>) => ({
+  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<Request>) => {
+  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<Record<string, unknown>>) {
+  const generation: Record<string, number | ReadonlyArray<string>> = {}
+  const options: Record<string, unknown> = {}
+  const body: Record<string, unknown> = {}
+  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 }
+}

+ 5 - 2
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<Info>("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<Info>("ModelV2.Info")({
       request: {
         headers: {},
         body: {},
+        generation: {},
+        options: {},
       },
       variants: [],
       time: {

+ 11 - 7
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"

+ 14 - 7
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)
   })
 }
 

+ 15 - 4
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",
@@ -182,6 +183,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<Record<string, unknown>>) => {
+    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,
@@ -203,7 +211,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,
@@ -219,12 +226,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 && {

+ 4 - 0
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 })
     }),
   )
 

+ 36 - 2
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,

+ 112 - 0
packages/core/test/config/provider.test.ts

@@ -18,6 +18,118 @@ function request(headers: Record<string, string>, 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

+ 44 - 0
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: {},
+    })
+  })
+})

+ 96 - 10
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" }),
       )

+ 3 - 0
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 } : {}),
   }
 })
 

+ 9 - 0
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<string>(ReasoningEfforts)
 const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
 const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
 const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
+const SERVICE_TIERS = new Set<string>(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

+ 4 - 2
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<OpenAIResponseIncludable>
   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

+ 21 - 0
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<OpenAIResponses.OpenAIResponsesBody>(