From 04a93bad8f1536dcffc62201441fd11eed5c397a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 21 Oct 2026 11:18:16 +0000 Subject: [PATCH] fix(ai,agent,coding-agent,durable): client identity follow-ups - ClientAttribution is { name, url, categories? } with required name and url, so OpenRouter always gets an HTTP-Referer. identity.ts maps the fields to each provider's headers; NVIDIA gets the name with its first letter upper-cased. attribution: null turns it off. PI_ATTRIBUTION is pi's, including the cli-agent category; agent-core, durable, and the coding agent use it. - Categories are a string array, joined for OpenRouter. - withClientIdentity() is a typed wrapper instead of a Proxy and keeps a MutableModels mutable. Durable no longer caches wrappers in a WeakMap. - The Codex originator stays pi; login agentName no longer follows attribution. - Models and the coding agent's ModelRuntime add x-opencode-session for custom providers pointing at OpenCode, replacing the coding agent's transformHeaders. --- packages/agent/CHANGELOG.md | 2 +- packages/agent/README.md | 6 +- packages/agent/src/agent-loop.ts | 3 +- packages/agent/src/agent.ts | 2 +- packages/agent/test/agent.test.ts | 11 +- packages/ai/CHANGELOG.md | 3 +- packages/ai/README.md | 17 +- packages/ai/src/api/openai-codex-responses.ts | 9 +- packages/ai/src/models.ts | 20 +-- packages/ai/src/utils/identity.ts | 147 ++++++++++-------- packages/ai/test/identity.test.ts | 100 ++++++++---- .../coding-agent/src/core/client-identity.ts | 28 +--- .../coding-agent/src/core/model-runtime.ts | 12 +- packages/coding-agent/src/core/sdk.ts | 17 +- .../test/sdk-client-identity.test.ts | 7 +- packages/durable/CHANGELOG.md | 2 +- packages/durable/README.md | 2 +- packages/durable/src/harness/harness.ts | 30 ++-- packages/durable/src/harness/types.ts | 6 +- .../durable/test/harness-generation.test.ts | 6 +- 20 files changed, 236 insertions(+), 194 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 86901c811..0cf8c6a48 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Added `AgentOptions.identity` and `Agent.identity` (a pi-ai `ClientIdentity`). Agent loop requests use `DEFAULT_AGENT_IDENTITY` (`{ attribution: true }`) under it, so OpenRouter, NVIDIA NIM, and OpenCode requests are attributed to pi by default; pass `{ attribution: false }` to send no attribution headers. +- Added `AgentOptions.identity` and `Agent.identity` (a pi-ai `ClientIdentity`) to set the `User-Agent` and provider attribution of agent loop requests. ## [1.3.0] - 2026-10-15 diff --git a/packages/agent/README.md b/packages/agent/README.md index 80f5f8076..38625ce11 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -233,9 +233,9 @@ const agent = new Agent({ sessionId: "session-123", // How requests identify the app to providers (pi-ai ClientIdentity), merged over - // DEFAULT_AGENT_IDENTITY ({ attribution: true }: provider attribution headers name pi). - // Pass a name to attribute requests to your app, or false to send none. - identity: { attribution: "my-app" }, + // DEFAULT_AGENT_IDENTITY ({ attribution: PI_ATTRIBUTION }: provider attribution headers name pi). + // Pass your app's attribution, or null to send none. + identity: { attribution: { name: "my-app", url: "https://my-app.example" } }, // Dynamic API key resolution (for expiring OAuth tokens) getApiKey: async (provider) => refreshToken(), diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 6d2fc9f1f..cfc9401e5 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -11,6 +11,7 @@ import { getToolStateChanges, mergeClientIdentity, normalizeContext, + PI_ATTRIBUTION, type SystemMessage, type ToolResultMessage, type ToolStateChanges, @@ -37,7 +38,7 @@ export type AgentEventSink = (event: AgentEvent) => Promise | void; * Request identity of agent loops: pi-ai's defaults with provider attribution to pi. `AgentLoopConfig.identity` * overrides it field by field. */ -export const DEFAULT_AGENT_IDENTITY: ClientIdentity = { attribution: true }; +export const DEFAULT_AGENT_IDENTITY: ClientIdentity = { attribution: PI_ATTRIBUTION }; /** * Start an agent loop with a new prompt message. diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index c34cad078..c0a482d3a 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -141,7 +141,7 @@ export interface AgentOptions { toolExecution?: ToolExecutionMode; /** * How requests identify the app to providers, merged field by field over `DEFAULT_AGENT_IDENTITY` - * (`{ attribution: true }`). Pass `{ attribution: false }` to send no attribution headers. + * (`{ attribution: PI_ATTRIBUTION }`). Pass `{ attribution: null }` to send no attribution headers. */ identity?: ClientIdentity; } diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index b21b9f7fd..6fad6109f 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,4 +1,4 @@ -import type { ClientIdentity } from "@earendil-works/pi-ai"; +import { type ClientIdentity, PI_ATTRIBUTION } from "@earendil-works/pi-ai"; import { type AssistantMessage, createAssistantMessageEventStream, @@ -1214,15 +1214,16 @@ describe("Agent", () => { }); await agent.prompt("hello"); - agent.identity = { userAgent: "short", attribution: "acme" }; + const acme = { name: "acme", url: "https://acme.example" }; + agent.identity = { userAgent: "short", attribution: acme }; await agent.prompt("hello again"); agent.identity = { userAgent: "short" }; await agent.prompt("and again"); expect(received).toEqual([ - { attribution: true }, - { attribution: "acme", userAgent: "short" }, - { attribution: true, userAgent: "short" }, + { attribution: PI_ATTRIBUTION }, + { attribution: acme, userAgent: "short" }, + { attribution: PI_ATTRIBUTION, userAgent: "short" }, ]); }); }); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9fff2e389..c9caf6a17 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,8 +10,9 @@ ### Added -- Added client identity configuration: an `identity` request option and `createModels({ identity })` default choose the `User-Agent` (`"detailed"` with a version, `"short"`, or custom) and provider attribution headers for OpenRouter, NVIDIA NIM, and OpenCode (off by default; `true` attributes to pi, a string or `{ name, url, categories }` to your app). The attribution name is also the Codex `originator` and the default login `agentName`. `withClientIdentity()` applies an identity to a `Models` a library receives from its host. `formatUserAgent()`, `getIdentityHeaders()`, `mergeClientIdentity()`, and `PI_AI_VERSION` are exported. +- Added client identity configuration: an `identity` request option and `createModels({ identity })` default choose the `User-Agent` (`"detailed"` with a version, `"short"`, or custom) and provider attribution headers for OpenRouter, NVIDIA NIM, and OpenCode from `attribution: { name, url, categories? }`. `withClientIdentity()` applies an identity to a `Models` or `MutableModels` a library receives from its host. `formatUserAgent()`, `getIdentityHeaders()`, `getOpenCodeSessionHeaders()`, `mergeClientIdentity()`, `PI_ATTRIBUTION`, and `PI_AI_VERSION` are exported. - Bedrock, OpenRouter image generation, pi-messages, and classifier requests now send pi's `User-Agent` too. +- `Models` requests with a `sessionId` to custom providers pointing at OpenCode (`opencode.ai`) now send the `x-opencode-session` header too, like the built-in OpenCode providers. - Added `AI_TELEMETRY_SCHEMA` and opt-in `pi.ai.request`/`pi.ai.poll` tracing around Models requests, with content-free first-event, block-size, and final usage attributes, plus duration, token, and USD cost metrics through `telemetry.metrics`. - Failed model request spans report `provider_error` status with up to 500 error-message characters; cancelled requests keep `ok` status with `stop: "aborted"`. - Added `MiniMax-M3.1-Flash` to the `minimax` and `minimax-cn` providers, with thinking levels low through max sent as `output_config.effort`. Thinking cannot be turned off for this model. New MiniMax models from models.dev now appear without a manual allowlist entry; only the older M2, M2.1, M2.5, and M2.5-highspeed models stay hidden ([#11121](https://github.com/earendil-works/pi/issues/11121)) diff --git a/packages/ai/README.md b/packages/ai/README.md index b0363ee21..88a76366e 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -432,17 +432,26 @@ Header names are merged case-insensitively. Explicit headers override auth/model ```typescript const models = createModels({ - identity: { userAgent: 'detailed', version: '2.1.0', attribution: 'my-app' }, + identity: { + userAgent: 'detailed', + version: '2.1.0', + attribution: { name: 'my-app', url: 'https://my-app.example', categories: ['cloud-agent'] }, + }, }); -await models.completeSimple(model, context, { identity: { attribution: false } }); +await models.completeSimple(model, context, { identity: { attribution: null } }); ``` - `userAgent`: `"detailed"` (default) sends `pi/ (; ; )`, `"short"` sends `pi`, and `{ custom: "..." }` sends the given value. On Bedrock the value is appended to the AWS SDK user agent. - `version`: the version in the detailed `User-Agent`. Default: the pi-ai version. -- `attribution`: provider attribution headers. `false` (default) sends none. `true` attributes requests to pi: OpenRouter gets `HTTP-Referer: https://pi.dev` and `X-OpenRouter-Title: pi`, NVIDIA NIM gets `X-BILLING-INVOKE-ORIGIN: Pi`, and OpenCode gets `x-opencode-client: pi`. A string attributes requests to that name instead. An object sets `name`, `url` (OpenRouter `HTTP-Referer`), and `categories` (OpenRouter `X-OpenRouter-Categories`); fields left out use pi's values only when `name` is left out too. The attribution name is also the Codex `originator` and the default `agentName` of `Models.login()`. +- `attribution`: provider attribution headers. Absent (default) or `null` sends none; `null` also turns off attribution from a merged identity. `{ name, url, categories? }` maps to each provider's headers: + - OpenRouter: `HTTP-Referer: ` (the app's identity in OpenRouter's rankings, grouped by origin), `X-OpenRouter-Title: `, and `X-OpenRouter-Categories` (comma-separated). + - NVIDIA NIM: `X-BILLING-INVOKE-ORIGIN: ` with its first letter upper-cased. + - OpenCode: `x-opencode-client: `. -Identity headers are defaults below provider, model, and request headers, so configured headers win and a `null` request header removes one. Providers that send a fixed client identity, such as GitHub Copilot and Anthropic OAuth, keep it. `withClientIdentity(models, identity)` returns a view of a `Models` whose requests use `identity` over the one the `Models` was created with, for libraries that receive a `Models` from their host. `identity` is plain JSON, so it can be persisted with other request options. + `PI_ATTRIBUTION` is pi's: `{ name: "pi", url: "https://pi.dev", categories: ["cli-agent"] }`. + +Identity headers are defaults below provider, model, and request headers, so configured headers win and a `null` request header removes one. Providers that send a fixed client identity, such as GitHub Copilot and Anthropic OAuth, keep it. `withClientIdentity(models, identity)` returns a wrapper of a `Models` (or `MutableModels`) whose requests use `identity` over the one the `Models` was created with, for libraries that receive a `Models` from their host. `identity` is plain JSON, so it can be persisted with other request options. ### Credential Store diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 5330764b5..90a599e66 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -28,7 +28,7 @@ import { import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; -import { getAttributionName, getIdentityHeaders } from "../utils/identity.ts"; +import { getIdentityHeaders } from "../utils/identity.ts"; import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts"; import { getSystemMessageText } from "../utils/text.ts"; import { @@ -285,11 +285,8 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons body = nextBody as RequestBody; } const websocketRequestId = codexSessionId || uuidv7(); - // Codex expects an originator on every request, so it stays on without attribution. - const identityHeaders = { - originator: getAttributionName(options?.identity) ?? "pi", - ...getIdentityHeaders(model, options?.identity), - }; + // Codex expects an originator on every request; it is not attribution. + const identityHeaders = { originator: "pi", ...getIdentityHeaders(model, options?.identity) }; const sseHeaders = buildSSEHeaders( identityHeaders, model.headers, diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index 310e971b9..ac0396f90 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -56,7 +56,7 @@ import type { Usage, } from "./types.ts"; import { operationSignal, raceWithAbortSignal } from "./utils/abort.ts"; -import { type ClientIdentity, getAttributionName, mergeClientIdentity } from "./utils/identity.ts"; +import { type ClientIdentity, getOpenCodeSessionHeaders, mergeClientIdentity } from "./utils/identity.ts"; import { assertChatModel, assertClassifierInputSupported, @@ -377,10 +377,7 @@ export interface CreateModelsOptions { credentials?: CredentialStore; modelsStore?: ModelsStore; authContext?: AuthContext; - /** - * Default identity of every request; a request's `identity` overrides it field by field. Its attribution name is - * also the default `LoginOptions.agentName`. - */ + /** Default identity of every request; a request's `identity` overrides it field by field. */ identity?: ClientIdentity; } @@ -789,9 +786,7 @@ class ModelsImpl implements MutableModels { if (!method?.login) { throw new ModelsError("auth", `${provider.name} does not support ${type} login`); } - const agentName = options?.agentName ?? getAttributionName(this.identity); - const loginOptions = agentName === undefined ? options : { ...options, agentName }; - const loginOperation: Promise = method.login({ ...interaction, signal }, loginOptions); + const loginOperation: Promise = method.login({ ...interaction, signal }, options); const credential = await raceWithAbortSignal(loginOperation, signal); let mutationStarted = false; let markMutationStarted: (() => void) | undefined; @@ -881,10 +876,15 @@ class ModelsImpl implements MutableModels { // Explicit request options win per-field; the Models-only transform runs last. const apiKey = options?.apiKey ?? auth.apiKey; - let headers = mergeHeaders(auth.headers, options?.headers); + const requestModel: TModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model; + const sessionId = + options && "sessionId" in options && typeof options.sessionId === "string" ? options.sessionId : undefined; + let headers = mergeHeaders( + mergeHeaders(getOpenCodeSessionHeaders(requestModel, sessionId), auth.headers), + options?.headers, + ); if (options?.transformHeaders) headers = await options.transformHeaders(headers ?? {}); const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined; - const requestModel: TModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model; const identity = mergeClientIdentity(this.identity, options?.identity); const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {}; const requestOptions = { diff --git a/packages/ai/src/utils/identity.ts b/packages/ai/src/utils/identity.ts index 0a87bc3d2..93db50eb1 100644 --- a/packages/ai/src/utils/identity.ts +++ b/packages/ai/src/utils/identity.ts @@ -1,4 +1,4 @@ -import type { Models } from "../models.ts"; +import type { Models, MutableModels } from "../models.ts"; import { PI_AI_VERSION } from "./version.ts"; /** @@ -10,24 +10,18 @@ import { PI_AI_VERSION } from "./version.ts"; */ export type UserAgentMode = "short" | "detailed" | { custom: string }; -/** Attribution with explicit values. Absent fields fall back to pi's own attribution only when `name` is absent. */ -export interface ClientAttributionOptions { - /** - * Name requests are attributed to (OpenRouter app title, NVIDIA invoke origin, OpenCode client, Codex originator). - * Absent: pi, spelled as each provider expects. - */ - name?: string; - /** App URL, sent as OpenRouter's `HTTP-Referer`. Absent: `https://pi.dev` when `name` is absent too. */ - url?: string; - /** OpenRouter app categories, comma-separated, e.g. `cli-agent`. */ - categories?: string; +/** Who requests are attributed to. `getAttributionHeaders()` maps the fields to each provider's headers. */ +export interface ClientAttribution { + /** App name: OpenRouter app title, NVIDIA invoke origin (first letter upper-cased), OpenCode client. */ + name: string; + /** App URL: OpenRouter `HTTP-Referer`, the app's identity in OpenRouter's rankings, grouped by origin. */ + url: string; + /** App categories, e.g. `["cli-agent"]`: OpenRouter `X-OpenRouter-Categories`, comma-separated. */ + categories?: string[]; } -/** - * Provider attribution headers. `false` sends none; `true` attributes requests to pi; a string attributes them to that - * name; an object gives the values explicitly. - */ -export type ClientAttribution = boolean | string | ClientAttributionOptions; +/** pi's attribution, used by pi-agent-core, pi-durable, and the coding agent. */ +export const PI_ATTRIBUTION: ClientAttribution = { name: "pi", url: "https://pi.dev", categories: ["cli-agent"] }; /** * How pi-ai identifies the calling app to providers. Every field is optional; absent fields use pi-ai's defaults: @@ -36,8 +30,8 @@ export type ClientAttribution = boolean | string | ClientAttributionOptions; export interface ClientIdentity { /** Default: `"detailed"`. */ userAgent?: UserAgentMode; - /** Default: `false`. */ - attribution?: ClientAttribution; + /** Provider attribution headers. Absent or `null`: none; `null` also turns off attribution set by a merged identity. */ + attribution?: ClientAttribution | null; /** Version in the detailed `User-Agent`. Default: the pi-ai version. */ version?: string; } @@ -48,7 +42,6 @@ export interface ClientIdentityTarget { readonly baseUrl: string; } -const PI_ATTRIBUTION_URL = "https://pi.dev"; const NVIDIA_NIM_HOST = "integrate.api.nvidia.com"; const OPENCODE_HOST = "opencode.ai"; @@ -88,19 +81,6 @@ export function formatUserAgent(identity?: ClientIdentity): string { return runtime ? `${product} (${process.platform}; ${runtime}; ${process.arch})` : `${product} (browser)`; } -function resolveAttribution(identity: ClientIdentity | undefined): ClientAttributionOptions | undefined { - const attribution = identity?.attribution; - if (attribution === undefined || attribution === false) return undefined; - if (attribution === true) return {}; - if (typeof attribution === "string") return { name: attribution }; - return attribution; -} - -/** The explicitly configured attribution name, if any. Absent when attribution is off or uses pi's defaults. */ -export function getAttributionName(identity?: ClientIdentity): string | undefined { - return resolveAttribution(identity)?.name; -} - function matchesHost(baseUrl: string, expectedHost: string): boolean { try { return new URL(baseUrl).hostname === expectedHost; @@ -125,22 +105,35 @@ function isOpenCode(target: ClientIdentityTarget): boolean { /** Provider attribution headers of an identity for a request target. Empty when attribution is off. */ export function getAttributionHeaders(target: ClientIdentityTarget, identity?: ClientIdentity): Record { - const attribution = resolveAttribution(identity); + const attribution = identity?.attribution; if (!attribution) return {}; - const { name, categories } = attribution; + const { name, url, categories } = attribution; if (isOpenRouter(target)) { - const url = attribution.url ?? (name === undefined ? PI_ATTRIBUTION_URL : undefined); return { - ...(url === undefined ? {} : { "HTTP-Referer": url }), - "X-OpenRouter-Title": name ?? "pi", - ...(categories === undefined ? {} : { "X-OpenRouter-Categories": categories }), + "HTTP-Referer": url, + "X-OpenRouter-Title": name, + ...(categories === undefined || categories.length === 0 + ? {} + : { "X-OpenRouter-Categories": categories.join(",") }), }; } - if (isNvidiaNim(target)) return { "X-BILLING-INVOKE-ORIGIN": name ?? "Pi" }; - if (isOpenCode(target)) return { "x-opencode-client": name ?? "pi" }; + // NVIDIA expects a capitalized origin, such as `Pi`. + if (isNvidiaNim(target)) return { "X-BILLING-INVOKE-ORIGIN": name.charAt(0).toUpperCase() + name.slice(1) }; + if (isOpenCode(target)) return { "x-opencode-client": name }; return {}; } +/** + * OpenCode's per-conversation routing header for requests to OpenCode, including custom providers pointing at it. + * Undefined without a session or for other targets. + */ +export function getOpenCodeSessionHeaders( + target: ClientIdentityTarget, + sessionId: string | undefined, +): Record | undefined { + return sessionId && isOpenCode(target) ? { "x-opencode-session": sessionId } : undefined; +} + /** * Default headers an identity adds to a request: `User-Agent` and provider attribution. APIs apply them below model, * provider, and request headers, so configured headers override them and `null` request headers remove them. @@ -149,34 +142,52 @@ export function getIdentityHeaders(target: ClientIdentityTarget, identity?: Clie return { "User-Agent": formatUserAgent(identity), ...getAttributionHeaders(target, identity) }; } -const IDENTITY_REQUEST_METHODS = new Set([ - "stream", - "complete", - "streamSimple", - "completeSimple", - "streamDeferred", - "fetchDeferred", - "cancelDeferred", - "generateImages", - "classify", -]); +function isMutableModels(models: Models): models is MutableModels { + return "setProvider" in models && "deleteProvider" in models && "clearProviders" in models; +} /** - * A view of `models` whose requests use `identity` as their default identity: a request's `identity` overrides it field - * by field, and it overrides the identity `models` was created with. Everything else is delegated unchanged. + * `models` with `identity` as the default identity of its requests: a request's `identity` overrides it field by + * field, and it overrides the identity `models` was created with. Everything else is delegated unchanged; a + * `MutableModels` stays mutable. */ -export function withClientIdentity(models: T, identity: ClientIdentity): T { - return new Proxy(models, { - get(target, property) { - const value: unknown = Reflect.get(target, property, target); - if (typeof value !== "function") return value; - if (!IDENTITY_REQUEST_METHODS.has(property)) return value.bind(target); - // Every request method takes (model, context or handle, options). - return (model: unknown, input: unknown, options?: { identity?: ClientIdentity }) => - value.call(target, model, input, { - ...options, - identity: mergeClientIdentity(identity, options?.identity), - }); - }, - }); +export function withClientIdentity(models: MutableModels, identity: ClientIdentity): MutableModels; +export function withClientIdentity(models: Models, identity: ClientIdentity): Models; +export function withClientIdentity(models: Models, identity: ClientIdentity): Models { + const withIdentity = (options: T | undefined): T => + ({ ...options, identity: mergeClientIdentity(identity, options?.identity) }) as T; + const wrapped: Models = { + getProviders: () => models.getProviders(), + getProvider: (id) => models.getProvider(id), + getModels: (provider) => models.getModels(provider), + getModel: (provider, id) => models.getModel(provider, id), + getModelsOfType: (type, provider) => models.getModelsOfType(type, provider), + getModelOfType: (type, provider, id) => models.getModelOfType(type, provider, id), + getAllModels: (provider) => models.getAllModels(provider), + refresh: (options) => models.refresh(options), + checkAuth: (providerId, options) => models.checkAuth(providerId, options), + getAvailable: (providerId, options) => models.getAvailable(providerId, options), + getAvailableOfType: (type, providerId, options) => models.getAvailableOfType(type, providerId, options), + getAllAvailable: (providerId, options) => models.getAllAvailable(providerId, options), + getAuth: models.getAuth.bind(models), + login: (providerId, type, interaction, options) => models.login(providerId, type, interaction, options), + logout: (providerId, options) => models.logout(providerId, options), + stream: (model, context, options) => models.stream(model, context, withIdentity(options)), + complete: (model, context, options) => models.complete(model, context, withIdentity(options)), + streamSimple: (model, context, options) => models.streamSimple(model, context, withIdentity(options)), + completeSimple: (model, context, options) => models.completeSimple(model, context, withIdentity(options)), + streamDeferred: (model, handle, options) => models.streamDeferred(model, handle, withIdentity(options)), + fetchDeferred: (model, handle, options) => models.fetchDeferred(model, handle, withIdentity(options)), + cancelDeferred: (model, handle, options) => models.cancelDeferred(model, handle, withIdentity(options)), + generateImages: (model, context, options) => models.generateImages(model, context, withIdentity(options)), + classify: (model, context, options) => models.classify(model, context, withIdentity(options)), + }; + if (!isMutableModels(models)) return wrapped; + const mutable: MutableModels = { + ...wrapped, + setProvider: (provider) => models.setProvider(provider), + deleteProvider: (id) => models.deleteProvider(id), + clearProviders: () => models.clearProviders(), + }; + return mutable; } diff --git a/packages/ai/test/identity.test.ts b/packages/ai/test/identity.test.ts index e93acd35d..6b12d02d8 100644 --- a/packages/ai/test/identity.test.ts +++ b/packages/ai/test/identity.test.ts @@ -9,11 +9,14 @@ import { getAttributionHeaders, getIdentityHeaders, mergeClientIdentity, + PI_ATTRIBUTION, withClientIdentity, } from "../src/utils/identity.ts"; import { normalizeContext } from "../src/utils/transcript.ts"; import { PI_AI_VERSION } from "../src/utils/version.ts"; +const ACME = { name: "acme", url: "https://acme.example" }; + const runtime = process.versions.bun ? `bun/${process.versions.bun}` : `node/${process.version}`; function target(provider: string, baseUrl: string) { @@ -78,66 +81,76 @@ describe("client identity", () => { it("merges identities field by field", () => { expect(mergeClientIdentity(undefined, undefined)).toBeUndefined(); expect( - mergeClientIdentity({ attribution: true, userAgent: "short" }, { attribution: undefined, version: "1.0.0" }), - ).toEqual({ attribution: true, userAgent: "short", version: "1.0.0" }); - expect(mergeClientIdentity({ attribution: true }, { attribution: false })).toEqual({ attribution: false }); + mergeClientIdentity( + { attribution: PI_ATTRIBUTION, userAgent: "short" }, + { attribution: undefined, version: "1.0.0" }, + ), + ).toEqual({ attribution: PI_ATTRIBUTION, userAgent: "short", version: "1.0.0" }); + expect(mergeClientIdentity({ attribution: PI_ATTRIBUTION }, { attribution: null })).toEqual({ + attribution: null, + }); }); it("sends no attribution unless enabled", () => { const openRouter = target("openrouter", "https://openrouter.ai/api/v1"); expect(getAttributionHeaders(openRouter)).toEqual({}); - expect(getAttributionHeaders(openRouter, { attribution: false })).toEqual({}); + expect(getAttributionHeaders(openRouter, { attribution: null })).toEqual({}); expect(getIdentityHeaders(openRouter)).toEqual({ "User-Agent": formatUserAgent() }); }); it("attributes OpenRouter requests", () => { const openRouter = target("openrouter", "https://openrouter.ai/api/v1"); - expect(getAttributionHeaders(openRouter, { attribution: true })).toEqual({ - "HTTP-Referer": "https://pi.dev", - "X-OpenRouter-Title": "pi", - }); - expect(getAttributionHeaders(openRouter, { attribution: { categories: "cli-agent" } })).toEqual({ + expect(getAttributionHeaders(openRouter, { attribution: PI_ATTRIBUTION })).toEqual({ "HTTP-Referer": "https://pi.dev", "X-OpenRouter-Title": "pi", "X-OpenRouter-Categories": "cli-agent", }); - expect(getAttributionHeaders(openRouter, { attribution: "acme" })).toEqual({ "X-OpenRouter-Title": "acme" }); expect( getAttributionHeaders(openRouter, { - attribution: { name: "acme", url: "https://acme.example", categories: "cloud-agent" }, + attribution: { ...ACME, categories: ["cli-agent", "cloud-agent"] }, }), ).toEqual({ "HTTP-Referer": "https://acme.example", "X-OpenRouter-Title": "acme", - "X-OpenRouter-Categories": "cloud-agent", + "X-OpenRouter-Categories": "cli-agent,cloud-agent", + }); + expect(getAttributionHeaders(openRouter, { attribution: { ...ACME, categories: [] } })).toEqual({ + "HTTP-Referer": "https://acme.example", + "X-OpenRouter-Title": "acme", }); // Custom providers routed through OpenRouter, including legacy base URLs that are not valid URLs. - expect(getAttributionHeaders(target("custom", "not-a-url-openrouter.ai"), { attribution: true })).toMatchObject({ - "X-OpenRouter-Title": "pi", - }); + expect( + getAttributionHeaders(target("custom", "not-a-url-openrouter.ai"), { attribution: PI_ATTRIBUTION }), + ).toMatchObject({ "X-OpenRouter-Title": "pi" }); }); - it("attributes NVIDIA NIM and OpenCode requests with their expected spellings", () => { - expect(getAttributionHeaders(target("nvidia", "https://example.test/v1"), { attribution: true })).toEqual({ + it("attributes NVIDIA NIM requests with a capitalized name and OpenCode requests with the name", () => { + expect( + getAttributionHeaders(target("nvidia", "https://example.test/v1"), { attribution: PI_ATTRIBUTION }), + ).toEqual({ "X-BILLING-INVOKE-ORIGIN": "Pi", }); expect( - getAttributionHeaders(target("custom-nim", "https://integrate.api.nvidia.com/v1"), { attribution: "acme" }), - ).toEqual({ "X-BILLING-INVOKE-ORIGIN": "acme" }); - expect(getAttributionHeaders(target("opencode", "https://opencode.ai/zen/v1"), { attribution: true })).toEqual({ + getAttributionHeaders(target("custom-nim", "https://integrate.api.nvidia.com/v1"), { attribution: ACME }), + ).toEqual({ "X-BILLING-INVOKE-ORIGIN": "Acme" }); + expect( + getAttributionHeaders(target("opencode", "https://opencode.ai/zen/v1"), { attribution: PI_ATTRIBUTION }), + ).toEqual({ "x-opencode-client": "pi", }); - expect(getAttributionHeaders(target("custom", "https://opencode.ai/zen/v1"), { attribution: "acme" })).toEqual({ + expect(getAttributionHeaders(target("custom", "https://opencode.ai/zen/v1"), { attribution: ACME })).toEqual({ "x-opencode-client": "acme", }); }); it("does not attribute NVIDIA models routed through gateways to NVIDIA", () => { expect( - getAttributionHeaders(target("openrouter", "https://openrouter.ai/api/v1"), { attribution: true }), + getAttributionHeaders(target("openrouter", "https://openrouter.ai/api/v1"), { attribution: PI_ATTRIBUTION }), ).not.toHaveProperty("X-BILLING-INVOKE-ORIGIN"); expect( - getAttributionHeaders(target("vercel-ai-gateway", "https://ai-gateway.vercel.sh/v1"), { attribution: true }), + getAttributionHeaders(target("vercel-ai-gateway", "https://ai-gateway.vercel.sh/v1"), { + attribution: PI_ATTRIBUTION, + }), ).toEqual({}); }); }); @@ -151,7 +164,7 @@ describe("client identity in requests", () => { await streamCompletions(model, context, { apiKey: "key", fetch: capture.fetch, - identity: { attribution: true, userAgent: "short" }, + identity: { attribution: PI_ATTRIBUTION, userAgent: "short" }, headers: { "HTTP-Referer": null } as ProviderHeaders, }).result(); @@ -177,7 +190,8 @@ describe("client identity in requests", () => { it("merges request identity over the Models and wrapper identities", async () => { const model = completionsModel("openrouter", "https://openrouter.ai/api/v1"); - const models = createModels({ identity: { attribution: "models", userAgent: "short" } }); + const attribution = (name: string) => ({ name, url: "https://acme.example" }); + const models = createModels({ identity: { attribution: attribution("models"), userAgent: "short" } }); models.setProvider( createProvider({ id: "openrouter", @@ -196,14 +210,46 @@ describe("client identity in requests", () => { expect(fromModels.get("user-agent")).toBe("pi"); expect(fromModels.get("x-openrouter-title")).toBe("models"); - const wrapped = withClientIdentity(models, { attribution: "wrapper" }); + const wrapped = withClientIdentity(models, { attribution: attribution("wrapper") }); const fromWrapper = await send(wrapped); expect(fromWrapper.get("user-agent")).toBe("pi"); expect(fromWrapper.get("x-openrouter-title")).toBe("wrapper"); expect(wrapped.getModel("openrouter", "test-model")).toBe(model); + // A MutableModels stays mutable. + wrapped.deleteProvider("openrouter"); + expect(models.getProvider("openrouter")).toBeUndefined(); + models.setProvider( + createProvider({ + id: "openrouter", + auth: { apiKey: { name: "Test", resolve: async () => ({ auth: { apiKey: "key" } }) } }, + models: [model], + api: { stream: streamCompletions, streamSimple: streamSimpleCompletions }, + }), + ); - const fromRequest = await send(wrapped, { attribution: false }); + const fromRequest = await send(wrapped, { attribution: null }); expect(fromRequest.has("x-openrouter-title")).toBe(false); expect(fromRequest.get("user-agent")).toBe("pi"); }); + + it("sends the OpenCode session header to custom providers pointing at OpenCode", async () => { + const model = completionsModel("custom-opencode", "https://opencode.ai/zen/v1"); + const models = createModels(); + models.setProvider( + createProvider({ + id: "custom-opencode", + auth: { apiKey: { name: "Test", resolve: async () => ({ auth: { apiKey: "key" } }) } }, + models: [model], + api: { stream: streamCompletions, streamSimple: streamSimpleCompletions }, + }), + ); + const send = async (headers?: ProviderHeaders) => { + const capture = capturingFetch(); + await models.streamSimple(model, context, { fetch: capture.fetch, sessionId: "session-1", headers }).result(); + return capture.headers(); + }; + + expect((await send()).get("x-opencode-session")).toBe("session-1"); + expect((await send({ "X-OpenCode-Session": "configured" })).get("x-opencode-session")).toBe("configured"); + }); }); diff --git a/packages/coding-agent/src/core/client-identity.ts b/packages/coding-agent/src/core/client-identity.ts index c0a7a74c1..555b1da8e 100644 --- a/packages/coding-agent/src/core/client-identity.ts +++ b/packages/coding-agent/src/core/client-identity.ts @@ -1,10 +1,8 @@ -import type { Api, ClientIdentity, Model, ProviderHeaders } from "@earendil-works/pi-ai"; +import { type ClientIdentity, PI_ATTRIBUTION } from "@earendil-works/pi-ai"; import { VERSION } from "../config.ts"; import type { SettingsManager } from "./settings-manager.ts"; import { isInstallTelemetryEnabled } from "./telemetry.ts"; -const OPENCODE_HOST = "opencode.ai"; - /** * How pi's requests identify it to providers: a detailed `User-Agent` with pi's version, and attribution to pi as a * CLI agent while install telemetry is enabled. @@ -13,28 +11,6 @@ export function getClientIdentity(settingsManager: SettingsManager): ClientIdent return { userAgent: "detailed", version: VERSION, - attribution: isInstallTelemetryEnabled(settingsManager) ? { categories: "cli-agent" } : false, + attribution: isInstallTelemetryEnabled(settingsManager) ? PI_ATTRIBUTION : null, }; } - -function isOpenCodeModel(model: Model): 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/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index 7c1dc820c..8e03f4d1f 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -25,6 +25,7 @@ import { type DeferredCancelOptions, type DeferredFetchOptions, type DeferredHandle, + getOpenCodeSessionHeaders, type ImageApi, type ImageModel, type ImagesContext, @@ -677,13 +678,20 @@ export class ModelRuntime implements Models { const { transformHeaders, ...rawProviderOptions } = options ?? {}; const providerOptions = rawProviderOptions as Omit & ProviderRequestOptions; - let headers = mergeHeaders(resolution.auth.headers, providerOptions.headers); + const requestModel: TModel = resolution.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model; + const sessionId = + "sessionId" in providerOptions && typeof providerOptions.sessionId === "string" + ? providerOptions.sessionId + : undefined; + let headers = mergeHeaders( + mergeHeaders(getOpenCodeSessionHeaders(requestModel, sessionId), resolution.auth.headers), + providerOptions.headers, + ); if (transformHeaders) headers = await transformHeaders(headers ?? {}); const env = resolution.env || providerOptions.env ? { ...(resolution.env ?? {}), ...(providerOptions.env ?? {}) } : undefined; - const requestModel: TModel = resolution.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model; return { provider, model: requestModel, diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 9749564ca..0bf874fff 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -7,7 +7,7 @@ import { resolvePath } from "../utils/paths.ts"; import { AgentSession } from "./agent-session.ts"; import { formatNoModelsAvailableMessage } from "./auth-guidance.ts"; import { CacheWarmer } from "./cache-warmer.ts"; -import { getClientIdentity, withOpenCodeSessionHeader } from "./client-identity.ts"; +import { getClientIdentity } from "./client-identity.ts"; import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; import { createToolNameMatcher } from "./mcp-servers.ts"; @@ -337,10 +337,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} () => settingsManager.getCacheWarmingMode(), async (event) => extensionRunnerRef.current?.emitCacheWarmingDecision(event) ?? event.action, ); - const buildRequestOptions = ( - requestModel: Model, - options: ModelsSimpleStreamOptions = {}, - ): ModelsSimpleStreamOptions => { + const buildRequestOptions = (options: ModelsSimpleStreamOptions = {}): ModelsSimpleStreamOptions => { const providerRetrySettings = settingsManager.getProviderRetrySettings(); const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs(); const effectiveTimeoutMs = httpIdleTimeoutMs === 0 ? 2147483647 : httpIdleTimeoutMs; @@ -353,12 +350,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} maxRetryDelayMs: options.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs, // Replaces the agent loop's default identity; read per request so telemetry changes apply at once. identity: getClientIdentity(settingsManager), - transformHeaders: async (requestHeaders) => { - const headers = withOpenCodeSessionHeader(requestModel, options.sessionId, requestHeaders); - return headerRunner?.hasHandlers("before_provider_headers") + transformHeaders: async (headers) => + headerRunner?.hasHandlers("before_provider_headers") ? headerRunner.emitBeforeProviderHeaders(headers) - : headers; - }, + : headers, }; }; // Warm only requests for the selected model. Requests a virtual selection routed, or that an @@ -415,7 +410,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }, convertToLlm: convertToLlmWithBlockImages, streamFn: async (model, context, options) => { - const requestOptions = buildRequestOptions(model, options); + const requestOptions = buildRequestOptions(options); // Compaction and summaries use their own routing ids; only session requests // replace the cache entry, so warming restarts from them. Keep warming while // the current transcript still extends the request's prefix. Agent state may diff --git a/packages/coding-agent/test/sdk-client-identity.test.ts b/packages/coding-agent/test/sdk-client-identity.test.ts index 96444fd05..bf9c97082 100644 --- a/packages/coding-agent/test/sdk-client-identity.test.ts +++ b/packages/coding-agent/test/sdk-client-identity.test.ts @@ -8,6 +8,7 @@ import { getAttributionHeaders, type Model, normalizeContext, + PI_ATTRIBUTION, type SimpleStreamOptions, } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -145,7 +146,7 @@ describe("createAgentSession client identity", () => { expect(options?.identity).toEqual({ userAgent: "detailed", version: VERSION, - attribution: { categories: "cli-agent" }, + attribution: PI_ATTRIBUTION, }); expect(getAttributionHeaders(model, options?.identity)).toEqual({ "HTTP-Referer": "https://pi.dev", @@ -158,7 +159,7 @@ describe("createAgentSession client identity", () => { const model = createModel("nvidia", "https://integrate.api.nvidia.com/v1"); const options = await captureOptions(model, { telemetryEnabled: false }); - expect(options?.identity).toEqual({ userAgent: "detailed", version: VERSION, attribution: false }); + expect(options?.identity).toEqual({ userAgent: "detailed", version: VERSION, attribution: null }); expect(getAttributionHeaders(model, options?.identity)).toEqual({}); }); @@ -166,7 +167,7 @@ describe("createAgentSession client identity", () => { process.env.PI_TELEMETRY = "0"; const options = await captureOptions(createModel("openrouter", "https://openrouter.ai/api/v1")); - expect(options?.identity?.attribution).toBe(false); + expect(options?.identity?.attribution).toBeNull(); }); it("adds OpenCode session headers for custom OpenCode providers", async () => { diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 4d7ae0fab..900351d0b 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -23,7 +23,7 @@ - `onReport(error, info)`: `info` (`ReportInfo`) says what happened in `type`: `session_failed`; `task_faulted`, `task_parked`, `migration_failed`, `task_outdated`, or `task_error` with the task and the extension that installed it; `extension_error` with the extension's name; `host_error` with the failing `HostCallback`; or `listener_error`. - `settings.afterFailedRun`: a run that fails with `model_error` or `no_model` keeps the inputs queued behind it (`keep`, the default), starts the next run with them (`continue`), or settles them `unanswered` with reason `run_failed` (`withdraw`). - The task graph shows why a task cannot run (`TaskGraphNode.blocked`: `missing_task`, `task_too_old`, `migration_failed`, or `restart_limit`) and follows registry changes and failed migrations. -- Added `HarnessOptions.identity` (a pi-ai `ClientIdentity`) for every model request through the Harness, including hooks and tools, over `DEFAULT_HARNESS_IDENTITY` (`{ attribution: true }`), so OpenRouter, NVIDIA NIM, and OpenCode requests are attributed to pi by default; pass `{ attribution: false }` to send no attribution headers. `api.models` in tools and hooks is now a view of the host's `Models` that adds the identity, not the same object. +- Added `HarnessOptions.identity` (a pi-ai `ClientIdentity`) to set the `User-Agent` and provider attribution of every model request through the Harness, including hooks and tools. `api.models` in tools and hooks is now a wrapper of the host's `Models` that adds the identity, not the same object. - `HarnessOptions.models` and `HarnessOptions.settings` also accept a function returning the current value, like `env`. Generation, compaction, hooks and tools call it at each use (`api.models` reads the current value at each access), so a host can replace its model configuration or settings without reopening the Harness. A plain object still works. ### Changed diff --git a/packages/durable/README.md b/packages/durable/README.md index 5755fe228..71aa68376 100644 --- a/packages/durable/README.md +++ b/packages/durable/README.md @@ -77,7 +77,7 @@ What happened: Every async call takes a Chord `Context`, which carries cancellation and its current `TelemetrySpan`. `BACKGROUND_CONTEXT` never cancels and uses a NOOP span. The Harness owns the root adapter; adapters are never stored in a `Context`. Cancelling a wait only cancels that wait, never the work. -`HarnessOptions.identity` sets how model requests identify the app to providers (a pi-ai `ClientIdentity`), merged over `DEFAULT_HARNESS_IDENTITY` (`{ attribution: true }`: OpenRouter, NVIDIA NIM, and OpenCode attribution headers name pi). It applies to generation, compaction, hooks, and tools, over the identity the host's `Models` was created with. Pass `{ attribution: "my-app" }` to attribute requests to your app, or `{ attribution: false }` to send none. +`HarnessOptions.identity` sets how model requests identify the app to providers (a pi-ai `ClientIdentity`), merged over `DEFAULT_HARNESS_IDENTITY` (`{ attribution: PI_ATTRIBUTION }`: OpenRouter, NVIDIA NIM, and OpenCode attribution headers name pi). It applies to generation, compaction, hooks, and tools, over the identity the host's `Models` was created with. Pass your app's attribution, for example `{ attribution: { name: "my-app", url: "https://my-app.example" } }`, or `{ attribution: null }` to send none. `HarnessOptions.telemetry` accepts a host-owned `TelemetryContext` from `@earendil-works/pi-telemetry`, with optional `propagation` and `metrics` capabilities; omitted, it is a no-op. `DURABLE_TELEMETRY_SCHEMA`, exported from the package root, documents its typed spans, events, and metrics. Attributes contain IDs and control metadata, never messages, arguments, or results; IDs point to the transcript. diff --git a/packages/durable/src/harness/harness.ts b/packages/durable/src/harness/harness.ts index ec9e40358..e7f19dfea 100644 --- a/packages/durable/src/harness/harness.ts +++ b/packages/durable/src/harness/harness.ts @@ -1,7 +1,12 @@ import type { AttachedReplicatedState, Context, JsonValue } from "@earendil-works/chord"; import { withAbortSignal, withoutAbortSignal } from "@earendil-works/chord/context"; import type { Models } from "@earendil-works/pi-ai"; -import { type ClientIdentity, mergeClientIdentity, withClientIdentity } from "@earendil-works/pi-ai/utils/identity"; +import { + type ClientIdentity, + mergeClientIdentity, + PI_ATTRIBUTION, + withClientIdentity, +} from "@earendil-works/pi-ai/utils/identity"; import { createTypedMetrics, NOOP_TELEMETRY_CONTEXT, @@ -180,28 +185,17 @@ class ConversationImpl implements Conversation { } /** Request identity of a Harness's model requests: pi-ai's defaults with provider attribution to pi. */ -export const DEFAULT_HARNESS_IDENTITY: ClientIdentity = { attribution: true }; +export const DEFAULT_HARNESS_IDENTITY: ClientIdentity = { attribution: PI_ATTRIBUTION }; /** - * `HarnessOptions.models` as a function with `HarnessOptions.identity` applied. A plain `Models` is wrapped once and - * returned at every call; a function's results are wrapped once per `Models` it returns. + * `HarnessOptions.models` as a function with `HarnessOptions.identity` applied. A plain `Models` is wrapped once; a + * function's result is wrapped at each call. */ function currentModels(models: HarnessOptions["models"], identity: ClientIdentity | undefined): () => Models { const merged = mergeClientIdentity(DEFAULT_HARNESS_IDENTITY, identity) ?? DEFAULT_HARNESS_IDENTITY; - if (typeof models !== "function") { - const wrapped = withClientIdentity(models, merged); - return () => wrapped; - } - const wrappers = new WeakMap(); - return () => { - const current = models(); - let wrapped = wrappers.get(current); - if (wrapped === undefined) { - wrapped = withClientIdentity(current, merged); - wrappers.set(current, wrapped); - } - return wrapped; - }; + if (typeof models === "function") return () => withClientIdentity(models(), merged); + const wrapped = withClientIdentity(models, merged); + return () => wrapped; } /** `HarnessOptions.settings` as a function: plain settings, or none, are returned at every call. */ diff --git a/packages/durable/src/harness/types.ts b/packages/durable/src/harness/types.ts index f65ddd139..289b5c4e1 100644 --- a/packages/durable/src/harness/types.ts +++ b/packages/durable/src/harness/types.ts @@ -616,9 +616,9 @@ export type HarnessOptions = { readonly models: Models | (() => Models); /** * How model requests identify the app to providers, merged field by field over `DEFAULT_HARNESS_IDENTITY` - * (`{ attribution: true }`, provider attribution to pi). It applies to every request through the Harness's models, - * including hooks and tools, and overrides the identity the `Models` was created with; a request's own `identity` - * overrides it. Pass `{ attribution: false }` to send no attribution headers. + * (`{ attribution: PI_ATTRIBUTION }`, provider attribution to pi). It applies to every request through the Harness's + * models, including hooks and tools, and overrides the identity the `Models` was created with; a request's own + * `identity` overrides it. Pass `{ attribution: null }` to send no attribution headers. */ readonly identity?: ClientIdentity; readonly registry: RegistryReader; diff --git a/packages/durable/test/harness-generation.test.ts b/packages/durable/test/harness-generation.test.ts index 8dd5e0849..50bae0a58 100644 --- a/packages/durable/test/harness-generation.test.ts +++ b/packages/durable/test/harness-generation.test.ts @@ -3,6 +3,7 @@ import { fauxAssistantMessage, type Message, type Models, + PI_ATTRIBUTION, type SimpleStreamOptions, type SystemMessage, } from "@earendil-works/pi-ai"; @@ -391,7 +392,8 @@ describe("generation", () => { seen.push(options?.identity); return fauxAssistantMessage("ok"); }; - for (const identity of [undefined, { userAgent: "short" as const, attribution: "acme" }]) { + const acme = { name: "acme", url: "https://acme.example" }; + for (const identity of [undefined, { userAgent: "short" as const, attribution: acme }]) { const setup = chatSetup(); setup.identity = identity; setup.faux.setResponses([capture]); @@ -400,7 +402,7 @@ describe("generation", () => { await (await root.submit({ type: "input", content: "hi" }, context)).wait(context); await harness.close(context); } - expect(seen).toEqual([{ attribution: true }, { attribution: "acme", userAgent: "short" }]); + expect(seen).toEqual([{ attribution: PI_ATTRIBUTION }, { attribution: acme, userAgent: "short" }]); }); // Regression coverage for #10424.