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

This commit is contained in:
Kit Langton 2026-06-05 14:23:31 -04:00 committed by GitHub
commit 0bdd9aa494
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 525 additions and 48 deletions

View file

@ -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({

View file

@ -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) {

View file

@ -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 }
}

View file

@ -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: {

View file

@ -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"

View file

@ -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)
})
}

View file

@ -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 && {