fix(ai,agent,coding-agent,durable): client identity follow-ups

- ClientAttribution is { name, url, categories? } with required name and url, so
  OpenRouter always gets an HTTP-Referer. identity.ts maps the fields to each
  provider's headers; NVIDIA gets the name with its first letter upper-cased.
  attribution: null turns it off. PI_ATTRIBUTION is pi's, including the
  cli-agent category; agent-core, durable, and the coding agent use it.
- Categories are a string array, joined for OpenRouter.
- withClientIdentity() is a typed wrapper instead of a Proxy and keeps a
  MutableModels mutable. Durable no longer caches wrappers in a WeakMap.
- The Codex originator stays pi; login agentName no longer follows attribution.
- Models and the coding agent's ModelRuntime add x-opencode-session for custom
  providers pointing at OpenCode, replacing the coding agent's transformHeaders.
This commit is contained in:
Mario Zechner 2026-10-21 11:18:16 +00:00
parent adc5fa9f80
commit 04a93bad8f
20 changed files with 236 additions and 194 deletions

View file

@ -4,7 +4,7 @@
### 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.
- Added `AgentOptions.identity` and `Agent.identity` (a pi-ai `ClientIdentity`) to set the `User-Agent` and provider attribution of agent loop requests.
## [1.3.0] - 2026-10-15

View file

@ -233,9 +233,9 @@ const agent = new Agent({
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" },
// DEFAULT_AGENT_IDENTITY ({ attribution: PI_ATTRIBUTION }: provider attribution headers name pi).
// Pass your app's attribution, or null to send none.
identity: { attribution: { name: "my-app", url: "https://my-app.example" } },
// Dynamic API key resolution (for expiring OAuth tokens)
getApiKey: async (provider) => refreshToken(),

View file

@ -11,6 +11,7 @@ import {
getToolStateChanges,
mergeClientIdentity,
normalizeContext,
PI_ATTRIBUTION,
type SystemMessage,
type ToolResultMessage,
type ToolStateChanges,
@ -37,7 +38,7 @@ 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 };
export const DEFAULT_AGENT_IDENTITY: ClientIdentity = { attribution: PI_ATTRIBUTION };
/**
* Start an agent loop with a new prompt message.

View file

@ -141,7 +141,7 @@ export interface AgentOptions {
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.
* (`{ attribution: PI_ATTRIBUTION }`). Pass `{ attribution: null }` to send no attribution headers.
*/
identity?: ClientIdentity;
}

View file

@ -1,4 +1,4 @@
import type { ClientIdentity } from "@earendil-works/pi-ai";
import { type ClientIdentity, PI_ATTRIBUTION } from "@earendil-works/pi-ai";
import {
type AssistantMessage,
createAssistantMessageEventStream,
@ -1214,15 +1214,16 @@ describe("Agent", () => {
});
await agent.prompt("hello");
agent.identity = { userAgent: "short", attribution: "acme" };
const acme = { name: "acme", url: "https://acme.example" };
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" },
{ attribution: PI_ATTRIBUTION },
{ attribution: acme, userAgent: "short" },
{ attribution: PI_ATTRIBUTION, userAgent: "short" },
]);
});
});

View file

@ -10,8 +10,9 @@
### Added
- Added client identity configuration: an `identity` request option and `createModels({ identity })` default choose the `User-Agent` (`"detailed"` with a version, `"short"`, or custom) and provider attribution headers for OpenRouter, NVIDIA NIM, and OpenCode (off by default; `true` attributes to pi, a string or `{ name, url, categories }` to your app). The attribution name is also the Codex `originator` and the default login `agentName`. `withClientIdentity()` applies an identity to a `Models` a library receives from its host. `formatUserAgent()`, `getIdentityHeaders()`, `mergeClientIdentity()`, and `PI_AI_VERSION` are exported.
- Added client identity configuration: an `identity` request option and `createModels({ identity })` default choose the `User-Agent` (`"detailed"` with a version, `"short"`, or custom) and provider attribution headers for OpenRouter, NVIDIA NIM, and OpenCode from `attribution: { name, url, categories? }`. `withClientIdentity()` applies an identity to a `Models` or `MutableModels` a library receives from its host. `formatUserAgent()`, `getIdentityHeaders()`, `getOpenCodeSessionHeaders()`, `mergeClientIdentity()`, `PI_ATTRIBUTION`, and `PI_AI_VERSION` are exported.
- Bedrock, OpenRouter image generation, pi-messages, and classifier requests now send pi's `User-Agent` too.
- `Models` requests with a `sessionId` to custom providers pointing at OpenCode (`opencode.ai`) now send the `x-opencode-session` header too, like the built-in OpenCode providers.
- Added `AI_TELEMETRY_SCHEMA` and opt-in `pi.ai.request`/`pi.ai.poll` tracing around Models requests, with content-free first-event, block-size, and final usage attributes, plus duration, token, and USD cost metrics through `telemetry.metrics`.
- Failed model request spans report `provider_error` status with up to 500 error-message characters; cancelled requests keep `ok` status with `stop: "aborted"`.
- Added `MiniMax-M3.1-Flash` to the `minimax` and `minimax-cn` providers, with thinking levels low through max sent as `output_config.effort`. Thinking cannot be turned off for this model. New MiniMax models from models.dev now appear without a manual allowlist entry; only the older M2, M2.1, M2.5, and M2.5-highspeed models stay hidden ([#11121](https://github.com/earendil-works/pi/issues/11121))

View file

@ -432,17 +432,26 @@ Header names are merged case-insensitively. Explicit headers override auth/model
```typescript
const models = createModels({
identity: { userAgent: 'detailed', version: '2.1.0', attribution: 'my-app' },
identity: {
userAgent: 'detailed',
version: '2.1.0',
attribution: { name: 'my-app', url: 'https://my-app.example', categories: ['cloud-agent'] },
},
});
await models.completeSimple(model, context, { identity: { attribution: false } });
await models.completeSimple(model, context, { identity: { attribution: null } });
```
- `userAgent`: `"detailed"` (default) sends `pi/<version> (<platform>; <runtime>; <arch>)`, `"short"` sends `pi`, and `{ custom: "..." }` sends the given value. On Bedrock the value is appended to the AWS SDK user agent.
- `version`: the version in the detailed `User-Agent`. Default: the pi-ai version.
- `attribution`: provider attribution headers. `false` (default) sends none. `true` attributes requests to pi: OpenRouter gets `HTTP-Referer: https://pi.dev` and `X-OpenRouter-Title: pi`, NVIDIA NIM gets `X-BILLING-INVOKE-ORIGIN: Pi`, and OpenCode gets `x-opencode-client: pi`. A string attributes requests to that name instead. An object sets `name`, `url` (OpenRouter `HTTP-Referer`), and `categories` (OpenRouter `X-OpenRouter-Categories`); fields left out use pi's values only when `name` is left out too. The attribution name is also the Codex `originator` and the default `agentName` of `Models.login()`.
- `attribution`: provider attribution headers. Absent (default) or `null` sends none; `null` also turns off attribution from a merged identity. `{ name, url, categories? }` maps to each provider's headers:
- OpenRouter: `HTTP-Referer: <url>` (the app's identity in OpenRouter's rankings, grouped by origin), `X-OpenRouter-Title: <name>`, and `X-OpenRouter-Categories` (comma-separated).
- NVIDIA NIM: `X-BILLING-INVOKE-ORIGIN: <name>` with its first letter upper-cased.
- OpenCode: `x-opencode-client: <name>`.
Identity headers are defaults below provider, model, and request headers, so configured headers win and a `null` request header removes one. Providers that send a fixed client identity, such as GitHub Copilot and Anthropic OAuth, keep it. `withClientIdentity(models, identity)` returns a view of a `Models` whose requests use `identity` over the one the `Models` was created with, for libraries that receive a `Models` from their host. `identity` is plain JSON, so it can be persisted with other request options.
`PI_ATTRIBUTION` is pi's: `{ name: "pi", url: "https://pi.dev", categories: ["cli-agent"] }`.
Identity headers are defaults below provider, model, and request headers, so configured headers win and a `null` request header removes one. Providers that send a fixed client identity, such as GitHub Copilot and Anthropic OAuth, keep it. `withClientIdentity(models, identity)` returns a wrapper of a `Models` (or `MutableModels`) whose requests use `identity` over the one the `Models` was created with, for libraries that receive a `Models` from their host. `identity` is plain JSON, so it can be persisted with other request options.
### Credential Store

View file

@ -28,7 +28,7 @@ import {
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { getAttributionName, getIdentityHeaders } from "../utils/identity.ts";
import { getIdentityHeaders } from "../utils/identity.ts";
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
import { getSystemMessageText } from "../utils/text.ts";
import {
@ -285,11 +285,8 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
body = nextBody as RequestBody;
}
const websocketRequestId = codexSessionId || uuidv7();
// Codex expects an originator on every request, so it stays on without attribution.
const identityHeaders = {
originator: getAttributionName(options?.identity) ?? "pi",
...getIdentityHeaders(model, options?.identity),
};
// Codex expects an originator on every request; it is not attribution.
const identityHeaders = { originator: "pi", ...getIdentityHeaders(model, options?.identity) };
const sseHeaders = buildSSEHeaders(
identityHeaders,
model.headers,

View file

@ -56,7 +56,7 @@ import type {
Usage,
} from "./types.ts";
import { operationSignal, raceWithAbortSignal } from "./utils/abort.ts";
import { type ClientIdentity, getAttributionName, mergeClientIdentity } from "./utils/identity.ts";
import { type ClientIdentity, getOpenCodeSessionHeaders, mergeClientIdentity } from "./utils/identity.ts";
import {
assertChatModel,
assertClassifierInputSupported,
@ -377,10 +377,7 @@ export interface CreateModelsOptions {
credentials?: CredentialStore;
modelsStore?: ModelsStore;
authContext?: AuthContext;
/**
* Default identity of every request; a request's `identity` overrides it field by field. Its attribution name is
* also the default `LoginOptions.agentName`.
*/
/** Default identity of every request; a request's `identity` overrides it field by field. */
identity?: ClientIdentity;
}
@ -789,9 +786,7 @@ class ModelsImpl implements MutableModels {
if (!method?.login) {
throw new ModelsError("auth", `${provider.name} does not support ${type} login`);
}
const agentName = options?.agentName ?? getAttributionName(this.identity);
const loginOptions = agentName === undefined ? options : { ...options, agentName };
const loginOperation: Promise<Credential> = method.login({ ...interaction, signal }, loginOptions);
const loginOperation: Promise<Credential> = method.login({ ...interaction, signal }, options);
const credential = await raceWithAbortSignal(loginOperation, signal);
let mutationStarted = false;
let markMutationStarted: (() => void) | undefined;
@ -881,10 +876,15 @@ class ModelsImpl implements MutableModels {
// Explicit request options win per-field; the Models-only transform runs last.
const apiKey = options?.apiKey ?? auth.apiKey;
let headers = mergeHeaders(auth.headers, options?.headers);
const requestModel: TModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
const sessionId =
options && "sessionId" in options && typeof options.sessionId === "string" ? options.sessionId : undefined;
let headers = mergeHeaders(
mergeHeaders(getOpenCodeSessionHeaders(requestModel, sessionId), auth.headers),
options?.headers,
);
if (options?.transformHeaders) headers = await options.transformHeaders(headers ?? {});
const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined;
const requestModel: TModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
const identity = mergeClientIdentity(this.identity, options?.identity);
const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {};
const requestOptions = {

View file

@ -1,4 +1,4 @@
import type { Models } from "../models.ts";
import type { Models, MutableModels } from "../models.ts";
import { PI_AI_VERSION } from "./version.ts";
/**
@ -10,24 +10,18 @@ import { PI_AI_VERSION } from "./version.ts";
*/
export type UserAgentMode = "short" | "detailed" | { custom: string };
/** Attribution with explicit values. Absent fields fall back to pi's own attribution only when `name` is absent. */
export interface ClientAttributionOptions {
/**
* Name requests are attributed to (OpenRouter app title, NVIDIA invoke origin, OpenCode client, Codex originator).
* Absent: pi, spelled as each provider expects.
*/
name?: string;
/** App URL, sent as OpenRouter's `HTTP-Referer`. Absent: `https://pi.dev` when `name` is absent too. */
url?: string;
/** OpenRouter app categories, comma-separated, e.g. `cli-agent`. */
categories?: string;
/** Who requests are attributed to. `getAttributionHeaders()` maps the fields to each provider's headers. */
export interface ClientAttribution {
/** App name: OpenRouter app title, NVIDIA invoke origin (first letter upper-cased), OpenCode client. */
name: string;
/** App URL: OpenRouter `HTTP-Referer`, the app's identity in OpenRouter's rankings, grouped by origin. */
url: string;
/** App categories, e.g. `["cli-agent"]`: OpenRouter `X-OpenRouter-Categories`, comma-separated. */
categories?: string[];
}
/**
* Provider attribution headers. `false` sends none; `true` attributes requests to pi; a string attributes them to that
* name; an object gives the values explicitly.
*/
export type ClientAttribution = boolean | string | ClientAttributionOptions;
/** pi's attribution, used by pi-agent-core, pi-durable, and the coding agent. */
export const PI_ATTRIBUTION: ClientAttribution = { name: "pi", url: "https://pi.dev", categories: ["cli-agent"] };
/**
* How pi-ai identifies the calling app to providers. Every field is optional; absent fields use pi-ai's defaults:
@ -36,8 +30,8 @@ export type ClientAttribution = boolean | string | ClientAttributionOptions;
export interface ClientIdentity {
/** Default: `"detailed"`. */
userAgent?: UserAgentMode;
/** Default: `false`. */
attribution?: ClientAttribution;
/** Provider attribution headers. Absent or `null`: none; `null` also turns off attribution set by a merged identity. */
attribution?: ClientAttribution | null;
/** Version in the detailed `User-Agent`. Default: the pi-ai version. */
version?: string;
}
@ -48,7 +42,6 @@ export interface ClientIdentityTarget {
readonly baseUrl: string;
}
const PI_ATTRIBUTION_URL = "https://pi.dev";
const NVIDIA_NIM_HOST = "integrate.api.nvidia.com";
const OPENCODE_HOST = "opencode.ai";
@ -88,19 +81,6 @@ export function formatUserAgent(identity?: ClientIdentity): string {
return runtime ? `${product} (${process.platform}; ${runtime}; ${process.arch})` : `${product} (browser)`;
}
function resolveAttribution(identity: ClientIdentity | undefined): ClientAttributionOptions | undefined {
const attribution = identity?.attribution;
if (attribution === undefined || attribution === false) return undefined;
if (attribution === true) return {};
if (typeof attribution === "string") return { name: attribution };
return attribution;
}
/** The explicitly configured attribution name, if any. Absent when attribution is off or uses pi's defaults. */
export function getAttributionName(identity?: ClientIdentity): string | undefined {
return resolveAttribution(identity)?.name;
}
function matchesHost(baseUrl: string, expectedHost: string): boolean {
try {
return new URL(baseUrl).hostname === expectedHost;
@ -125,22 +105,35 @@ function isOpenCode(target: ClientIdentityTarget): boolean {
/** Provider attribution headers of an identity for a request target. Empty when attribution is off. */
export function getAttributionHeaders(target: ClientIdentityTarget, identity?: ClientIdentity): Record<string, string> {
const attribution = resolveAttribution(identity);
const attribution = identity?.attribution;
if (!attribution) return {};
const { name, categories } = attribution;
const { name, url, categories } = attribution;
if (isOpenRouter(target)) {
const url = attribution.url ?? (name === undefined ? PI_ATTRIBUTION_URL : undefined);
return {
...(url === undefined ? {} : { "HTTP-Referer": url }),
"X-OpenRouter-Title": name ?? "pi",
...(categories === undefined ? {} : { "X-OpenRouter-Categories": categories }),
"HTTP-Referer": url,
"X-OpenRouter-Title": name,
...(categories === undefined || categories.length === 0
? {}
: { "X-OpenRouter-Categories": categories.join(",") }),
};
}
if (isNvidiaNim(target)) return { "X-BILLING-INVOKE-ORIGIN": name ?? "Pi" };
if (isOpenCode(target)) return { "x-opencode-client": name ?? "pi" };
// NVIDIA expects a capitalized origin, such as `Pi`.
if (isNvidiaNim(target)) return { "X-BILLING-INVOKE-ORIGIN": name.charAt(0).toUpperCase() + name.slice(1) };
if (isOpenCode(target)) return { "x-opencode-client": name };
return {};
}
/**
* OpenCode's per-conversation routing header for requests to OpenCode, including custom providers pointing at it.
* Undefined without a session or for other targets.
*/
export function getOpenCodeSessionHeaders(
target: ClientIdentityTarget,
sessionId: string | undefined,
): Record<string, string> | undefined {
return sessionId && isOpenCode(target) ? { "x-opencode-session": sessionId } : undefined;
}
/**
* Default headers an identity adds to a request: `User-Agent` and provider attribution. APIs apply them below model,
* provider, and request headers, so configured headers override them and `null` request headers remove them.
@ -149,34 +142,52 @@ export function getIdentityHeaders(target: ClientIdentityTarget, identity?: Clie
return { "User-Agent": formatUserAgent(identity), ...getAttributionHeaders(target, identity) };
}
const IDENTITY_REQUEST_METHODS = new Set<PropertyKey>([
"stream",
"complete",
"streamSimple",
"completeSimple",
"streamDeferred",
"fetchDeferred",
"cancelDeferred",
"generateImages",
"classify",
]);
function isMutableModels(models: Models): models is MutableModels {
return "setProvider" in models && "deleteProvider" in models && "clearProviders" in models;
}
/**
* A view of `models` whose requests use `identity` as their default identity: a request's `identity` overrides it field
* by field, and it overrides the identity `models` was created with. Everything else is delegated unchanged.
* `models` with `identity` as the default identity of its requests: a request's `identity` overrides it field by
* field, and it overrides the identity `models` was created with. Everything else is delegated unchanged; a
* `MutableModels` stays mutable.
*/
export function withClientIdentity<T extends Models>(models: T, identity: ClientIdentity): T {
return new Proxy(models, {
get(target, property) {
const value: unknown = Reflect.get(target, property, target);
if (typeof value !== "function") return value;
if (!IDENTITY_REQUEST_METHODS.has(property)) return value.bind(target);
// Every request method takes (model, context or handle, options).
return (model: unknown, input: unknown, options?: { identity?: ClientIdentity }) =>
value.call(target, model, input, {
...options,
identity: mergeClientIdentity(identity, options?.identity),
});
},
});
export function withClientIdentity(models: MutableModels, identity: ClientIdentity): MutableModels;
export function withClientIdentity(models: Models, identity: ClientIdentity): Models;
export function withClientIdentity(models: Models, identity: ClientIdentity): Models {
const withIdentity = <T extends { identity?: ClientIdentity }>(options: T | undefined): T =>
({ ...options, identity: mergeClientIdentity(identity, options?.identity) }) as T;
const wrapped: Models = {
getProviders: () => models.getProviders(),
getProvider: (id) => models.getProvider(id),
getModels: (provider) => models.getModels(provider),
getModel: (provider, id) => models.getModel(provider, id),
getModelsOfType: (type, provider) => models.getModelsOfType(type, provider),
getModelOfType: (type, provider, id) => models.getModelOfType(type, provider, id),
getAllModels: (provider) => models.getAllModels(provider),
refresh: (options) => models.refresh(options),
checkAuth: (providerId, options) => models.checkAuth(providerId, options),
getAvailable: (providerId, options) => models.getAvailable(providerId, options),
getAvailableOfType: (type, providerId, options) => models.getAvailableOfType(type, providerId, options),
getAllAvailable: (providerId, options) => models.getAllAvailable(providerId, options),
getAuth: models.getAuth.bind(models),
login: (providerId, type, interaction, options) => models.login(providerId, type, interaction, options),
logout: (providerId, options) => models.logout(providerId, options),
stream: (model, context, options) => models.stream(model, context, withIdentity(options)),
complete: (model, context, options) => models.complete(model, context, withIdentity(options)),
streamSimple: (model, context, options) => models.streamSimple(model, context, withIdentity(options)),
completeSimple: (model, context, options) => models.completeSimple(model, context, withIdentity(options)),
streamDeferred: (model, handle, options) => models.streamDeferred(model, handle, withIdentity(options)),
fetchDeferred: (model, handle, options) => models.fetchDeferred(model, handle, withIdentity(options)),
cancelDeferred: (model, handle, options) => models.cancelDeferred(model, handle, withIdentity(options)),
generateImages: (model, context, options) => models.generateImages(model, context, withIdentity(options)),
classify: (model, context, options) => models.classify(model, context, withIdentity(options)),
};
if (!isMutableModels(models)) return wrapped;
const mutable: MutableModels = {
...wrapped,
setProvider: (provider) => models.setProvider(provider),
deleteProvider: (id) => models.deleteProvider(id),
clearProviders: () => models.clearProviders(),
};
return mutable;
}

View file

@ -9,11 +9,14 @@ import {
getAttributionHeaders,
getIdentityHeaders,
mergeClientIdentity,
PI_ATTRIBUTION,
withClientIdentity,
} from "../src/utils/identity.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
import { PI_AI_VERSION } from "../src/utils/version.ts";
const ACME = { name: "acme", url: "https://acme.example" };
const runtime = process.versions.bun ? `bun/${process.versions.bun}` : `node/${process.version}`;
function target(provider: string, baseUrl: string) {
@ -78,66 +81,76 @@ describe("client identity", () => {
it("merges identities field by field", () => {
expect(mergeClientIdentity(undefined, undefined)).toBeUndefined();
expect(
mergeClientIdentity({ attribution: true, userAgent: "short" }, { attribution: undefined, version: "1.0.0" }),
).toEqual({ attribution: true, userAgent: "short", version: "1.0.0" });
expect(mergeClientIdentity({ attribution: true }, { attribution: false })).toEqual({ attribution: false });
mergeClientIdentity(
{ attribution: PI_ATTRIBUTION, userAgent: "short" },
{ attribution: undefined, version: "1.0.0" },
),
).toEqual({ attribution: PI_ATTRIBUTION, userAgent: "short", version: "1.0.0" });
expect(mergeClientIdentity({ attribution: PI_ATTRIBUTION }, { attribution: null })).toEqual({
attribution: null,
});
});
it("sends no attribution unless enabled", () => {
const openRouter = target("openrouter", "https://openrouter.ai/api/v1");
expect(getAttributionHeaders(openRouter)).toEqual({});
expect(getAttributionHeaders(openRouter, { attribution: false })).toEqual({});
expect(getAttributionHeaders(openRouter, { attribution: null })).toEqual({});
expect(getIdentityHeaders(openRouter)).toEqual({ "User-Agent": formatUserAgent() });
});
it("attributes OpenRouter requests", () => {
const openRouter = target("openrouter", "https://openrouter.ai/api/v1");
expect(getAttributionHeaders(openRouter, { attribution: true })).toEqual({
"HTTP-Referer": "https://pi.dev",
"X-OpenRouter-Title": "pi",
});
expect(getAttributionHeaders(openRouter, { attribution: { categories: "cli-agent" } })).toEqual({
expect(getAttributionHeaders(openRouter, { attribution: PI_ATTRIBUTION })).toEqual({
"HTTP-Referer": "https://pi.dev",
"X-OpenRouter-Title": "pi",
"X-OpenRouter-Categories": "cli-agent",
});
expect(getAttributionHeaders(openRouter, { attribution: "acme" })).toEqual({ "X-OpenRouter-Title": "acme" });
expect(
getAttributionHeaders(openRouter, {
attribution: { name: "acme", url: "https://acme.example", categories: "cloud-agent" },
attribution: { ...ACME, categories: ["cli-agent", "cloud-agent"] },
}),
).toEqual({
"HTTP-Referer": "https://acme.example",
"X-OpenRouter-Title": "acme",
"X-OpenRouter-Categories": "cloud-agent",
"X-OpenRouter-Categories": "cli-agent,cloud-agent",
});
expect(getAttributionHeaders(openRouter, { attribution: { ...ACME, categories: [] } })).toEqual({
"HTTP-Referer": "https://acme.example",
"X-OpenRouter-Title": "acme",
});
// Custom providers routed through OpenRouter, including legacy base URLs that are not valid URLs.
expect(getAttributionHeaders(target("custom", "not-a-url-openrouter.ai"), { attribution: true })).toMatchObject({
"X-OpenRouter-Title": "pi",
});
expect(
getAttributionHeaders(target("custom", "not-a-url-openrouter.ai"), { attribution: PI_ATTRIBUTION }),
).toMatchObject({ "X-OpenRouter-Title": "pi" });
});
it("attributes NVIDIA NIM and OpenCode requests with their expected spellings", () => {
expect(getAttributionHeaders(target("nvidia", "https://example.test/v1"), { attribution: true })).toEqual({
it("attributes NVIDIA NIM requests with a capitalized name and OpenCode requests with the name", () => {
expect(
getAttributionHeaders(target("nvidia", "https://example.test/v1"), { attribution: PI_ATTRIBUTION }),
).toEqual({
"X-BILLING-INVOKE-ORIGIN": "Pi",
});
expect(
getAttributionHeaders(target("custom-nim", "https://integrate.api.nvidia.com/v1"), { attribution: "acme" }),
).toEqual({ "X-BILLING-INVOKE-ORIGIN": "acme" });
expect(getAttributionHeaders(target("opencode", "https://opencode.ai/zen/v1"), { attribution: true })).toEqual({
getAttributionHeaders(target("custom-nim", "https://integrate.api.nvidia.com/v1"), { attribution: ACME }),
).toEqual({ "X-BILLING-INVOKE-ORIGIN": "Acme" });
expect(
getAttributionHeaders(target("opencode", "https://opencode.ai/zen/v1"), { attribution: PI_ATTRIBUTION }),
).toEqual({
"x-opencode-client": "pi",
});
expect(getAttributionHeaders(target("custom", "https://opencode.ai/zen/v1"), { attribution: "acme" })).toEqual({
expect(getAttributionHeaders(target("custom", "https://opencode.ai/zen/v1"), { attribution: ACME })).toEqual({
"x-opencode-client": "acme",
});
});
it("does not attribute NVIDIA models routed through gateways to NVIDIA", () => {
expect(
getAttributionHeaders(target("openrouter", "https://openrouter.ai/api/v1"), { attribution: true }),
getAttributionHeaders(target("openrouter", "https://openrouter.ai/api/v1"), { attribution: PI_ATTRIBUTION }),
).not.toHaveProperty("X-BILLING-INVOKE-ORIGIN");
expect(
getAttributionHeaders(target("vercel-ai-gateway", "https://ai-gateway.vercel.sh/v1"), { attribution: true }),
getAttributionHeaders(target("vercel-ai-gateway", "https://ai-gateway.vercel.sh/v1"), {
attribution: PI_ATTRIBUTION,
}),
).toEqual({});
});
});
@ -151,7 +164,7 @@ describe("client identity in requests", () => {
await streamCompletions(model, context, {
apiKey: "key",
fetch: capture.fetch,
identity: { attribution: true, userAgent: "short" },
identity: { attribution: PI_ATTRIBUTION, userAgent: "short" },
headers: { "HTTP-Referer": null } as ProviderHeaders,
}).result();
@ -177,7 +190,8 @@ describe("client identity in requests", () => {
it("merges request identity over the Models and wrapper identities", async () => {
const model = completionsModel("openrouter", "https://openrouter.ai/api/v1");
const models = createModels({ identity: { attribution: "models", userAgent: "short" } });
const attribution = (name: string) => ({ name, url: "https://acme.example" });
const models = createModels({ identity: { attribution: attribution("models"), userAgent: "short" } });
models.setProvider(
createProvider({
id: "openrouter",
@ -196,14 +210,46 @@ describe("client identity in requests", () => {
expect(fromModels.get("user-agent")).toBe("pi");
expect(fromModels.get("x-openrouter-title")).toBe("models");
const wrapped = withClientIdentity(models, { attribution: "wrapper" });
const wrapped = withClientIdentity(models, { attribution: attribution("wrapper") });
const fromWrapper = await send(wrapped);
expect(fromWrapper.get("user-agent")).toBe("pi");
expect(fromWrapper.get("x-openrouter-title")).toBe("wrapper");
expect(wrapped.getModel("openrouter", "test-model")).toBe(model);
// A MutableModels stays mutable.
wrapped.deleteProvider("openrouter");
expect(models.getProvider("openrouter")).toBeUndefined();
models.setProvider(
createProvider({
id: "openrouter",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: { apiKey: "key" } }) } },
models: [model],
api: { stream: streamCompletions, streamSimple: streamSimpleCompletions },
}),
);
const fromRequest = await send(wrapped, { attribution: false });
const fromRequest = await send(wrapped, { attribution: null });
expect(fromRequest.has("x-openrouter-title")).toBe(false);
expect(fromRequest.get("user-agent")).toBe("pi");
});
it("sends the OpenCode session header to custom providers pointing at OpenCode", async () => {
const model = completionsModel("custom-opencode", "https://opencode.ai/zen/v1");
const models = createModels();
models.setProvider(
createProvider({
id: "custom-opencode",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: { apiKey: "key" } }) } },
models: [model],
api: { stream: streamCompletions, streamSimple: streamSimpleCompletions },
}),
);
const send = async (headers?: ProviderHeaders) => {
const capture = capturingFetch();
await models.streamSimple(model, context, { fetch: capture.fetch, sessionId: "session-1", headers }).result();
return capture.headers();
};
expect((await send()).get("x-opencode-session")).toBe("session-1");
expect((await send({ "X-OpenCode-Session": "configured" })).get("x-opencode-session")).toBe("configured");
});
});

View file

@ -1,10 +1,8 @@
import type { Api, ClientIdentity, Model, ProviderHeaders } from "@earendil-works/pi-ai";
import { type ClientIdentity, PI_ATTRIBUTION } from "@earendil-works/pi-ai";
import { VERSION } from "../config.ts";
import type { SettingsManager } from "./settings-manager.ts";
import { isInstallTelemetryEnabled } from "./telemetry.ts";
const OPENCODE_HOST = "opencode.ai";
/**
* How pi's requests identify it to providers: a detailed `User-Agent` with pi's version, and attribution to pi as a
* CLI agent while install telemetry is enabled.
@ -13,28 +11,6 @@ export function getClientIdentity(settingsManager: SettingsManager): ClientIdent
return {
userAgent: "detailed",
version: VERSION,
attribution: isInstallTelemetryEnabled(settingsManager) ? { categories: "cli-agent" } : false,
attribution: isInstallTelemetryEnabled(settingsManager) ? PI_ATTRIBUTION : null,
};
}
function isOpenCodeModel(model: Model<Api>): boolean {
if (model.provider === "opencode" || model.provider === "opencode-go") return true;
try {
return new URL(model.baseUrl).hostname === OPENCODE_HOST;
} catch {
return false;
}
}
/**
* OpenCode's per-conversation routing header. pi-ai's OpenCode providers add it themselves; this also covers custom
* providers pointing at OpenCode. Configured and request headers override it.
*/
export function withOpenCodeSessionHeader(
model: Model<Api>,
sessionId: string | undefined,
headers: ProviderHeaders,
): ProviderHeaders {
if (!sessionId || !isOpenCodeModel(model)) return headers;
return { "x-opencode-session": sessionId, ...headers };
}

View file

@ -25,6 +25,7 @@ import {
type DeferredCancelOptions,
type DeferredFetchOptions,
type DeferredHandle,
getOpenCodeSessionHeaders,
type ImageApi,
type ImageModel,
type ImagesContext,
@ -677,13 +678,20 @@ export class ModelRuntime implements Models {
const { transformHeaders, ...rawProviderOptions } = options ?? {};
const providerOptions = rawProviderOptions as Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>;
let headers = mergeHeaders(resolution.auth.headers, providerOptions.headers);
const requestModel: TModel = resolution.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model;
const sessionId =
"sessionId" in providerOptions && typeof providerOptions.sessionId === "string"
? providerOptions.sessionId
: undefined;
let headers = mergeHeaders(
mergeHeaders(getOpenCodeSessionHeaders(requestModel, sessionId), resolution.auth.headers),
providerOptions.headers,
);
if (transformHeaders) headers = await transformHeaders(headers ?? {});
const env =
resolution.env || providerOptions.env
? { ...(resolution.env ?? {}), ...(providerOptions.env ?? {}) }
: undefined;
const requestModel: TModel = resolution.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model;
return {
provider,
model: requestModel,

View file

@ -7,7 +7,7 @@ import { resolvePath } from "../utils/paths.ts";
import { AgentSession } from "./agent-session.ts";
import { formatNoModelsAvailableMessage } from "./auth-guidance.ts";
import { CacheWarmer } from "./cache-warmer.ts";
import { getClientIdentity, withOpenCodeSessionHeader } from "./client-identity.ts";
import { getClientIdentity } from "./client-identity.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { createToolNameMatcher } from "./mcp-servers.ts";
@ -337,10 +337,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
() => settingsManager.getCacheWarmingMode(),
async (event) => extensionRunnerRef.current?.emitCacheWarmingDecision(event) ?? event.action,
);
const buildRequestOptions = (
requestModel: Model<any>,
options: ModelsSimpleStreamOptions = {},
): ModelsSimpleStreamOptions => {
const buildRequestOptions = (options: ModelsSimpleStreamOptions = {}): ModelsSimpleStreamOptions => {
const providerRetrySettings = settingsManager.getProviderRetrySettings();
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
const effectiveTimeoutMs = httpIdleTimeoutMs === 0 ? 2147483647 : httpIdleTimeoutMs;
@ -353,12 +350,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
maxRetryDelayMs: options.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs,
// Replaces the agent loop's default identity; read per request so telemetry changes apply at once.
identity: getClientIdentity(settingsManager),
transformHeaders: async (requestHeaders) => {
const headers = withOpenCodeSessionHeader(requestModel, options.sessionId, requestHeaders);
return headerRunner?.hasHandlers("before_provider_headers")
transformHeaders: async (headers) =>
headerRunner?.hasHandlers("before_provider_headers")
? headerRunner.emitBeforeProviderHeaders(headers)
: headers;
},
: headers,
};
};
// Warm only requests for the selected model. Requests a virtual selection routed, or that an
@ -415,7 +410,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
},
convertToLlm: convertToLlmWithBlockImages,
streamFn: async (model, context, options) => {
const requestOptions = buildRequestOptions(model, options);
const requestOptions = buildRequestOptions(options);
// Compaction and summaries use their own routing ids; only session requests
// replace the cache entry, so warming restarts from them. Keep warming while
// the current transcript still extends the request's prefix. Agent state may

View file

@ -8,6 +8,7 @@ import {
getAttributionHeaders,
type Model,
normalizeContext,
PI_ATTRIBUTION,
type SimpleStreamOptions,
} from "@earendil-works/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@ -145,7 +146,7 @@ describe("createAgentSession client identity", () => {
expect(options?.identity).toEqual({
userAgent: "detailed",
version: VERSION,
attribution: { categories: "cli-agent" },
attribution: PI_ATTRIBUTION,
});
expect(getAttributionHeaders(model, options?.identity)).toEqual({
"HTTP-Referer": "https://pi.dev",
@ -158,7 +159,7 @@ describe("createAgentSession client identity", () => {
const model = createModel("nvidia", "https://integrate.api.nvidia.com/v1");
const options = await captureOptions(model, { telemetryEnabled: false });
expect(options?.identity).toEqual({ userAgent: "detailed", version: VERSION, attribution: false });
expect(options?.identity).toEqual({ userAgent: "detailed", version: VERSION, attribution: null });
expect(getAttributionHeaders(model, options?.identity)).toEqual({});
});
@ -166,7 +167,7 @@ describe("createAgentSession client identity", () => {
process.env.PI_TELEMETRY = "0";
const options = await captureOptions(createModel("openrouter", "https://openrouter.ai/api/v1"));
expect(options?.identity?.attribution).toBe(false);
expect(options?.identity?.attribution).toBeNull();
});
it("adds OpenCode session headers for custom OpenCode providers", async () => {

View file

@ -23,7 +23,7 @@
- `onReport(error, info)`: `info` (`ReportInfo`) says what happened in `type`: `session_failed`; `task_faulted`, `task_parked`, `migration_failed`, `task_outdated`, or `task_error` with the task and the extension that installed it; `extension_error` with the extension's name; `host_error` with the failing `HostCallback`; or `listener_error`.
- `settings.afterFailedRun`: a run that fails with `model_error` or `no_model` keeps the inputs queued behind it (`keep`, the default), starts the next run with them (`continue`), or settles them `unanswered` with reason `run_failed` (`withdraw`).
- The task graph shows why a task cannot run (`TaskGraphNode.blocked`: `missing_task`, `task_too_old`, `migration_failed`, or `restart_limit`) and follows registry changes and failed migrations.
- Added `HarnessOptions.identity` (a pi-ai `ClientIdentity`) for every model request through the Harness, including hooks and tools, over `DEFAULT_HARNESS_IDENTITY` (`{ attribution: true }`), so OpenRouter, NVIDIA NIM, and OpenCode requests are attributed to pi by default; pass `{ attribution: false }` to send no attribution headers. `api.models` in tools and hooks is now a view of the host's `Models` that adds the identity, not the same object.
- Added `HarnessOptions.identity` (a pi-ai `ClientIdentity`) to set the `User-Agent` and provider attribution of every model request through the Harness, including hooks and tools. `api.models` in tools and hooks is now a wrapper of the host's `Models` that adds the identity, not the same object.
- `HarnessOptions.models` and `HarnessOptions.settings` also accept a function returning the current value, like `env`. Generation, compaction, hooks and tools call it at each use (`api.models` reads the current value at each access), so a host can replace its model configuration or settings without reopening the Harness. A plain object still works.
### Changed

View file

@ -77,7 +77,7 @@ What happened:
Every async call takes a Chord `Context`, which carries cancellation and its current `TelemetrySpan`. `BACKGROUND_CONTEXT` never cancels and uses a NOOP span. The Harness owns the root adapter; adapters are never stored in a `Context`. Cancelling a wait only cancels that wait, never the work.
`HarnessOptions.identity` sets how model requests identify the app to providers (a pi-ai `ClientIdentity`), merged over `DEFAULT_HARNESS_IDENTITY` (`{ attribution: true }`: OpenRouter, NVIDIA NIM, and OpenCode attribution headers name pi). It applies to generation, compaction, hooks, and tools, over the identity the host's `Models` was created with. Pass `{ attribution: "my-app" }` to attribute requests to your app, or `{ attribution: false }` to send none.
`HarnessOptions.identity` sets how model requests identify the app to providers (a pi-ai `ClientIdentity`), merged over `DEFAULT_HARNESS_IDENTITY` (`{ attribution: PI_ATTRIBUTION }`: OpenRouter, NVIDIA NIM, and OpenCode attribution headers name pi). It applies to generation, compaction, hooks, and tools, over the identity the host's `Models` was created with. Pass your app's attribution, for example `{ attribution: { name: "my-app", url: "https://my-app.example" } }`, or `{ attribution: null }` to send none.
`HarnessOptions.telemetry` accepts a host-owned `TelemetryContext` from `@earendil-works/pi-telemetry`, with optional `propagation` and `metrics` capabilities; omitted, it is a no-op. `DURABLE_TELEMETRY_SCHEMA`, exported from the package root, documents its typed spans, events, and metrics. Attributes contain IDs and control metadata, never messages, arguments, or results; IDs point to the transcript.

View file

@ -1,7 +1,12 @@
import type { AttachedReplicatedState, Context, JsonValue } from "@earendil-works/chord";
import { withAbortSignal, withoutAbortSignal } from "@earendil-works/chord/context";
import type { Models } from "@earendil-works/pi-ai";
import { type ClientIdentity, mergeClientIdentity, withClientIdentity } from "@earendil-works/pi-ai/utils/identity";
import {
type ClientIdentity,
mergeClientIdentity,
PI_ATTRIBUTION,
withClientIdentity,
} from "@earendil-works/pi-ai/utils/identity";
import {
createTypedMetrics,
NOOP_TELEMETRY_CONTEXT,
@ -180,28 +185,17 @@ class ConversationImpl<Tool extends ToolRegistration> implements Conversation {
}
/** Request identity of a Harness's model requests: pi-ai's defaults with provider attribution to pi. */
export const DEFAULT_HARNESS_IDENTITY: ClientIdentity = { attribution: true };
export const DEFAULT_HARNESS_IDENTITY: ClientIdentity = { attribution: PI_ATTRIBUTION };
/**
* `HarnessOptions.models` as a function with `HarnessOptions.identity` applied. A plain `Models` is wrapped once and
* returned at every call; a function's results are wrapped once per `Models` it returns.
* `HarnessOptions.models` as a function with `HarnessOptions.identity` applied. A plain `Models` is wrapped once; a
* function's result is wrapped at each call.
*/
function currentModels(models: HarnessOptions["models"], identity: ClientIdentity | undefined): () => Models {
const merged = mergeClientIdentity(DEFAULT_HARNESS_IDENTITY, identity) ?? DEFAULT_HARNESS_IDENTITY;
if (typeof models !== "function") {
const wrapped = withClientIdentity(models, merged);
return () => wrapped;
}
const wrappers = new WeakMap<Models, Models>();
return () => {
const current = models();
let wrapped = wrappers.get(current);
if (wrapped === undefined) {
wrapped = withClientIdentity(current, merged);
wrappers.set(current, wrapped);
}
return wrapped;
};
if (typeof models === "function") return () => withClientIdentity(models(), merged);
const wrapped = withClientIdentity(models, merged);
return () => wrapped;
}
/** `HarnessOptions.settings` as a function: plain settings, or none, are returned at every call. */

View file

@ -616,9 +616,9 @@ export type HarnessOptions<Tool extends ToolRegistration = ToolRegistration> = {
readonly models: Models | (() => Models);
/**
* How model requests identify the app to providers, merged field by field over `DEFAULT_HARNESS_IDENTITY`
* (`{ attribution: true }`, provider attribution to pi). It applies to every request through the Harness's models,
* including hooks and tools, and overrides the identity the `Models` was created with; a request's own `identity`
* overrides it. Pass `{ attribution: false }` to send no attribution headers.
* (`{ attribution: PI_ATTRIBUTION }`, provider attribution to pi). It applies to every request through the Harness's
* models, including hooks and tools, and overrides the identity the `Models` was created with; a request's own
* `identity` overrides it. Pass `{ attribution: null }` to send no attribution headers.
*/
readonly identity?: ClientIdentity;
readonly registry: RegistryReader<Tool>;

View file

@ -3,6 +3,7 @@ import {
fauxAssistantMessage,
type Message,
type Models,
PI_ATTRIBUTION,
type SimpleStreamOptions,
type SystemMessage,
} from "@earendil-works/pi-ai";
@ -391,7 +392,8 @@ describe("generation", () => {
seen.push(options?.identity);
return fauxAssistantMessage("ok");
};
for (const identity of [undefined, { userAgent: "short" as const, attribution: "acme" }]) {
const acme = { name: "acme", url: "https://acme.example" };
for (const identity of [undefined, { userAgent: "short" as const, attribution: acme }]) {
const setup = chatSetup();
setup.identity = identity;
setup.faux.setResponses([capture]);
@ -400,7 +402,7 @@ describe("generation", () => {
await (await root.submit({ type: "input", content: "hi" }, context)).wait(context);
await harness.close(context);
}
expect(seen).toEqual([{ attribution: true }, { attribution: "acme", userAgent: "short" }]);
expect(seen).toEqual([{ attribution: PI_ATTRIBUTION }, { attribution: acme, userAgent: "short" }]);
});
// Regression coverage for #10424.