Shoubhit Dash 1 месяц назад
Родитель
Сommit
f7eeb08942
2 измененных файлов с 70 добавлено и 5 удалено
  1. 47 0
      packages/llm/src/route/transport/http.ts
  2. 23 5
      packages/llm/test/prepare.test.ts

+ 47 - 0
packages/llm/src/route/transport/http.ts

@@ -28,9 +28,56 @@ const applyQuery = (url: string, query: Record<string, string> | 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<string, unknown>) =>
+  Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
+
 const bodyWithOverlay = <Body>(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) }

+ 23 - 5
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({