mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(ai,agent,coding-agent,durable): client identity follow-ups
- ClientAttribution is { name, url, categories? } with required name and url, so
OpenRouter always gets an HTTP-Referer. identity.ts maps the fields to each
provider's headers; NVIDIA gets the name with its first letter upper-cased.
attribution: null turns it off. PI_ATTRIBUTION is pi's, including the
cli-agent category; agent-core, durable, and the coding agent use it.
- Categories are a string array, joined for OpenRouter.
- withClientIdentity() is a typed wrapper instead of a Proxy and keeps a
MutableModels mutable. Durable no longer caches wrappers in a WeakMap.
- The Codex originator stays pi; login agentName no longer follows attribution.
- Models and the coding agent's ModelRuntime add x-opencode-session for custom
providers pointing at OpenCode, replacing the coding agent's transformHeaders.
This commit is contained in:
parent
adc5fa9f80
commit
04a93bad8f
20 changed files with 236 additions and 194 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
getToolStateChanges,
|
||||
mergeClientIdentity,
|
||||
normalizeContext,
|
||||
PI_ATTRIBUTION,
|
||||
type SystemMessage,
|
||||
type ToolResultMessage,
|
||||
type ToolStateChanges,
|
||||
|
|
@ -37,7 +38,7 @@ export type AgentEventSink = (event: AgentEvent) => Promise<void> | void;
|
|||
* Request identity of agent loops: pi-ai's defaults with provider attribution to pi. `AgentLoopConfig.identity`
|
||||
* overrides it field by field.
|
||||
*/
|
||||
export const DEFAULT_AGENT_IDENTITY: ClientIdentity = { attribution: true };
|
||||
export const DEFAULT_AGENT_IDENTITY: ClientIdentity = { attribution: PI_ATTRIBUTION };
|
||||
|
||||
/**
|
||||
* Start an agent loop with a new prompt message.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -432,17 +432,26 @@ Header names are merged case-insensitively. Explicit headers override auth/model
|
|||
|
||||
```typescript
|
||||
const models = createModels({
|
||||
identity: { userAgent: 'detailed', version: '2.1.0', attribution: 'my-app' },
|
||||
identity: {
|
||||
userAgent: 'detailed',
|
||||
version: '2.1.0',
|
||||
attribution: { name: 'my-app', url: 'https://my-app.example', categories: ['cloud-agent'] },
|
||||
},
|
||||
});
|
||||
|
||||
await models.completeSimple(model, context, { identity: { attribution: false } });
|
||||
await models.completeSimple(model, context, { identity: { attribution: null } });
|
||||
```
|
||||
|
||||
- `userAgent`: `"detailed"` (default) sends `pi/<version> (<platform>; <runtime>; <arch>)`, `"short"` sends `pi`, and `{ custom: "..." }` sends the given value. On Bedrock the value is appended to the AWS SDK user agent.
|
||||
- `version`: the version in the detailed `User-Agent`. Default: the pi-ai version.
|
||||
- `attribution`: provider attribution headers. `false` (default) sends none. `true` attributes requests to pi: OpenRouter gets `HTTP-Referer: https://pi.dev` and `X-OpenRouter-Title: pi`, NVIDIA NIM gets `X-BILLING-INVOKE-ORIGIN: Pi`, and OpenCode gets `x-opencode-client: pi`. A string attributes requests to that name instead. An object sets `name`, `url` (OpenRouter `HTTP-Referer`), and `categories` (OpenRouter `X-OpenRouter-Categories`); fields left out use pi's values only when `name` is left out too. The attribution name is also the Codex `originator` and the default `agentName` of `Models.login()`.
|
||||
- `attribution`: provider attribution headers. Absent (default) or `null` sends none; `null` also turns off attribution from a merged identity. `{ name, url, categories? }` maps to each provider's headers:
|
||||
- OpenRouter: `HTTP-Referer: <url>` (the app's identity in OpenRouter's rankings, grouped by origin), `X-OpenRouter-Title: <name>`, and `X-OpenRouter-Categories` (comma-separated).
|
||||
- NVIDIA NIM: `X-BILLING-INVOKE-ORIGIN: <name>` with its first letter upper-cased.
|
||||
- OpenCode: `x-opencode-client: <name>`.
|
||||
|
||||
Identity headers are defaults below provider, model, and request headers, so configured headers win and a `null` request header removes one. Providers that send a fixed client identity, such as GitHub Copilot and Anthropic OAuth, keep it. `withClientIdentity(models, identity)` returns a view of a `Models` whose requests use `identity` over the one the `Models` was created with, for libraries that receive a `Models` from their host. `identity` is plain JSON, so it can be persisted with other request options.
|
||||
`PI_ATTRIBUTION` is pi's: `{ name: "pi", url: "https://pi.dev", categories: ["cli-agent"] }`.
|
||||
|
||||
Identity headers are defaults below provider, model, and request headers, so configured headers win and a `null` request header removes one. Providers that send a fixed client identity, such as GitHub Copilot and Anthropic OAuth, keep it. `withClientIdentity(models, identity)` returns a wrapper of a `Models` (or `MutableModels`) whose requests use `identity` over the one the `Models` was created with, for libraries that receive a `Models` from their host. `identity` is plain JSON, so it can be persisted with other request options.
|
||||
|
||||
### Credential Store
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ import type {
|
|||
Usage,
|
||||
} from "./types.ts";
|
||||
import { operationSignal, raceWithAbortSignal } from "./utils/abort.ts";
|
||||
import { type ClientIdentity, getAttributionName, mergeClientIdentity } from "./utils/identity.ts";
|
||||
import { type ClientIdentity, getOpenCodeSessionHeaders, mergeClientIdentity } from "./utils/identity.ts";
|
||||
import {
|
||||
assertChatModel,
|
||||
assertClassifierInputSupported,
|
||||
|
|
@ -377,10 +377,7 @@ export interface CreateModelsOptions {
|
|||
credentials?: CredentialStore;
|
||||
modelsStore?: ModelsStore;
|
||||
authContext?: AuthContext;
|
||||
/**
|
||||
* Default identity of every request; a request's `identity` overrides it field by field. Its attribution name is
|
||||
* also the default `LoginOptions.agentName`.
|
||||
*/
|
||||
/** Default identity of every request; a request's `identity` overrides it field by field. */
|
||||
identity?: ClientIdentity;
|
||||
}
|
||||
|
||||
|
|
@ -789,9 +786,7 @@ class ModelsImpl implements MutableModels {
|
|||
if (!method?.login) {
|
||||
throw new ModelsError("auth", `${provider.name} does not support ${type} login`);
|
||||
}
|
||||
const agentName = options?.agentName ?? getAttributionName(this.identity);
|
||||
const loginOptions = agentName === undefined ? options : { ...options, agentName };
|
||||
const loginOperation: Promise<Credential> = method.login({ ...interaction, signal }, loginOptions);
|
||||
const loginOperation: Promise<Credential> = method.login({ ...interaction, signal }, options);
|
||||
const credential = await raceWithAbortSignal(loginOperation, signal);
|
||||
let mutationStarted = false;
|
||||
let markMutationStarted: (() => void) | undefined;
|
||||
|
|
@ -881,10 +876,15 @@ class ModelsImpl implements MutableModels {
|
|||
|
||||
// Explicit request options win per-field; the Models-only transform runs last.
|
||||
const apiKey = options?.apiKey ?? auth.apiKey;
|
||||
let headers = mergeHeaders(auth.headers, options?.headers);
|
||||
const requestModel: TModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
|
||||
const sessionId =
|
||||
options && "sessionId" in options && typeof options.sessionId === "string" ? options.sessionId : undefined;
|
||||
let headers = mergeHeaders(
|
||||
mergeHeaders(getOpenCodeSessionHeaders(requestModel, sessionId), auth.headers),
|
||||
options?.headers,
|
||||
);
|
||||
if (options?.transformHeaders) headers = await options.transformHeaders(headers ?? {});
|
||||
const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined;
|
||||
const requestModel: TModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
|
||||
const identity = mergeClientIdentity(this.identity, options?.identity);
|
||||
const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {};
|
||||
const requestOptions = {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Models } from "../models.ts";
|
||||
import type { Models, MutableModels } from "../models.ts";
|
||||
import { PI_AI_VERSION } from "./version.ts";
|
||||
|
||||
/**
|
||||
|
|
@ -10,24 +10,18 @@ import { PI_AI_VERSION } from "./version.ts";
|
|||
*/
|
||||
export type UserAgentMode = "short" | "detailed" | { custom: string };
|
||||
|
||||
/** Attribution with explicit values. Absent fields fall back to pi's own attribution only when `name` is absent. */
|
||||
export interface ClientAttributionOptions {
|
||||
/**
|
||||
* Name requests are attributed to (OpenRouter app title, NVIDIA invoke origin, OpenCode client, Codex originator).
|
||||
* Absent: pi, spelled as each provider expects.
|
||||
*/
|
||||
name?: string;
|
||||
/** App URL, sent as OpenRouter's `HTTP-Referer`. Absent: `https://pi.dev` when `name` is absent too. */
|
||||
url?: string;
|
||||
/** OpenRouter app categories, comma-separated, e.g. `cli-agent`. */
|
||||
categories?: string;
|
||||
/** Who requests are attributed to. `getAttributionHeaders()` maps the fields to each provider's headers. */
|
||||
export interface ClientAttribution {
|
||||
/** App name: OpenRouter app title, NVIDIA invoke origin (first letter upper-cased), OpenCode client. */
|
||||
name: string;
|
||||
/** App URL: OpenRouter `HTTP-Referer`, the app's identity in OpenRouter's rankings, grouped by origin. */
|
||||
url: string;
|
||||
/** App categories, e.g. `["cli-agent"]`: OpenRouter `X-OpenRouter-Categories`, comma-separated. */
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider attribution headers. `false` sends none; `true` attributes requests to pi; a string attributes them to that
|
||||
* name; an object gives the values explicitly.
|
||||
*/
|
||||
export type ClientAttribution = boolean | string | ClientAttributionOptions;
|
||||
/** pi's attribution, used by pi-agent-core, pi-durable, and the coding agent. */
|
||||
export const PI_ATTRIBUTION: ClientAttribution = { name: "pi", url: "https://pi.dev", categories: ["cli-agent"] };
|
||||
|
||||
/**
|
||||
* How pi-ai identifies the calling app to providers. Every field is optional; absent fields use pi-ai's defaults:
|
||||
|
|
@ -36,8 +30,8 @@ export type ClientAttribution = boolean | string | ClientAttributionOptions;
|
|||
export interface ClientIdentity {
|
||||
/** Default: `"detailed"`. */
|
||||
userAgent?: UserAgentMode;
|
||||
/** Default: `false`. */
|
||||
attribution?: ClientAttribution;
|
||||
/** Provider attribution headers. Absent or `null`: none; `null` also turns off attribution set by a merged identity. */
|
||||
attribution?: ClientAttribution | null;
|
||||
/** Version in the detailed `User-Agent`. Default: the pi-ai version. */
|
||||
version?: string;
|
||||
}
|
||||
|
|
@ -48,7 +42,6 @@ export interface ClientIdentityTarget {
|
|||
readonly baseUrl: string;
|
||||
}
|
||||
|
||||
const PI_ATTRIBUTION_URL = "https://pi.dev";
|
||||
const NVIDIA_NIM_HOST = "integrate.api.nvidia.com";
|
||||
const OPENCODE_HOST = "opencode.ai";
|
||||
|
||||
|
|
@ -88,19 +81,6 @@ export function formatUserAgent(identity?: ClientIdentity): string {
|
|||
return runtime ? `${product} (${process.platform}; ${runtime}; ${process.arch})` : `${product} (browser)`;
|
||||
}
|
||||
|
||||
function resolveAttribution(identity: ClientIdentity | undefined): ClientAttributionOptions | undefined {
|
||||
const attribution = identity?.attribution;
|
||||
if (attribution === undefined || attribution === false) return undefined;
|
||||
if (attribution === true) return {};
|
||||
if (typeof attribution === "string") return { name: attribution };
|
||||
return attribution;
|
||||
}
|
||||
|
||||
/** The explicitly configured attribution name, if any. Absent when attribution is off or uses pi's defaults. */
|
||||
export function getAttributionName(identity?: ClientIdentity): string | undefined {
|
||||
return resolveAttribution(identity)?.name;
|
||||
}
|
||||
|
||||
function matchesHost(baseUrl: string, expectedHost: string): boolean {
|
||||
try {
|
||||
return new URL(baseUrl).hostname === expectedHost;
|
||||
|
|
@ -125,22 +105,35 @@ function isOpenCode(target: ClientIdentityTarget): boolean {
|
|||
|
||||
/** Provider attribution headers of an identity for a request target. Empty when attribution is off. */
|
||||
export function getAttributionHeaders(target: ClientIdentityTarget, identity?: ClientIdentity): Record<string, string> {
|
||||
const attribution = resolveAttribution(identity);
|
||||
const attribution = identity?.attribution;
|
||||
if (!attribution) return {};
|
||||
const { name, categories } = attribution;
|
||||
const { name, url, categories } = attribution;
|
||||
if (isOpenRouter(target)) {
|
||||
const url = attribution.url ?? (name === undefined ? PI_ATTRIBUTION_URL : undefined);
|
||||
return {
|
||||
...(url === undefined ? {} : { "HTTP-Referer": url }),
|
||||
"X-OpenRouter-Title": name ?? "pi",
|
||||
...(categories === undefined ? {} : { "X-OpenRouter-Categories": categories }),
|
||||
"HTTP-Referer": url,
|
||||
"X-OpenRouter-Title": name,
|
||||
...(categories === undefined || categories.length === 0
|
||||
? {}
|
||||
: { "X-OpenRouter-Categories": categories.join(",") }),
|
||||
};
|
||||
}
|
||||
if (isNvidiaNim(target)) return { "X-BILLING-INVOKE-ORIGIN": name ?? "Pi" };
|
||||
if (isOpenCode(target)) return { "x-opencode-client": name ?? "pi" };
|
||||
// NVIDIA expects a capitalized origin, such as `Pi`.
|
||||
if (isNvidiaNim(target)) return { "X-BILLING-INVOKE-ORIGIN": name.charAt(0).toUpperCase() + name.slice(1) };
|
||||
if (isOpenCode(target)) return { "x-opencode-client": name };
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenCode's per-conversation routing header for requests to OpenCode, including custom providers pointing at it.
|
||||
* Undefined without a session or for other targets.
|
||||
*/
|
||||
export function getOpenCodeSessionHeaders(
|
||||
target: ClientIdentityTarget,
|
||||
sessionId: string | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
return sessionId && isOpenCode(target) ? { "x-opencode-session": sessionId } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default headers an identity adds to a request: `User-Agent` and provider attribution. APIs apply them below model,
|
||||
* provider, and request headers, so configured headers override them and `null` request headers remove them.
|
||||
|
|
@ -149,34 +142,52 @@ export function getIdentityHeaders(target: ClientIdentityTarget, identity?: Clie
|
|||
return { "User-Agent": formatUserAgent(identity), ...getAttributionHeaders(target, identity) };
|
||||
}
|
||||
|
||||
const IDENTITY_REQUEST_METHODS = new Set<PropertyKey>([
|
||||
"stream",
|
||||
"complete",
|
||||
"streamSimple",
|
||||
"completeSimple",
|
||||
"streamDeferred",
|
||||
"fetchDeferred",
|
||||
"cancelDeferred",
|
||||
"generateImages",
|
||||
"classify",
|
||||
]);
|
||||
function isMutableModels(models: Models): models is MutableModels {
|
||||
return "setProvider" in models && "deleteProvider" in models && "clearProviders" in models;
|
||||
}
|
||||
|
||||
/**
|
||||
* A view of `models` whose requests use `identity` as their default identity: a request's `identity` overrides it field
|
||||
* by field, and it overrides the identity `models` was created with. Everything else is delegated unchanged.
|
||||
* `models` with `identity` as the default identity of its requests: a request's `identity` overrides it field by
|
||||
* field, and it overrides the identity `models` was created with. Everything else is delegated unchanged; a
|
||||
* `MutableModels` stays mutable.
|
||||
*/
|
||||
export function withClientIdentity<T extends Models>(models: T, identity: ClientIdentity): T {
|
||||
return new Proxy(models, {
|
||||
get(target, property) {
|
||||
const value: unknown = Reflect.get(target, property, target);
|
||||
if (typeof value !== "function") return value;
|
||||
if (!IDENTITY_REQUEST_METHODS.has(property)) return value.bind(target);
|
||||
// Every request method takes (model, context or handle, options).
|
||||
return (model: unknown, input: unknown, options?: { identity?: ClientIdentity }) =>
|
||||
value.call(target, model, input, {
|
||||
...options,
|
||||
identity: mergeClientIdentity(identity, options?.identity),
|
||||
});
|
||||
},
|
||||
});
|
||||
export function withClientIdentity(models: MutableModels, identity: ClientIdentity): MutableModels;
|
||||
export function withClientIdentity(models: Models, identity: ClientIdentity): Models;
|
||||
export function withClientIdentity(models: Models, identity: ClientIdentity): Models {
|
||||
const withIdentity = <T extends { identity?: ClientIdentity }>(options: T | undefined): T =>
|
||||
({ ...options, identity: mergeClientIdentity(identity, options?.identity) }) as T;
|
||||
const wrapped: Models = {
|
||||
getProviders: () => models.getProviders(),
|
||||
getProvider: (id) => models.getProvider(id),
|
||||
getModels: (provider) => models.getModels(provider),
|
||||
getModel: (provider, id) => models.getModel(provider, id),
|
||||
getModelsOfType: (type, provider) => models.getModelsOfType(type, provider),
|
||||
getModelOfType: (type, provider, id) => models.getModelOfType(type, provider, id),
|
||||
getAllModels: (provider) => models.getAllModels(provider),
|
||||
refresh: (options) => models.refresh(options),
|
||||
checkAuth: (providerId, options) => models.checkAuth(providerId, options),
|
||||
getAvailable: (providerId, options) => models.getAvailable(providerId, options),
|
||||
getAvailableOfType: (type, providerId, options) => models.getAvailableOfType(type, providerId, options),
|
||||
getAllAvailable: (providerId, options) => models.getAllAvailable(providerId, options),
|
||||
getAuth: models.getAuth.bind(models),
|
||||
login: (providerId, type, interaction, options) => models.login(providerId, type, interaction, options),
|
||||
logout: (providerId, options) => models.logout(providerId, options),
|
||||
stream: (model, context, options) => models.stream(model, context, withIdentity(options)),
|
||||
complete: (model, context, options) => models.complete(model, context, withIdentity(options)),
|
||||
streamSimple: (model, context, options) => models.streamSimple(model, context, withIdentity(options)),
|
||||
completeSimple: (model, context, options) => models.completeSimple(model, context, withIdentity(options)),
|
||||
streamDeferred: (model, handle, options) => models.streamDeferred(model, handle, withIdentity(options)),
|
||||
fetchDeferred: (model, handle, options) => models.fetchDeferred(model, handle, withIdentity(options)),
|
||||
cancelDeferred: (model, handle, options) => models.cancelDeferred(model, handle, withIdentity(options)),
|
||||
generateImages: (model, context, options) => models.generateImages(model, context, withIdentity(options)),
|
||||
classify: (model, context, options) => models.classify(model, context, withIdentity(options)),
|
||||
};
|
||||
if (!isMutableModels(models)) return wrapped;
|
||||
const mutable: MutableModels = {
|
||||
...wrapped,
|
||||
setProvider: (provider) => models.setProvider(provider),
|
||||
deleteProvider: (id) => models.deleteProvider(id),
|
||||
clearProviders: () => models.clearProviders(),
|
||||
};
|
||||
return mutable;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import type { Api, ClientIdentity, Model, ProviderHeaders } from "@earendil-works/pi-ai";
|
||||
import { type ClientIdentity, PI_ATTRIBUTION } from "@earendil-works/pi-ai";
|
||||
import { VERSION } from "../config.ts";
|
||||
import type { SettingsManager } from "./settings-manager.ts";
|
||||
import { isInstallTelemetryEnabled } from "./telemetry.ts";
|
||||
|
||||
const OPENCODE_HOST = "opencode.ai";
|
||||
|
||||
/**
|
||||
* How pi's requests identify it to providers: a detailed `User-Agent` with pi's version, and attribution to pi as a
|
||||
* CLI agent while install telemetry is enabled.
|
||||
|
|
@ -13,28 +11,6 @@ export function getClientIdentity(settingsManager: SettingsManager): ClientIdent
|
|||
return {
|
||||
userAgent: "detailed",
|
||||
version: VERSION,
|
||||
attribution: isInstallTelemetryEnabled(settingsManager) ? { categories: "cli-agent" } : false,
|
||||
attribution: isInstallTelemetryEnabled(settingsManager) ? PI_ATTRIBUTION : null,
|
||||
};
|
||||
}
|
||||
|
||||
function isOpenCodeModel(model: Model<Api>): boolean {
|
||||
if (model.provider === "opencode" || model.provider === "opencode-go") return true;
|
||||
try {
|
||||
return new URL(model.baseUrl).hostname === OPENCODE_HOST;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenCode's per-conversation routing header. pi-ai's OpenCode providers add it themselves; this also covers custom
|
||||
* providers pointing at OpenCode. Configured and request headers override it.
|
||||
*/
|
||||
export function withOpenCodeSessionHeader(
|
||||
model: Model<Api>,
|
||||
sessionId: string | undefined,
|
||||
headers: ProviderHeaders,
|
||||
): ProviderHeaders {
|
||||
if (!sessionId || !isOpenCodeModel(model)) return headers;
|
||||
return { "x-opencode-session": sessionId, ...headers };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
type DeferredCancelOptions,
|
||||
type DeferredFetchOptions,
|
||||
type DeferredHandle,
|
||||
getOpenCodeSessionHeaders,
|
||||
type ImageApi,
|
||||
type ImageModel,
|
||||
type ImagesContext,
|
||||
|
|
@ -677,13 +678,20 @@ export class ModelRuntime implements Models {
|
|||
|
||||
const { transformHeaders, ...rawProviderOptions } = options ?? {};
|
||||
const providerOptions = rawProviderOptions as Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>;
|
||||
let headers = mergeHeaders(resolution.auth.headers, providerOptions.headers);
|
||||
const requestModel: TModel = resolution.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model;
|
||||
const sessionId =
|
||||
"sessionId" in providerOptions && typeof providerOptions.sessionId === "string"
|
||||
? providerOptions.sessionId
|
||||
: undefined;
|
||||
let headers = mergeHeaders(
|
||||
mergeHeaders(getOpenCodeSessionHeaders(requestModel, sessionId), resolution.auth.headers),
|
||||
providerOptions.headers,
|
||||
);
|
||||
if (transformHeaders) headers = await transformHeaders(headers ?? {});
|
||||
const env =
|
||||
resolution.env || providerOptions.env
|
||||
? { ...(resolution.env ?? {}), ...(providerOptions.env ?? {}) }
|
||||
: undefined;
|
||||
const requestModel: TModel = resolution.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model;
|
||||
return {
|
||||
provider,
|
||||
model: requestModel,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { resolvePath } from "../utils/paths.ts";
|
|||
import { AgentSession } from "./agent-session.ts";
|
||||
import { formatNoModelsAvailableMessage } from "./auth-guidance.ts";
|
||||
import { CacheWarmer } from "./cache-warmer.ts";
|
||||
import { getClientIdentity, withOpenCodeSessionHeader } from "./client-identity.ts";
|
||||
import { getClientIdentity } from "./client-identity.ts";
|
||||
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
|
||||
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
|
||||
import { createToolNameMatcher } from "./mcp-servers.ts";
|
||||
|
|
@ -337,10 +337,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
() => settingsManager.getCacheWarmingMode(),
|
||||
async (event) => extensionRunnerRef.current?.emitCacheWarmingDecision(event) ?? event.action,
|
||||
);
|
||||
const buildRequestOptions = (
|
||||
requestModel: Model<any>,
|
||||
options: ModelsSimpleStreamOptions = {},
|
||||
): ModelsSimpleStreamOptions => {
|
||||
const buildRequestOptions = (options: ModelsSimpleStreamOptions = {}): ModelsSimpleStreamOptions => {
|
||||
const providerRetrySettings = settingsManager.getProviderRetrySettings();
|
||||
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
|
||||
const effectiveTimeoutMs = httpIdleTimeoutMs === 0 ? 2147483647 : httpIdleTimeoutMs;
|
||||
|
|
@ -353,12 +350,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
maxRetryDelayMs: options.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs,
|
||||
// Replaces the agent loop's default identity; read per request so telemetry changes apply at once.
|
||||
identity: getClientIdentity(settingsManager),
|
||||
transformHeaders: async (requestHeaders) => {
|
||||
const headers = withOpenCodeSessionHeader(requestModel, options.sessionId, requestHeaders);
|
||||
return headerRunner?.hasHandlers("before_provider_headers")
|
||||
transformHeaders: async (headers) =>
|
||||
headerRunner?.hasHandlers("before_provider_headers")
|
||||
? headerRunner.emitBeforeProviderHeaders(headers)
|
||||
: headers;
|
||||
},
|
||||
: headers,
|
||||
};
|
||||
};
|
||||
// Warm only requests for the selected model. Requests a virtual selection routed, or that an
|
||||
|
|
@ -415,7 +410,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
},
|
||||
convertToLlm: convertToLlmWithBlockImages,
|
||||
streamFn: async (model, context, options) => {
|
||||
const requestOptions = buildRequestOptions(model, options);
|
||||
const requestOptions = buildRequestOptions(options);
|
||||
// Compaction and summaries use their own routing ids; only session requests
|
||||
// replace the cache entry, so warming restarts from them. Keep warming while
|
||||
// the current transcript still extends the request's prefix. Agent state may
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import type { AttachedReplicatedState, Context, JsonValue } from "@earendil-works/chord";
|
||||
import { withAbortSignal, withoutAbortSignal } from "@earendil-works/chord/context";
|
||||
import type { Models } from "@earendil-works/pi-ai";
|
||||
import { type ClientIdentity, mergeClientIdentity, withClientIdentity } from "@earendil-works/pi-ai/utils/identity";
|
||||
import {
|
||||
type ClientIdentity,
|
||||
mergeClientIdentity,
|
||||
PI_ATTRIBUTION,
|
||||
withClientIdentity,
|
||||
} from "@earendil-works/pi-ai/utils/identity";
|
||||
import {
|
||||
createTypedMetrics,
|
||||
NOOP_TELEMETRY_CONTEXT,
|
||||
|
|
@ -180,28 +185,17 @@ class ConversationImpl<Tool extends ToolRegistration> implements Conversation {
|
|||
}
|
||||
|
||||
/** Request identity of a Harness's model requests: pi-ai's defaults with provider attribution to pi. */
|
||||
export const DEFAULT_HARNESS_IDENTITY: ClientIdentity = { attribution: true };
|
||||
export const DEFAULT_HARNESS_IDENTITY: ClientIdentity = { attribution: PI_ATTRIBUTION };
|
||||
|
||||
/**
|
||||
* `HarnessOptions.models` as a function with `HarnessOptions.identity` applied. A plain `Models` is wrapped once and
|
||||
* returned at every call; a function's results are wrapped once per `Models` it returns.
|
||||
* `HarnessOptions.models` as a function with `HarnessOptions.identity` applied. A plain `Models` is wrapped once; a
|
||||
* function's result is wrapped at each call.
|
||||
*/
|
||||
function currentModels(models: HarnessOptions["models"], identity: ClientIdentity | undefined): () => Models {
|
||||
const merged = mergeClientIdentity(DEFAULT_HARNESS_IDENTITY, identity) ?? DEFAULT_HARNESS_IDENTITY;
|
||||
if (typeof models !== "function") {
|
||||
const wrapped = withClientIdentity(models, merged);
|
||||
return () => wrapped;
|
||||
}
|
||||
const wrappers = new WeakMap<Models, Models>();
|
||||
return () => {
|
||||
const current = models();
|
||||
let wrapped = wrappers.get(current);
|
||||
if (wrapped === undefined) {
|
||||
wrapped = withClientIdentity(current, merged);
|
||||
wrappers.set(current, wrapped);
|
||||
}
|
||||
return wrapped;
|
||||
};
|
||||
if (typeof models === "function") return () => withClientIdentity(models(), merged);
|
||||
const wrapped = withClientIdentity(models, merged);
|
||||
return () => wrapped;
|
||||
}
|
||||
|
||||
/** `HarnessOptions.settings` as a function: plain settings, or none, are returned at every call. */
|
||||
|
|
|
|||
|
|
@ -616,9 +616,9 @@ export type HarnessOptions<Tool extends ToolRegistration = ToolRegistration> = {
|
|||
readonly models: Models | (() => Models);
|
||||
/**
|
||||
* How model requests identify the app to providers, merged field by field over `DEFAULT_HARNESS_IDENTITY`
|
||||
* (`{ attribution: true }`, provider attribution to pi). It applies to every request through the Harness's models,
|
||||
* including hooks and tools, and overrides the identity the `Models` was created with; a request's own `identity`
|
||||
* overrides it. Pass `{ attribution: false }` to send no attribution headers.
|
||||
* (`{ attribution: PI_ATTRIBUTION }`, provider attribution to pi). It applies to every request through the Harness's
|
||||
* models, including hooks and tools, and overrides the identity the `Models` was created with; a request's own
|
||||
* `identity` overrides it. Pass `{ attribution: null }` to send no attribution headers.
|
||||
*/
|
||||
readonly identity?: ClientIdentity;
|
||||
readonly registry: RegistryReader<Tool>;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue