fix(core): preserve model request semantics (#30990)
This commit is contained in:
parent
ca9bf7abf9
commit
0bdd9aa494
17 changed files with 525 additions and 48 deletions
|
|
@ -39,6 +39,13 @@ An expected temporary inability to observe a **Context Source** value; the runti
|
||||||
**Safe Provider-Turn Boundary**:
|
**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.
|
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
|
## Relationships
|
||||||
|
|
||||||
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export * as Catalog from "./catalog"
|
||||||
import { Context, Effect, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect"
|
import { Context, Effect, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect"
|
||||||
import { castDraft, enableMapSet, type Draft } from "immer"
|
import { castDraft, enableMapSet, type Draft } from "immer"
|
||||||
import { ModelV2 } from "./model"
|
import { ModelV2 } from "./model"
|
||||||
|
import { ModelRequest } from "./model-request"
|
||||||
import { PluginV2 } from "./plugin"
|
import { PluginV2 } from "./plugin"
|
||||||
import { ProviderV2 } from "./provider"
|
import { ProviderV2 } from "./provider"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
|
|
@ -106,14 +107,7 @@ export const layer = Layer.effect(
|
||||||
? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } }
|
? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } }
|
||||||
: model.api
|
: model.api
|
||||||
const request = {
|
const request = {
|
||||||
headers: {
|
...ModelRequest.merge({ ...provider.request, generation: {}, options: {} }, model.request),
|
||||||
...provider.request.headers,
|
|
||||||
...model.request.headers,
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
...provider.request.body,
|
|
||||||
...model.request.body,
|
|
||||||
},
|
|
||||||
variant: model.request.variant,
|
variant: model.request.variant,
|
||||||
}
|
}
|
||||||
return new ModelV2.Info({
|
return new ModelV2.Info({
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Effect } from "effect"
|
||||||
import { Catalog } from "../../catalog"
|
import { Catalog } from "../../catalog"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
|
import { ModelRequest } from "../../model-request"
|
||||||
import { PluginV2 } from "../../plugin"
|
import { PluginV2 } from "../../plugin"
|
||||||
import { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
|
|
||||||
|
|
@ -31,16 +32,19 @@ export const Plugin = PluginV2.define({
|
||||||
provider.enabled = { via: "custom", data: {} }
|
provider.enabled = { via: "custom", data: {} }
|
||||||
if (item.api !== undefined) provider.api = { ...item.api }
|
if (item.api !== undefined) provider.api = { ...item.api }
|
||||||
if (item.request !== undefined) {
|
if (item.request !== undefined) {
|
||||||
Object.assign(provider.request.headers, item.request.headers ?? {})
|
Object.assign(provider.request.headers, item.request.headers)
|
||||||
Object.assign(provider.request.body, item.request.body ?? {})
|
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 ?? {})) {
|
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||||
catalog.model.update(providerID, ModelV2.ID.make(id), (model) => {
|
catalog.model.update(providerID, ModelV2.ID.make(id), (model) => {
|
||||||
if (config.family !== undefined) model.family = config.family
|
if (config.family !== undefined) model.family = config.family
|
||||||
if (config.name !== undefined) model.name = config.name
|
if (config.name !== undefined) model.name = config.name
|
||||||
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
|
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
|
||||||
|
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
|
||||||
if (config.capabilities !== undefined) {
|
if (config.capabilities !== undefined) {
|
||||||
model.capabilities = {
|
model.capabilities = {
|
||||||
tools: config.capabilities.tools,
|
tools: config.capabilities.tools,
|
||||||
|
|
@ -49,8 +53,10 @@ export const Plugin = PluginV2.define({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (config.request !== undefined) {
|
if (config.request !== undefined) {
|
||||||
Object.assign(model.request.headers, config.request.headers ?? {})
|
ModelRequest.assign(model.request, {
|
||||||
Object.assign(model.request.body, config.request.body ?? {})
|
headers: config.request.headers,
|
||||||
|
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
|
||||||
|
})
|
||||||
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
||||||
}
|
}
|
||||||
if (config.variants !== undefined) {
|
if (config.variants !== undefined) {
|
||||||
|
|
@ -61,11 +67,15 @@ export const Plugin = PluginV2.define({
|
||||||
id: variant.id,
|
id: variant.id,
|
||||||
headers: {},
|
headers: {},
|
||||||
body: {},
|
body: {},
|
||||||
|
generation: {},
|
||||||
|
options: {},
|
||||||
}
|
}
|
||||||
model.variants.push(existing)
|
model.variants.push(existing)
|
||||||
}
|
}
|
||||||
Object.assign(existing.headers, variant.headers ?? {})
|
ModelRequest.assign(existing, {
|
||||||
Object.assign(existing.body, variant.body ?? {})
|
headers: variant.headers,
|
||||||
|
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (config.cost !== undefined) {
|
if (config.cost !== undefined) {
|
||||||
|
|
|
||||||
124
packages/core/src/model-request.ts
Normal file
124
packages/core/src/model-request.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { DateTime, Schema } from "effect"
|
import { DateTime, Schema } from "effect"
|
||||||
import { DateTimeUtcFromMillis } from "effect/Schema"
|
import { DateTimeUtcFromMillis } from "effect/Schema"
|
||||||
import { ProviderV2 } from "./provider"
|
import { ProviderV2 } from "./provider"
|
||||||
|
import { ModelRequest } from "./model-request"
|
||||||
|
|
||||||
export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID"))
|
export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID"))
|
||||||
export type ID = typeof ID.Type
|
export type ID = typeof ID.Type
|
||||||
|
|
@ -60,12 +61,12 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
||||||
api: Api,
|
api: Api,
|
||||||
capabilities: Capabilities,
|
capabilities: Capabilities,
|
||||||
request: Schema.Struct({
|
request: Schema.Struct({
|
||||||
...ProviderV2.Request.fields,
|
...ModelRequest.Request.fields,
|
||||||
variant: Schema.String.pipe(Schema.optional),
|
variant: Schema.String.pipe(Schema.optional),
|
||||||
}),
|
}),
|
||||||
variants: Schema.Struct({
|
variants: Schema.Struct({
|
||||||
id: VariantID,
|
id: VariantID,
|
||||||
...ProviderV2.Request.fields,
|
...ModelRequest.Request.fields,
|
||||||
}).pipe(Schema.Array),
|
}).pipe(Schema.Array),
|
||||||
time: Schema.Struct({
|
time: Schema.Struct({
|
||||||
released: DateTimeUtcFromMillis,
|
released: DateTimeUtcFromMillis,
|
||||||
|
|
@ -97,6 +98,8 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
||||||
request: {
|
request: {
|
||||||
headers: {},
|
headers: {},
|
||||||
body: {},
|
body: {},
|
||||||
|
generation: {},
|
||||||
|
options: {},
|
||||||
},
|
},
|
||||||
variants: [],
|
variants: [],
|
||||||
time: {
|
time: {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { DateTime, Effect, Scope, Stream } from "effect"
|
||||||
import { Catalog } from "../catalog"
|
import { Catalog } from "../catalog"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { ModelV2 } from "../model"
|
import { ModelV2 } from "../model"
|
||||||
|
import { ModelRequest } from "../model-request"
|
||||||
import { ModelsDev } from "../models-dev"
|
import { ModelsDev } from "../models-dev"
|
||||||
import { PluginV2 } from "../plugin"
|
import { PluginV2 } from "../plugin"
|
||||||
import { ProviderV2 } from "../provider"
|
import { ProviderV2 } from "../provider"
|
||||||
|
|
@ -38,12 +39,15 @@ function cost(input: ModelsDev.Model["cost"]) {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
function variants(model: ModelsDev.Model) {
|
function variants(model: ModelsDev.Model, packageName?: string) {
|
||||||
return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => ({
|
return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => {
|
||||||
id: ModelV2.VariantID.make(id),
|
const request = ModelRequest.normalizeAiSdkOptions(packageName, item.provider?.body ?? {})
|
||||||
headers: { ...(item.provider?.headers ?? {}) },
|
return {
|
||||||
body: { ...(item.provider?.body ?? {}) },
|
id: ModelV2.VariantID.make(id),
|
||||||
}))
|
headers: { ...(item.provider?.headers ?? {}) },
|
||||||
|
...request,
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ModelsDevPlugin = PluginV2.define({
|
export const ModelsDevPlugin = PluginV2.define({
|
||||||
|
|
@ -98,7 +102,7 @@ export const ModelsDevPlugin = PluginV2.define({
|
||||||
input: [...(model.modalities?.input ?? [])],
|
input: [...(model.modalities?.input ?? [])],
|
||||||
output: [...(model.modalities?.output ?? [])],
|
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.time.released = released(model.release_date)
|
||||||
draft.cost = cost(model.cost)
|
draft.cost = cost(model.cost)
|
||||||
draft.status = model.status ?? "active"
|
draft.status = model.status ?? "active"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||||
import { produce } from "immer"
|
import { produce } from "immer"
|
||||||
import { Catalog } from "../../catalog"
|
import { Catalog } from "../../catalog"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
|
import { ModelRequest } from "../../model-request"
|
||||||
import { PluginBoot } from "../../plugin/boot"
|
import { PluginBoot } from "../../plugin/boot"
|
||||||
import { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
import { SessionSchema } from "../schema"
|
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
|
return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const withDefaults = (model: ModelV2.Info, route: AnyRoute) =>
|
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
||||||
route.with({
|
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,
|
provider: model.providerID,
|
||||||
endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url },
|
endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url },
|
||||||
headers: model.request.headers,
|
headers: model.request.headers,
|
||||||
http: {
|
generation: model.request.generation,
|
||||||
body: Object.fromEntries(Object.entries(model.request.body).filter(([key]) => key !== "apiKey")),
|
providerOptions: namespace && Object.keys(options).length > 0 ? { [namespace]: options } : undefined,
|
||||||
},
|
http: { body: httpBody },
|
||||||
limits: { context: model.limit.context, output: model.limit.output },
|
limits: { context: model.limit.context, output: model.limit.output },
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => {
|
const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => {
|
||||||
const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID
|
const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID
|
||||||
const variant = model.variants.find((item) => item.id === id)
|
const variant = model.variants.find((item) => item.id === id)
|
||||||
if (!variant) return model
|
if (!variant) return model
|
||||||
return produce(model, (draft) => {
|
return produce(model, (draft) => {
|
||||||
Object.assign(draft.request.headers, variant.headers)
|
ModelRequest.assign(draft.request, variant)
|
||||||
Object.assign(draft.request.body, variant.body)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { ConfigMCPV1 } from "./mcp"
|
||||||
import { ConfigPermissionV1 } from "./permission"
|
import { ConfigPermissionV1 } from "./permission"
|
||||||
import { ConfigProviderV1 } from "./provider"
|
import { ConfigProviderV1 } from "./provider"
|
||||||
import { ConfigProviderOptionsV1 } from "./provider-options"
|
import { ConfigProviderOptionsV1 } from "./provider-options"
|
||||||
|
import { ModelRequest } from "../../model-request"
|
||||||
|
|
||||||
const keys = new Set([
|
const keys = new Set([
|
||||||
"logLevel",
|
"logLevel",
|
||||||
|
|
@ -182,6 +183,13 @@ function migrateProvider(info: ConfigProviderV1.Info) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: string) {
|
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 && [
|
const costs = info.cost && [
|
||||||
{
|
{
|
||||||
input: info.cost.input,
|
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
|
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 ?? [] }
|
? { tools: info.tool_call ?? false, input: info.modalities?.input ?? [], output: info.modalities?.output ?? [] }
|
||||||
: undefined
|
: undefined
|
||||||
const lowerer = ConfigProviderOptionsV1.get(info.provider?.npm ?? packageName)
|
|
||||||
return {
|
return {
|
||||||
family: info.family,
|
family: info.family,
|
||||||
name: info.name,
|
name: info.name,
|
||||||
|
|
@ -219,12 +226,16 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st
|
||||||
? undefined
|
? undefined
|
||||||
: { id: info.id },
|
: { id: info.id },
|
||||||
capabilities,
|
capabilities,
|
||||||
request: (info.headers || info.options) && {
|
request: (info.headers || request) && {
|
||||||
headers: info.headers,
|
headers: info.headers,
|
||||||
body: info.options && lowerer.request(info.options),
|
body: request,
|
||||||
},
|
},
|
||||||
variants:
|
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,
|
cost: costs,
|
||||||
disabled: info.status === "deprecated" ? true : undefined,
|
disabled: info.status === "deprecated" ? true : undefined,
|
||||||
limit: info.limit && {
|
limit: info.limit && {
|
||||||
|
|
|
||||||
|
|
@ -219,12 +219,16 @@ describe("CatalogV2", () => {
|
||||||
model.request.headers.shared = "model"
|
model.request.headers.shared = "model"
|
||||||
model.request.body.model = true
|
model.request.body.model = true
|
||||||
model.request.body.request = 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)
|
const model = yield* catalog.model.get(providerID, modelID)
|
||||||
expect(model.request.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
|
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.body).toEqual({ provider: true, model: true, request: true })
|
||||||
|
expect(model.request.options).toEqual({ shared: "model", model: true })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -479,7 +479,22 @@ describe("Config", () => {
|
||||||
npm: "@ai-sdk/openai",
|
npm: "@ai-sdk/openai",
|
||||||
options: { apiKey: "secret", organization: "org" },
|
options: { apiKey: "secret", organization: "org" },
|
||||||
models: {
|
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({
|
expect(documents[0]?.info.providers?.openai).toMatchObject({
|
||||||
api: { settings: {} },
|
api: { settings: {} },
|
||||||
request: { headers: { Authorization: "Bearer secret", "OpenAI-Organization": "org" } },
|
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({
|
expect(documents[0]?.info.compaction).toEqual({
|
||||||
auto: true,
|
auto: true,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,118 @@ function request(headers: Record<string, string>, variant?: string) {
|
||||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||||
|
|
||||||
describe("ConfigProviderPlugin.Plugin", () => {
|
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", () =>
|
it.effect("loads configured providers and applies later model overrides", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
|
|
|
||||||
44
packages/core/test/model-request.test.ts
Normal file
44
packages/core/test/model-request.test.ts
Normal file
|
|
@ -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: {},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -29,7 +29,9 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
|
||||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||||
request: {
|
request: {
|
||||||
headers: { "x-test": "header" },
|
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,
|
variants,
|
||||||
time: { released: DateTime.makeUnsafe(0) },
|
time: { released: DateTime.makeUnsafe(0) },
|
||||||
|
|
@ -63,7 +65,9 @@ describe("SessionRunnerModel", () => {
|
||||||
defaults: {
|
defaults: {
|
||||||
headers: { "x-test": "header" },
|
headers: { "x-test": "header" },
|
||||||
limits: { context: 100, output: 20 },
|
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",
|
url: "https://compatible.example/v1",
|
||||||
settings: { apiKey: "settings-secret", compatibility: "strict" },
|
settings: { apiKey: "settings-secret", compatibility: "strict" },
|
||||||
}),
|
}),
|
||||||
request: { headers: {}, body: {} },
|
request: { headers: {}, body: {}, generation: {}, options: {} },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
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* () {
|
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"),
|
id: ModelV2.VariantID.make("high"),
|
||||||
headers: { "x-variant": "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({
|
const session = SessionV2.Info.make({
|
||||||
id: SessionV2.ID.make("ses_model_variant"),
|
id: SessionV2.ID.make("ses_model_variant"),
|
||||||
projectID: ProjectV2.ID.global,
|
projectID: ProjectV2.ID.global,
|
||||||
|
|
@ -133,11 +143,87 @@ describe("SessionRunnerModel", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
|
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
|
||||||
|
const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||||
|
|
||||||
expect(resolved.route.defaults).toMatchObject({
|
expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
|
||||||
headers: { "x-test": "header", "x-variant": "high" },
|
expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } })
|
||||||
http: { body: { store: false, reasoningEffort: "high" } },
|
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.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(
|
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
new ModelV2.Info({
|
new ModelV2.Info({
|
||||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
...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" }),
|
provider({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,7 @@ const OpenAIResponsesCoreFields = {
|
||||||
tools: optionalArray(OpenAIResponsesTool),
|
tools: optionalArray(OpenAIResponsesTool),
|
||||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||||
store: Schema.optional(Schema.Boolean),
|
store: Schema.optional(Schema.Boolean),
|
||||||
|
service_tier: Schema.optional(OpenAIOptions.OpenAIServiceTier),
|
||||||
prompt_cache_key: Schema.optional(Schema.String),
|
prompt_cache_key: Schema.optional(Schema.String),
|
||||||
include: optionalArray(OpenAIOptions.OpenAIResponseIncludable),
|
include: optionalArray(OpenAIOptions.OpenAIResponseIncludable),
|
||||||
reasoning: Schema.optional(
|
reasoning: Schema.optional(
|
||||||
|
|
@ -447,6 +448,7 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
|
||||||
const include = OpenAIOptions.include(request)
|
const include = OpenAIOptions.include(request)
|
||||||
const verbosity = OpenAIOptions.textVerbosity(request)
|
const verbosity = OpenAIOptions.textVerbosity(request)
|
||||||
const instructions = OpenAIOptions.instructions(request)
|
const instructions = OpenAIOptions.instructions(request)
|
||||||
|
const serviceTier = OpenAIOptions.serviceTier(request)
|
||||||
return {
|
return {
|
||||||
...(instructions ? { instructions } : {}),
|
...(instructions ? { instructions } : {}),
|
||||||
...(store !== undefined ? { store } : {}),
|
...(store !== undefined ? { store } : {}),
|
||||||
|
|
@ -454,6 +456,7 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
|
||||||
...(include ? { include } : {}),
|
...(include ? { include } : {}),
|
||||||
...(effort || summary ? { reasoning: { effort, summary } } : {}),
|
...(effort || summary ? { reasoning: { effort, summary } } : {}),
|
||||||
...(verbosity ? { text: { verbosity } } : {}),
|
...(verbosity ? { text: { verbosity } } : {}),
|
||||||
|
...(serviceTier ? { service_tier: serviceTier } : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,19 @@ export const OpenAIResponseIncludables = [
|
||||||
"message.output_text.logprobs",
|
"message.output_text.logprobs",
|
||||||
] as const
|
] as const
|
||||||
export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number]
|
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 REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
|
||||||
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
|
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
|
||||||
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
|
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
|
||||||
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
|
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
|
||||||
|
const SERVICE_TIERS = new Set<string>(OpenAIServiceTiers)
|
||||||
|
|
||||||
export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
|
export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
|
||||||
export const OpenAITextVerbosity = TextVerbosity
|
export const OpenAITextVerbosity = TextVerbosity
|
||||||
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
|
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
|
||||||
|
export const OpenAIServiceTier = Schema.Literals(OpenAIServiceTiers)
|
||||||
|
|
||||||
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
|
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
|
||||||
typeof effort === "string" && REASONING_EFFORTS.has(effort)
|
typeof effort === "string" && REASONING_EFFORTS.has(effort)
|
||||||
|
|
@ -76,6 +80,11 @@ export const textVerbosity = (request: LLMRequest) => {
|
||||||
return isTextVerbosity(value) ? value : undefined
|
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) => {
|
export const instructions = (request: LLMRequest) => {
|
||||||
const value = options(request)?.instructions
|
const value = options(request)?.instructions
|
||||||
return typeof value === "string" ? value : undefined
|
return typeof value === "string" ? value : undefined
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
|
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
|
||||||
import { mergeProviderOptions } 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 {
|
export interface OpenAIOptionsInput {
|
||||||
readonly [key: string]: unknown
|
readonly [key: string]: unknown
|
||||||
|
|
@ -15,6 +15,7 @@ export interface OpenAIOptionsInput {
|
||||||
// native-SDK callers share one shape and no translation is required.
|
// native-SDK callers share one shape and no translation is required.
|
||||||
readonly include?: ReadonlyArray<OpenAIResponseIncludable>
|
readonly include?: ReadonlyArray<OpenAIResponseIncludable>
|
||||||
readonly textVerbosity?: TextVerbosity
|
readonly textVerbosity?: TextVerbosity
|
||||||
|
readonly serviceTier?: OpenAIServiceTier
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpenAIProviderOptionsInput = ProviderOptions & {
|
export type OpenAIProviderOptionsInput = ProviderOptions & {
|
||||||
|
|
@ -33,6 +34,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
|
||||||
reasoningSummary: options?.reasoningSummary,
|
reasoningSummary: options?.reasoningSummary,
|
||||||
include: options?.include,
|
include: options?.include,
|
||||||
textVerbosity: options?.textVerbosity,
|
textVerbosity: options?.textVerbosity,
|
||||||
|
serviceTier: options?.serviceTier,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (Object.keys(openai).length === 0) return undefined
|
if (Object.keys(openai).length === 0) return undefined
|
||||||
|
|
|
||||||
|
|
@ -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", () =>
|
it.effect("flattens top-level object unions in function schemas", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue