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" },
]);
});
});

View file

@ -5,10 +5,13 @@
### Breaking Changes
- Renamed provider request option `telemetryContext` to `telemetry?: TelemetrySpan`, without a compatibility alias. Pass a parent span, not an adapter.
- The default `User-Agent` is now `pi/<version> (<platform>; <runtime>; <arch>)` instead of `pi (<platform> <release>; <arch>)`, and `@earendil-works/pi-ai/utils/pi-user-agent` (`getPiUserAgent()`) is removed; use `formatUserAgent()`.
- `resolveTranscriptTools()` now takes a `TranscriptToolSupport` (`"none"`, `"additions"`, or `"changes"`) instead of a boolean, and `TranscriptTools.anchorsAdditions` is replaced by `foldedSystemMessages`, the number of leading system messages folded into `requestTools` ([#10940](https://github.com/earendil-works/pi/issues/10940))
### 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.
- Bedrock, OpenRouter image generation, pi-messages, and classifier requests now send pi's `User-Agent` too.
- 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

@ -18,6 +18,7 @@ Unified LLM API with provider collections, automatic auth resolution, token and
- [Auth](#auth)
- [How Auth Resolves](#how-auth-resolves)
- [Transforming Request Headers](#transforming-request-headers)
- [Client Identity](#client-identity)
- [Credential Store](#credential-store)
- [Environment Variables](#environment-variables)
- [Tools](#tools)
@ -425,6 +426,24 @@ Header names are merged case-insensitively. Explicit headers override auth/model
`transformHeaders` belongs to `Models`, not `Provider`. A `Models` implementation must consume it and remove it before calling `Provider.stream*()`. Provider implementations continue receiving ordinary `ApiStreamOptions` or `SimpleStreamOptions` and never handle the transform themselves. Use this option instead of calling `getAuth(model)` before `stream*()`, which would resolve request auth twice.
### Client Identity
`identity` controls how requests identify the calling app to providers. Set it on `createModels()` as a default, or per request; a request's fields override the `Models` default field by field:
```typescript
const models = createModels({
identity: { userAgent: 'detailed', version: '2.1.0', attribution: 'my-app' },
});
await models.completeSimple(model, context, { identity: { attribution: false } });
```
- `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()`.
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.
### Credential Store
Stored credentials (API keys entered interactively, OAuth tokens) live in a `CredentialStore` — one type-tagged credential per provider. pi-ai ships an in-memory default; apps inject persistent storage:

View file

@ -40,8 +40,8 @@ import type {
import { appendAssistantMessageDiagnostic } from "../utils/diagnostics.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts";
import { parseJsonWithRepair, parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
@ -304,10 +304,6 @@ function mergeHeaders(...headerSources: (ProviderHeaders | undefined)[]): Provid
return merged;
}
function mergeClientHeaders(...headerSources: (ProviderHeaders | undefined)[]): ProviderHeaders {
return mergeHeaders({ "User-Agent": getPiUserAgent() }, ...headerSources);
}
function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean {
if (!headers) return false;
const expected = name.toLowerCase();
@ -634,6 +630,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
copilotDynamicHeaders,
cacheSessionId,
federation,
options?.identity,
);
client = created.client;
isOAuth = created.isOAuthToken;
@ -1023,6 +1020,7 @@ function createClient(
dynamicHeaders?: Record<string, string>,
sessionId?: string,
federation?: AnthropicFederationConfig,
identity?: ClientIdentity,
): { client: Anthropic; isOAuthToken: boolean } {
// Copilot: Bearer auth.
if (model.provider === "github-copilot") {
@ -1032,7 +1030,8 @@ function createClient(
baseURL: model.baseUrl,
dangerouslyAllowBrowser: true,
fetch,
defaultHeaders: mergeClientHeaders(
defaultHeaders: mergeHeaders(
getIdentityHeaders(model, identity),
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
@ -1046,7 +1045,7 @@ function createClient(
return { client, isOAuthToken: false };
}
// OAuth: Bearer auth, Claude Code identity headers
// OAuth: Bearer auth, Claude Code identity headers instead of the client identity.
if (apiKey && isOAuthToken(apiKey)) {
const client = new PiAnthropic({
apiKey: null,
@ -1054,7 +1053,7 @@ function createClient(
baseURL: model.baseUrl,
dangerouslyAllowBrowser: true,
fetch,
defaultHeaders: mergeClientHeaders(
defaultHeaders: mergeHeaders(
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
@ -1076,7 +1075,8 @@ function createClient(
const header = compat.sessionAffinityFormat === "openrouter" ? "x-session-id" : "x-session-affinity";
sessionAffinityHeaders[header] = sessionId;
}
const defaultHeaders = mergeClientHeaders(
const defaultHeaders = mergeHeaders(
getIdentityHeaders(model, identity),
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",

View file

@ -5,7 +5,7 @@ import type { Api, AssistantMessage, Model, SimpleStreamOptions, StreamFunction,
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { getIdentityHeaders } from "../utils/identity.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { getDeclaredTools, resolveTranscript, resolveTranscriptTools } from "../utils/transcript.ts";
import { type AzureEndpointOptions, resolveAzureConfig, resolveDeploymentName } from "./azure-openai-config.ts";
@ -158,7 +158,7 @@ export const streamSimple: StreamFunction<"azure-openai-responses", SimpleStream
};
function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) {
const headers = { "User-Agent": getPiUserAgent(), ...model.headers };
const headers = { ...getIdentityHeaders(model, options?.identity), ...model.headers };
if (options?.headers) {
Object.assign(headers, options.headers);

View file

@ -53,6 +53,7 @@ import { appendAssistantMessageDiagnostic } from "../utils/diagnostics.ts";
import { normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { providerHeadersToRecord } from "../utils/headers.ts";
import { type ClientIdentity, getClientVersion } from "../utils/identity.ts";
import { parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts";
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
@ -162,6 +163,7 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> =
const optionsProfile = options.profile || options.env?.AWS_PROFILE;
const config: BedrockRuntimeClientConfig = {
profile: optionsProfile || getProviderEnvValue("AWS_PROFILE", options.env),
customUserAgent: bedrockUserAgent(options.identity),
};
const configuredRegion = getConfiguredBedrockRegion(options);
const hasAmbientConfiguredProfile = Boolean(getProviderEnvValue("AWS_PROFILE"));
@ -470,6 +472,16 @@ function isReservedHeader(key: string): boolean {
return lower.startsWith("x-amz-") || RESERVED_HEADER_EXACT.has(lower);
}
/**
* The client identity as AWS SDK user agent pairs. The SDK appends them to its own `User-Agent` and escapes characters
* that are not valid in a user agent token, so the detailed form is reduced to name and version.
*/
function bedrockUserAgent(identity: ClientIdentity | undefined): string | [string, string?][] {
const mode = identity?.userAgent ?? "detailed";
if (typeof mode === "object") return mode.custom;
return mode === "short" ? [["pi"]] : [["pi", getClientVersion(identity)]];
}
/**
* Attach caller-supplied headers to the outgoing Bedrock request via a Smithy
* `build`-step middleware. The `build` step runs after request serialisation but

View file

@ -1,6 +1,7 @@
import { calculateCost } from "../models.ts";
import type { ClassifierApi, ClassifierModel, ClassifierOptions, ProviderHeaders, Usage } from "../types.ts";
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
/** An HTTP failure in the shape `retryProviderRequest` and `normalizeProviderError` understand. */
@ -42,9 +43,11 @@ function requestHeaders(
model: ClassifierModel<ClassifierApi>,
apiKey: string,
optionsHeaders?: ProviderHeaders,
identity?: ClientIdentity,
): Record<string, string> {
return (
providerHeadersToRecord(
getIdentityHeaders(model, identity),
{ authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
model.headers,
optionsHeaders,
@ -81,7 +84,7 @@ export async function postClassifierRequest(
try {
const next = await requestFetch(url, {
method: "POST",
headers: requestHeaders(model, apiKey, options.headers),
headers: requestHeaders(model, apiKey, options.headers, options.identity),
body: JSON.stringify(payload),
signal,
});

View file

@ -22,7 +22,7 @@ import type {
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { providerHeadersToRecord } from "../utils/headers.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { getSystemMessageText } from "../utils/text.ts";
import { collapseSystemMessages, getCurrentTools, getInitialSystemMessage } from "../utils/transcript.ts";
@ -91,7 +91,7 @@ export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = (
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const client = createClient(model, apiKey, options?.headers);
const client = createClient(model, apiKey, options?.headers, options?.identity);
let params = buildParams(model, normalizedContext, options);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
@ -349,13 +349,14 @@ function createClient(
model: Model<"google-generative-ai">,
apiKey?: string,
optionsHeaders?: ProviderHeaders,
identity?: ClientIdentity,
): GoogleGenAI {
const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record<string, string> } = {};
if (model.baseUrl) {
httpOptions.baseUrl = model.baseUrl;
httpOptions.apiVersion = ""; // baseUrl already includes version path, don't append
}
const headers = providerHeadersToRecord({ "User-Agent": getPiUserAgent(), ...model.headers, ...optionsHeaders });
const headers = providerHeadersToRecord(getIdentityHeaders(model, identity), model.headers, optionsHeaders);
if (headers) {
httpOptions.headers = headers;
}

View file

@ -25,7 +25,7 @@ import type {
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { providerHeadersToRecord } from "../utils/headers.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { getSystemMessageText } from "../utils/text.ts";
@ -99,8 +99,15 @@ export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = (
const apiKey = resolveApiKey(options);
// Create the client using either a Vertex API key, if provided, or ADC with project and location
const client = apiKey
? createClientWithApiKey(model, apiKey, options?.headers)
: createClient(model, resolveProject(options), resolveLocation(options), options?.headers, options?.env);
? createClientWithApiKey(model, apiKey, options?.headers, options?.identity)
: createClient(
model,
resolveProject(options),
resolveLocation(options),
options?.headers,
options?.env,
options?.identity,
);
let params = buildParams(model, normalizedContext, options);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
@ -360,6 +367,7 @@ function createClient(
location: string,
optionsHeaders?: ProviderHeaders,
env?: ProviderEnv,
identity?: ClientIdentity,
): GoogleGenAI {
const googleAuthOptions = buildGoogleAuthOptions(env);
return new GoogleGenAI({
@ -368,7 +376,7 @@ function createClient(
location,
apiVersion: API_VERSION,
...(googleAuthOptions ? { googleAuthOptions } : {}),
httpOptions: buildHttpOptions(model, optionsHeaders),
httpOptions: buildHttpOptions(model, optionsHeaders, identity),
});
}
@ -376,16 +384,21 @@ function createClientWithApiKey(
model: Model<"google-vertex">,
apiKey: string,
optionsHeaders?: ProviderHeaders,
identity?: ClientIdentity,
): GoogleGenAI {
return new GoogleGenAI({
vertexai: true,
apiKey,
apiVersion: API_VERSION,
httpOptions: buildHttpOptions(model, optionsHeaders),
httpOptions: buildHttpOptions(model, optionsHeaders, identity),
});
}
function buildHttpOptions(model: Model<"google-vertex">, optionsHeaders?: ProviderHeaders): HttpOptions | undefined {
function buildHttpOptions(
model: Model<"google-vertex">,
optionsHeaders?: ProviderHeaders,
identity?: ClientIdentity,
): HttpOptions | undefined {
const httpOptions: HttpOptions = {};
const baseUrl = resolveCustomBaseUrl(model.baseUrl);
if (baseUrl) {
@ -396,7 +409,7 @@ function buildHttpOptions(model: Model<"google-vertex">, optionsHeaders?: Provid
}
}
const headers = providerHeadersToRecord({ "User-Agent": getPiUserAgent(), ...model.headers, ...optionsHeaders });
const headers = providerHeadersToRecord(getIdentityHeaders(model, identity), model.headers, optionsHeaders);
if (headers) {
httpOptions.headers = headers;
}

View file

@ -11,6 +11,7 @@ import type {
} from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
import { getIdentityHeaders } from "../utils/identity.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
/**
@ -234,6 +235,7 @@ async function post(request: RequestContext, path: string, body: unknown, observ
const requestFetch = options?.fetch ?? globalThis.fetch;
const headers =
providerHeadersToRecord(
getIdentityHeaders(model, options?.identity),
{
"content-type": "application/json",
...(options?.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}),

View file

@ -17,8 +17,8 @@ import { combineAbortSignals } from "../utils/abort-signals.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { shortHash } from "../utils/hash.ts";
import { headersToRecord } from "../utils/headers.ts";
import { getIdentityHeaders } from "../utils/identity.ts";
import { parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { getSystemMessageText, renderSystemMessageUpdate } from "../utils/text.ts";
import { getCurrentTools, resolveTranscript } from "../utils/transcript.ts";
@ -354,7 +354,7 @@ class MistralHttpError extends Error {
function buildMistralHeaders(model: Model<"mistral-conversations">, apiKey: string, options?: MistralOptions): Headers {
const headers = new Headers({
"User-Agent": getPiUserAgent(),
...getIdentityHeaders(model, options?.identity),
accept: "text/event-stream",
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",

View file

@ -5,7 +5,6 @@ import type {
ResponseInput,
ResponseStreamEvent,
} from "openai/resources/responses/responses.js";
import { clampThinkingLevel } from "../models.ts";
import { registerSessionResourceCleanup } from "../session-resources.ts";
import type {
@ -29,8 +28,8 @@ 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 { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { getSystemMessageText } from "../utils/text.ts";
import {
getDeclaredTools,
@ -286,8 +285,21 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
body = nextBody as RequestBody;
}
const websocketRequestId = codexSessionId || uuidv7();
const sseHeaders = buildSSEHeaders(model.headers, options?.headers, accountId, apiKey, codexSessionId);
// Codex expects an originator on every request, so it stays on without attribution.
const identityHeaders = {
originator: getAttributionName(options?.identity) ?? "pi",
...getIdentityHeaders(model, options?.identity),
};
const sseHeaders = buildSSEHeaders(
identityHeaders,
model.headers,
options?.headers,
accountId,
apiKey,
codexSessionId,
);
const websocketHeaders = buildWebSocketHeaders(
identityHeaders,
model.headers,
options?.headers,
accountId,
@ -1651,13 +1663,14 @@ function extractAccountId(token: string): string {
}
function buildBaseCodexHeaders(
defaultHeaders: Record<string, string>,
initHeaders: Record<string, string> | undefined,
additionalHeaders: ProviderHeaders | undefined,
accountId: string,
token: string,
): Headers {
// Defaults first so model and caller headers can override them, matching the other providers.
const headers = new Headers({ originator: "pi", "User-Agent": getPiUserAgent() });
const headers = new Headers(defaultHeaders);
for (const [key, value] of Object.entries(initHeaders || {})) {
headers.set(key, value);
}
@ -1674,13 +1687,14 @@ function buildBaseCodexHeaders(
}
function buildSSEHeaders(
defaultHeaders: Record<string, string>,
initHeaders: Record<string, string> | undefined,
additionalHeaders: ProviderHeaders | undefined,
accountId: string,
token: string,
sessionId?: string,
): Headers {
const headers = buildBaseCodexHeaders(initHeaders, additionalHeaders, accountId, token);
const headers = buildBaseCodexHeaders(defaultHeaders, initHeaders, additionalHeaders, accountId, token);
headers.set("OpenAI-Beta", "responses=experimental");
headers.set("accept", "text/event-stream");
headers.set("content-type", "application/json");
@ -1694,13 +1708,14 @@ function buildSSEHeaders(
}
function buildWebSocketHeaders(
defaultHeaders: Record<string, string>,
initHeaders: Record<string, string> | undefined,
additionalHeaders: ProviderHeaders | undefined,
accountId: string,
token: string,
requestId: string,
): Headers {
const headers = buildBaseCodexHeaders(initHeaders, additionalHeaders, accountId, token);
const headers = buildBaseCodexHeaders(defaultHeaders, initHeaders, additionalHeaders, accountId, token);
headers.delete("accept");
headers.delete("content-type");
headers.delete("OpenAI-Beta");

View file

@ -39,8 +39,8 @@ import { formatProviderError, normalizeProviderError } from "../utils/error-body
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { shortHash } from "../utils/hash.ts";
import { headersToRecord } from "../utils/headers.ts";
import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts";
import { parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
@ -351,6 +351,7 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
normalizedContext,
apiKey,
options?.headers,
options?.identity,
options?.fetch,
cacheSessionId,
compat,
@ -761,11 +762,12 @@ function createClient(
context: TranscriptContext,
apiKey: string,
optionsHeaders?: ProviderHeaders,
identity?: ClientIdentity,
fetch?: typeof globalThis.fetch,
sessionId?: string,
compat: ResolvedOpenAICompletionsCompat = getCompat(model),
) {
const headers: ProviderHeaders = { "User-Agent": getPiUserAgent(), ...model.headers };
const headers: ProviderHeaders = { ...getIdentityHeaders(model, identity), ...model.headers };
if (model.provider === "github-copilot") {
const hasImages = hasCopilotVisionInput(context.messages);
const copilotHeaders = buildCopilotDynamicHeaders({

View file

@ -18,7 +18,7 @@ import type {
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { getDeclaredTools, resolveTranscript, resolveTranscriptTools } from "../utils/transcript.ts";
@ -196,6 +196,7 @@ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions>
normalizedContext,
apiKey,
options?.headers,
options?.identity,
options?.fetch,
cacheSessionId,
);
@ -301,11 +302,12 @@ function createClient(
context: TranscriptContext,
apiKey: string,
optionsHeaders?: ProviderHeaders,
identity?: ClientIdentity,
fetch?: typeof globalThis.fetch,
sessionId?: string,
) {
const compat = getCompat(model);
const headers: ProviderHeaders = { "User-Agent": getPiUserAgent(), ...model.headers };
const headers: ProviderHeaders = { ...getIdentityHeaders(model, identity), ...model.headers };
if (model.provider === "github-copilot") {
const hasImages = hasCopilotVisionInput(context.messages);
const copilotHeaders = buildCopilotDynamicHeaders({

View file

@ -19,6 +19,7 @@ import type {
} from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
@ -58,7 +59,7 @@ export const generateImages: ImagesFunction<ImagesOptions> = async (
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const client = createClient(model, apiKey, options?.headers, options?.fetch);
const client = createClient(model, apiKey, options?.headers, options?.fetch, options?.identity);
let params = buildParams(model, context);
const nextParams = await options?.onPayload?.(params, model);
if (nextParams !== undefined) {
@ -121,13 +122,14 @@ function createClient(
apiKey: string,
optionsHeaders?: ProviderHeaders,
fetch?: typeof globalThis.fetch,
identity?: ClientIdentity,
): OpenAI {
return new OpenAI({
apiKey,
baseURL: model.baseUrl,
dangerouslyAllowBrowser: true,
fetch,
defaultHeaders: providerHeadersToRecord({ ...model.headers, ...optionsHeaders }),
defaultHeaders: providerHeadersToRecord(getIdentityHeaders(model, identity), model.headers, optionsHeaders),
});
}

View file

@ -27,6 +27,7 @@ import type {
import { appendAssistantMessageDiagnostic, createAssistantMessageDiagnostic } from "../utils/diagnostics.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
import { getIdentityHeaders } from "../utils/identity.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
@ -391,12 +392,15 @@ export const stream: StreamFunction<"pi-messages", PiMessagesOptions> = (
const response = await (options?.fetch ?? globalThis.fetch)(url, {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
accept: "text/event-stream",
"content-type": "application/json",
...providerHeadersToRecord(options?.headers),
},
headers: providerHeadersToRecord(
getIdentityHeaders(model, options?.identity),
{
authorization: `Bearer ${apiKey}`,
accept: "text/event-stream",
"content-type": "application/json",
},
options?.headers,
),
body: JSON.stringify(payload),
signal: options?.signal,
});

View file

@ -52,6 +52,7 @@ export function buildBaseOptions(
cacheRetention: options?.cacheRetention,
sessionId: options?.sessionId,
headers: options?.headers,
identity: options?.identity,
onPayload: options?.onPayload,
onResponse: options?.onResponse,
onProviderStreamEvent: options?.onProviderStreamEvent,

View file

@ -37,6 +37,7 @@ export * from "./session-resources.ts";
export * from "./types.ts";
export * from "./utils/diagnostics.ts";
export * from "./utils/event-stream.ts";
export * from "./utils/identity.ts";
export * from "./utils/json-parse.ts";
export * from "./utils/overflow.ts";
export * from "./utils/retry.ts";
@ -45,3 +46,4 @@ export * from "./utils/transcript.ts";
export * from "./utils/typebox-helpers.ts";
export { uuidv7 } from "./utils/uuid.ts";
export * from "./utils/validation.ts";
export { PI_AI_VERSION } from "./utils/version.ts";

View file

@ -56,6 +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 {
assertChatModel,
assertClassifierInputSupported,
@ -376,6 +377,11 @@ 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`.
*/
identity?: ClientIdentity;
}
function mergeHeaders(
@ -399,6 +405,7 @@ class ModelsImpl implements MutableModels {
private credentials: CredentialStore;
private modelsStore: ModelsStore;
private authContext: AuthContext;
private identity: ClientIdentity | undefined;
private refreshGenerations = new Map<string, number>();
private refreshControllers = new Map<string, AbortController>();
private publicationChains = new Map<string, Promise<unknown>>();
@ -407,6 +414,7 @@ class ModelsImpl implements MutableModels {
this.credentials = options?.credentials ?? new InMemoryCredentialStore();
this.modelsStore = options?.modelsStore ?? new InMemoryModelsStore();
this.authContext = options?.authContext ?? defaultAuthContext();
this.identity = options?.identity;
}
setProvider(provider: Provider): void {
@ -781,7 +789,9 @@ class ModelsImpl implements MutableModels {
if (!method?.login) {
throw new ModelsError("auth", `${provider.name} does not support ${type} login`);
}
const loginOperation: Promise<Credential> = method.login({ ...interaction, signal }, options);
const agentName = options?.agentName ?? getAttributionName(this.identity);
const loginOptions = agentName === undefined ? options : { ...options, agentName };
const loginOperation: Promise<Credential> = method.login({ ...interaction, signal }, loginOptions);
const credential = await raceWithAbortSignal(loginOperation, signal);
let mutationStarted = false;
let markMutationStarted: (() => void) | undefined;
@ -875,9 +885,15 @@ class ModelsImpl implements MutableModels {
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 = { ...providerOptions, apiKey, headers, env } as Omit<TOptions, "transformHeaders"> &
ProviderRequestOptions<TModel>;
const requestOptions = {
...providerOptions,
apiKey,
headers,
env,
...(identity === undefined ? {} : { identity }),
} as Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>;
return { requestModel, requestOptions };
}

View file

@ -28,6 +28,7 @@ import type {
} from "./providers/model-schema.ts";
import type { AssistantMessageDiagnostic } from "./utils/diagnostics.ts";
import type { AssistantMessageEventStream } from "./utils/event-stream.ts";
import type { ClientIdentity } from "./utils/identity.ts";
export type {
AnthropicAllowedFallbackModel,
@ -189,6 +190,11 @@ export interface ProviderRequestOptions<TModel = Model<Api>> {
* A null value suppresses a provider/API default header with the same name.
*/
headers?: ProviderHeaders;
/**
* How the request identifies the calling app: `User-Agent` and provider attribution headers.
* Absent fields use pi-ai's defaults: a detailed `User-Agent` and no attribution.
*/
identity?: ClientIdentity;
/**
* HTTP request timeout in milliseconds for providers/SDKs that support it.
* For example, OpenAI and Anthropic SDK clients default to 10 minutes.

View file

@ -0,0 +1,182 @@
import type { Models } from "../models.ts";
import { PI_AI_VERSION } from "./version.ts";
/**
* The `User-Agent` sent to providers:
* - `"detailed"`: `pi/<version> (<platform>; <runtime>/<runtime version>; <arch>)`, or `pi/<version> (browser)`
* outside Node.js and Bun.
* - `"short"`: `pi`.
* - `{ custom }`: the given value.
*/
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;
}
/**
* 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;
/**
* How pi-ai identifies the calling app to providers. Every field is optional; absent fields use pi-ai's defaults:
* a detailed `User-Agent` with pi-ai's version and no attribution headers. Plain JSON, so it can be persisted.
*/
export interface ClientIdentity {
/** Default: `"detailed"`. */
userAgent?: UserAgentMode;
/** Default: `false`. */
attribution?: ClientAttribution;
/** Version in the detailed `User-Agent`. Default: the pi-ai version. */
version?: string;
}
/** The provider and endpoint a request goes to. */
export interface ClientIdentityTarget {
readonly provider: string;
readonly baseUrl: string;
}
const PI_ATTRIBUTION_URL = "https://pi.dev";
const NVIDIA_NIM_HOST = "integrate.api.nvidia.com";
const OPENCODE_HOST = "opencode.ai";
/** Merge identities field by field; later sources win and undefined fields are skipped. */
export function mergeClientIdentity(...sources: (ClientIdentity | undefined)[]): ClientIdentity | undefined {
let merged: ClientIdentity | undefined;
for (const source of sources) {
if (!source) continue;
merged = { ...merged };
if (source.userAgent !== undefined) merged.userAgent = source.userAgent;
if (source.attribution !== undefined) merged.attribution = source.attribution;
if (source.version !== undefined) merged.version = source.version;
}
return merged;
}
/** The version in the detailed `User-Agent`. */
export function getClientVersion(identity?: ClientIdentity): string {
return identity?.version ?? PI_AI_VERSION;
}
function runtimeDescription(): string | undefined {
if (typeof process === "undefined") return undefined;
const versions = process.versions;
if (versions?.bun) return `bun/${versions.bun}`;
if (versions?.node) return `node/${process.version}`;
return undefined;
}
/** The `User-Agent` value of an identity. */
export function formatUserAgent(identity?: ClientIdentity): string {
const mode = identity?.userAgent ?? "detailed";
if (typeof mode === "object") return mode.custom;
if (mode === "short") return "pi";
const product = `pi/${getClientVersion(identity)}`;
const runtime = runtimeDescription();
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;
} catch {
return false;
}
}
function isOpenRouter(target: ClientIdentityTarget): boolean {
return target.provider === "openrouter" || target.baseUrl.includes("openrouter.ai");
}
function isNvidiaNim(target: ClientIdentityTarget): boolean {
return target.provider === "nvidia" || matchesHost(target.baseUrl, NVIDIA_NIM_HOST);
}
function isOpenCode(target: ClientIdentityTarget): boolean {
return (
target.provider === "opencode" || target.provider === "opencode-go" || matchesHost(target.baseUrl, OPENCODE_HOST)
);
}
/** 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);
if (!attribution) return {};
const { name, 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 }),
};
}
if (isNvidiaNim(target)) return { "X-BILLING-INVOKE-ORIGIN": name ?? "Pi" };
if (isOpenCode(target)) return { "x-opencode-client": name ?? "pi" };
return {};
}
/**
* 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.
*/
export function getIdentityHeaders(target: ClientIdentityTarget, identity?: ClientIdentity): Record<string, string> {
return { "User-Agent": formatUserAgent(identity), ...getAttributionHeaders(target, identity) };
}
const IDENTITY_REQUEST_METHODS = new Set<PropertyKey>([
"stream",
"complete",
"streamSimple",
"completeSimple",
"streamDeferred",
"fetchDeferred",
"cancelDeferred",
"generateImages",
"classify",
]);
/**
* 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.
*/
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),
});
},
});
}

View file

@ -1,19 +0,0 @@
import type * as NodeOs from "node:os";
type ProcessWithOsBuiltinModule = typeof process & {
getBuiltinModule?: (id: "node:os") => typeof NodeOs;
};
function loadNodeOs(): typeof NodeOs | null {
if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) {
return null;
}
return (process as ProcessWithOsBuiltinModule).getBuiltinModule?.("node:os") ?? null;
}
// Keep runtime OS loading browser-safe. A top-level runtime import of node:os breaks browser/Vite builds.
const nodeOs = loadNodeOs();
export function getPiUserAgent(): string {
return nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)";
}

View file

@ -0,0 +1,2 @@
// The @earendil-works/pi-ai package version. scripts/sync-versions.js keeps it in sync with package.json.
export const PI_AI_VERSION = "1.3.0";

View file

@ -1,10 +1,10 @@
import { arch, platform, release } from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV } from "../src/env-api-keys.ts";
import { createModels } from "../src/models.ts";
import { anthropicProvider } from "../src/providers/anthropic.ts";
import type { Model } from "../src/types.ts";
import { formatUserAgent } from "../src/utils/identity.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const mockState = vi.hoisted(() => ({
@ -55,7 +55,7 @@ vi.mock("@anthropic-ai/sdk", () => {
return { default: FakeAnthropic };
});
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
const PI_USER_AGENT = formatUserAgent();
const neverAbortedSignal = new AbortController().signal;
const context = normalizeContext({

View file

@ -1,10 +1,10 @@
import { arch, platform, release } from "node:os";
import type { ResponseStreamEvent } from "openai/resources/responses/responses.js";
import { Type } from "typebox";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { stream as streamAzureOpenAIResponses } from "../src/api/azure-openai-responses.ts";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { Api, Context, Model } from "../src/types.ts";
import { formatUserAgent } from "../src/utils/identity.ts";
interface CapturedAzureClientOptions {
apiKey: string;
@ -52,7 +52,7 @@ vi.mock("openai", () => {
return { AzureOpenAI };
});
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
const PI_USER_AGENT = formatUserAgent();
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],

View file

@ -1,4 +1,3 @@
import { arch, platform, release } from "node:os";
import { beforeEach, describe, expect, it, vi } from "vitest";
const googleGenAiMock = vi.hoisted(() => ({
@ -94,12 +93,13 @@ import { stream as streamGoogleGenerativeAi } from "../src/api/google-generative
import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { Api, Model, StreamOptions } from "../src/types.ts";
import { formatUserAgent } from "../src/utils/identity.ts";
beforeEach(() => {
googleGenAiMock.streamChunks = undefined;
});
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
const PI_USER_AGENT = formatUserAgent();
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],

View file

@ -1,4 +1,3 @@
import { arch, platform, release } from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const googleGenAiMock = vi.hoisted(() => ({
@ -49,8 +48,9 @@ vi.mock("@google/genai", () => {
import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { Model } from "../src/types.ts";
import { formatUserAgent } from "../src/utils/identity.ts";
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
const PI_USER_AGENT = formatUserAgent();
const model = getModel("google-vertex", "gemini-3-flash-preview");
const context = normalizeContext({
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],

View file

@ -0,0 +1,209 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { stream as streamCompletions, streamSimple as streamSimpleCompletions } from "../src/api/openai-completions.ts";
import { createModels, createProvider } from "../src/models.ts";
import type { FetchFunction, Model, ProviderHeaders } from "../src/types.ts";
import {
type ClientIdentity,
formatUserAgent,
getAttributionHeaders,
getIdentityHeaders,
mergeClientIdentity,
withClientIdentity,
} from "../src/utils/identity.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
import { PI_AI_VERSION } from "../src/utils/version.ts";
const runtime = process.versions.bun ? `bun/${process.versions.bun}` : `node/${process.version}`;
function target(provider: string, baseUrl: string) {
return { provider, baseUrl };
}
function completionsModel(provider: string, baseUrl: string, headers?: Record<string, string>) {
return {
id: "test-model",
name: "Test Model",
api: "openai-completions",
provider,
baseUrl,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
...(headers ? { headers } : {}),
} satisfies Model<"openai-completions">;
}
function capturingFetch(): { fetch: FetchFunction; headers: () => Headers } {
let captured: Headers | undefined;
const fetch: FetchFunction = async (input, init) => {
captured = new Request(input, init).headers;
const chunks = [
{ id: "chatcmpl-identity", choices: [{ delta: { content: "ok" }, finish_reason: null, index: 0 }] },
{
id: "chatcmpl-identity",
choices: [{ delta: {}, finish_reason: "stop", index: 0 }],
usage: { prompt_tokens: 1, completion_tokens: 1 },
},
];
const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n")}\n\ndata: [DONE]\n\n`;
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
};
return {
fetch,
headers: () => {
if (!captured) throw new Error("no request was sent");
return captured;
},
};
}
const context = normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: 1 }] });
describe("client identity", () => {
it("keeps the version constant in sync with package.json", () => {
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string };
expect(PI_AI_VERSION).toBe(pkg.version);
});
it("formats user agents", () => {
expect(formatUserAgent()).toBe(`pi/${PI_AI_VERSION} (${process.platform}; ${runtime}; ${process.arch})`);
expect(formatUserAgent({ version: "9.9.9" })).toBe(`pi/9.9.9 (${process.platform}; ${runtime}; ${process.arch})`);
expect(formatUserAgent({ userAgent: "short", version: "9.9.9" })).toBe("pi");
expect(formatUserAgent({ userAgent: { custom: "acme/1.0" } })).toBe("acme/1.0");
});
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 });
});
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(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({
"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" },
}),
).toEqual({
"HTTP-Referer": "https://acme.example",
"X-OpenRouter-Title": "acme",
"X-OpenRouter-Categories": "cloud-agent",
});
// 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",
});
});
it("attributes NVIDIA NIM and OpenCode requests with their expected spellings", () => {
expect(getAttributionHeaders(target("nvidia", "https://example.test/v1"), { attribution: true })).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({
"x-opencode-client": "pi",
});
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 }),
).not.toHaveProperty("X-BILLING-INVOKE-ORIGIN");
expect(
getAttributionHeaders(target("vercel-ai-gateway", "https://ai-gateway.vercel.sh/v1"), { attribution: true }),
).toEqual({});
});
});
describe("client identity in requests", () => {
it("applies the identity below model and request headers", async () => {
const capture = capturingFetch();
const model = completionsModel("openrouter", "https://openrouter.ai/api/v1", {
"X-OpenRouter-Title": "model-title",
});
await streamCompletions(model, context, {
apiKey: "key",
fetch: capture.fetch,
identity: { attribution: true, userAgent: "short" },
headers: { "HTTP-Referer": null } as ProviderHeaders,
}).result();
const headers = capture.headers();
expect(headers.get("user-agent")).toBe("pi");
expect(headers.get("x-openrouter-title")).toBe("model-title");
expect(headers.has("http-referer")).toBe(false);
});
it("keeps impersonating model headers over the identity User-Agent", async () => {
const capture = capturingFetch();
const model = completionsModel("github-copilot", "https://api.githubcopilot.com", {
"User-Agent": "GitHubCopilotChat/0.35.0",
});
await streamCompletions(model, context, {
apiKey: "key",
fetch: capture.fetch,
identity: { userAgent: { custom: "acme" } },
}).result();
expect(capture.headers().get("user-agent")).toBe("GitHubCopilotChat/0.35.0");
});
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" } });
models.setProvider(
createProvider({
id: "openrouter",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: { apiKey: "key" } }) } },
models: [model],
api: { stream: streamCompletions, streamSimple: streamSimpleCompletions },
}),
);
const send = async (target: typeof models, identity?: ClientIdentity) => {
const capture = capturingFetch();
await target.streamSimple(model, context, { fetch: capture.fetch, identity }).result();
return capture.headers();
};
const fromModels = await send(models);
expect(fromModels.get("user-agent")).toBe("pi");
expect(fromModels.get("x-openrouter-title")).toBe("models");
const wrapped = withClientIdentity(models, { 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);
const fromRequest = await send(wrapped, { attribution: false });
expect(fromRequest.has("x-openrouter-title")).toBe(false);
expect(fromRequest.get("user-agent")).toBe("pi");
});
});

View file

@ -1,11 +1,11 @@
import { arch, platform, release } from "node:os";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { stream as streamMistral } from "../src/api/mistral-conversations.ts";
import { getModel, normalizeContext } from "../src/compat.ts";
import type { Api, FetchFunction, Model, ProviderResponse } from "../src/types.ts";
import { formatUserAgent } from "../src/utils/identity.ts";
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
const PI_USER_AGENT = formatUserAgent();
function createSseResponse(events: unknown[], headers?: Record<string, string>): Response {
const body = `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\r\n\r\n")}\r\n\r\ndata: [DONE]\r\n\r\n`;

View file

@ -1,5 +1,5 @@
import { mkdtempSync } from "node:fs";
import { arch, platform, release, tmpdir } from "node:os";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { zstdDecompressSync } from "node:zlib";
import { Type } from "typebox";
@ -12,6 +12,7 @@ import {
streamSimple as streamSimpleOpenAICodexResponses,
} from "../src/api/openai-codex-responses.ts";
import type { Api, Context, Model } from "../src/types.ts";
import { formatUserAgent } from "../src/utils/identity.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
@ -161,7 +162,7 @@ describe("openai-codex streaming", () => {
expect(headers?.get("chatgpt-account-id")).toBe("acc_test");
expect(headers?.get("OpenAI-Beta")).toBe("responses=experimental");
expect(headers?.get("originator")).toBe("pi");
expect(headers?.get("User-Agent")).toBe(`pi (${platform()} ${release()}; ${arch()})`);
expect(headers?.get("User-Agent")).toBe(formatUserAgent());
expect(headers?.get("accept")).toBe("text/event-stream");
expect(headers?.has("x-api-key")).toBe(false);
return new Response(stream, {

View file

@ -1,4 +1,3 @@
import { arch, platform, release } from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
import type { OpenAIResponsesOptions } from "../src/api/openai-responses.ts";
@ -7,9 +6,10 @@ import { getSupportedThinkingLevels } from "../src/models.ts";
import { XAI_MODELS } from "../src/providers/xai.models.ts";
import { xaiProvider } from "../src/providers/xai.ts";
import type { Context, Model } from "../src/types.ts";
import { formatUserAgent } from "../src/utils/identity.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
const PI_USER_AGENT = formatUserAgent();
type CapturedRequest = {
url: string;

View file

@ -8,6 +8,7 @@
### Changed
- Provider attribution headers now come from pi-ai's client identity. Extensions' `before_provider_headers` handlers no longer see the OpenRouter, NVIDIA NIM, and OpenCode attribution headers; returning `null` for one still removes it. Cloudflare requests send pi's detailed `User-Agent` instead of `pi-coding-agent`, as do GitHub release checks for tool downloads, and OpenCode requests send `x-opencode-client` whenever attribution is enabled, not only with a session.
- `generateSummary()` and `generateSummaryWithUsage()` now ignore their `reserveTokens` parameter. Summary requests use the model's output limit instead.
- Changed the default GitHub Copilot model from `gpt-5.4`, which Copilot retired, to `gpt-6.1-sol`.

View file

@ -0,0 +1,40 @@
import type { Api, ClientIdentity, Model, ProviderHeaders } 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.
*/
export function getClientIdentity(settingsManager: SettingsManager): ClientIdentity {
return {
userAgent: "detailed",
version: VERSION,
attribution: isInstallTelemetryEnabled(settingsManager) ? { categories: "cli-agent" } : false,
};
}
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

@ -1,97 +0,0 @@
import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai";
import type { SettingsManager } from "./settings-manager.ts";
import { isInstallTelemetryEnabled } from "./telemetry.ts";
const OPENROUTER_HOST = "openrouter.ai";
const NVIDIA_NIM_HOST = "integrate.api.nvidia.com";
const CLOUDFLARE_API_HOST = "api.cloudflare.com";
const CLOUDFLARE_AI_GATEWAY_HOST = "gateway.ai.cloudflare.com";
const OPENCODE_HOST = "opencode.ai";
function matchesHost(baseUrl: string, expectedHost: string): boolean {
try {
return new URL(baseUrl).hostname === expectedHost;
} catch {
return false;
}
}
function isOpenRouterModel(model: Model<Api>): boolean {
return model.provider === "openrouter" || model.baseUrl.includes(OPENROUTER_HOST);
}
function isNvidiaNimModel(model: Model<Api>): boolean {
return model.provider === "nvidia" || matchesHost(model.baseUrl, NVIDIA_NIM_HOST);
}
function isCloudflareModel(model: Model<Api>): boolean {
return (
model.provider === "cloudflare-workers-ai" ||
model.provider === "cloudflare-ai-gateway" ||
matchesHost(model.baseUrl, CLOUDFLARE_API_HOST) ||
matchesHost(model.baseUrl, CLOUDFLARE_AI_GATEWAY_HOST)
);
}
function getDefaultAttributionHeaders(
model: Model<Api>,
settingsManager: SettingsManager,
): Record<string, string> | undefined {
if (!isInstallTelemetryEnabled(settingsManager)) {
return undefined;
}
if (isOpenRouterModel(model)) {
return {
"HTTP-Referer": "https://pi.dev",
"X-OpenRouter-Title": "pi",
"X-OpenRouter-Categories": "cli-agent",
};
}
if (isNvidiaNimModel(model)) {
return {
"X-BILLING-INVOKE-ORIGIN": "Pi",
};
}
if (isCloudflareModel(model)) {
return {
"User-Agent": "pi-coding-agent",
};
}
return undefined;
}
function getSessionHeaders(model: Model<Api>, sessionId: string | undefined): Record<string, string> | undefined {
if (!sessionId) return undefined;
if (
model.provider !== "opencode" &&
model.provider !== "opencode-go" &&
!matchesHost(model.baseUrl, OPENCODE_HOST)
) {
return undefined;
}
return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
}
export function mergeProviderAttributionHeaders(
model: Model<Api>,
settingsManager: SettingsManager,
sessionId: string | undefined,
...headerSources: Array<ProviderHeaders | undefined>
): ProviderHeaders | undefined {
const merged: ProviderHeaders = {
...getSessionHeaders(model, sessionId),
...getDefaultAttributionHeaders(model, settingsManager),
};
for (const headers of headerSources) {
if (headers) {
Object.assign(merged, headers);
}
}
return Object.keys(merged).length > 0 ? merged : undefined;
}

View file

@ -7,13 +7,13 @@ 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 { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { createToolNameMatcher } from "./mcp-servers.ts";
import { convertToLlm } from "./messages.ts";
import { findInitialModel } from "./model-resolver.ts";
import { ModelRuntime } from "./model-runtime.ts";
import { mergeProviderAttributionHeaders } from "./provider-attribution.ts";
import type { ResourceLoader } from "./resource-loader.ts";
import { DefaultResourceLoader } from "./resource-loader.ts";
import { getDefaultSessionDir, SessionManager } from "./session-manager.ts";
@ -351,16 +351,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
websocketConnectTimeoutMs: options.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs(),
maxRetries: options.maxRetries ?? providerRetrySettings.maxRetries,
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 = mergeProviderAttributionHeaders(
requestModel,
settingsManager,
options.sessionId,
requestHeaders,
);
const headers = withOpenCodeSessionHeader(requestModel, options.sessionId, requestHeaders);
return headerRunner?.hasHandlers("before_provider_headers")
? headerRunner.emitBeforeProviderHeaders(headers ?? {})
: (headers ?? {});
? headerRunner.emitBeforeProviderHeaders(headers)
: headers;
},
};
};

View file

@ -1,4 +1,6 @@
import { formatUserAgent } from "@earendil-works/pi-ai";
/** pi's `User-Agent` for its own network requests; the same detailed form pi-ai sends to providers. */
export function getPiUserAgent(version: string): string {
const runtime = process.versions.bun ? `bun/${process.versions.bun}` : `node/${process.version}`;
return `pi/${version} (${process.platform}; ${runtime}; ${process.arch})`;
return formatUserAgent({ userAgent: "detailed", version });
}

View file

@ -4,8 +4,9 @@ import { arch, platform } from "os";
import { join } from "path";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
import { APP_NAME, getBinDir } from "../config.ts";
import { getBinDir, VERSION } from "../config.ts";
import { fetchWithRetry } from "./management-http.ts";
import { getPiUserAgent } from "./pi-user-agent.ts";
const TOOLS_DIR = getBinDir();
const NETWORK_TIMEOUT_MS = 10_000;
@ -111,7 +112,7 @@ export async function getLatestVersion(repo: string): Promise<string> {
const response = await fetchWithRetry(
`https://github.com/${repo}/releases/latest`,
{
headers: { "User-Agent": `${APP_NAME}-coding-agent` },
headers: { "User-Agent": getPiUserAgent(VERSION) },
redirect: "manual",
},
{ timeoutMs: NETWORK_TIMEOUT_MS },

View file

@ -0,0 +1,190 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
type Api,
type AssistantMessage,
createAssistantMessageEventStream,
getAttributionHeaders,
type Model,
normalizeContext,
type SimpleStreamOptions,
} from "@earendil-works/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { VERSION } from "../src/config.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { createAgentSession } from "../src/core/sdk.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { createInMemoryModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
describe("createAgentSession client identity", () => {
let tempDir: string;
let cwd: string;
let agentDir: string;
let originalTelemetryEnv: string | undefined;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-sdk-identity-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
cwd = join(tempDir, "project");
agentDir = join(tempDir, "agent");
mkdirSync(cwd, { recursive: true });
mkdirSync(agentDir, { recursive: true });
originalTelemetryEnv = process.env.PI_TELEMETRY;
delete process.env.PI_TELEMETRY;
});
afterEach(() => {
if (originalTelemetryEnv === undefined) {
delete process.env.PI_TELEMETRY;
} else {
process.env.PI_TELEMETRY = originalTelemetryEnv;
}
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
function createModel(provider: string, baseUrl: string, id = `${provider}-test-model`): Model<Api> {
return {
id,
name: `${provider} Test Model`,
api: "openai-completions",
provider,
baseUrl,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
};
}
function createDoneStream() {
const stream = createAssistantMessageEventStream();
const message: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "ok" }],
api: "openai-completions",
provider: "capture-provider",
model: "capture-model",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
stream.end(message);
return stream;
}
async function captureOptions(
model: Model<Api>,
options: {
telemetryEnabled?: boolean;
providerHeaders?: Record<string, string>;
sessionId?: string;
} = {},
): Promise<SimpleStreamOptions | undefined> {
const settingsManager = SettingsManager.create(cwd, agentDir);
if (options.telemetryEnabled === false) {
settingsManager.setEnableInstallTelemetry(false);
}
const authStorage = AuthStorage.inMemory({
[model.provider]: { type: "api_key", key: "test-api-key" },
});
const modelRegistry = await createInMemoryModelRegistry(authStorage);
let capturedOptions: SimpleStreamOptions | undefined;
modelRegistry.registerProvider(model.provider, {
api: model.api,
headers: options.providerHeaders,
streamSimple: (_model, _context, providerOptions) => {
capturedOptions = providerOptions;
return createDoneStream();
},
});
const modelRuntime = getModelRuntime(modelRegistry);
const sessionManager = SessionManager.inMemory(cwd);
if (options.sessionId) {
sessionManager.newSession({ id: options.sessionId });
}
const { session } = await createAgentSession({
cwd,
agentDir,
model,
modelRuntime,
settingsManager,
sessionManager,
});
try {
const stream = await session.agent.streamFunction(model, normalizeContext({ messages: [] }), {
sessionId: session.sessionId,
});
await stream.result();
return capturedOptions;
} finally {
session.dispose();
modelRegistry.unregisterProvider(model.provider);
}
}
it("identifies pi with a detailed User-Agent and attribution as a CLI agent", async () => {
const model = createModel("openrouter", "https://openrouter.ai/api/v1");
const options = await captureOptions(model);
expect(options?.identity).toEqual({
userAgent: "detailed",
version: VERSION,
attribution: { categories: "cli-agent" },
});
expect(getAttributionHeaders(model, options?.identity)).toEqual({
"HTTP-Referer": "https://pi.dev",
"X-OpenRouter-Title": "pi",
"X-OpenRouter-Categories": "cli-agent",
});
});
it("turns attribution off when telemetry is disabled", async () => {
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(getAttributionHeaders(model, options?.identity)).toEqual({});
});
it("turns attribution off with PI_TELEMETRY=0", async () => {
process.env.PI_TELEMETRY = "0";
const options = await captureOptions(createModel("openrouter", "https://openrouter.ai/api/v1"));
expect(options?.identity?.attribution).toBe(false);
});
it("adds OpenCode session headers for custom OpenCode providers", async () => {
const options = await captureOptions(createModel("custom-opencode", "https://opencode.ai/zen/v1"), {
sessionId: "opencode-session",
});
expect(options?.headers?.["x-opencode-session"]).toBe("opencode-session");
});
it("lets configured OpenCode headers override the session header", async () => {
const options = await captureOptions(createModel("opencode", "https://opencode.ai/zen/v1"), {
sessionId: "opencode-session",
providerHeaders: {
"x-opencode-session": "configured-session",
},
});
expect(options?.headers?.["x-opencode-session"]).toBe("configured-session");
});
});

View file

@ -1,263 +0,0 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
type Api,
type AssistantMessage,
createAssistantMessageEventStream,
type Model,
normalizeContext,
type ProviderHeaders,
type SimpleStreamOptions,
} from "@earendil-works/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { createAgentSession } from "../src/core/sdk.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { createInMemoryModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
describe("createAgentSession provider attribution headers", () => {
let tempDir: string;
let cwd: string;
let agentDir: string;
let originalTelemetryEnv: string | undefined;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-sdk-attribution-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
cwd = join(tempDir, "project");
agentDir = join(tempDir, "agent");
mkdirSync(cwd, { recursive: true });
mkdirSync(agentDir, { recursive: true });
originalTelemetryEnv = process.env.PI_TELEMETRY;
delete process.env.PI_TELEMETRY;
});
afterEach(() => {
if (originalTelemetryEnv === undefined) {
delete process.env.PI_TELEMETRY;
} else {
process.env.PI_TELEMETRY = originalTelemetryEnv;
}
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
function createModel(provider: string, baseUrl: string, id = `${provider}-test-model`): Model<Api> {
return {
id,
name: `${provider} Test Model`,
api: "openai-completions",
provider,
baseUrl,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
};
}
function createDoneStream() {
const stream = createAssistantMessageEventStream();
const message: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "ok" }],
api: "openai-completions",
provider: "capture-provider",
model: "capture-model",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
stream.end(message);
return stream;
}
async function captureHeaders(
model: Model<Api>,
options: {
telemetryEnabled?: boolean;
providerHeaders?: Record<string, string>;
requestHeaders?: Record<string, string>;
sessionId?: string;
} = {},
): Promise<ProviderHeaders | undefined> {
const settingsManager = SettingsManager.create(cwd, agentDir);
if (options.telemetryEnabled === false) {
settingsManager.setEnableInstallTelemetry(false);
}
const authStorage = AuthStorage.inMemory({
[model.provider]: { type: "api_key", key: "test-api-key" },
});
const modelRegistry = await createInMemoryModelRegistry(authStorage);
let capturedOptions: SimpleStreamOptions | undefined;
modelRegistry.registerProvider(model.provider, {
api: model.api,
headers: options.providerHeaders,
streamSimple: (_model, _context, providerOptions) => {
capturedOptions = providerOptions;
return createDoneStream();
},
});
const modelRuntime = getModelRuntime(modelRegistry);
const sessionManager = SessionManager.inMemory(cwd);
if (options.sessionId) {
sessionManager.newSession({ id: options.sessionId });
}
const { session } = await createAgentSession({
cwd,
agentDir,
model,
modelRuntime,
settingsManager,
sessionManager,
});
try {
const stream = await session.agent.streamFunction(model, normalizeContext({ messages: [] }), {
sessionId: session.sessionId,
...(options.requestHeaders ? { headers: options.requestHeaders } : {}),
});
await stream.result();
return capturedOptions?.headers;
} finally {
session.dispose();
modelRegistry.unregisterProvider(model.provider);
}
}
it("adds default attribution headers for OpenRouter models", async () => {
const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1"));
expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev");
expect(headers?.["X-OpenRouter-Title"]).toBe("pi");
expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent");
});
it("does not add attribution headers when telemetry is disabled", async () => {
const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1"), {
telemetryEnabled: false,
});
expect(headers?.["HTTP-Referer"]).toBeUndefined();
expect(headers?.["X-OpenRouter-Title"]).toBeUndefined();
expect(headers?.["X-OpenRouter-Categories"]).toBeUndefined();
});
it("adds attribution headers for custom providers routed through OpenRouter", async () => {
const headers = await captureHeaders(createModel("custom-openrouter", "https://openrouter.ai/api/v1"));
expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev");
expect(headers?.["X-OpenRouter-Title"]).toBe("pi");
expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent");
});
it("preserves legacy OpenRouter base URL substring attribution matching", async () => {
const headers = await captureHeaders(createModel("custom-openrouter", "not-a-url-openrouter.ai"));
expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev");
expect(headers?.["X-OpenRouter-Title"]).toBe("pi");
expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent");
});
it("lets provider and request headers override the defaults", async () => {
const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1"), {
providerHeaders: {
"HTTP-Referer": "https://provider.example",
"X-OpenRouter-Categories": "provider-category",
},
requestHeaders: {
"X-OpenRouter-Title": "request-title",
},
});
expect(headers?.["HTTP-Referer"]).toBe("https://provider.example");
expect(headers?.["X-OpenRouter-Title"]).toBe("request-title");
expect(headers?.["X-OpenRouter-Categories"]).toBe("provider-category");
});
it("adds default attribution headers for direct NVIDIA NIM endpoints", async () => {
const headers = await captureHeaders(createModel("custom-nim", "https://integrate.api.nvidia.com/v1"));
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Pi");
});
it("adds default attribution headers for the NVIDIA provider", async () => {
const headers = await captureHeaders(createModel("nvidia", "https://example.test/v1"));
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Pi");
});
it("does not add NVIDIA NIM attribution headers when telemetry is disabled", async () => {
const headers = await captureHeaders(createModel("nvidia", "https://integrate.api.nvidia.com/v1"), {
telemetryEnabled: false,
});
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined();
});
it("lets provider and request headers override NVIDIA NIM defaults", async () => {
const headers = await captureHeaders(createModel("nvidia", "https://integrate.api.nvidia.com/v1"), {
providerHeaders: {
"X-BILLING-INVOKE-ORIGIN": "Provider",
},
requestHeaders: {
"X-BILLING-INVOKE-ORIGIN": "Request",
},
});
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Request");
});
it("does not add NVIDIA NIM attribution headers for NVIDIA models routed through OpenRouter", async () => {
const headers = await captureHeaders(
createModel("openrouter", "https://openrouter.ai/api/v1", "nvidia/nemotron-3-super-120b-a12b"),
);
expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev");
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined();
});
it("does not add NVIDIA NIM attribution headers for NVIDIA models routed through Vercel AI Gateway", async () => {
const headers = await captureHeaders(
createModel("vercel-ai-gateway", "https://ai-gateway.vercel.sh/v1", "nvidia/nemotron-3-super-120b-a12b"),
);
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined();
});
it("adds OpenCode session headers", async () => {
const headers = await captureHeaders(createModel("opencode", "https://opencode.ai/zen/v1"), {
sessionId: "opencode-session",
});
expect(headers?.["x-opencode-session"]).toBe("opencode-session");
expect(headers?.["x-opencode-client"]).toBe("pi");
});
it("lets configured OpenCode headers override the defaults", async () => {
const headers = await captureHeaders(createModel("opencode", "https://opencode.ai/zen/v1"), {
sessionId: "opencode-session",
providerHeaders: {
"x-opencode-session": "configured-session",
"x-opencode-client": "configured-client",
},
});
expect(headers?.["x-opencode-session"]).toBe("configured-session");
expect(headers?.["x-opencode-client"]).toBe("configured-client");
});
});

View file

@ -23,6 +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.
- `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,6 +77,8 @@ 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.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.
- `pi.durable.submit` covers the public submit call and its commit. Each input starts a new root trace with a link to its caller's current span; a write is a child of its caller. Duplicate `requestId` preserves the original submission and carrier. Internal admissions, such as compaction writes, emit events on their phase span without a submit span.

View file

@ -1,6 +1,7 @@
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 {
createTypedMetrics,
NOOP_TELEMETRY_CONTEXT,
@ -178,9 +179,29 @@ class ConversationImpl<Tool extends ToolRegistration> implements Conversation {
}
}
/** `HarnessOptions.models` as a function: a plain `Models` is returned at every call. */
function currentModels(models: HarnessOptions["models"]): () => Models {
return typeof models === "function" ? models : () => models;
/** 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 };
/**
* `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.
*/
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;
};
}
/** `HarnessOptions.settings` as a function: plain settings, or none, are returned at every call. */
@ -215,7 +236,7 @@ class HarnessImpl<Tool extends ToolRegistration> extends SessionImpl implements
session: this,
storage,
registry: options.registry,
models: currentModels(options.models),
models: currentModels(options.models, options.identity),
agent: (id, snapshot, callContext) => this.resolveAgent(id, snapshot as RegistrySnapshot<Tool>, callContext),
settings,
env: (id, callContext) => this.buildEnv(id, callContext),

View file

@ -2,6 +2,7 @@ import type { AttachedReplicatedState, Context, JsonValue } from "@earendil-work
import type {
AssistantMessage,
CacheRetention,
ClientIdentity,
Message,
Models,
ModelThinkingLevel,
@ -613,6 +614,13 @@ export type HarnessOptions<Tool extends ToolRegistration = ToolRegistration> = {
* one, called at each use, so a host can replace its model configuration without reopening the Harness.
*/
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.
*/
readonly identity?: ClientIdentity;
readonly registry: RegistryReader<Tool>;
/** Run settings, or a function returning the current ones; read at each use either way. */
readonly settings?: HarnessSettings | (() => HarnessSettings | undefined);

View file

@ -38,7 +38,7 @@ export {
type GenerationResult,
GenerationTask,
} from "./harness/generation.ts";
export { Harness } from "./harness/harness.ts";
export { DEFAULT_HARNESS_IDENTITY, Harness } from "./harness/harness.ts";
export { InboxDoc, type InboxItem, type InboxState } from "./harness/inbox.ts";
export {
type CompactionFailure,

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);
});

View file

@ -37,7 +37,8 @@ const BUDGETS = {
"packages/ai": {
"./models": {
// Request telemetry reaches pi-ai's telemetry.ts and pi-telemetry's root (index, noop, metrics, memory).
maxFiles: 18,
// Request identity reaches utils/identity.ts and its version constant.
maxFiles: 20,
forbid: ["providers/", "models.generated.ts", "index.ts", "utils/validation.ts", "utils/typebox-helpers.ts"],
allow: ["packages/telemetry/src/index.ts"],
},
@ -63,7 +64,8 @@ const BUDGETS = {
// memory and SQLite storages share scan order and cursor handling. Values from Chord's root (copyJson,
// replicatedState) reach its host runtime through the root barrel. Chord's context reaches pi-telemetry's root
// (index, noop, metrics, memory) for its NOOP telemetry default; harness/telemetry.ts is Durable's schema.
maxFiles: 71,
// The Harness's request identity reaches pi-ai's utils/identity.ts and its version constant.
maxFiles: 73,
forbid: [
"packages/ai/src/index.ts",
"packages/ai/src/utils/typebox-helpers.ts",

View file

@ -5,8 +5,8 @@
* exact internal dependency versions in all workspace packages, including private ones.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { findPackageDirectories } from "./package-workspaces.mjs";
const GENERATED_PACKAGE_SUFFIXES = [join("coding-agent", "install-lock")];
@ -75,3 +75,14 @@ if (totalUpdates === 0) {
} else {
console.log(`\nUpdated ${totalUpdates} dependency version(s).`);
}
// pi-ai reports its version in the User-Agent without reading package.json, which browsers cannot do.
const aiPackage = workspacePackages.find((pkg) => pkg.data.name === "@earendil-works/pi-ai");
const aiVersionPath = aiPackage && join(dirname(aiPackage.path), "src", "utils", "version.ts");
if (aiVersionPath && existsSync(aiVersionPath)) {
const content = `// The @earendil-works/pi-ai package version. scripts/sync-versions.js keeps it in sync with package.json.\nexport const PI_AI_VERSION = ${JSON.stringify(aiPackage.data.version)};\n`;
if (readFileSync(aiVersionPath, "utf8") !== content) {
writeFileSync(aiVersionPath, content);
console.log(`\nUpdated ${aiVersionPath} to ${aiPackage.data.version}.`);
}
}

View file

@ -91,3 +91,22 @@ test("pins published and private dependencies without touching registry aliases,
await rm(root, { recursive: true, force: true });
}
});
test("writes the pi-ai version constant", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-sync-versions-"));
try {
await writeManifest(root, "packages/ai", {
name: "@earendil-works/pi-ai",
version: "2.0.0",
});
const versionPath = join(root, "packages/ai/src/utils/version.ts");
await mkdir(join(root, "packages/ai/src/utils"), { recursive: true });
await writeFile(versionPath, 'export const PI_AI_VERSION = "1.0.0";\n');
const result = runSyncVersions(root);
assert.equal(result.status, 0, result.stderr);
assert.match(await readFile(versionPath, "utf8"), /export const PI_AI_VERSION = "2\.0\.0";\n$/);
} finally {
await rm(root, { recursive: true, force: true });
}
});