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

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added `AgentOptions.identity` and `Agent.identity` (a pi-ai `ClientIdentity`). Agent loop requests use `DEFAULT_AGENT_IDENTITY` (`{ attribution: true }`) under it, so OpenRouter, NVIDIA NIM, and OpenCode requests are attributed to pi by default; pass `{ attribution: false }` to send no attribution headers.
## [1.3.0] - 2026-10-15
## [1.2.0] - 2026-10-12

View file

@ -232,6 +232,11 @@ const agent = new Agent({
// Session ID for provider caching
sessionId: "session-123",
// How requests identify the app to providers (pi-ai ClientIdentity), merged over
// DEFAULT_AGENT_IDENTITY ({ attribution: true }: provider attribution headers name pi).
// Pass a name to attribute requests to your app, or false to send none.
identity: { attribution: "my-app" },
// Dynamic API key resolution (for expiring OAuth tokens)
getApiKey: async (provider) => refreshToken(),

View file

@ -5,9 +5,11 @@
import {
type AssistantMessage,
type ClientIdentity,
EventStream,
getCurrentTools,
getToolStateChanges,
mergeClientIdentity,
normalizeContext,
type SystemMessage,
type ToolResultMessage,
@ -31,6 +33,12 @@ import type {
export type AgentEventSink = (event: AgentEvent) => Promise<void> | void;
/**
* Request identity of agent loops: pi-ai's defaults with provider attribution to pi. `AgentLoopConfig.identity`
* overrides it field by field.
*/
export const DEFAULT_AGENT_IDENTITY: ClientIdentity = { attribution: true };
/**
* Start an agent loop with a new prompt message.
* The prompt is added to the context and events are emitted for it.
@ -403,6 +411,7 @@ async function streamAssistantResponse(
const response = await streamFunction(config.model, llmContext, {
...config,
apiKey: resolvedApiKey,
identity: mergeClientIdentity(DEFAULT_AGENT_IDENTITY, config.identity),
signal,
});
// Record the requested level, whichever stream function answered.

View file

@ -1,4 +1,5 @@
import {
type ClientIdentity,
createInitialSystemMessage,
getCurrentSystemMessage,
getCurrentSystemPrompt,
@ -138,6 +139,11 @@ export interface AgentOptions {
transport?: Transport;
maxRetryDelayMs?: number;
toolExecution?: ToolExecutionMode;
/**
* How requests identify the app to providers, merged field by field over `DEFAULT_AGENT_IDENTITY`
* (`{ attribution: true }`). Pass `{ attribution: false }` to send no attribution headers.
*/
identity?: ClientIdentity;
}
class PendingMessageQueue {
@ -226,6 +232,8 @@ export class Agent {
public maxRetryDelayMs?: number;
/** Tool execution strategy for assistant messages that contain multiple tool calls. */
public toolExecution: ToolExecutionMode;
/** Request identity forwarded to the stream function, over `DEFAULT_AGENT_IDENTITY`. */
public identity?: ClientIdentity;
constructor(options: AgentOptions) {
// Older compiled consumers may omit options or streamFn even though the current API requires them.
@ -251,6 +259,7 @@ export class Agent {
this.transport = runtimeOptions.transport ?? "auto";
this.maxRetryDelayMs = runtimeOptions.maxRetryDelayMs;
this.toolExecution = runtimeOptions.toolExecution ?? "parallel";
this.identity = runtimeOptions.identity;
}
/**
@ -476,6 +485,7 @@ export class Agent {
transport: this.transport,
thinkingBudgets: this.thinkingBudgets,
maxRetryDelayMs: this.maxRetryDelayMs,
identity: this.identity,
toolExecution: this.toolExecution,
beforeToolCall: this.beforeToolCall,
afterToolCall: this.afterToolCall,

View file

@ -1,3 +1,4 @@
import type { ClientIdentity } from "@earendil-works/pi-ai";
import {
type AssistantMessage,
createAssistantMessageEventStream,
@ -1198,4 +1199,30 @@ describe("Agent", () => {
await agent.prompt("hello again");
expect(receivedSessionId).toBe("session-def");
});
it("attributes requests to pi by default and merges a configured identity over it", async () => {
const received: (ClientIdentity | undefined)[] = [];
const agent = new Agent({
streamFn: (_model, _context, options) => {
received.push(options?.identity);
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") });
});
return stream;
},
});
await agent.prompt("hello");
agent.identity = { userAgent: "short", attribution: "acme" };
await agent.prompt("hello again");
agent.identity = { userAgent: "short" };
await agent.prompt("and again");
expect(received).toEqual([
{ attribution: true },
{ attribution: "acme", userAgent: "short" },
{ attribution: true, userAgent: "short" },
]);
});
});