diff --git a/packages/llm/src/route/transport/http.ts b/packages/llm/src/route/transport/http.ts index 00508957a76..acc52c6ea17 100644 --- a/packages/llm/src/route/transport/http.ts +++ b/packages/llm/src/route/transport/http.ts @@ -28,9 +28,56 @@ const applyQuery = (url: string, query: Record | undefined) => { 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) => + Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key)) + const bodyWithOverlay = (body: Body, request: LLMRequest, encodeBody: (body: Body) => string) => Effect.gen(function* () { 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)) { const overlaid = mergeJsonRecords(body, request.http.body) ?? {} return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) } diff --git a/packages/llm/test/prepare.test.ts b/packages/llm/test/prepare.test.ts index d6006095f4e..312802a9a05 100644 --- a/packages/llm/test/prepare.test.ts +++ b/packages/llm/test/prepare.test.ts @@ -117,11 +117,9 @@ describe("request option precedence", () => { 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.url).toBe( + "https://api.openai.test/v1/chat/completions?route=1&shared=model&model=1&request=1", + ) 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") @@ -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", () => Effect.gen(function* () { const route = AnthropicMessages.route.with({