fix(llm): narrow raw overlays (#34448)
This commit is contained in:
@@ -28,9 +28,56 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
|||||||
return next.toString()
|
return next.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([
|
||||||
|
"content",
|
||||||
|
"contents",
|
||||||
|
"frequencyPenalty",
|
||||||
|
"frequency_penalty",
|
||||||
|
"generationConfig",
|
||||||
|
"inferenceConfig",
|
||||||
|
"input",
|
||||||
|
"maxTokens",
|
||||||
|
"max_tokens",
|
||||||
|
"messages",
|
||||||
|
"model",
|
||||||
|
"presencePenalty",
|
||||||
|
"presence_penalty",
|
||||||
|
"responseFormat",
|
||||||
|
"response_format",
|
||||||
|
"seed",
|
||||||
|
"stop",
|
||||||
|
"stopSequences",
|
||||||
|
"stop_sequences",
|
||||||
|
"stream",
|
||||||
|
"streamOptions",
|
||||||
|
"stream_options",
|
||||||
|
"system",
|
||||||
|
"systemInstruction",
|
||||||
|
"system_instruction",
|
||||||
|
"temperature",
|
||||||
|
"thinking",
|
||||||
|
"toolChoice",
|
||||||
|
"toolConfig",
|
||||||
|
"tool_choice",
|
||||||
|
"tool_config",
|
||||||
|
"tools",
|
||||||
|
"topK",
|
||||||
|
"topP",
|
||||||
|
"top_k",
|
||||||
|
"top_p",
|
||||||
|
])
|
||||||
|
|
||||||
|
const forbiddenBodyOverlayKeys = (body: Record<string, unknown>) =>
|
||||||
|
Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
|
||||||
|
|
||||||
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
|
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
|
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
|
||||||
|
const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body)
|
||||||
|
if (forbiddenKeys.length > 0)
|
||||||
|
return yield* ProviderShared.invalidRequest(
|
||||||
|
`http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`,
|
||||||
|
)
|
||||||
if (ProviderShared.isRecord(body)) {
|
if (ProviderShared.isRecord(body)) {
|
||||||
const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
|
const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
|
||||||
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
|
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
|
||||||
|
|||||||
@@ -117,11 +117,9 @@ describe("request option precedence", () => {
|
|||||||
dynamicResponse((input) =>
|
dynamicResponse((input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
const url = new URL(web.url)
|
expect(web.url).toBe(
|
||||||
expect(url.searchParams.get("route")).toBe("1")
|
"https://api.openai.test/v1/chat/completions?route=1&shared=model&model=1&request=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("authorization")).toBe("Bearer fresh-key")
|
||||||
expect(web.headers.get("x-route")).toBe("route")
|
expect(web.headers.get("x-route")).toBe("route")
|
||||||
expect(web.headers.get("x-model")).toBe("model")
|
expect(web.headers.get("x-model")).toBe("model")
|
||||||
@@ -140,6 +138,26 @@ describe("request option precedence", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("rejects raw body overlays for protocol-owned roots", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const model = OpenAIChat.route
|
||||||
|
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||||
|
.model({ id: "gpt-4o-mini" })
|
||||||
|
const error = yield* LLMClient.prepare(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
prompt: "Say hello.",
|
||||||
|
http: { body: { model: "gpt-5", messages: [], tools: [] } },
|
||||||
|
}),
|
||||||
|
).pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(error.reason).toMatchObject({
|
||||||
|
_tag: "InvalidRequest",
|
||||||
|
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("uses model output limits after route limits and before call maxTokens", () =>
|
it.effect("uses model output limits after route limits and before call maxTokens", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const route = AnthropicMessages.route.with({
|
const route = AnthropicMessages.route.with({
|
||||||
|
|||||||
Reference in New Issue
Block a user