feat(ai,agent,coding-agent): move client identity and provider attribution into pi-ai

pi-ai gets a ClientIdentity request option and createModels() default that choose the User-Agent (detailed with version, short, or custom) and provider attribution headers for OpenRouter, NVIDIA NIM, and OpenCode. Attribution is off by default in pi-ai and on by default in pi-agent-core and pi-durable (HarnessOptions.identity). pi-coding-agent sets its identity from the telemetry setting instead of adding headers itself.
This commit is contained in:
Armin Ronacher 2026-10-21 12:17:35 +02:00 • committed by Mario Zechner
parent adfc14461a
commit adc5fa9f80
55 changed files with 958 additions and 465 deletions

View file

@ -31,6 +31,8 @@ export type ChatSetup = {
/** Live Harness settings; tests assign fields to change them between decisions. */
readonly settings: { -readonly [K in keyof HarnessSettings]: HarnessSettings[K] };
now: () => number;
/** `HarnessOptions.identity`. */
identity?: HarnessOptions["identity"];
};
export function chatSetup(options: RegisterFauxProviderOptions = {}): ChatSetup {
@ -53,6 +55,7 @@ export async function openChat(
models: setup.models,
registry: setup.registry,
settings: setup.settings,
...(setup.identity === undefined ? {} : { identity: setup.identity }),
...(options.env === undefined
? {}
: { env: typeof options.env === "function" ? options.env : () => options.env as ExecutionEnv }),

View file

@ -385,6 +385,24 @@ describe("generation", () => {
await harness.close(context);
});
it("attributes model requests to pi by default and merges the Harness identity over it", async () => {
const seen: (SimpleStreamOptions["identity"] | undefined)[] = [];
const capture = (_context: unknown, options?: SimpleStreamOptions) => {
seen.push(options?.identity);
return fauxAssistantMessage("ok");
};
for (const identity of [undefined, { userAgent: "short" as const, attribution: "acme" }]) {
const setup = chatSetup();
setup.identity = identity;
setup.faux.setResponses([capture]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
harness.resume();
await (await root.submit({ type: "input", content: "hi" }, context)).wait(context);
await harness.close(context);
}
expect(seen).toEqual([{ attribution: true }, { attribution: "acme", userAgent: "short" }]);
});
// Regression coverage for #10424.
it("keeps provider session IDs request-local across concurrent conversations", async () => {
const setup = chatSetup();

View file

@ -74,6 +74,8 @@ describe("HarnessOptions.models and settings as functions", () => {
]);
first.setProvider(faux.provider);
second.setProvider(faux.provider);
// Marks the second Models: tools see views of the host's Models that add the Harness identity.
second.setProvider(fauxProvider({ provider: "second-only" }).provider);
let current = first;
const seen: Models[] = [];
const registry = createRegistry();
@ -97,9 +99,8 @@ describe("HarnessOptions.models and settings as functions", () => {
);
const { harness, root } = await open({ models: () => current }, registry);
expect(await turn(root, "go")).toBe("done");
expect(seen).toEqual([first, second]);
expect(seen[0]).toBe(first);
expect(seen[1]).toBe(second);
expect(seen[0]!.getProvider("second-only")).toBeUndefined();
expect(seen[1]!.getProvider("second-only")).toBeDefined();
await harness.close(context);
});

View file

@ -8,6 +8,7 @@ import {
fauxAssistantMessage,
fauxText,
fauxToolCall,
type Models,
type SystemMessage,
type ToolResultMessage,
Type,
@ -130,8 +131,10 @@ describe("tool round", () => {
});
const { harness, status } = await run(setup, [calls(["echo", {}, "c1"]), DONE]);
expect(status).toBe("done");
expect(seen).toEqual([setup.models, setup.models]);
expect(seen.every((models) => models === setup.models)).toBe(true);
// One view of the host's Models that adds the Harness identity, shared by tools and hooks.
expect(seen).toHaveLength(2);
expect(seen[0]).toBe(seen[1]);
expect((seen[0] as Models).getProviders()).toEqual(setup.models.getProviders());
await harness.close(context);
});