feat(llm): enforce request precedence (#34440)
This commit is contained in:
@@ -504,6 +504,7 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re
|
|||||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||||
const generation = request.generation
|
const generation = request.generation
|
||||||
|
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
|
||||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||||
// messages. Tools live highest in the cache hierarchy, so when callers
|
// messages. Tools live highest in the cache hierarchy, so when callers
|
||||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||||
@@ -533,7 +534,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
|||||||
tools,
|
tools,
|
||||||
tool_choice: toolChoice,
|
tool_choice: toolChoice,
|
||||||
stream: true as const,
|
stream: true as const,
|
||||||
max_tokens: generation?.maxTokens ?? request.model.route.defaults.limits?.output ?? 4096,
|
max_tokens: generation?.maxTokens ?? outputLimit,
|
||||||
temperature: generation?.temperature,
|
temperature: generation?.temperature,
|
||||||
top_p: generation?.topP,
|
top_p: generation?.topP,
|
||||||
top_k: generation?.topK,
|
top_k: generation?.topK,
|
||||||
|
|||||||
@@ -164,13 +164,16 @@ export interface GenerateMethod {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||||
|
|
||||||
const resolveRequestOptions = (request: LLMRequest) =>
|
const resolveRequestOptions = (request: LLMRequest) => {
|
||||||
LLMRequest.update(request, {
|
const routeDefaults = request.model.route.defaults
|
||||||
generation:
|
const modelDefaults = request.model.defaults
|
||||||
mergeGenerationOptions(request.model.route.defaults.generation, request.generation) ?? new GenerationOptions({}),
|
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation)
|
||||||
providerOptions: mergeProviderOptions(request.model.route.defaults.providerOptions, request.providerOptions),
|
return LLMRequest.update(request, {
|
||||||
http: mergeHttpOptions(request.model.route.defaults.http, request.http),
|
generation: generation ?? new GenerationOptions({}),
|
||||||
|
providerOptions: mergeProviderOptions(routeDefaults.providerOptions, modelDefaults?.providerOptions, request.providerOptions),
|
||||||
|
http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export interface MakeInput<Body, Frame, Event, State> {
|
export interface MakeInput<Body, Frame, Event, State> {
|
||||||
/** Route id used in diagnostics and prepared request metadata. */
|
/** Route id used in diagnostics and prepared request metadata. */
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { LLM, mergeProviderOptions } from "../src"
|
||||||
|
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
|
||||||
|
import { Auth, LLMClient } from "../src/route"
|
||||||
|
import { it } from "./lib/effect"
|
||||||
|
import { dynamicResponse } from "./lib/http"
|
||||||
|
import { deltaChunk } from "./lib/openai-chunks"
|
||||||
|
import { sseEvents } from "./lib/sse"
|
||||||
|
|
||||||
|
const TargetJson = Schema.fromJsonString(Schema.Unknown)
|
||||||
|
const decodeJson = Schema.decodeUnknownSync(TargetJson)
|
||||||
|
|
||||||
|
describe("request option precedence", () => {
|
||||||
|
test("deep-merges provider option records and replaces arrays, primitives, and null", () => {
|
||||||
|
const merged = mergeProviderOptions(
|
||||||
|
{
|
||||||
|
openai: {
|
||||||
|
include: ["route"],
|
||||||
|
metadata: { route: true, shared: "route" },
|
||||||
|
nullable: "route",
|
||||||
|
primitive: "route",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
openai: {
|
||||||
|
include: ["model"],
|
||||||
|
metadata: { model: true, shared: "model" },
|
||||||
|
nullable: null,
|
||||||
|
primitive: "model",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ openai: { metadata: { request: true }, primitive: false } },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(merged).toEqual({
|
||||||
|
openai: {
|
||||||
|
include: ["model"],
|
||||||
|
metadata: { route: true, model: true, request: true, shared: "model" },
|
||||||
|
nullable: null,
|
||||||
|
primitive: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.effect("prepares bodies with route defaults, model defaults, and call options in order", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const route = OpenAIChat.route.with({
|
||||||
|
endpoint: { baseURL: "https://api.openai.test/v1/" },
|
||||||
|
auth: Auth.bearer("test"),
|
||||||
|
generation: { maxTokens: 10, temperature: 1, stop: ["route"] },
|
||||||
|
providerOptions: { openai: { store: false, reasoningEffort: "low" } },
|
||||||
|
})
|
||||||
|
const model = route.model({
|
||||||
|
id: "gpt-4o-mini",
|
||||||
|
defaults: {
|
||||||
|
generation: { maxTokens: 20, temperature: 0.5, frequencyPenalty: 0.25, stop: ["model"] },
|
||||||
|
providerOptions: { openai: { reasoningEffort: "medium" } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
prompt: "Say hello.",
|
||||||
|
generation: { maxTokens: 30, topP: 0.9, stop: ["request"] },
|
||||||
|
providerOptions: { openai: { store: true } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body).toMatchObject({
|
||||||
|
model: "gpt-4o-mini",
|
||||||
|
stream: true,
|
||||||
|
max_tokens: 30,
|
||||||
|
temperature: 0.5,
|
||||||
|
top_p: 0.9,
|
||||||
|
frequency_penalty: 0.25,
|
||||||
|
store: true,
|
||||||
|
reasoning_effort: "medium",
|
||||||
|
})
|
||||||
|
expect(prepared.body.stop).toEqual(["request"])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("applies model HTTP defaults before request HTTP overlays", () =>
|
||||||
|
LLMClient.generate(
|
||||||
|
LLM.request({
|
||||||
|
model: OpenAIChat.route
|
||||||
|
.with({
|
||||||
|
endpoint: { baseURL: "https://api.openai.test/v1/" },
|
||||||
|
auth: Auth.bearer("fresh-key"),
|
||||||
|
http: {
|
||||||
|
body: { metadata: { route: true, shared: "route" }, value: "route" },
|
||||||
|
headers: { "x-route": "route", "x-shared": "route" },
|
||||||
|
query: { route: "1", shared: "route" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.model({
|
||||||
|
id: "gpt-4o-mini",
|
||||||
|
defaults: {
|
||||||
|
http: {
|
||||||
|
body: { metadata: { model: true, shared: "model" }, value: "model" },
|
||||||
|
headers: { "x-model": "model", "x-shared": "model" },
|
||||||
|
query: { model: "1", shared: "model" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prompt: "Say hello.",
|
||||||
|
http: {
|
||||||
|
body: { metadata: { request: true }, value: null },
|
||||||
|
headers: { "x-request": "request" },
|
||||||
|
query: { request: "1" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
const url = new URL(web.url)
|
||||||
|
expect(url.searchParams.get("route")).toBe("1")
|
||||||
|
expect(url.searchParams.get("model")).toBe("1")
|
||||||
|
expect(url.searchParams.get("request")).toBe("1")
|
||||||
|
expect(url.searchParams.get("shared")).toBe("model")
|
||||||
|
expect(web.headers.get("authorization")).toBe("Bearer fresh-key")
|
||||||
|
expect(web.headers.get("x-route")).toBe("route")
|
||||||
|
expect(web.headers.get("x-model")).toBe("model")
|
||||||
|
expect(web.headers.get("x-request")).toBe("request")
|
||||||
|
expect(web.headers.get("x-shared")).toBe("model")
|
||||||
|
expect(decodeJson(input.text)).toMatchObject({
|
||||||
|
metadata: { route: true, model: true, request: true, shared: "model" },
|
||||||
|
value: null,
|
||||||
|
})
|
||||||
|
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("uses model output limits after route limits and before call maxTokens", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const route = AnthropicMessages.route.with({
|
||||||
|
endpoint: { baseURL: "https://api.anthropic.test/v1/" },
|
||||||
|
auth: Auth.header("x-api-key", "test"),
|
||||||
|
limits: { output: 128 },
|
||||||
|
})
|
||||||
|
const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } })
|
||||||
|
const withoutMaxTokens = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||||
|
LLM.request({ model, prompt: "Say hello.", cache: "none" }),
|
||||||
|
)
|
||||||
|
const withMaxTokens = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||||
|
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(withoutMaxTokens.body.max_tokens).toBe(64)
|
||||||
|
expect(withMaxTokens.body.max_tokens).toBe(32)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user