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
|
|
@ -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 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
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"] },
|
||||
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).toMatchObject({
|
||||
headers: { "x-test": "header", "x-variant": "high" },
|
||||
http: { body: { store: false, reasoningEffort: "high" } },
|
||||
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.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" }),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue