From adc5fa9f800bee587e35b1a7b316e4b11a8aef24 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 21 Oct 2026 12:17:35 +0200 Subject: [PATCH] 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. --- packages/agent/CHANGELOG.md | 4 + packages/agent/README.md | 5 + packages/agent/src/agent-loop.ts | 9 + packages/agent/src/agent.ts | 10 + packages/agent/test/agent.test.ts | 27 ++ packages/ai/CHANGELOG.md | 3 + packages/ai/README.md | 19 ++ packages/ai/src/api/anthropic-messages.ts | 18 +- packages/ai/src/api/azure-openai-responses.ts | 4 +- .../ai/src/api/bedrock-converse-stream.ts | 12 + packages/ai/src/api/classifier-shared.ts | 5 +- packages/ai/src/api/google-generative-ai.ts | 7 +- packages/ai/src/api/google-vertex.ts | 27 +- packages/ai/src/api/llama-cpp-classify.ts | 2 + packages/ai/src/api/mistral-conversations.ts | 4 +- packages/ai/src/api/openai-codex-responses.ts | 27 +- packages/ai/src/api/openai-completions.ts | 6 +- packages/ai/src/api/openai-responses.ts | 6 +- packages/ai/src/api/openrouter-images.ts | 6 +- packages/ai/src/api/pi-messages.ts | 16 +- packages/ai/src/api/simple-options.ts | 1 + packages/ai/src/index.ts | 2 + packages/ai/src/models.ts | 22 +- packages/ai/src/types.ts | 6 + packages/ai/src/utils/identity.ts | 182 ++++++++++++ packages/ai/src/utils/pi-user-agent.ts | 19 -- packages/ai/src/utils/version.ts | 2 + packages/ai/test/anthropic-auth-token.test.ts | 4 +- .../ai/test/azure-openai-base-url.test.ts | 4 +- .../ai/test/google-raw-stop-reason.test.ts | 4 +- .../google-vertex-api-key-resolution.test.ts | 4 +- packages/ai/test/identity.test.ts | 209 ++++++++++++++ .../ai/test/mistral-http-transport.test.ts | 4 +- packages/ai/test/openai-codex-stream.test.ts | 5 +- packages/ai/test/xai-responses.test.ts | 4 +- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/client-identity.ts | 40 +++ .../src/core/provider-attribution.ts | 97 ------- packages/coding-agent/src/core/sdk.ts | 15 +- .../coding-agent/src/utils/pi-user-agent.ts | 6 +- .../coding-agent/src/utils/tools-manager.ts | 5 +- .../test/sdk-client-identity.test.ts | 190 +++++++++++++ .../test/sdk-openrouter-attribution.test.ts | 263 ------------------ packages/durable/CHANGELOG.md | 1 + packages/durable/README.md | 2 + packages/durable/src/harness/harness.ts | 29 +- packages/durable/src/harness/types.ts | 8 + packages/durable/src/index.ts | 2 +- packages/durable/test/chat-support.ts | 3 + .../durable/test/harness-generation.test.ts | 18 ++ .../durable/test/harness-host-inputs.test.ts | 7 +- packages/durable/test/harness-tools.test.ts | 7 +- scripts/check-entry-graphs.mjs | 6 +- scripts/sync-versions.js | 15 +- scripts/sync-versions.test.mjs | 19 ++ 55 files changed, 958 insertions(+), 465 deletions(-) create mode 100644 packages/ai/src/utils/identity.ts delete mode 100644 packages/ai/src/utils/pi-user-agent.ts create mode 100644 packages/ai/src/utils/version.ts create mode 100644 packages/ai/test/identity.test.ts create mode 100644 packages/coding-agent/src/core/client-identity.ts delete mode 100644 packages/coding-agent/src/core/provider-attribution.ts create mode 100644 packages/coding-agent/test/sdk-client-identity.test.ts delete mode 100644 packages/coding-agent/test/sdk-openrouter-attribution.test.ts diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 50e4b0bd6..86901c811 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -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 diff --git a/packages/agent/README.md b/packages/agent/README.md index 5cb3954e1..80f5f8076 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -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(), diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index f406aba56..6d2fc9f1f 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -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; +/** + * 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. diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index ee8e6e591..c34cad078 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -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, diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 8ada745d5..b21b9f7fd 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -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" }, + ]); + }); }); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 993cc8493..9fff2e389 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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/ (; ; )` instead of `pi ( ; )`, 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)) diff --git a/packages/ai/README.md b/packages/ai/README.md index cc958bc40..b0363ee21 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -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/ (; ; )`, `"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: diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index af4327e8a..2754794c3 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -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, 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", diff --git a/packages/ai/src/api/azure-openai-responses.ts b/packages/ai/src/api/azure-openai-responses.ts index c65d4a236..07ce1a289 100644 --- a/packages/ai/src/api/azure-openai-responses.ts +++ b/packages/ai/src/api/azure-openai-responses.ts @@ -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); diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index 7434d81d2..8c6df2531 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -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 diff --git a/packages/ai/src/api/classifier-shared.ts b/packages/ai/src/api/classifier-shared.ts index 9788fc072..81818f759 100644 --- a/packages/ai/src/api/classifier-shared.ts +++ b/packages/ai/src/api/classifier-shared.ts @@ -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, apiKey: string, optionsHeaders?: ProviderHeaders, + identity?: ClientIdentity, ): Record { 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, }); diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index 24fe9134c..5e44ce2a4 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -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 } = {}; 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; } diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index 4e667df18..1865cbceb 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -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; } diff --git a/packages/ai/src/api/llama-cpp-classify.ts b/packages/ai/src/api/llama-cpp-classify.ts index d69bb3329..817caea39 100644 --- a/packages/ai/src/api/llama-cpp-classify.ts +++ b/packages/ai/src/api/llama-cpp-classify.ts @@ -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}` } : {}), diff --git a/packages/ai/src/api/mistral-conversations.ts b/packages/ai/src/api/mistral-conversations.ts index 11788eb39..9ac710399 100644 --- a/packages/ai/src/api/mistral-conversations.ts +++ b/packages/ai/src/api/mistral-conversations.ts @@ -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", diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 5ce24e068..5330764b5 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -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, initHeaders: Record | 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, initHeaders: Record | 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, initHeaders: Record | 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"); diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 8686373c9..083b6dc8f 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -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({ diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index da04f0a0f..8ddaa6944 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -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({ diff --git a/packages/ai/src/api/openrouter-images.ts b/packages/ai/src/api/openrouter-images.ts index cbb3b0005..be9c37007 100644 --- a/packages/ai/src/api/openrouter-images.ts +++ b/packages/ai/src/api/openrouter-images.ts @@ -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 = 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), }); } diff --git a/packages/ai/src/api/pi-messages.ts b/packages/ai/src/api/pi-messages.ts index 2137b551f..605194937 100644 --- a/packages/ai/src/api/pi-messages.ts +++ b/packages/ai/src/api/pi-messages.ts @@ -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, }); diff --git a/packages/ai/src/api/simple-options.ts b/packages/ai/src/api/simple-options.ts index 5b2d70c8a..85bfa78f9 100644 --- a/packages/ai/src/api/simple-options.ts +++ b/packages/ai/src/api/simple-options.ts @@ -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, diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 095605238..d58b2a259 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -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"; diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index 967496e88..310e971b9 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -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(); private refreshControllers = new Map(); private publicationChains = new Map>(); @@ -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 = method.login({ ...interaction, signal }, options); + const agentName = options?.agentName ?? getAttributionName(this.identity); + const loginOptions = agentName === undefined ? options : { ...options, agentName }; + const loginOperation: Promise = 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 & - ProviderRequestOptions; + const requestOptions = { + ...providerOptions, + apiKey, + headers, + env, + ...(identity === undefined ? {} : { identity }), + } as Omit & ProviderRequestOptions; return { requestModel, requestOptions }; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index b729c1521..a04b9d212 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -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> { * 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. diff --git a/packages/ai/src/utils/identity.ts b/packages/ai/src/utils/identity.ts new file mode 100644 index 000000000..0a87bc3d2 --- /dev/null +++ b/packages/ai/src/utils/identity.ts @@ -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/ (; /; )`, or `pi/ (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 { + 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 { + return { "User-Agent": formatUserAgent(identity), ...getAttributionHeaders(target, identity) }; +} + +const IDENTITY_REQUEST_METHODS = new Set([ + "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(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), + }); + }, + }); +} diff --git a/packages/ai/src/utils/pi-user-agent.ts b/packages/ai/src/utils/pi-user-agent.ts deleted file mode 100644 index 93b23dd35..000000000 --- a/packages/ai/src/utils/pi-user-agent.ts +++ /dev/null @@ -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)"; -} diff --git a/packages/ai/src/utils/version.ts b/packages/ai/src/utils/version.ts new file mode 100644 index 000000000..e2741c484 --- /dev/null +++ b/packages/ai/src/utils/version.ts @@ -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"; diff --git a/packages/ai/test/anthropic-auth-token.test.ts b/packages/ai/test/anthropic-auth-token.test.ts index 6b1da0abb..a49108638 100644 --- a/packages/ai/test/anthropic-auth-token.test.ts +++ b/packages/ai/test/anthropic-auth-token.test.ts @@ -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({ diff --git a/packages/ai/test/azure-openai-base-url.test.ts b/packages/ai/test/azure-openai-base-url.test.ts index c3f8ae7eb..c1b5ffeda 100644 --- a/packages/ai/test/azure-openai-base-url.test.ts +++ b/packages/ai/test/azure-openai-base-url.test.ts @@ -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() }], diff --git a/packages/ai/test/google-raw-stop-reason.test.ts b/packages/ai/test/google-raw-stop-reason.test.ts index 37b79a66e..c5115cc1a 100644 --- a/packages/ai/test/google-raw-stop-reason.test.ts +++ b/packages/ai/test/google-raw-stop-reason.test.ts @@ -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() }], diff --git a/packages/ai/test/google-vertex-api-key-resolution.test.ts b/packages/ai/test/google-vertex-api-key-resolution.test.ts index 69d25cb49..42e3abb6d 100644 --- a/packages/ai/test/google-vertex-api-key-resolution.test.ts +++ b/packages/ai/test/google-vertex-api-key-resolution.test.ts @@ -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() }], diff --git a/packages/ai/test/identity.test.ts b/packages/ai/test/identity.test.ts new file mode 100644 index 000000000..e93acd35d --- /dev/null +++ b/packages/ai/test/identity.test.ts @@ -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) { + 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"); + }); +}); diff --git a/packages/ai/test/mistral-http-transport.test.ts b/packages/ai/test/mistral-http-transport.test.ts index 6afe745f5..cc1f115a5 100644 --- a/packages/ai/test/mistral-http-transport.test.ts +++ b/packages/ai/test/mistral-http-transport.test.ts @@ -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): Response { const body = `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\r\n\r\n")}\r\n\r\ndata: [DONE]\r\n\r\n`; diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 9283a95d6..fbb6c10a4 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -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, { diff --git a/packages/ai/test/xai-responses.test.ts b/packages/ai/test/xai-responses.test.ts index b32ff1b87..228ebe468 100644 --- a/packages/ai/test/xai-responses.test.ts +++ b/packages/ai/test/xai-responses.test.ts @@ -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; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fbd7ac3dc..31ada19d9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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`. diff --git a/packages/coding-agent/src/core/client-identity.ts b/packages/coding-agent/src/core/client-identity.ts new file mode 100644 index 000000000..c0a7a74c1 --- /dev/null +++ b/packages/coding-agent/src/core/client-identity.ts @@ -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): 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, + sessionId: string | undefined, + headers: ProviderHeaders, +): ProviderHeaders { + if (!sessionId || !isOpenCodeModel(model)) return headers; + return { "x-opencode-session": sessionId, ...headers }; +} diff --git a/packages/coding-agent/src/core/provider-attribution.ts b/packages/coding-agent/src/core/provider-attribution.ts deleted file mode 100644 index 3a541f527..000000000 --- a/packages/coding-agent/src/core/provider-attribution.ts +++ /dev/null @@ -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): boolean { - return model.provider === "openrouter" || model.baseUrl.includes(OPENROUTER_HOST); -} - -function isNvidiaNimModel(model: Model): boolean { - return model.provider === "nvidia" || matchesHost(model.baseUrl, NVIDIA_NIM_HOST); -} - -function isCloudflareModel(model: Model): 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, - settingsManager: SettingsManager, -): Record | 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, sessionId: string | undefined): Record | 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, - settingsManager: SettingsManager, - sessionId: string | undefined, - ...headerSources: Array -): 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; -} diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index f678ba43c..9749564ca 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -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; }, }; }; diff --git a/packages/coding-agent/src/utils/pi-user-agent.ts b/packages/coding-agent/src/utils/pi-user-agent.ts index b1f0a7260..aac6dc163 100644 --- a/packages/coding-agent/src/utils/pi-user-agent.ts +++ b/packages/coding-agent/src/utils/pi-user-agent.ts @@ -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 }); } diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index 3f204489b..4c5009921 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -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 { 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 }, diff --git a/packages/coding-agent/test/sdk-client-identity.test.ts b/packages/coding-agent/test/sdk-client-identity.test.ts new file mode 100644 index 000000000..96444fd05 --- /dev/null +++ b/packages/coding-agent/test/sdk-client-identity.test.ts @@ -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 { + 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, + options: { + telemetryEnabled?: boolean; + providerHeaders?: Record; + sessionId?: string; + } = {}, + ): Promise { + 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"); + }); +}); diff --git a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts deleted file mode 100644 index 851f3358d..000000000 --- a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts +++ /dev/null @@ -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 { - 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, - options: { - telemetryEnabled?: boolean; - providerHeaders?: Record; - requestHeaders?: Record; - sessionId?: string; - } = {}, - ): Promise { - 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"); - }); -}); diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 8d0ff1fc8..4d7ae0fab 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -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 diff --git a/packages/durable/README.md b/packages/durable/README.md index b6c28f997..5755fe228 100644 --- a/packages/durable/README.md +++ b/packages/durable/README.md @@ -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. diff --git a/packages/durable/src/harness/harness.ts b/packages/durable/src/harness/harness.ts index 6f368b0e6..ec9e40358 100644 --- a/packages/durable/src/harness/harness.ts +++ b/packages/durable/src/harness/harness.ts @@ -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 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(); + 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 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, callContext), settings, env: (id, callContext) => this.buildEnv(id, callContext), diff --git a/packages/durable/src/harness/types.ts b/packages/durable/src/harness/types.ts index 8b33e2baf..f65ddd139 100644 --- a/packages/durable/src/harness/types.ts +++ b/packages/durable/src/harness/types.ts @@ -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 = { * 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; /** Run settings, or a function returning the current ones; read at each use either way. */ readonly settings?: HarnessSettings | (() => HarnessSettings | undefined); diff --git a/packages/durable/src/index.ts b/packages/durable/src/index.ts index e4881c536..95d9606ae 100644 --- a/packages/durable/src/index.ts +++ b/packages/durable/src/index.ts @@ -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, diff --git a/packages/durable/test/chat-support.ts b/packages/durable/test/chat-support.ts index 5f72a7edb..06b019d3d 100644 --- a/packages/durable/test/chat-support.ts +++ b/packages/durable/test/chat-support.ts @@ -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 }), diff --git a/packages/durable/test/harness-generation.test.ts b/packages/durable/test/harness-generation.test.ts index 381a2cfc1..8dd5e0849 100644 --- a/packages/durable/test/harness-generation.test.ts +++ b/packages/durable/test/harness-generation.test.ts @@ -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(); diff --git a/packages/durable/test/harness-host-inputs.test.ts b/packages/durable/test/harness-host-inputs.test.ts index b1a06fd98..4719cea94 100644 --- a/packages/durable/test/harness-host-inputs.test.ts +++ b/packages/durable/test/harness-host-inputs.test.ts @@ -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); }); diff --git a/packages/durable/test/harness-tools.test.ts b/packages/durable/test/harness-tools.test.ts index 4c06ce805..29f6fd3f7 100644 --- a/packages/durable/test/harness-tools.test.ts +++ b/packages/durable/test/harness-tools.test.ts @@ -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); }); diff --git a/scripts/check-entry-graphs.mjs b/scripts/check-entry-graphs.mjs index 63e61082e..f53303d99 100644 --- a/scripts/check-entry-graphs.mjs +++ b/scripts/check-entry-graphs.mjs @@ -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", diff --git a/scripts/sync-versions.js b/scripts/sync-versions.js index 63ccb8ccc..e921a6981 100644 --- a/scripts/sync-versions.js +++ b/scripts/sync-versions.js @@ -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}.`); + } +} diff --git a/scripts/sync-versions.test.mjs b/scripts/sync-versions.test.mjs index 628b964c0..092e45d54 100644 --- a/scripts/sync-versions.test.mjs +++ b/scripts/sync-versions.test.mjs @@ -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 }); + } +});