feat(durable): beforeProviderRequest generation hook (#11619)

Adds a `beforeProviderRequest` generation hook so extensions can replace
the provider request body before it is sent. It is the durable
counterpart of coding-agent's `before_provider_request`.

Durable calls `streamSimple()`, so apps can't set provider-specific
fields such as Anthropic's `thinking.display: "updates"` (#10804)
without wrapping `Models`. Now it's an extension:

```ts
hook(GenerationTask, {
	beforeProviderRequest: ({ model, payload }) => {
		if (model.api !== "anthropic-messages") return undefined;
		// change payload
		return { payload };
	},
})
```
This commit is contained in:
David Brailovsky 2026-10-30 18:15:05 +01:00 • committed by GitHub
parent 3bbb06aad7
commit d280e1ffa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 109 additions and 2 deletions

View file

@ -752,6 +752,75 @@ describe("generation hooks", () => {
await harness.close(context);
});
it("chains beforeProviderRequest over every provider request body", async () => {
const setup = chatSetup();
const seen: { hook: string; model: string; payload: unknown }[] = [];
addHooks(setup.registry, GenerationTask, {
beforeProviderRequest: ({ model, payload }) => {
seen.push({ hook: "first", model: `${model.provider}/${model.id}`, payload });
return { payload: { ...(payload as object), first: true } };
},
});
addHooks(setup.registry, GenerationTask, {
beforeProviderRequest: ({ model, payload }) => {
seen.push({ hook: "keep", model: `${model.provider}/${model.id}`, payload });
return undefined;
},
});
addHooks(setup.registry, GenerationTask, {
beforeProviderRequest: ({ payload }) => ({ payload: { ...(payload as object), last: true } }),
});
const sent: unknown[] = [];
// pi-ai builds a body per provider call, and again when it resends within one attempt.
const respond: FauxResponseStep = async (_context, options, _state, model) => {
sent.push(await options!.onPayload!({ body: 1 }, model));
sent.push(await options!.onPayload!({ body: 2 }, model));
return DONE;
};
const { harness, status } = await run(setup, [respond]);
expect(status).toBe("done");
expect(sent).toEqual([
{ body: 1, first: true, last: true },
{ body: 2, first: true, last: true },
]);
expect(seen).toEqual([
{ hook: "first", model: "faux/faux-1", payload: { body: 1 } },
{ hook: "keep", model: "faux/faux-1", payload: { body: 1, first: true } },
{ hook: "first", model: "faux/faux-1", payload: { body: 2 } },
{ hook: "keep", model: "faux/faux-1", payload: { body: 2, first: true } },
]);
await harness.close(context);
});
it("reports a throwing beforeProviderRequest hook and continues the chain", async () => {
const setup = chatSetup();
addHooks(
setup.registry,
GenerationTask,
{
beforeProviderRequest: () => {
throw new Error("broken hook");
},
},
"broken",
);
addHooks(setup.registry, GenerationTask, {
beforeProviderRequest: ({ payload }) => ({ payload: { ...(payload as object), after: true } }),
});
let sent: unknown;
const respond: FauxResponseStep = async (_context, options, _state, model) => {
sent = await options!.onPayload!({ body: 1 }, model);
return DONE;
};
const { harness, status } = await run(setup, [respond]);
expect(status).toBe("done");
expect(sent).toEqual({ body: 1, after: true });
expect(setup.infos.filter((info) => info.kind === "extension_error")).toEqual([
expect.objectContaining({ extension: "broken" }),
]);
await harness.close(context);
});
it("keeps the run's input open across an onYield continuation and answers it with the final answer", async () => {
const setup = chatSetup();
let yields = 0;