Files
oh-my-pi/packages/ai/test/bedrock-on-payload.test.ts
T
ranxianglei 574a83f5f5 fix(pi-ai): honor onPayload replacement payloads in openai-completions, bedrock and cursor
The onPayload hook contract (README, docs/extensions.md) is that a non-undefined
return replaces the provider request payload, and every provider except these
three implements it (anthropic, openai-responses family, google, ollama — see
the earlier fix for the responses providers). openai-completions, amazon-bedrock
and cursor invoked the hook fire-and-forget and sent the original payload, so
extensions hooking before_provider_request could never transform the wire body
on these providers.

- openai-completions: await the hook and apply a non-undefined replacement to
  the params used for the request body, raw request dump and error-path
  fallback state
- amazon-bedrock: same for the ConverseStream command input
- cursor: await the hook for the AgentRunRequest; buildGrpcRequest becomes
  async and is exported for direct testing (transport is HTTP/2)
- devin-agent intentionally unchanged: it does not fire the hook at all (its
  payload is a protobuf object), which is a feature gap rather than a dropped
  replacement; documented in README/docs instead
- regression tests: captured wire body reflects async/sync replacement, and an
  undefined return keeps the original payload (completions + bedrock over a
  mocked fetch; cursor by decoding the serialized run request)
2026-08-16 19:22:49 +08:00

75 lines
2.9 KiB
TypeScript

// Regression: amazon-bedrock ignored the onPayload replacement return value
// (fire-and-forget), so the hook could never change the body actually sent
// upstream. The replacement contract matches anthropic / openai-responses /
// google: await the hook and use its non-undefined return as the request body.
import { describe, expect, it, vi } from "bun:test";
import { streamBedrock } from "@oh-my-pi/pi-ai/providers/amazon-bedrock";
import type { Context, Model } from "@oh-my-pi/pi-ai/types";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
function model(): Model<"bedrock-converse-stream"> {
return buildModel({
id: "us.anthropic.claude-haiku-4-5-20251001-v1:0",
name: "haiku",
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
reasoning: false,
input: ["text"],
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 1_000_000,
maxTokens: 128_000,
});
}
const context: Context = {
messages: [{ role: "user", content: "hi", timestamp: 0 }],
};
// Capture the serialized body the provider sends. The response is an empty
// event stream: the fetch (and thus the body capture) happens before any
// response parsing, and the stream's outcome is irrelevant to the assertion.
async function captureSentBody(onPayload: (payload: unknown) => unknown | Promise<unknown>): Promise<Record<string, any>> {
const { promise, resolve } = Promise.withResolvers<Record<string, any>>();
const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
const body = init?.body;
const text = body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body);
resolve(JSON.parse(text));
return new Response(
new ReadableStream<Uint8Array>({ start(controller) { controller.close(); } }),
{ status: 200, headers: { "content-type": "application/vnd.amazon.eventstream" } },
);
}) as unknown as typeof fetch;
const stream = streamBedrock(model(), context, { bearerToken: "test-token", fetch: fetchMock, onPayload });
void (async () => {
try {
for await (const _ of stream) {
// ignore events
}
} catch {
// empty event stream: stream errors are expected and irrelevant
}
})();
return promise;
}
describe("bedrock onPayload replacement", () => {
it("sends an async onPayload replacement body", async () => {
const body = await captureSentBody(async payload => ({
...(payload as Record<string, unknown>),
messages: [{ role: "user", content: [{ text: "replacement" }] }],
}));
expect(body.messages).toEqual([{ role: "user", content: [{ text: "replacement" }] }]);
expect(JSON.stringify(body.messages)).not.toContain("hi");
}, 10_000);
it("keeps the original body when onPayload returns undefined", async () => {
const body = await captureSentBody(async () => undefined);
expect(body.messages[0].content[0].text).toBe("hi");
}, 10_000);
});