mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(ai,agent,coding-agent): move client identity and provider attribution into pi-ai
pi-ai gets a ClientIdentity request option and createModels() default that choose the User-Agent (detailed with version, short, or custom) and provider attribution headers for OpenRouter, NVIDIA NIM, and OpenCode. Attribution is off by default in pi-ai and on by default in pi-agent-core and pi-durable (HarnessOptions.identity). pi-coding-agent sets its identity from the telemetry setting instead of adding headers itself.
This commit is contained in:
parent
adfc14461a
commit
adc5fa9f80
55 changed files with 958 additions and 465 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added `AgentOptions.identity` and `Agent.identity` (a pi-ai `ClientIdentity`). Agent loop requests use `DEFAULT_AGENT_IDENTITY` (`{ attribution: true }`) under it, so OpenRouter, NVIDIA NIM, and OpenCode requests are attributed to pi by default; pass `{ attribution: false }` to send no attribution headers.
|
||||
|
||||
## [1.3.0] - 2026-10-15
|
||||
|
||||
## [1.2.0] - 2026-10-12
|
||||
|
|
|
|||
|
|
@ -232,6 +232,11 @@ const agent = new Agent({
|
|||
// Session ID for provider caching
|
||||
sessionId: "session-123",
|
||||
|
||||
// How requests identify the app to providers (pi-ai ClientIdentity), merged over
|
||||
// DEFAULT_AGENT_IDENTITY ({ attribution: true }: provider attribution headers name pi).
|
||||
// Pass a name to attribute requests to your app, or false to send none.
|
||||
identity: { attribution: "my-app" },
|
||||
|
||||
// Dynamic API key resolution (for expiring OAuth tokens)
|
||||
getApiKey: async (provider) => refreshToken(),
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@
|
|||
|
||||
import {
|
||||
type AssistantMessage,
|
||||
type ClientIdentity,
|
||||
EventStream,
|
||||
getCurrentTools,
|
||||
getToolStateChanges,
|
||||
mergeClientIdentity,
|
||||
normalizeContext,
|
||||
type SystemMessage,
|
||||
type ToolResultMessage,
|
||||
|
|
@ -31,6 +33,12 @@ import type {
|
|||
|
||||
export type AgentEventSink = (event: AgentEvent) => Promise<void> | void;
|
||||
|
||||
/**
|
||||
* Request identity of agent loops: pi-ai's defaults with provider attribution to pi. `AgentLoopConfig.identity`
|
||||
* overrides it field by field.
|
||||
*/
|
||||
export const DEFAULT_AGENT_IDENTITY: ClientIdentity = { attribution: true };
|
||||
|
||||
/**
|
||||
* Start an agent loop with a new prompt message.
|
||||
* The prompt is added to the context and events are emitted for it.
|
||||
|
|
@ -403,6 +411,7 @@ async function streamAssistantResponse(
|
|||
const response = await streamFunction(config.model, llmContext, {
|
||||
...config,
|
||||
apiKey: resolvedApiKey,
|
||||
identity: mergeClientIdentity(DEFAULT_AGENT_IDENTITY, config.identity),
|
||||
signal,
|
||||
});
|
||||
// Record the requested level, whichever stream function answered.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
type ClientIdentity,
|
||||
createInitialSystemMessage,
|
||||
getCurrentSystemMessage,
|
||||
getCurrentSystemPrompt,
|
||||
|
|
@ -138,6 +139,11 @@ export interface AgentOptions {
|
|||
transport?: Transport;
|
||||
maxRetryDelayMs?: number;
|
||||
toolExecution?: ToolExecutionMode;
|
||||
/**
|
||||
* How requests identify the app to providers, merged field by field over `DEFAULT_AGENT_IDENTITY`
|
||||
* (`{ attribution: true }`). Pass `{ attribution: false }` to send no attribution headers.
|
||||
*/
|
||||
identity?: ClientIdentity;
|
||||
}
|
||||
|
||||
class PendingMessageQueue {
|
||||
|
|
@ -226,6 +232,8 @@ export class Agent {
|
|||
public maxRetryDelayMs?: number;
|
||||
/** Tool execution strategy for assistant messages that contain multiple tool calls. */
|
||||
public toolExecution: ToolExecutionMode;
|
||||
/** Request identity forwarded to the stream function, over `DEFAULT_AGENT_IDENTITY`. */
|
||||
public identity?: ClientIdentity;
|
||||
|
||||
constructor(options: AgentOptions) {
|
||||
// Older compiled consumers may omit options or streamFn even though the current API requires them.
|
||||
|
|
@ -251,6 +259,7 @@ export class Agent {
|
|||
this.transport = runtimeOptions.transport ?? "auto";
|
||||
this.maxRetryDelayMs = runtimeOptions.maxRetryDelayMs;
|
||||
this.toolExecution = runtimeOptions.toolExecution ?? "parallel";
|
||||
this.identity = runtimeOptions.identity;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -476,6 +485,7 @@ export class Agent {
|
|||
transport: this.transport,
|
||||
thinkingBudgets: this.thinkingBudgets,
|
||||
maxRetryDelayMs: this.maxRetryDelayMs,
|
||||
identity: this.identity,
|
||||
toolExecution: this.toolExecution,
|
||||
beforeToolCall: this.beforeToolCall,
|
||||
afterToolCall: this.afterToolCall,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ClientIdentity } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
type AssistantMessage,
|
||||
createAssistantMessageEventStream,
|
||||
|
|
@ -1198,4 +1199,30 @@ describe("Agent", () => {
|
|||
await agent.prompt("hello again");
|
||||
expect(receivedSessionId).toBe("session-def");
|
||||
});
|
||||
|
||||
it("attributes requests to pi by default and merges a configured identity over it", async () => {
|
||||
const received: (ClientIdentity | undefined)[] = [];
|
||||
const agent = new Agent({
|
||||
streamFn: (_model, _context, options) => {
|
||||
received.push(options?.identity);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
await agent.prompt("hello");
|
||||
agent.identity = { userAgent: "short", attribution: "acme" };
|
||||
await agent.prompt("hello again");
|
||||
agent.identity = { userAgent: "short" };
|
||||
await agent.prompt("and again");
|
||||
|
||||
expect(received).toEqual([
|
||||
{ attribution: true },
|
||||
{ attribution: "acme", userAgent: "short" },
|
||||
{ attribution: true, userAgent: "short" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,10 +5,13 @@
|
|||
### Breaking Changes
|
||||
|
||||
- Renamed provider request option `telemetryContext` to `telemetry?: TelemetrySpan`, without a compatibility alias. Pass a parent span, not an adapter.
|
||||
- The default `User-Agent` is now `pi/<version> (<platform>; <runtime>; <arch>)` instead of `pi (<platform> <release>; <arch>)`, and `@earendil-works/pi-ai/utils/pi-user-agent` (`getPiUserAgent()`) is removed; use `formatUserAgent()`.
|
||||
- `resolveTranscriptTools()` now takes a `TranscriptToolSupport` (`"none"`, `"additions"`, or `"changes"`) instead of a boolean, and `TranscriptTools.anchorsAdditions` is replaced by `foldedSystemMessages`, the number of leading system messages folded into `requestTools` ([#10940](https://github.com/earendil-works/pi/issues/10940))
|
||||
|
||||
### Added
|
||||
|
||||
- Added client identity configuration: an `identity` request option and `createModels({ identity })` default choose the `User-Agent` (`"detailed"` with a version, `"short"`, or custom) and provider attribution headers for OpenRouter, NVIDIA NIM, and OpenCode (off by default; `true` attributes to pi, a string or `{ name, url, categories }` to your app). The attribution name is also the Codex `originator` and the default login `agentName`. `withClientIdentity()` applies an identity to a `Models` a library receives from its host. `formatUserAgent()`, `getIdentityHeaders()`, `mergeClientIdentity()`, and `PI_AI_VERSION` are exported.
|
||||
- Bedrock, OpenRouter image generation, pi-messages, and classifier requests now send pi's `User-Agent` too.
|
||||
- Added `AI_TELEMETRY_SCHEMA` and opt-in `pi.ai.request`/`pi.ai.poll` tracing around Models requests, with content-free first-event, block-size, and final usage attributes, plus duration, token, and USD cost metrics through `telemetry.metrics`.
|
||||
- Failed model request spans report `provider_error` status with up to 500 error-message characters; cancelled requests keep `ok` status with `stop: "aborted"`.
|
||||
- Added `MiniMax-M3.1-Flash` to the `minimax` and `minimax-cn` providers, with thinking levels low through max sent as `output_config.effort`. Thinking cannot be turned off for this model. New MiniMax models from models.dev now appear without a manual allowlist entry; only the older M2, M2.1, M2.5, and M2.5-highspeed models stay hidden ([#11121](https://github.com/earendil-works/pi/issues/11121))
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ Unified LLM API with provider collections, automatic auth resolution, token and
|
|||
- [Auth](#auth)
|
||||
- [How Auth Resolves](#how-auth-resolves)
|
||||
- [Transforming Request Headers](#transforming-request-headers)
|
||||
- [Client Identity](#client-identity)
|
||||
- [Credential Store](#credential-store)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Tools](#tools)
|
||||
|
|
@ -425,6 +426,24 @@ Header names are merged case-insensitively. Explicit headers override auth/model
|
|||
|
||||
`transformHeaders` belongs to `Models`, not `Provider`. A `Models` implementation must consume it and remove it before calling `Provider.stream*()`. Provider implementations continue receiving ordinary `ApiStreamOptions` or `SimpleStreamOptions` and never handle the transform themselves. Use this option instead of calling `getAuth(model)` before `stream*()`, which would resolve request auth twice.
|
||||
|
||||
### Client Identity
|
||||
|
||||
`identity` controls how requests identify the calling app to providers. Set it on `createModels()` as a default, or per request; a request's fields override the `Models` default field by field:
|
||||
|
||||
```typescript
|
||||
const models = createModels({
|
||||
identity: { userAgent: 'detailed', version: '2.1.0', attribution: 'my-app' },
|
||||
});
|
||||
|
||||
await models.completeSimple(model, context, { identity: { attribution: false } });
|
||||
```
|
||||
|
||||
- `userAgent`: `"detailed"` (default) sends `pi/<version> (<platform>; <runtime>; <arch>)`, `"short"` sends `pi`, and `{ custom: "..." }` sends the given value. On Bedrock the value is appended to the AWS SDK user agent.
|
||||
- `version`: the version in the detailed `User-Agent`. Default: the pi-ai version.
|
||||
- `attribution`: provider attribution headers. `false` (default) sends none. `true` attributes requests to pi: OpenRouter gets `HTTP-Referer: https://pi.dev` and `X-OpenRouter-Title: pi`, NVIDIA NIM gets `X-BILLING-INVOKE-ORIGIN: Pi`, and OpenCode gets `x-opencode-client: pi`. A string attributes requests to that name instead. An object sets `name`, `url` (OpenRouter `HTTP-Referer`), and `categories` (OpenRouter `X-OpenRouter-Categories`); fields left out use pi's values only when `name` is left out too. The attribution name is also the Codex `originator` and the default `agentName` of `Models.login()`.
|
||||
|
||||
Identity headers are defaults below provider, model, and request headers, so configured headers win and a `null` request header removes one. Providers that send a fixed client identity, such as GitHub Copilot and Anthropic OAuth, keep it. `withClientIdentity(models, identity)` returns a view of a `Models` whose requests use `identity` over the one the `Models` was created with, for libraries that receive a `Models` from their host. `identity` is plain JSON, so it can be persisted with other request options.
|
||||
|
||||
### Credential Store
|
||||
|
||||
Stored credentials (API keys entered interactively, OAuth tokens) live in a `CredentialStore` — one type-tagged credential per provider. pi-ai ships an in-memory default; apps inject persistent storage:
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ import type {
|
|||
import { appendAssistantMessageDiagnostic } from "../utils/diagnostics.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts";
|
||||
import { parseJsonWithRepair, parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
|
|
@ -304,10 +304,6 @@ function mergeHeaders(...headerSources: (ProviderHeaders | undefined)[]): Provid
|
|||
return merged;
|
||||
}
|
||||
|
||||
function mergeClientHeaders(...headerSources: (ProviderHeaders | undefined)[]): ProviderHeaders {
|
||||
return mergeHeaders({ "User-Agent": getPiUserAgent() }, ...headerSources);
|
||||
}
|
||||
|
||||
function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean {
|
||||
if (!headers) return false;
|
||||
const expected = name.toLowerCase();
|
||||
|
|
@ -634,6 +630,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
|||
copilotDynamicHeaders,
|
||||
cacheSessionId,
|
||||
federation,
|
||||
options?.identity,
|
||||
);
|
||||
client = created.client;
|
||||
isOAuth = created.isOAuthToken;
|
||||
|
|
@ -1023,6 +1020,7 @@ function createClient(
|
|||
dynamicHeaders?: Record<string, string>,
|
||||
sessionId?: string,
|
||||
federation?: AnthropicFederationConfig,
|
||||
identity?: ClientIdentity,
|
||||
): { client: Anthropic; isOAuthToken: boolean } {
|
||||
// Copilot: Bearer auth.
|
||||
if (model.provider === "github-copilot") {
|
||||
|
|
@ -1032,7 +1030,8 @@ function createClient(
|
|||
baseURL: model.baseUrl,
|
||||
dangerouslyAllowBrowser: true,
|
||||
fetch,
|
||||
defaultHeaders: mergeClientHeaders(
|
||||
defaultHeaders: mergeHeaders(
|
||||
getIdentityHeaders(model, identity),
|
||||
{
|
||||
accept: "application/json",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
|
|
@ -1046,7 +1045,7 @@ function createClient(
|
|||
return { client, isOAuthToken: false };
|
||||
}
|
||||
|
||||
// OAuth: Bearer auth, Claude Code identity headers
|
||||
// OAuth: Bearer auth, Claude Code identity headers instead of the client identity.
|
||||
if (apiKey && isOAuthToken(apiKey)) {
|
||||
const client = new PiAnthropic({
|
||||
apiKey: null,
|
||||
|
|
@ -1054,7 +1053,7 @@ function createClient(
|
|||
baseURL: model.baseUrl,
|
||||
dangerouslyAllowBrowser: true,
|
||||
fetch,
|
||||
defaultHeaders: mergeClientHeaders(
|
||||
defaultHeaders: mergeHeaders(
|
||||
{
|
||||
accept: "application/json",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
|
|
@ -1076,7 +1075,8 @@ function createClient(
|
|||
const header = compat.sessionAffinityFormat === "openrouter" ? "x-session-id" : "x-session-affinity";
|
||||
sessionAffinityHeaders[header] = sessionId;
|
||||
}
|
||||
const defaultHeaders = mergeClientHeaders(
|
||||
const defaultHeaders = mergeHeaders(
|
||||
getIdentityHeaders(model, identity),
|
||||
{
|
||||
accept: "application/json",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { Api, AssistantMessage, Model, SimpleStreamOptions, StreamFunction,
|
|||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { getIdentityHeaders } from "../utils/identity.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { getDeclaredTools, resolveTranscript, resolveTranscriptTools } from "../utils/transcript.ts";
|
||||
import { type AzureEndpointOptions, resolveAzureConfig, resolveDeploymentName } from "./azure-openai-config.ts";
|
||||
|
|
@ -158,7 +158,7 @@ export const streamSimple: StreamFunction<"azure-openai-responses", SimpleStream
|
|||
};
|
||||
|
||||
function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) {
|
||||
const headers = { "User-Agent": getPiUserAgent(), ...model.headers };
|
||||
const headers = { ...getIdentityHeaders(model, options?.identity), ...model.headers };
|
||||
|
||||
if (options?.headers) {
|
||||
Object.assign(headers, options.headers);
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import { appendAssistantMessageDiagnostic } from "../utils/diagnostics.ts";
|
|||
import { normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { providerHeadersToRecord } from "../utils/headers.ts";
|
||||
import { type ClientIdentity, getClientVersion } from "../utils/identity.ts";
|
||||
import { parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts";
|
||||
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
|
|
@ -162,6 +163,7 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> =
|
|||
const optionsProfile = options.profile || options.env?.AWS_PROFILE;
|
||||
const config: BedrockRuntimeClientConfig = {
|
||||
profile: optionsProfile || getProviderEnvValue("AWS_PROFILE", options.env),
|
||||
customUserAgent: bedrockUserAgent(options.identity),
|
||||
};
|
||||
const configuredRegion = getConfiguredBedrockRegion(options);
|
||||
const hasAmbientConfiguredProfile = Boolean(getProviderEnvValue("AWS_PROFILE"));
|
||||
|
|
@ -470,6 +472,16 @@ function isReservedHeader(key: string): boolean {
|
|||
return lower.startsWith("x-amz-") || RESERVED_HEADER_EXACT.has(lower);
|
||||
}
|
||||
|
||||
/**
|
||||
* The client identity as AWS SDK user agent pairs. The SDK appends them to its own `User-Agent` and escapes characters
|
||||
* that are not valid in a user agent token, so the detailed form is reduced to name and version.
|
||||
*/
|
||||
function bedrockUserAgent(identity: ClientIdentity | undefined): string | [string, string?][] {
|
||||
const mode = identity?.userAgent ?? "detailed";
|
||||
if (typeof mode === "object") return mode.custom;
|
||||
return mode === "short" ? [["pi"]] : [["pi", getClientVersion(identity)]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach caller-supplied headers to the outgoing Bedrock request via a Smithy
|
||||
* `build`-step middleware. The `build` step runs after request serialisation but
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { calculateCost } from "../models.ts";
|
||||
import type { ClassifierApi, ClassifierModel, ClassifierOptions, ProviderHeaders, Usage } from "../types.ts";
|
||||
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
|
||||
import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
|
||||
/** An HTTP failure in the shape `retryProviderRequest` and `normalizeProviderError` understand. */
|
||||
|
|
@ -42,9 +43,11 @@ function requestHeaders(
|
|||
model: ClassifierModel<ClassifierApi>,
|
||||
apiKey: string,
|
||||
optionsHeaders?: ProviderHeaders,
|
||||
identity?: ClientIdentity,
|
||||
): Record<string, string> {
|
||||
return (
|
||||
providerHeadersToRecord(
|
||||
getIdentityHeaders(model, identity),
|
||||
{ authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
||||
model.headers,
|
||||
optionsHeaders,
|
||||
|
|
@ -81,7 +84,7 @@ export async function postClassifierRequest(
|
|||
try {
|
||||
const next = await requestFetch(url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders(model, apiKey, options.headers),
|
||||
headers: requestHeaders(model, apiKey, options.headers, options.identity),
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import type {
|
|||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { providerHeadersToRecord } from "../utils/headers.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { getSystemMessageText } from "../utils/text.ts";
|
||||
import { collapseSystemMessages, getCurrentTools, getInitialSystemMessage } from "../utils/transcript.ts";
|
||||
|
|
@ -91,7 +91,7 @@ export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = (
|
|||
if (!apiKey) {
|
||||
throw new Error(`No API key for provider: ${model.provider}`);
|
||||
}
|
||||
const client = createClient(model, apiKey, options?.headers);
|
||||
const client = createClient(model, apiKey, options?.headers, options?.identity);
|
||||
let params = buildParams(model, normalizedContext, options);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
|
|
@ -349,13 +349,14 @@ function createClient(
|
|||
model: Model<"google-generative-ai">,
|
||||
apiKey?: string,
|
||||
optionsHeaders?: ProviderHeaders,
|
||||
identity?: ClientIdentity,
|
||||
): GoogleGenAI {
|
||||
const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record<string, string> } = {};
|
||||
if (model.baseUrl) {
|
||||
httpOptions.baseUrl = model.baseUrl;
|
||||
httpOptions.apiVersion = ""; // baseUrl already includes version path, don't append
|
||||
}
|
||||
const headers = providerHeadersToRecord({ "User-Agent": getPiUserAgent(), ...model.headers, ...optionsHeaders });
|
||||
const headers = providerHeadersToRecord(getIdentityHeaders(model, identity), model.headers, optionsHeaders);
|
||||
if (headers) {
|
||||
httpOptions.headers = headers;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import type {
|
|||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { providerHeadersToRecord } from "../utils/headers.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { getSystemMessageText } from "../utils/text.ts";
|
||||
|
|
@ -99,8 +99,15 @@ export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = (
|
|||
const apiKey = resolveApiKey(options);
|
||||
// Create the client using either a Vertex API key, if provided, or ADC with project and location
|
||||
const client = apiKey
|
||||
? createClientWithApiKey(model, apiKey, options?.headers)
|
||||
: createClient(model, resolveProject(options), resolveLocation(options), options?.headers, options?.env);
|
||||
? createClientWithApiKey(model, apiKey, options?.headers, options?.identity)
|
||||
: createClient(
|
||||
model,
|
||||
resolveProject(options),
|
||||
resolveLocation(options),
|
||||
options?.headers,
|
||||
options?.env,
|
||||
options?.identity,
|
||||
);
|
||||
let params = buildParams(model, normalizedContext, options);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
|
|
@ -360,6 +367,7 @@ function createClient(
|
|||
location: string,
|
||||
optionsHeaders?: ProviderHeaders,
|
||||
env?: ProviderEnv,
|
||||
identity?: ClientIdentity,
|
||||
): GoogleGenAI {
|
||||
const googleAuthOptions = buildGoogleAuthOptions(env);
|
||||
return new GoogleGenAI({
|
||||
|
|
@ -368,7 +376,7 @@ function createClient(
|
|||
location,
|
||||
apiVersion: API_VERSION,
|
||||
...(googleAuthOptions ? { googleAuthOptions } : {}),
|
||||
httpOptions: buildHttpOptions(model, optionsHeaders),
|
||||
httpOptions: buildHttpOptions(model, optionsHeaders, identity),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -376,16 +384,21 @@ function createClientWithApiKey(
|
|||
model: Model<"google-vertex">,
|
||||
apiKey: string,
|
||||
optionsHeaders?: ProviderHeaders,
|
||||
identity?: ClientIdentity,
|
||||
): GoogleGenAI {
|
||||
return new GoogleGenAI({
|
||||
vertexai: true,
|
||||
apiKey,
|
||||
apiVersion: API_VERSION,
|
||||
httpOptions: buildHttpOptions(model, optionsHeaders),
|
||||
httpOptions: buildHttpOptions(model, optionsHeaders, identity),
|
||||
});
|
||||
}
|
||||
|
||||
function buildHttpOptions(model: Model<"google-vertex">, optionsHeaders?: ProviderHeaders): HttpOptions | undefined {
|
||||
function buildHttpOptions(
|
||||
model: Model<"google-vertex">,
|
||||
optionsHeaders?: ProviderHeaders,
|
||||
identity?: ClientIdentity,
|
||||
): HttpOptions | undefined {
|
||||
const httpOptions: HttpOptions = {};
|
||||
const baseUrl = resolveCustomBaseUrl(model.baseUrl);
|
||||
if (baseUrl) {
|
||||
|
|
@ -396,7 +409,7 @@ function buildHttpOptions(model: Model<"google-vertex">, optionsHeaders?: Provid
|
|||
}
|
||||
}
|
||||
|
||||
const headers = providerHeadersToRecord({ "User-Agent": getPiUserAgent(), ...model.headers, ...optionsHeaders });
|
||||
const headers = providerHeadersToRecord(getIdentityHeaders(model, identity), model.headers, optionsHeaders);
|
||||
if (headers) {
|
||||
httpOptions.headers = headers;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
} from "../types.ts";
|
||||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
|
||||
import { getIdentityHeaders } from "../utils/identity.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
|
||||
/**
|
||||
|
|
@ -234,6 +235,7 @@ async function post(request: RequestContext, path: string, body: unknown, observ
|
|||
const requestFetch = options?.fetch ?? globalThis.fetch;
|
||||
const headers =
|
||||
providerHeadersToRecord(
|
||||
getIdentityHeaders(model, options?.identity),
|
||||
{
|
||||
"content-type": "application/json",
|
||||
...(options?.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}),
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ import { combineAbortSignals } from "../utils/abort-signals.ts";
|
|||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { shortHash } from "../utils/hash.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { getIdentityHeaders } from "../utils/identity.ts";
|
||||
import { parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { getSystemMessageText, renderSystemMessageUpdate } from "../utils/text.ts";
|
||||
import { getCurrentTools, resolveTranscript } from "../utils/transcript.ts";
|
||||
|
|
@ -354,7 +354,7 @@ class MistralHttpError extends Error {
|
|||
|
||||
function buildMistralHeaders(model: Model<"mistral-conversations">, apiKey: string, options?: MistralOptions): Headers {
|
||||
const headers = new Headers({
|
||||
"User-Agent": getPiUserAgent(),
|
||||
...getIdentityHeaders(model, options?.identity),
|
||||
accept: "text/event-stream",
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
"content-type": "application/json",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type {
|
|||
ResponseInput,
|
||||
ResponseStreamEvent,
|
||||
} from "openai/resources/responses/responses.js";
|
||||
|
||||
import { clampThinkingLevel } from "../models.ts";
|
||||
import { registerSessionResourceCleanup } from "../session-resources.ts";
|
||||
import type {
|
||||
|
|
@ -29,8 +28,8 @@ import {
|
|||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { getAttributionName, getIdentityHeaders } from "../utils/identity.ts";
|
||||
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { getSystemMessageText } from "../utils/text.ts";
|
||||
import {
|
||||
getDeclaredTools,
|
||||
|
|
@ -286,8 +285,21 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
|
|||
body = nextBody as RequestBody;
|
||||
}
|
||||
const websocketRequestId = codexSessionId || uuidv7();
|
||||
const sseHeaders = buildSSEHeaders(model.headers, options?.headers, accountId, apiKey, codexSessionId);
|
||||
// Codex expects an originator on every request, so it stays on without attribution.
|
||||
const identityHeaders = {
|
||||
originator: getAttributionName(options?.identity) ?? "pi",
|
||||
...getIdentityHeaders(model, options?.identity),
|
||||
};
|
||||
const sseHeaders = buildSSEHeaders(
|
||||
identityHeaders,
|
||||
model.headers,
|
||||
options?.headers,
|
||||
accountId,
|
||||
apiKey,
|
||||
codexSessionId,
|
||||
);
|
||||
const websocketHeaders = buildWebSocketHeaders(
|
||||
identityHeaders,
|
||||
model.headers,
|
||||
options?.headers,
|
||||
accountId,
|
||||
|
|
@ -1651,13 +1663,14 @@ function extractAccountId(token: string): string {
|
|||
}
|
||||
|
||||
function buildBaseCodexHeaders(
|
||||
defaultHeaders: Record<string, string>,
|
||||
initHeaders: Record<string, string> | undefined,
|
||||
additionalHeaders: ProviderHeaders | undefined,
|
||||
accountId: string,
|
||||
token: string,
|
||||
): Headers {
|
||||
// Defaults first so model and caller headers can override them, matching the other providers.
|
||||
const headers = new Headers({ originator: "pi", "User-Agent": getPiUserAgent() });
|
||||
const headers = new Headers(defaultHeaders);
|
||||
for (const [key, value] of Object.entries(initHeaders || {})) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
|
|
@ -1674,13 +1687,14 @@ function buildBaseCodexHeaders(
|
|||
}
|
||||
|
||||
function buildSSEHeaders(
|
||||
defaultHeaders: Record<string, string>,
|
||||
initHeaders: Record<string, string> | undefined,
|
||||
additionalHeaders: ProviderHeaders | undefined,
|
||||
accountId: string,
|
||||
token: string,
|
||||
sessionId?: string,
|
||||
): Headers {
|
||||
const headers = buildBaseCodexHeaders(initHeaders, additionalHeaders, accountId, token);
|
||||
const headers = buildBaseCodexHeaders(defaultHeaders, initHeaders, additionalHeaders, accountId, token);
|
||||
headers.set("OpenAI-Beta", "responses=experimental");
|
||||
headers.set("accept", "text/event-stream");
|
||||
headers.set("content-type", "application/json");
|
||||
|
|
@ -1694,13 +1708,14 @@ function buildSSEHeaders(
|
|||
}
|
||||
|
||||
function buildWebSocketHeaders(
|
||||
defaultHeaders: Record<string, string>,
|
||||
initHeaders: Record<string, string> | undefined,
|
||||
additionalHeaders: ProviderHeaders | undefined,
|
||||
accountId: string,
|
||||
token: string,
|
||||
requestId: string,
|
||||
): Headers {
|
||||
const headers = buildBaseCodexHeaders(initHeaders, additionalHeaders, accountId, token);
|
||||
const headers = buildBaseCodexHeaders(defaultHeaders, initHeaders, additionalHeaders, accountId, token);
|
||||
headers.delete("accept");
|
||||
headers.delete("content-type");
|
||||
headers.delete("OpenAI-Beta");
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ import { formatProviderError, normalizeProviderError } from "../utils/error-body
|
|||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { shortHash } from "../utils/hash.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts";
|
||||
import { parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
|
|
@ -351,6 +351,7 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
|||
normalizedContext,
|
||||
apiKey,
|
||||
options?.headers,
|
||||
options?.identity,
|
||||
options?.fetch,
|
||||
cacheSessionId,
|
||||
compat,
|
||||
|
|
@ -761,11 +762,12 @@ function createClient(
|
|||
context: TranscriptContext,
|
||||
apiKey: string,
|
||||
optionsHeaders?: ProviderHeaders,
|
||||
identity?: ClientIdentity,
|
||||
fetch?: typeof globalThis.fetch,
|
||||
sessionId?: string,
|
||||
compat: ResolvedOpenAICompletionsCompat = getCompat(model),
|
||||
) {
|
||||
const headers: ProviderHeaders = { "User-Agent": getPiUserAgent(), ...model.headers };
|
||||
const headers: ProviderHeaders = { ...getIdentityHeaders(model, identity), ...model.headers };
|
||||
if (model.provider === "github-copilot") {
|
||||
const hasImages = hasCopilotVisionInput(context.messages);
|
||||
const copilotHeaders = buildCopilotDynamicHeaders({
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import type {
|
|||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { getDeclaredTools, resolveTranscript, resolveTranscriptTools } from "../utils/transcript.ts";
|
||||
|
|
@ -196,6 +196,7 @@ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions>
|
|||
normalizedContext,
|
||||
apiKey,
|
||||
options?.headers,
|
||||
options?.identity,
|
||||
options?.fetch,
|
||||
cacheSessionId,
|
||||
);
|
||||
|
|
@ -301,11 +302,12 @@ function createClient(
|
|||
context: TranscriptContext,
|
||||
apiKey: string,
|
||||
optionsHeaders?: ProviderHeaders,
|
||||
identity?: ClientIdentity,
|
||||
fetch?: typeof globalThis.fetch,
|
||||
sessionId?: string,
|
||||
) {
|
||||
const compat = getCompat(model);
|
||||
const headers: ProviderHeaders = { "User-Agent": getPiUserAgent(), ...model.headers };
|
||||
const headers: ProviderHeaders = { ...getIdentityHeaders(model, identity), ...model.headers };
|
||||
if (model.provider === "github-copilot") {
|
||||
const hasImages = hasCopilotVisionInput(context.messages);
|
||||
const copilotHeaders = buildCopilotDynamicHeaders({
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type {
|
|||
} from "../types.ts";
|
||||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
|
||||
import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
|
||||
|
|
@ -58,7 +59,7 @@ export const generateImages: ImagesFunction<ImagesOptions> = async (
|
|||
if (!apiKey) {
|
||||
throw new Error(`No API key for provider: ${model.provider}`);
|
||||
}
|
||||
const client = createClient(model, apiKey, options?.headers, options?.fetch);
|
||||
const client = createClient(model, apiKey, options?.headers, options?.fetch, options?.identity);
|
||||
let params = buildParams(model, context);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
|
|
@ -121,13 +122,14 @@ function createClient(
|
|||
apiKey: string,
|
||||
optionsHeaders?: ProviderHeaders,
|
||||
fetch?: typeof globalThis.fetch,
|
||||
identity?: ClientIdentity,
|
||||
): OpenAI {
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
baseURL: model.baseUrl,
|
||||
dangerouslyAllowBrowser: true,
|
||||
fetch,
|
||||
defaultHeaders: providerHeadersToRecord({ ...model.headers, ...optionsHeaders }),
|
||||
defaultHeaders: providerHeadersToRecord(getIdentityHeaders(model, identity), model.headers, optionsHeaders),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import type {
|
|||
import { appendAssistantMessageDiagnostic, createAssistantMessageDiagnostic } from "../utils/diagnostics.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
|
||||
import { getIdentityHeaders } from "../utils/identity.ts";
|
||||
import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
|
||||
|
|
@ -391,12 +392,15 @@ export const stream: StreamFunction<"pi-messages", PiMessagesOptions> = (
|
|||
|
||||
const response = await (options?.fetch ?? globalThis.fetch)(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
accept: "text/event-stream",
|
||||
"content-type": "application/json",
|
||||
...providerHeadersToRecord(options?.headers),
|
||||
},
|
||||
headers: providerHeadersToRecord(
|
||||
getIdentityHeaders(model, options?.identity),
|
||||
{
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
accept: "text/event-stream",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
options?.headers,
|
||||
),
|
||||
body: JSON.stringify(payload),
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export function buildBaseOptions(
|
|||
cacheRetention: options?.cacheRetention,
|
||||
sessionId: options?.sessionId,
|
||||
headers: options?.headers,
|
||||
identity: options?.identity,
|
||||
onPayload: options?.onPayload,
|
||||
onResponse: options?.onResponse,
|
||||
onProviderStreamEvent: options?.onProviderStreamEvent,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export * from "./session-resources.ts";
|
|||
export * from "./types.ts";
|
||||
export * from "./utils/diagnostics.ts";
|
||||
export * from "./utils/event-stream.ts";
|
||||
export * from "./utils/identity.ts";
|
||||
export * from "./utils/json-parse.ts";
|
||||
export * from "./utils/overflow.ts";
|
||||
export * from "./utils/retry.ts";
|
||||
|
|
@ -45,3 +46,4 @@ export * from "./utils/transcript.ts";
|
|||
export * from "./utils/typebox-helpers.ts";
|
||||
export { uuidv7 } from "./utils/uuid.ts";
|
||||
export * from "./utils/validation.ts";
|
||||
export { PI_AI_VERSION } from "./utils/version.ts";
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import type {
|
|||
Usage,
|
||||
} from "./types.ts";
|
||||
import { operationSignal, raceWithAbortSignal } from "./utils/abort.ts";
|
||||
import { type ClientIdentity, getAttributionName, mergeClientIdentity } from "./utils/identity.ts";
|
||||
import {
|
||||
assertChatModel,
|
||||
assertClassifierInputSupported,
|
||||
|
|
@ -376,6 +377,11 @@ export interface CreateModelsOptions {
|
|||
credentials?: CredentialStore;
|
||||
modelsStore?: ModelsStore;
|
||||
authContext?: AuthContext;
|
||||
/**
|
||||
* Default identity of every request; a request's `identity` overrides it field by field. Its attribution name is
|
||||
* also the default `LoginOptions.agentName`.
|
||||
*/
|
||||
identity?: ClientIdentity;
|
||||
}
|
||||
|
||||
function mergeHeaders(
|
||||
|
|
@ -399,6 +405,7 @@ class ModelsImpl implements MutableModels {
|
|||
private credentials: CredentialStore;
|
||||
private modelsStore: ModelsStore;
|
||||
private authContext: AuthContext;
|
||||
private identity: ClientIdentity | undefined;
|
||||
private refreshGenerations = new Map<string, number>();
|
||||
private refreshControllers = new Map<string, AbortController>();
|
||||
private publicationChains = new Map<string, Promise<unknown>>();
|
||||
|
|
@ -407,6 +414,7 @@ class ModelsImpl implements MutableModels {
|
|||
this.credentials = options?.credentials ?? new InMemoryCredentialStore();
|
||||
this.modelsStore = options?.modelsStore ?? new InMemoryModelsStore();
|
||||
this.authContext = options?.authContext ?? defaultAuthContext();
|
||||
this.identity = options?.identity;
|
||||
}
|
||||
|
||||
setProvider(provider: Provider): void {
|
||||
|
|
@ -781,7 +789,9 @@ class ModelsImpl implements MutableModels {
|
|||
if (!method?.login) {
|
||||
throw new ModelsError("auth", `${provider.name} does not support ${type} login`);
|
||||
}
|
||||
const loginOperation: Promise<Credential> = method.login({ ...interaction, signal }, options);
|
||||
const agentName = options?.agentName ?? getAttributionName(this.identity);
|
||||
const loginOptions = agentName === undefined ? options : { ...options, agentName };
|
||||
const loginOperation: Promise<Credential> = method.login({ ...interaction, signal }, loginOptions);
|
||||
const credential = await raceWithAbortSignal(loginOperation, signal);
|
||||
let mutationStarted = false;
|
||||
let markMutationStarted: (() => void) | undefined;
|
||||
|
|
@ -875,9 +885,15 @@ class ModelsImpl implements MutableModels {
|
|||
if (options?.transformHeaders) headers = await options.transformHeaders(headers ?? {});
|
||||
const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined;
|
||||
const requestModel: TModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
|
||||
const identity = mergeClientIdentity(this.identity, options?.identity);
|
||||
const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {};
|
||||
const requestOptions = { ...providerOptions, apiKey, headers, env } as Omit<TOptions, "transformHeaders"> &
|
||||
ProviderRequestOptions<TModel>;
|
||||
const requestOptions = {
|
||||
...providerOptions,
|
||||
apiKey,
|
||||
headers,
|
||||
env,
|
||||
...(identity === undefined ? {} : { identity }),
|
||||
} as Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>;
|
||||
|
||||
return { requestModel, requestOptions };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import type {
|
|||
} from "./providers/model-schema.ts";
|
||||
import type { AssistantMessageDiagnostic } from "./utils/diagnostics.ts";
|
||||
import type { AssistantMessageEventStream } from "./utils/event-stream.ts";
|
||||
import type { ClientIdentity } from "./utils/identity.ts";
|
||||
|
||||
export type {
|
||||
AnthropicAllowedFallbackModel,
|
||||
|
|
@ -189,6 +190,11 @@ export interface ProviderRequestOptions<TModel = Model<Api>> {
|
|||
* A null value suppresses a provider/API default header with the same name.
|
||||
*/
|
||||
headers?: ProviderHeaders;
|
||||
/**
|
||||
* How the request identifies the calling app: `User-Agent` and provider attribution headers.
|
||||
* Absent fields use pi-ai's defaults: a detailed `User-Agent` and no attribution.
|
||||
*/
|
||||
identity?: ClientIdentity;
|
||||
/**
|
||||
* HTTP request timeout in milliseconds for providers/SDKs that support it.
|
||||
* For example, OpenAI and Anthropic SDK clients default to 10 minutes.
|
||||
|
|
|
|||
182
packages/ai/src/utils/identity.ts
Normal file
182
packages/ai/src/utils/identity.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import type { Models } from "../models.ts";
|
||||
import { PI_AI_VERSION } from "./version.ts";
|
||||
|
||||
/**
|
||||
* The `User-Agent` sent to providers:
|
||||
* - `"detailed"`: `pi/<version> (<platform>; <runtime>/<runtime version>; <arch>)`, or `pi/<version> (browser)`
|
||||
* outside Node.js and Bun.
|
||||
* - `"short"`: `pi`.
|
||||
* - `{ custom }`: the given value.
|
||||
*/
|
||||
export type UserAgentMode = "short" | "detailed" | { custom: string };
|
||||
|
||||
/** Attribution with explicit values. Absent fields fall back to pi's own attribution only when `name` is absent. */
|
||||
export interface ClientAttributionOptions {
|
||||
/**
|
||||
* Name requests are attributed to (OpenRouter app title, NVIDIA invoke origin, OpenCode client, Codex originator).
|
||||
* Absent: pi, spelled as each provider expects.
|
||||
*/
|
||||
name?: string;
|
||||
/** App URL, sent as OpenRouter's `HTTP-Referer`. Absent: `https://pi.dev` when `name` is absent too. */
|
||||
url?: string;
|
||||
/** OpenRouter app categories, comma-separated, e.g. `cli-agent`. */
|
||||
categories?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider attribution headers. `false` sends none; `true` attributes requests to pi; a string attributes them to that
|
||||
* name; an object gives the values explicitly.
|
||||
*/
|
||||
export type ClientAttribution = boolean | string | ClientAttributionOptions;
|
||||
|
||||
/**
|
||||
* How pi-ai identifies the calling app to providers. Every field is optional; absent fields use pi-ai's defaults:
|
||||
* a detailed `User-Agent` with pi-ai's version and no attribution headers. Plain JSON, so it can be persisted.
|
||||
*/
|
||||
export interface ClientIdentity {
|
||||
/** Default: `"detailed"`. */
|
||||
userAgent?: UserAgentMode;
|
||||
/** Default: `false`. */
|
||||
attribution?: ClientAttribution;
|
||||
/** Version in the detailed `User-Agent`. Default: the pi-ai version. */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/** The provider and endpoint a request goes to. */
|
||||
export interface ClientIdentityTarget {
|
||||
readonly provider: string;
|
||||
readonly baseUrl: string;
|
||||
}
|
||||
|
||||
const PI_ATTRIBUTION_URL = "https://pi.dev";
|
||||
const NVIDIA_NIM_HOST = "integrate.api.nvidia.com";
|
||||
const OPENCODE_HOST = "opencode.ai";
|
||||
|
||||
/** Merge identities field by field; later sources win and undefined fields are skipped. */
|
||||
export function mergeClientIdentity(...sources: (ClientIdentity | undefined)[]): ClientIdentity | undefined {
|
||||
let merged: ClientIdentity | undefined;
|
||||
for (const source of sources) {
|
||||
if (!source) continue;
|
||||
merged = { ...merged };
|
||||
if (source.userAgent !== undefined) merged.userAgent = source.userAgent;
|
||||
if (source.attribution !== undefined) merged.attribution = source.attribution;
|
||||
if (source.version !== undefined) merged.version = source.version;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** The version in the detailed `User-Agent`. */
|
||||
export function getClientVersion(identity?: ClientIdentity): string {
|
||||
return identity?.version ?? PI_AI_VERSION;
|
||||
}
|
||||
|
||||
function runtimeDescription(): string | undefined {
|
||||
if (typeof process === "undefined") return undefined;
|
||||
const versions = process.versions;
|
||||
if (versions?.bun) return `bun/${versions.bun}`;
|
||||
if (versions?.node) return `node/${process.version}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The `User-Agent` value of an identity. */
|
||||
export function formatUserAgent(identity?: ClientIdentity): string {
|
||||
const mode = identity?.userAgent ?? "detailed";
|
||||
if (typeof mode === "object") return mode.custom;
|
||||
if (mode === "short") return "pi";
|
||||
const product = `pi/${getClientVersion(identity)}`;
|
||||
const runtime = runtimeDescription();
|
||||
return runtime ? `${product} (${process.platform}; ${runtime}; ${process.arch})` : `${product} (browser)`;
|
||||
}
|
||||
|
||||
function resolveAttribution(identity: ClientIdentity | undefined): ClientAttributionOptions | undefined {
|
||||
const attribution = identity?.attribution;
|
||||
if (attribution === undefined || attribution === false) return undefined;
|
||||
if (attribution === true) return {};
|
||||
if (typeof attribution === "string") return { name: attribution };
|
||||
return attribution;
|
||||
}
|
||||
|
||||
/** The explicitly configured attribution name, if any. Absent when attribution is off or uses pi's defaults. */
|
||||
export function getAttributionName(identity?: ClientIdentity): string | undefined {
|
||||
return resolveAttribution(identity)?.name;
|
||||
}
|
||||
|
||||
function matchesHost(baseUrl: string, expectedHost: string): boolean {
|
||||
try {
|
||||
return new URL(baseUrl).hostname === expectedHost;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenRouter(target: ClientIdentityTarget): boolean {
|
||||
return target.provider === "openrouter" || target.baseUrl.includes("openrouter.ai");
|
||||
}
|
||||
|
||||
function isNvidiaNim(target: ClientIdentityTarget): boolean {
|
||||
return target.provider === "nvidia" || matchesHost(target.baseUrl, NVIDIA_NIM_HOST);
|
||||
}
|
||||
|
||||
function isOpenCode(target: ClientIdentityTarget): boolean {
|
||||
return (
|
||||
target.provider === "opencode" || target.provider === "opencode-go" || matchesHost(target.baseUrl, OPENCODE_HOST)
|
||||
);
|
||||
}
|
||||
|
||||
/** Provider attribution headers of an identity for a request target. Empty when attribution is off. */
|
||||
export function getAttributionHeaders(target: ClientIdentityTarget, identity?: ClientIdentity): Record<string, string> {
|
||||
const attribution = resolveAttribution(identity);
|
||||
if (!attribution) return {};
|
||||
const { name, categories } = attribution;
|
||||
if (isOpenRouter(target)) {
|
||||
const url = attribution.url ?? (name === undefined ? PI_ATTRIBUTION_URL : undefined);
|
||||
return {
|
||||
...(url === undefined ? {} : { "HTTP-Referer": url }),
|
||||
"X-OpenRouter-Title": name ?? "pi",
|
||||
...(categories === undefined ? {} : { "X-OpenRouter-Categories": categories }),
|
||||
};
|
||||
}
|
||||
if (isNvidiaNim(target)) return { "X-BILLING-INVOKE-ORIGIN": name ?? "Pi" };
|
||||
if (isOpenCode(target)) return { "x-opencode-client": name ?? "pi" };
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default headers an identity adds to a request: `User-Agent` and provider attribution. APIs apply them below model,
|
||||
* provider, and request headers, so configured headers override them and `null` request headers remove them.
|
||||
*/
|
||||
export function getIdentityHeaders(target: ClientIdentityTarget, identity?: ClientIdentity): Record<string, string> {
|
||||
return { "User-Agent": formatUserAgent(identity), ...getAttributionHeaders(target, identity) };
|
||||
}
|
||||
|
||||
const IDENTITY_REQUEST_METHODS = new Set<PropertyKey>([
|
||||
"stream",
|
||||
"complete",
|
||||
"streamSimple",
|
||||
"completeSimple",
|
||||
"streamDeferred",
|
||||
"fetchDeferred",
|
||||
"cancelDeferred",
|
||||
"generateImages",
|
||||
"classify",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A view of `models` whose requests use `identity` as their default identity: a request's `identity` overrides it field
|
||||
* by field, and it overrides the identity `models` was created with. Everything else is delegated unchanged.
|
||||
*/
|
||||
export function withClientIdentity<T extends Models>(models: T, identity: ClientIdentity): T {
|
||||
return new Proxy(models, {
|
||||
get(target, property) {
|
||||
const value: unknown = Reflect.get(target, property, target);
|
||||
if (typeof value !== "function") return value;
|
||||
if (!IDENTITY_REQUEST_METHODS.has(property)) return value.bind(target);
|
||||
// Every request method takes (model, context or handle, options).
|
||||
return (model: unknown, input: unknown, options?: { identity?: ClientIdentity }) =>
|
||||
value.call(target, model, input, {
|
||||
...options,
|
||||
identity: mergeClientIdentity(identity, options?.identity),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import type * as NodeOs from "node:os";
|
||||
|
||||
type ProcessWithOsBuiltinModule = typeof process & {
|
||||
getBuiltinModule?: (id: "node:os") => typeof NodeOs;
|
||||
};
|
||||
|
||||
function loadNodeOs(): typeof NodeOs | null {
|
||||
if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) {
|
||||
return null;
|
||||
}
|
||||
return (process as ProcessWithOsBuiltinModule).getBuiltinModule?.("node:os") ?? null;
|
||||
}
|
||||
|
||||
// Keep runtime OS loading browser-safe. A top-level runtime import of node:os breaks browser/Vite builds.
|
||||
const nodeOs = loadNodeOs();
|
||||
|
||||
export function getPiUserAgent(): string {
|
||||
return nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)";
|
||||
}
|
||||
2
packages/ai/src/utils/version.ts
Normal file
2
packages/ai/src/utils/version.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// The @earendil-works/pi-ai package version. scripts/sync-versions.js keeps it in sync with package.json.
|
||||
export const PI_AI_VERSION = "1.3.0";
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { arch, platform, release } from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
|
||||
import { ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV } from "../src/env-api-keys.ts";
|
||||
import { createModels } from "../src/models.ts";
|
||||
import { anthropicProvider } from "../src/providers/anthropic.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
import { formatUserAgent } from "../src/utils/identity.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
|
|
@ -55,7 +55,7 @@ vi.mock("@anthropic-ai/sdk", () => {
|
|||
return { default: FakeAnthropic };
|
||||
});
|
||||
|
||||
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
|
||||
const PI_USER_AGENT = formatUserAgent();
|
||||
const neverAbortedSignal = new AbortController().signal;
|
||||
|
||||
const context = normalizeContext({
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { arch, platform, release } from "node:os";
|
||||
import type { ResponseStreamEvent } from "openai/resources/responses/responses.js";
|
||||
import { Type } from "typebox";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { stream as streamAzureOpenAIResponses } from "../src/api/azure-openai-responses.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { Api, Context, Model } from "../src/types.ts";
|
||||
import { formatUserAgent } from "../src/utils/identity.ts";
|
||||
|
||||
interface CapturedAzureClientOptions {
|
||||
apiKey: string;
|
||||
|
|
@ -52,7 +52,7 @@ vi.mock("openai", () => {
|
|||
return { AzureOpenAI };
|
||||
});
|
||||
|
||||
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
|
||||
const PI_USER_AGENT = formatUserAgent();
|
||||
|
||||
const context: Context = {
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { arch, platform, release } from "node:os";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const googleGenAiMock = vi.hoisted(() => ({
|
||||
|
|
@ -94,12 +93,13 @@ import { stream as streamGoogleGenerativeAi } from "../src/api/google-generative
|
|||
import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { Api, Model, StreamOptions } from "../src/types.ts";
|
||||
import { formatUserAgent } from "../src/utils/identity.ts";
|
||||
|
||||
beforeEach(() => {
|
||||
googleGenAiMock.streamChunks = undefined;
|
||||
});
|
||||
|
||||
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
|
||||
const PI_USER_AGENT = formatUserAgent();
|
||||
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { arch, platform, release } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const googleGenAiMock = vi.hoisted(() => ({
|
||||
|
|
@ -49,8 +48,9 @@ vi.mock("@google/genai", () => {
|
|||
import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
import { formatUserAgent } from "../src/utils/identity.ts";
|
||||
|
||||
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
|
||||
const PI_USER_AGENT = formatUserAgent();
|
||||
const model = getModel("google-vertex", "gemini-3-flash-preview");
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
|
|
|
|||
209
packages/ai/test/identity.test.ts
Normal file
209
packages/ai/test/identity.test.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stream as streamCompletions, streamSimple as streamSimpleCompletions } from "../src/api/openai-completions.ts";
|
||||
import { createModels, createProvider } from "../src/models.ts";
|
||||
import type { FetchFunction, Model, ProviderHeaders } from "../src/types.ts";
|
||||
import {
|
||||
type ClientIdentity,
|
||||
formatUserAgent,
|
||||
getAttributionHeaders,
|
||||
getIdentityHeaders,
|
||||
mergeClientIdentity,
|
||||
withClientIdentity,
|
||||
} from "../src/utils/identity.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
import { PI_AI_VERSION } from "../src/utils/version.ts";
|
||||
|
||||
const runtime = process.versions.bun ? `bun/${process.versions.bun}` : `node/${process.version}`;
|
||||
|
||||
function target(provider: string, baseUrl: string) {
|
||||
return { provider, baseUrl };
|
||||
}
|
||||
|
||||
function completionsModel(provider: string, baseUrl: string, headers?: Record<string, string>) {
|
||||
return {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
api: "openai-completions",
|
||||
provider,
|
||||
baseUrl,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
...(headers ? { headers } : {}),
|
||||
} satisfies Model<"openai-completions">;
|
||||
}
|
||||
|
||||
function capturingFetch(): { fetch: FetchFunction; headers: () => Headers } {
|
||||
let captured: Headers | undefined;
|
||||
const fetch: FetchFunction = async (input, init) => {
|
||||
captured = new Request(input, init).headers;
|
||||
const chunks = [
|
||||
{ id: "chatcmpl-identity", choices: [{ delta: { content: "ok" }, finish_reason: null, index: 0 }] },
|
||||
{
|
||||
id: "chatcmpl-identity",
|
||||
choices: [{ delta: {}, finish_reason: "stop", index: 0 }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
},
|
||||
];
|
||||
const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n")}\n\ndata: [DONE]\n\n`;
|
||||
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
};
|
||||
return {
|
||||
fetch,
|
||||
headers: () => {
|
||||
if (!captured) throw new Error("no request was sent");
|
||||
return captured;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const context = normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: 1 }] });
|
||||
|
||||
describe("client identity", () => {
|
||||
it("keeps the version constant in sync with package.json", () => {
|
||||
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string };
|
||||
expect(PI_AI_VERSION).toBe(pkg.version);
|
||||
});
|
||||
|
||||
it("formats user agents", () => {
|
||||
expect(formatUserAgent()).toBe(`pi/${PI_AI_VERSION} (${process.platform}; ${runtime}; ${process.arch})`);
|
||||
expect(formatUserAgent({ version: "9.9.9" })).toBe(`pi/9.9.9 (${process.platform}; ${runtime}; ${process.arch})`);
|
||||
expect(formatUserAgent({ userAgent: "short", version: "9.9.9" })).toBe("pi");
|
||||
expect(formatUserAgent({ userAgent: { custom: "acme/1.0" } })).toBe("acme/1.0");
|
||||
});
|
||||
|
||||
it("merges identities field by field", () => {
|
||||
expect(mergeClientIdentity(undefined, undefined)).toBeUndefined();
|
||||
expect(
|
||||
mergeClientIdentity({ attribution: true, userAgent: "short" }, { attribution: undefined, version: "1.0.0" }),
|
||||
).toEqual({ attribution: true, userAgent: "short", version: "1.0.0" });
|
||||
expect(mergeClientIdentity({ attribution: true }, { attribution: false })).toEqual({ attribution: false });
|
||||
});
|
||||
|
||||
it("sends no attribution unless enabled", () => {
|
||||
const openRouter = target("openrouter", "https://openrouter.ai/api/v1");
|
||||
expect(getAttributionHeaders(openRouter)).toEqual({});
|
||||
expect(getAttributionHeaders(openRouter, { attribution: false })).toEqual({});
|
||||
expect(getIdentityHeaders(openRouter)).toEqual({ "User-Agent": formatUserAgent() });
|
||||
});
|
||||
|
||||
it("attributes OpenRouter requests", () => {
|
||||
const openRouter = target("openrouter", "https://openrouter.ai/api/v1");
|
||||
expect(getAttributionHeaders(openRouter, { attribution: true })).toEqual({
|
||||
"HTTP-Referer": "https://pi.dev",
|
||||
"X-OpenRouter-Title": "pi",
|
||||
});
|
||||
expect(getAttributionHeaders(openRouter, { attribution: { categories: "cli-agent" } })).toEqual({
|
||||
"HTTP-Referer": "https://pi.dev",
|
||||
"X-OpenRouter-Title": "pi",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
});
|
||||
expect(getAttributionHeaders(openRouter, { attribution: "acme" })).toEqual({ "X-OpenRouter-Title": "acme" });
|
||||
expect(
|
||||
getAttributionHeaders(openRouter, {
|
||||
attribution: { name: "acme", url: "https://acme.example", categories: "cloud-agent" },
|
||||
}),
|
||||
).toEqual({
|
||||
"HTTP-Referer": "https://acme.example",
|
||||
"X-OpenRouter-Title": "acme",
|
||||
"X-OpenRouter-Categories": "cloud-agent",
|
||||
});
|
||||
// Custom providers routed through OpenRouter, including legacy base URLs that are not valid URLs.
|
||||
expect(getAttributionHeaders(target("custom", "not-a-url-openrouter.ai"), { attribution: true })).toMatchObject({
|
||||
"X-OpenRouter-Title": "pi",
|
||||
});
|
||||
});
|
||||
|
||||
it("attributes NVIDIA NIM and OpenCode requests with their expected spellings", () => {
|
||||
expect(getAttributionHeaders(target("nvidia", "https://example.test/v1"), { attribution: true })).toEqual({
|
||||
"X-BILLING-INVOKE-ORIGIN": "Pi",
|
||||
});
|
||||
expect(
|
||||
getAttributionHeaders(target("custom-nim", "https://integrate.api.nvidia.com/v1"), { attribution: "acme" }),
|
||||
).toEqual({ "X-BILLING-INVOKE-ORIGIN": "acme" });
|
||||
expect(getAttributionHeaders(target("opencode", "https://opencode.ai/zen/v1"), { attribution: true })).toEqual({
|
||||
"x-opencode-client": "pi",
|
||||
});
|
||||
expect(getAttributionHeaders(target("custom", "https://opencode.ai/zen/v1"), { attribution: "acme" })).toEqual({
|
||||
"x-opencode-client": "acme",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not attribute NVIDIA models routed through gateways to NVIDIA", () => {
|
||||
expect(
|
||||
getAttributionHeaders(target("openrouter", "https://openrouter.ai/api/v1"), { attribution: true }),
|
||||
).not.toHaveProperty("X-BILLING-INVOKE-ORIGIN");
|
||||
expect(
|
||||
getAttributionHeaders(target("vercel-ai-gateway", "https://ai-gateway.vercel.sh/v1"), { attribution: true }),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("client identity in requests", () => {
|
||||
it("applies the identity below model and request headers", async () => {
|
||||
const capture = capturingFetch();
|
||||
const model = completionsModel("openrouter", "https://openrouter.ai/api/v1", {
|
||||
"X-OpenRouter-Title": "model-title",
|
||||
});
|
||||
await streamCompletions(model, context, {
|
||||
apiKey: "key",
|
||||
fetch: capture.fetch,
|
||||
identity: { attribution: true, userAgent: "short" },
|
||||
headers: { "HTTP-Referer": null } as ProviderHeaders,
|
||||
}).result();
|
||||
|
||||
const headers = capture.headers();
|
||||
expect(headers.get("user-agent")).toBe("pi");
|
||||
expect(headers.get("x-openrouter-title")).toBe("model-title");
|
||||
expect(headers.has("http-referer")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps impersonating model headers over the identity User-Agent", async () => {
|
||||
const capture = capturingFetch();
|
||||
const model = completionsModel("github-copilot", "https://api.githubcopilot.com", {
|
||||
"User-Agent": "GitHubCopilotChat/0.35.0",
|
||||
});
|
||||
await streamCompletions(model, context, {
|
||||
apiKey: "key",
|
||||
fetch: capture.fetch,
|
||||
identity: { userAgent: { custom: "acme" } },
|
||||
}).result();
|
||||
|
||||
expect(capture.headers().get("user-agent")).toBe("GitHubCopilotChat/0.35.0");
|
||||
});
|
||||
|
||||
it("merges request identity over the Models and wrapper identities", async () => {
|
||||
const model = completionsModel("openrouter", "https://openrouter.ai/api/v1");
|
||||
const models = createModels({ identity: { attribution: "models", userAgent: "short" } });
|
||||
models.setProvider(
|
||||
createProvider({
|
||||
id: "openrouter",
|
||||
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: { apiKey: "key" } }) } },
|
||||
models: [model],
|
||||
api: { stream: streamCompletions, streamSimple: streamSimpleCompletions },
|
||||
}),
|
||||
);
|
||||
const send = async (target: typeof models, identity?: ClientIdentity) => {
|
||||
const capture = capturingFetch();
|
||||
await target.streamSimple(model, context, { fetch: capture.fetch, identity }).result();
|
||||
return capture.headers();
|
||||
};
|
||||
|
||||
const fromModels = await send(models);
|
||||
expect(fromModels.get("user-agent")).toBe("pi");
|
||||
expect(fromModels.get("x-openrouter-title")).toBe("models");
|
||||
|
||||
const wrapped = withClientIdentity(models, { attribution: "wrapper" });
|
||||
const fromWrapper = await send(wrapped);
|
||||
expect(fromWrapper.get("user-agent")).toBe("pi");
|
||||
expect(fromWrapper.get("x-openrouter-title")).toBe("wrapper");
|
||||
expect(wrapped.getModel("openrouter", "test-model")).toBe(model);
|
||||
|
||||
const fromRequest = await send(wrapped, { attribution: false });
|
||||
expect(fromRequest.has("x-openrouter-title")).toBe(false);
|
||||
expect(fromRequest.get("user-agent")).toBe("pi");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { arch, platform, release } from "node:os";
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stream as streamMistral } from "../src/api/mistral-conversations.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { Api, FetchFunction, Model, ProviderResponse } from "../src/types.ts";
|
||||
import { formatUserAgent } from "../src/utils/identity.ts";
|
||||
|
||||
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
|
||||
const PI_USER_AGENT = formatUserAgent();
|
||||
|
||||
function createSseResponse(events: unknown[], headers?: Record<string, string>): Response {
|
||||
const body = `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\r\n\r\n")}\r\n\r\ndata: [DONE]\r\n\r\n`;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { mkdtempSync } from "node:fs";
|
||||
import { arch, platform, release, tmpdir } from "node:os";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { zstdDecompressSync } from "node:zlib";
|
||||
import { Type } from "typebox";
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
streamSimple as streamSimpleOpenAICodexResponses,
|
||||
} from "../src/api/openai-codex-responses.ts";
|
||||
import type { Api, Context, Model } from "../src/types.ts";
|
||||
import { formatUserAgent } from "../src/utils/identity.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
|
|
@ -161,7 +162,7 @@ describe("openai-codex streaming", () => {
|
|||
expect(headers?.get("chatgpt-account-id")).toBe("acc_test");
|
||||
expect(headers?.get("OpenAI-Beta")).toBe("responses=experimental");
|
||||
expect(headers?.get("originator")).toBe("pi");
|
||||
expect(headers?.get("User-Agent")).toBe(`pi (${platform()} ${release()}; ${arch()})`);
|
||||
expect(headers?.get("User-Agent")).toBe(formatUserAgent());
|
||||
expect(headers?.get("accept")).toBe("text/event-stream");
|
||||
expect(headers?.has("x-api-key")).toBe(false);
|
||||
return new Response(stream, {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { arch, platform, release } from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
||||
import type { OpenAIResponsesOptions } from "../src/api/openai-responses.ts";
|
||||
|
|
@ -7,9 +6,10 @@ import { getSupportedThinkingLevels } from "../src/models.ts";
|
|||
import { XAI_MODELS } from "../src/providers/xai.models.ts";
|
||||
import { xaiProvider } from "../src/providers/xai.ts";
|
||||
import type { Context, Model } from "../src/types.ts";
|
||||
import { formatUserAgent } from "../src/utils/identity.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
|
||||
const PI_USER_AGENT = formatUserAgent();
|
||||
|
||||
type CapturedRequest = {
|
||||
url: string;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
### Changed
|
||||
|
||||
- Provider attribution headers now come from pi-ai's client identity. Extensions' `before_provider_headers` handlers no longer see the OpenRouter, NVIDIA NIM, and OpenCode attribution headers; returning `null` for one still removes it. Cloudflare requests send pi's detailed `User-Agent` instead of `pi-coding-agent`, as do GitHub release checks for tool downloads, and OpenCode requests send `x-opencode-client` whenever attribution is enabled, not only with a session.
|
||||
- `generateSummary()` and `generateSummaryWithUsage()` now ignore their `reserveTokens` parameter. Summary requests use the model's output limit instead.
|
||||
- Changed the default GitHub Copilot model from `gpt-5.4`, which Copilot retired, to `gpt-6.1-sol`.
|
||||
|
||||
|
|
|
|||
40
packages/coding-agent/src/core/client-identity.ts
Normal file
40
packages/coding-agent/src/core/client-identity.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { Api, ClientIdentity, Model, ProviderHeaders } from "@earendil-works/pi-ai";
|
||||
import { VERSION } from "../config.ts";
|
||||
import type { SettingsManager } from "./settings-manager.ts";
|
||||
import { isInstallTelemetryEnabled } from "./telemetry.ts";
|
||||
|
||||
const OPENCODE_HOST = "opencode.ai";
|
||||
|
||||
/**
|
||||
* How pi's requests identify it to providers: a detailed `User-Agent` with pi's version, and attribution to pi as a
|
||||
* CLI agent while install telemetry is enabled.
|
||||
*/
|
||||
export function getClientIdentity(settingsManager: SettingsManager): ClientIdentity {
|
||||
return {
|
||||
userAgent: "detailed",
|
||||
version: VERSION,
|
||||
attribution: isInstallTelemetryEnabled(settingsManager) ? { categories: "cli-agent" } : false,
|
||||
};
|
||||
}
|
||||
|
||||
function isOpenCodeModel(model: Model<Api>): boolean {
|
||||
if (model.provider === "opencode" || model.provider === "opencode-go") return true;
|
||||
try {
|
||||
return new URL(model.baseUrl).hostname === OPENCODE_HOST;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenCode's per-conversation routing header. pi-ai's OpenCode providers add it themselves; this also covers custom
|
||||
* providers pointing at OpenCode. Configured and request headers override it.
|
||||
*/
|
||||
export function withOpenCodeSessionHeader(
|
||||
model: Model<Api>,
|
||||
sessionId: string | undefined,
|
||||
headers: ProviderHeaders,
|
||||
): ProviderHeaders {
|
||||
if (!sessionId || !isOpenCodeModel(model)) return headers;
|
||||
return { "x-opencode-session": sessionId, ...headers };
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai";
|
||||
import type { SettingsManager } from "./settings-manager.ts";
|
||||
import { isInstallTelemetryEnabled } from "./telemetry.ts";
|
||||
|
||||
const OPENROUTER_HOST = "openrouter.ai";
|
||||
const NVIDIA_NIM_HOST = "integrate.api.nvidia.com";
|
||||
const CLOUDFLARE_API_HOST = "api.cloudflare.com";
|
||||
const CLOUDFLARE_AI_GATEWAY_HOST = "gateway.ai.cloudflare.com";
|
||||
const OPENCODE_HOST = "opencode.ai";
|
||||
|
||||
function matchesHost(baseUrl: string, expectedHost: string): boolean {
|
||||
try {
|
||||
return new URL(baseUrl).hostname === expectedHost;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenRouterModel(model: Model<Api>): boolean {
|
||||
return model.provider === "openrouter" || model.baseUrl.includes(OPENROUTER_HOST);
|
||||
}
|
||||
|
||||
function isNvidiaNimModel(model: Model<Api>): boolean {
|
||||
return model.provider === "nvidia" || matchesHost(model.baseUrl, NVIDIA_NIM_HOST);
|
||||
}
|
||||
|
||||
function isCloudflareModel(model: Model<Api>): boolean {
|
||||
return (
|
||||
model.provider === "cloudflare-workers-ai" ||
|
||||
model.provider === "cloudflare-ai-gateway" ||
|
||||
matchesHost(model.baseUrl, CLOUDFLARE_API_HOST) ||
|
||||
matchesHost(model.baseUrl, CLOUDFLARE_AI_GATEWAY_HOST)
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultAttributionHeaders(
|
||||
model: Model<Api>,
|
||||
settingsManager: SettingsManager,
|
||||
): Record<string, string> | undefined {
|
||||
if (!isInstallTelemetryEnabled(settingsManager)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isOpenRouterModel(model)) {
|
||||
return {
|
||||
"HTTP-Referer": "https://pi.dev",
|
||||
"X-OpenRouter-Title": "pi",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
};
|
||||
}
|
||||
|
||||
if (isNvidiaNimModel(model)) {
|
||||
return {
|
||||
"X-BILLING-INVOKE-ORIGIN": "Pi",
|
||||
};
|
||||
}
|
||||
|
||||
if (isCloudflareModel(model)) {
|
||||
return {
|
||||
"User-Agent": "pi-coding-agent",
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getSessionHeaders(model: Model<Api>, sessionId: string | undefined): Record<string, string> | undefined {
|
||||
if (!sessionId) return undefined;
|
||||
if (
|
||||
model.provider !== "opencode" &&
|
||||
model.provider !== "opencode-go" &&
|
||||
!matchesHost(model.baseUrl, OPENCODE_HOST)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
|
||||
}
|
||||
|
||||
export function mergeProviderAttributionHeaders(
|
||||
model: Model<Api>,
|
||||
settingsManager: SettingsManager,
|
||||
sessionId: string | undefined,
|
||||
...headerSources: Array<ProviderHeaders | undefined>
|
||||
): ProviderHeaders | undefined {
|
||||
const merged: ProviderHeaders = {
|
||||
...getSessionHeaders(model, sessionId),
|
||||
...getDefaultAttributionHeaders(model, settingsManager),
|
||||
};
|
||||
|
||||
for (const headers of headerSources) {
|
||||
if (headers) {
|
||||
Object.assign(merged, headers);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
|
@ -7,13 +7,13 @@ import { resolvePath } from "../utils/paths.ts";
|
|||
import { AgentSession } from "./agent-session.ts";
|
||||
import { formatNoModelsAvailableMessage } from "./auth-guidance.ts";
|
||||
import { CacheWarmer } from "./cache-warmer.ts";
|
||||
import { getClientIdentity, withOpenCodeSessionHeader } from "./client-identity.ts";
|
||||
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
|
||||
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
|
||||
import { createToolNameMatcher } from "./mcp-servers.ts";
|
||||
import { convertToLlm } from "./messages.ts";
|
||||
import { findInitialModel } from "./model-resolver.ts";
|
||||
import { ModelRuntime } from "./model-runtime.ts";
|
||||
import { mergeProviderAttributionHeaders } from "./provider-attribution.ts";
|
||||
import type { ResourceLoader } from "./resource-loader.ts";
|
||||
import { DefaultResourceLoader } from "./resource-loader.ts";
|
||||
import { getDefaultSessionDir, SessionManager } from "./session-manager.ts";
|
||||
|
|
@ -351,16 +351,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
websocketConnectTimeoutMs: options.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs(),
|
||||
maxRetries: options.maxRetries ?? providerRetrySettings.maxRetries,
|
||||
maxRetryDelayMs: options.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs,
|
||||
// Replaces the agent loop's default identity; read per request so telemetry changes apply at once.
|
||||
identity: getClientIdentity(settingsManager),
|
||||
transformHeaders: async (requestHeaders) => {
|
||||
const headers = mergeProviderAttributionHeaders(
|
||||
requestModel,
|
||||
settingsManager,
|
||||
options.sessionId,
|
||||
requestHeaders,
|
||||
);
|
||||
const headers = withOpenCodeSessionHeader(requestModel, options.sessionId, requestHeaders);
|
||||
return headerRunner?.hasHandlers("before_provider_headers")
|
||||
? headerRunner.emitBeforeProviderHeaders(headers ?? {})
|
||||
: (headers ?? {});
|
||||
? headerRunner.emitBeforeProviderHeaders(headers)
|
||||
: headers;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { formatUserAgent } from "@earendil-works/pi-ai";
|
||||
|
||||
/** pi's `User-Agent` for its own network requests; the same detailed form pi-ai sends to providers. */
|
||||
export function getPiUserAgent(version: string): string {
|
||||
const runtime = process.versions.bun ? `bun/${process.versions.bun}` : `node/${process.version}`;
|
||||
return `pi/${version} (${process.platform}; ${runtime}; ${process.arch})`;
|
||||
return formatUserAgent({ userAgent: "detailed", version });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import { arch, platform } from "os";
|
|||
import { join } from "path";
|
||||
import { Readable } from "stream";
|
||||
import { pipeline } from "stream/promises";
|
||||
import { APP_NAME, getBinDir } from "../config.ts";
|
||||
import { getBinDir, VERSION } from "../config.ts";
|
||||
import { fetchWithRetry } from "./management-http.ts";
|
||||
import { getPiUserAgent } from "./pi-user-agent.ts";
|
||||
|
||||
const TOOLS_DIR = getBinDir();
|
||||
const NETWORK_TIMEOUT_MS = 10_000;
|
||||
|
|
@ -111,7 +112,7 @@ export async function getLatestVersion(repo: string): Promise<string> {
|
|||
const response = await fetchWithRetry(
|
||||
`https://github.com/${repo}/releases/latest`,
|
||||
{
|
||||
headers: { "User-Agent": `${APP_NAME}-coding-agent` },
|
||||
headers: { "User-Agent": getPiUserAgent(VERSION) },
|
||||
redirect: "manual",
|
||||
},
|
||||
{ timeoutMs: NETWORK_TIMEOUT_MS },
|
||||
|
|
|
|||
190
packages/coding-agent/test/sdk-client-identity.test.ts
Normal file
190
packages/coding-agent/test/sdk-client-identity.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
type Api,
|
||||
type AssistantMessage,
|
||||
createAssistantMessageEventStream,
|
||||
getAttributionHeaders,
|
||||
type Model,
|
||||
normalizeContext,
|
||||
type SimpleStreamOptions,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { VERSION } from "../src/config.ts";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { createAgentSession } from "../src/core/sdk.ts";
|
||||
import { SessionManager } from "../src/core/session-manager.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
import { createInMemoryModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
|
||||
|
||||
describe("createAgentSession client identity", () => {
|
||||
let tempDir: string;
|
||||
let cwd: string;
|
||||
let agentDir: string;
|
||||
let originalTelemetryEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-sdk-identity-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
cwd = join(tempDir, "project");
|
||||
agentDir = join(tempDir, "agent");
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
originalTelemetryEnv = process.env.PI_TELEMETRY;
|
||||
delete process.env.PI_TELEMETRY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalTelemetryEnv === undefined) {
|
||||
delete process.env.PI_TELEMETRY;
|
||||
} else {
|
||||
process.env.PI_TELEMETRY = originalTelemetryEnv;
|
||||
}
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createModel(provider: string, baseUrl: string, id = `${provider}-test-model`): Model<Api> {
|
||||
return {
|
||||
id,
|
||||
name: `${provider} Test Model`,
|
||||
api: "openai-completions",
|
||||
provider,
|
||||
baseUrl,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
};
|
||||
}
|
||||
|
||||
function createDoneStream() {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
api: "openai-completions",
|
||||
provider: "capture-provider",
|
||||
model: "capture-model",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
stream.end(message);
|
||||
return stream;
|
||||
}
|
||||
|
||||
async function captureOptions(
|
||||
model: Model<Api>,
|
||||
options: {
|
||||
telemetryEnabled?: boolean;
|
||||
providerHeaders?: Record<string, string>;
|
||||
sessionId?: string;
|
||||
} = {},
|
||||
): Promise<SimpleStreamOptions | undefined> {
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir);
|
||||
if (options.telemetryEnabled === false) {
|
||||
settingsManager.setEnableInstallTelemetry(false);
|
||||
}
|
||||
|
||||
const authStorage = AuthStorage.inMemory({
|
||||
[model.provider]: { type: "api_key", key: "test-api-key" },
|
||||
});
|
||||
const modelRegistry = await createInMemoryModelRegistry(authStorage);
|
||||
let capturedOptions: SimpleStreamOptions | undefined;
|
||||
|
||||
modelRegistry.registerProvider(model.provider, {
|
||||
api: model.api,
|
||||
headers: options.providerHeaders,
|
||||
streamSimple: (_model, _context, providerOptions) => {
|
||||
capturedOptions = providerOptions;
|
||||
return createDoneStream();
|
||||
},
|
||||
});
|
||||
|
||||
const modelRuntime = getModelRuntime(modelRegistry);
|
||||
const sessionManager = SessionManager.inMemory(cwd);
|
||||
if (options.sessionId) {
|
||||
sessionManager.newSession({ id: options.sessionId });
|
||||
}
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd,
|
||||
agentDir,
|
||||
model,
|
||||
modelRuntime,
|
||||
settingsManager,
|
||||
sessionManager,
|
||||
});
|
||||
|
||||
try {
|
||||
const stream = await session.agent.streamFunction(model, normalizeContext({ messages: [] }), {
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
await stream.result();
|
||||
return capturedOptions;
|
||||
} finally {
|
||||
session.dispose();
|
||||
modelRegistry.unregisterProvider(model.provider);
|
||||
}
|
||||
}
|
||||
|
||||
it("identifies pi with a detailed User-Agent and attribution as a CLI agent", async () => {
|
||||
const model = createModel("openrouter", "https://openrouter.ai/api/v1");
|
||||
const options = await captureOptions(model);
|
||||
|
||||
expect(options?.identity).toEqual({
|
||||
userAgent: "detailed",
|
||||
version: VERSION,
|
||||
attribution: { categories: "cli-agent" },
|
||||
});
|
||||
expect(getAttributionHeaders(model, options?.identity)).toEqual({
|
||||
"HTTP-Referer": "https://pi.dev",
|
||||
"X-OpenRouter-Title": "pi",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("turns attribution off when telemetry is disabled", async () => {
|
||||
const model = createModel("nvidia", "https://integrate.api.nvidia.com/v1");
|
||||
const options = await captureOptions(model, { telemetryEnabled: false });
|
||||
|
||||
expect(options?.identity).toEqual({ userAgent: "detailed", version: VERSION, attribution: false });
|
||||
expect(getAttributionHeaders(model, options?.identity)).toEqual({});
|
||||
});
|
||||
|
||||
it("turns attribution off with PI_TELEMETRY=0", async () => {
|
||||
process.env.PI_TELEMETRY = "0";
|
||||
const options = await captureOptions(createModel("openrouter", "https://openrouter.ai/api/v1"));
|
||||
|
||||
expect(options?.identity?.attribution).toBe(false);
|
||||
});
|
||||
|
||||
it("adds OpenCode session headers for custom OpenCode providers", async () => {
|
||||
const options = await captureOptions(createModel("custom-opencode", "https://opencode.ai/zen/v1"), {
|
||||
sessionId: "opencode-session",
|
||||
});
|
||||
|
||||
expect(options?.headers?.["x-opencode-session"]).toBe("opencode-session");
|
||||
});
|
||||
|
||||
it("lets configured OpenCode headers override the session header", async () => {
|
||||
const options = await captureOptions(createModel("opencode", "https://opencode.ai/zen/v1"), {
|
||||
sessionId: "opencode-session",
|
||||
providerHeaders: {
|
||||
"x-opencode-session": "configured-session",
|
||||
},
|
||||
});
|
||||
|
||||
expect(options?.headers?.["x-opencode-session"]).toBe("configured-session");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
type Api,
|
||||
type AssistantMessage,
|
||||
createAssistantMessageEventStream,
|
||||
type Model,
|
||||
normalizeContext,
|
||||
type ProviderHeaders,
|
||||
type SimpleStreamOptions,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { createAgentSession } from "../src/core/sdk.ts";
|
||||
import { SessionManager } from "../src/core/session-manager.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
import { createInMemoryModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
|
||||
|
||||
describe("createAgentSession provider attribution headers", () => {
|
||||
let tempDir: string;
|
||||
let cwd: string;
|
||||
let agentDir: string;
|
||||
let originalTelemetryEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-sdk-attribution-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
cwd = join(tempDir, "project");
|
||||
agentDir = join(tempDir, "agent");
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
originalTelemetryEnv = process.env.PI_TELEMETRY;
|
||||
delete process.env.PI_TELEMETRY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalTelemetryEnv === undefined) {
|
||||
delete process.env.PI_TELEMETRY;
|
||||
} else {
|
||||
process.env.PI_TELEMETRY = originalTelemetryEnv;
|
||||
}
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createModel(provider: string, baseUrl: string, id = `${provider}-test-model`): Model<Api> {
|
||||
return {
|
||||
id,
|
||||
name: `${provider} Test Model`,
|
||||
api: "openai-completions",
|
||||
provider,
|
||||
baseUrl,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
};
|
||||
}
|
||||
|
||||
function createDoneStream() {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
api: "openai-completions",
|
||||
provider: "capture-provider",
|
||||
model: "capture-model",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
stream.end(message);
|
||||
return stream;
|
||||
}
|
||||
|
||||
async function captureHeaders(
|
||||
model: Model<Api>,
|
||||
options: {
|
||||
telemetryEnabled?: boolean;
|
||||
providerHeaders?: Record<string, string>;
|
||||
requestHeaders?: Record<string, string>;
|
||||
sessionId?: string;
|
||||
} = {},
|
||||
): Promise<ProviderHeaders | undefined> {
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir);
|
||||
if (options.telemetryEnabled === false) {
|
||||
settingsManager.setEnableInstallTelemetry(false);
|
||||
}
|
||||
|
||||
const authStorage = AuthStorage.inMemory({
|
||||
[model.provider]: { type: "api_key", key: "test-api-key" },
|
||||
});
|
||||
const modelRegistry = await createInMemoryModelRegistry(authStorage);
|
||||
let capturedOptions: SimpleStreamOptions | undefined;
|
||||
|
||||
modelRegistry.registerProvider(model.provider, {
|
||||
api: model.api,
|
||||
headers: options.providerHeaders,
|
||||
streamSimple: (_model, _context, providerOptions) => {
|
||||
capturedOptions = providerOptions;
|
||||
return createDoneStream();
|
||||
},
|
||||
});
|
||||
|
||||
const modelRuntime = getModelRuntime(modelRegistry);
|
||||
const sessionManager = SessionManager.inMemory(cwd);
|
||||
if (options.sessionId) {
|
||||
sessionManager.newSession({ id: options.sessionId });
|
||||
}
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd,
|
||||
agentDir,
|
||||
model,
|
||||
modelRuntime,
|
||||
settingsManager,
|
||||
sessionManager,
|
||||
});
|
||||
|
||||
try {
|
||||
const stream = await session.agent.streamFunction(model, normalizeContext({ messages: [] }), {
|
||||
sessionId: session.sessionId,
|
||||
...(options.requestHeaders ? { headers: options.requestHeaders } : {}),
|
||||
});
|
||||
await stream.result();
|
||||
return capturedOptions?.headers;
|
||||
} finally {
|
||||
session.dispose();
|
||||
modelRegistry.unregisterProvider(model.provider);
|
||||
}
|
||||
}
|
||||
|
||||
it("adds default attribution headers for OpenRouter models", async () => {
|
||||
const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1"));
|
||||
|
||||
expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev");
|
||||
expect(headers?.["X-OpenRouter-Title"]).toBe("pi");
|
||||
expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent");
|
||||
});
|
||||
|
||||
it("does not add attribution headers when telemetry is disabled", async () => {
|
||||
const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1"), {
|
||||
telemetryEnabled: false,
|
||||
});
|
||||
|
||||
expect(headers?.["HTTP-Referer"]).toBeUndefined();
|
||||
expect(headers?.["X-OpenRouter-Title"]).toBeUndefined();
|
||||
expect(headers?.["X-OpenRouter-Categories"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds attribution headers for custom providers routed through OpenRouter", async () => {
|
||||
const headers = await captureHeaders(createModel("custom-openrouter", "https://openrouter.ai/api/v1"));
|
||||
|
||||
expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev");
|
||||
expect(headers?.["X-OpenRouter-Title"]).toBe("pi");
|
||||
expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent");
|
||||
});
|
||||
|
||||
it("preserves legacy OpenRouter base URL substring attribution matching", async () => {
|
||||
const headers = await captureHeaders(createModel("custom-openrouter", "not-a-url-openrouter.ai"));
|
||||
|
||||
expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev");
|
||||
expect(headers?.["X-OpenRouter-Title"]).toBe("pi");
|
||||
expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent");
|
||||
});
|
||||
|
||||
it("lets provider and request headers override the defaults", async () => {
|
||||
const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1"), {
|
||||
providerHeaders: {
|
||||
"HTTP-Referer": "https://provider.example",
|
||||
"X-OpenRouter-Categories": "provider-category",
|
||||
},
|
||||
requestHeaders: {
|
||||
"X-OpenRouter-Title": "request-title",
|
||||
},
|
||||
});
|
||||
|
||||
expect(headers?.["HTTP-Referer"]).toBe("https://provider.example");
|
||||
expect(headers?.["X-OpenRouter-Title"]).toBe("request-title");
|
||||
expect(headers?.["X-OpenRouter-Categories"]).toBe("provider-category");
|
||||
});
|
||||
|
||||
it("adds default attribution headers for direct NVIDIA NIM endpoints", async () => {
|
||||
const headers = await captureHeaders(createModel("custom-nim", "https://integrate.api.nvidia.com/v1"));
|
||||
|
||||
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Pi");
|
||||
});
|
||||
|
||||
it("adds default attribution headers for the NVIDIA provider", async () => {
|
||||
const headers = await captureHeaders(createModel("nvidia", "https://example.test/v1"));
|
||||
|
||||
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Pi");
|
||||
});
|
||||
|
||||
it("does not add NVIDIA NIM attribution headers when telemetry is disabled", async () => {
|
||||
const headers = await captureHeaders(createModel("nvidia", "https://integrate.api.nvidia.com/v1"), {
|
||||
telemetryEnabled: false,
|
||||
});
|
||||
|
||||
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets provider and request headers override NVIDIA NIM defaults", async () => {
|
||||
const headers = await captureHeaders(createModel("nvidia", "https://integrate.api.nvidia.com/v1"), {
|
||||
providerHeaders: {
|
||||
"X-BILLING-INVOKE-ORIGIN": "Provider",
|
||||
},
|
||||
requestHeaders: {
|
||||
"X-BILLING-INVOKE-ORIGIN": "Request",
|
||||
},
|
||||
});
|
||||
|
||||
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Request");
|
||||
});
|
||||
|
||||
it("does not add NVIDIA NIM attribution headers for NVIDIA models routed through OpenRouter", async () => {
|
||||
const headers = await captureHeaders(
|
||||
createModel("openrouter", "https://openrouter.ai/api/v1", "nvidia/nemotron-3-super-120b-a12b"),
|
||||
);
|
||||
|
||||
expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev");
|
||||
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not add NVIDIA NIM attribution headers for NVIDIA models routed through Vercel AI Gateway", async () => {
|
||||
const headers = await captureHeaders(
|
||||
createModel("vercel-ai-gateway", "https://ai-gateway.vercel.sh/v1", "nvidia/nemotron-3-super-120b-a12b"),
|
||||
);
|
||||
|
||||
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds OpenCode session headers", async () => {
|
||||
const headers = await captureHeaders(createModel("opencode", "https://opencode.ai/zen/v1"), {
|
||||
sessionId: "opencode-session",
|
||||
});
|
||||
|
||||
expect(headers?.["x-opencode-session"]).toBe("opencode-session");
|
||||
expect(headers?.["x-opencode-client"]).toBe("pi");
|
||||
});
|
||||
|
||||
it("lets configured OpenCode headers override the defaults", async () => {
|
||||
const headers = await captureHeaders(createModel("opencode", "https://opencode.ai/zen/v1"), {
|
||||
sessionId: "opencode-session",
|
||||
providerHeaders: {
|
||||
"x-opencode-session": "configured-session",
|
||||
"x-opencode-client": "configured-client",
|
||||
},
|
||||
});
|
||||
|
||||
expect(headers?.["x-opencode-session"]).toBe("configured-session");
|
||||
expect(headers?.["x-opencode-client"]).toBe("configured-client");
|
||||
});
|
||||
});
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
- `onReport(error, info)`: `info` (`ReportInfo`) says what happened in `type`: `session_failed`; `task_faulted`, `task_parked`, `migration_failed`, `task_outdated`, or `task_error` with the task and the extension that installed it; `extension_error` with the extension's name; `host_error` with the failing `HostCallback`; or `listener_error`.
|
||||
- `settings.afterFailedRun`: a run that fails with `model_error` or `no_model` keeps the inputs queued behind it (`keep`, the default), starts the next run with them (`continue`), or settles them `unanswered` with reason `run_failed` (`withdraw`).
|
||||
- The task graph shows why a task cannot run (`TaskGraphNode.blocked`: `missing_task`, `task_too_old`, `migration_failed`, or `restart_limit`) and follows registry changes and failed migrations.
|
||||
- Added `HarnessOptions.identity` (a pi-ai `ClientIdentity`) for every model request through the Harness, including hooks and tools, over `DEFAULT_HARNESS_IDENTITY` (`{ attribution: true }`), so OpenRouter, NVIDIA NIM, and OpenCode requests are attributed to pi by default; pass `{ attribution: false }` to send no attribution headers. `api.models` in tools and hooks is now a view of the host's `Models` that adds the identity, not the same object.
|
||||
- `HarnessOptions.models` and `HarnessOptions.settings` also accept a function returning the current value, like `env`. Generation, compaction, hooks and tools call it at each use (`api.models` reads the current value at each access), so a host can replace its model configuration or settings without reopening the Harness. A plain object still works.
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ What happened:
|
|||
|
||||
Every async call takes a Chord `Context`, which carries cancellation and its current `TelemetrySpan`. `BACKGROUND_CONTEXT` never cancels and uses a NOOP span. The Harness owns the root adapter; adapters are never stored in a `Context`. Cancelling a wait only cancels that wait, never the work.
|
||||
|
||||
`HarnessOptions.identity` sets how model requests identify the app to providers (a pi-ai `ClientIdentity`), merged over `DEFAULT_HARNESS_IDENTITY` (`{ attribution: true }`: OpenRouter, NVIDIA NIM, and OpenCode attribution headers name pi). It applies to generation, compaction, hooks, and tools, over the identity the host's `Models` was created with. Pass `{ attribution: "my-app" }` to attribute requests to your app, or `{ attribution: false }` to send none.
|
||||
|
||||
`HarnessOptions.telemetry` accepts a host-owned `TelemetryContext` from `@earendil-works/pi-telemetry`, with optional `propagation` and `metrics` capabilities; omitted, it is a no-op. `DURABLE_TELEMETRY_SCHEMA`, exported from the package root, documents its typed spans, events, and metrics. Attributes contain IDs and control metadata, never messages, arguments, or results; IDs point to the transcript.
|
||||
|
||||
- `pi.durable.submit` covers the public submit call and its commit. Each input starts a new root trace with a link to its caller's current span; a write is a child of its caller. Duplicate `requestId` preserves the original submission and carrier. Internal admissions, such as compaction writes, emit events on their phase span without a submit span.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { AttachedReplicatedState, Context, JsonValue } from "@earendil-works/chord";
|
||||
import { withAbortSignal, withoutAbortSignal } from "@earendil-works/chord/context";
|
||||
import type { Models } from "@earendil-works/pi-ai";
|
||||
import { type ClientIdentity, mergeClientIdentity, withClientIdentity } from "@earendil-works/pi-ai/utils/identity";
|
||||
import {
|
||||
createTypedMetrics,
|
||||
NOOP_TELEMETRY_CONTEXT,
|
||||
|
|
@ -178,9 +179,29 @@ class ConversationImpl<Tool extends ToolRegistration> implements Conversation {
|
|||
}
|
||||
}
|
||||
|
||||
/** `HarnessOptions.models` as a function: a plain `Models` is returned at every call. */
|
||||
function currentModels(models: HarnessOptions["models"]): () => Models {
|
||||
return typeof models === "function" ? models : () => models;
|
||||
/** Request identity of a Harness's model requests: pi-ai's defaults with provider attribution to pi. */
|
||||
export const DEFAULT_HARNESS_IDENTITY: ClientIdentity = { attribution: true };
|
||||
|
||||
/**
|
||||
* `HarnessOptions.models` as a function with `HarnessOptions.identity` applied. A plain `Models` is wrapped once and
|
||||
* returned at every call; a function's results are wrapped once per `Models` it returns.
|
||||
*/
|
||||
function currentModels(models: HarnessOptions["models"], identity: ClientIdentity | undefined): () => Models {
|
||||
const merged = mergeClientIdentity(DEFAULT_HARNESS_IDENTITY, identity) ?? DEFAULT_HARNESS_IDENTITY;
|
||||
if (typeof models !== "function") {
|
||||
const wrapped = withClientIdentity(models, merged);
|
||||
return () => wrapped;
|
||||
}
|
||||
const wrappers = new WeakMap<Models, Models>();
|
||||
return () => {
|
||||
const current = models();
|
||||
let wrapped = wrappers.get(current);
|
||||
if (wrapped === undefined) {
|
||||
wrapped = withClientIdentity(current, merged);
|
||||
wrappers.set(current, wrapped);
|
||||
}
|
||||
return wrapped;
|
||||
};
|
||||
}
|
||||
|
||||
/** `HarnessOptions.settings` as a function: plain settings, or none, are returned at every call. */
|
||||
|
|
@ -215,7 +236,7 @@ class HarnessImpl<Tool extends ToolRegistration> extends SessionImpl implements
|
|||
session: this,
|
||||
storage,
|
||||
registry: options.registry,
|
||||
models: currentModels(options.models),
|
||||
models: currentModels(options.models, options.identity),
|
||||
agent: (id, snapshot, callContext) => this.resolveAgent(id, snapshot as RegistrySnapshot<Tool>, callContext),
|
||||
settings,
|
||||
env: (id, callContext) => this.buildEnv(id, callContext),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { AttachedReplicatedState, Context, JsonValue } from "@earendil-work
|
|||
import type {
|
||||
AssistantMessage,
|
||||
CacheRetention,
|
||||
ClientIdentity,
|
||||
Message,
|
||||
Models,
|
||||
ModelThinkingLevel,
|
||||
|
|
@ -613,6 +614,13 @@ export type HarnessOptions<Tool extends ToolRegistration = ToolRegistration> = {
|
|||
* one, called at each use, so a host can replace its model configuration without reopening the Harness.
|
||||
*/
|
||||
readonly models: Models | (() => Models);
|
||||
/**
|
||||
* How model requests identify the app to providers, merged field by field over `DEFAULT_HARNESS_IDENTITY`
|
||||
* (`{ attribution: true }`, provider attribution to pi). It applies to every request through the Harness's models,
|
||||
* including hooks and tools, and overrides the identity the `Models` was created with; a request's own `identity`
|
||||
* overrides it. Pass `{ attribution: false }` to send no attribution headers.
|
||||
*/
|
||||
readonly identity?: ClientIdentity;
|
||||
readonly registry: RegistryReader<Tool>;
|
||||
/** Run settings, or a function returning the current ones; read at each use either way. */
|
||||
readonly settings?: HarnessSettings | (() => HarnessSettings | undefined);
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export {
|
|||
type GenerationResult,
|
||||
GenerationTask,
|
||||
} from "./harness/generation.ts";
|
||||
export { Harness } from "./harness/harness.ts";
|
||||
export { DEFAULT_HARNESS_IDENTITY, Harness } from "./harness/harness.ts";
|
||||
export { InboxDoc, type InboxItem, type InboxState } from "./harness/inbox.ts";
|
||||
export {
|
||||
type CompactionFailure,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ export type ChatSetup = {
|
|||
/** Live Harness settings; tests assign fields to change them between decisions. */
|
||||
readonly settings: { -readonly [K in keyof HarnessSettings]: HarnessSettings[K] };
|
||||
now: () => number;
|
||||
/** `HarnessOptions.identity`. */
|
||||
identity?: HarnessOptions["identity"];
|
||||
};
|
||||
|
||||
export function chatSetup(options: RegisterFauxProviderOptions = {}): ChatSetup {
|
||||
|
|
@ -53,6 +55,7 @@ export async function openChat(
|
|||
models: setup.models,
|
||||
registry: setup.registry,
|
||||
settings: setup.settings,
|
||||
...(setup.identity === undefined ? {} : { identity: setup.identity }),
|
||||
...(options.env === undefined
|
||||
? {}
|
||||
: { env: typeof options.env === "function" ? options.env : () => options.env as ExecutionEnv }),
|
||||
|
|
|
|||
|
|
@ -385,6 +385,24 @@ describe("generation", () => {
|
|||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("attributes model requests to pi by default and merges the Harness identity over it", async () => {
|
||||
const seen: (SimpleStreamOptions["identity"] | undefined)[] = [];
|
||||
const capture = (_context: unknown, options?: SimpleStreamOptions) => {
|
||||
seen.push(options?.identity);
|
||||
return fauxAssistantMessage("ok");
|
||||
};
|
||||
for (const identity of [undefined, { userAgent: "short" as const, attribution: "acme" }]) {
|
||||
const setup = chatSetup();
|
||||
setup.identity = identity;
|
||||
setup.faux.setResponses([capture]);
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
harness.resume();
|
||||
await (await root.submit({ type: "input", content: "hi" }, context)).wait(context);
|
||||
await harness.close(context);
|
||||
}
|
||||
expect(seen).toEqual([{ attribution: true }, { attribution: "acme", userAgent: "short" }]);
|
||||
});
|
||||
|
||||
// Regression coverage for #10424.
|
||||
it("keeps provider session IDs request-local across concurrent conversations", async () => {
|
||||
const setup = chatSetup();
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ describe("HarnessOptions.models and settings as functions", () => {
|
|||
]);
|
||||
first.setProvider(faux.provider);
|
||||
second.setProvider(faux.provider);
|
||||
// Marks the second Models: tools see views of the host's Models that add the Harness identity.
|
||||
second.setProvider(fauxProvider({ provider: "second-only" }).provider);
|
||||
let current = first;
|
||||
const seen: Models[] = [];
|
||||
const registry = createRegistry();
|
||||
|
|
@ -97,9 +99,8 @@ describe("HarnessOptions.models and settings as functions", () => {
|
|||
);
|
||||
const { harness, root } = await open({ models: () => current }, registry);
|
||||
expect(await turn(root, "go")).toBe("done");
|
||||
expect(seen).toEqual([first, second]);
|
||||
expect(seen[0]).toBe(first);
|
||||
expect(seen[1]).toBe(second);
|
||||
expect(seen[0]!.getProvider("second-only")).toBeUndefined();
|
||||
expect(seen[1]!.getProvider("second-only")).toBeDefined();
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
fauxAssistantMessage,
|
||||
fauxText,
|
||||
fauxToolCall,
|
||||
type Models,
|
||||
type SystemMessage,
|
||||
type ToolResultMessage,
|
||||
Type,
|
||||
|
|
@ -130,8 +131,10 @@ describe("tool round", () => {
|
|||
});
|
||||
const { harness, status } = await run(setup, [calls(["echo", {}, "c1"]), DONE]);
|
||||
expect(status).toBe("done");
|
||||
expect(seen).toEqual([setup.models, setup.models]);
|
||||
expect(seen.every((models) => models === setup.models)).toBe(true);
|
||||
// One view of the host's Models that adds the Harness identity, shared by tools and hooks.
|
||||
expect(seen).toHaveLength(2);
|
||||
expect(seen[0]).toBe(seen[1]);
|
||||
expect((seen[0] as Models).getProviders()).toEqual(setup.models.getProviders());
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ const BUDGETS = {
|
|||
"packages/ai": {
|
||||
"./models": {
|
||||
// Request telemetry reaches pi-ai's telemetry.ts and pi-telemetry's root (index, noop, metrics, memory).
|
||||
maxFiles: 18,
|
||||
// Request identity reaches utils/identity.ts and its version constant.
|
||||
maxFiles: 20,
|
||||
forbid: ["providers/", "models.generated.ts", "index.ts", "utils/validation.ts", "utils/typebox-helpers.ts"],
|
||||
allow: ["packages/telemetry/src/index.ts"],
|
||||
},
|
||||
|
|
@ -63,7 +64,8 @@ const BUDGETS = {
|
|||
// memory and SQLite storages share scan order and cursor handling. Values from Chord's root (copyJson,
|
||||
// replicatedState) reach its host runtime through the root barrel. Chord's context reaches pi-telemetry's root
|
||||
// (index, noop, metrics, memory) for its NOOP telemetry default; harness/telemetry.ts is Durable's schema.
|
||||
maxFiles: 71,
|
||||
// The Harness's request identity reaches pi-ai's utils/identity.ts and its version constant.
|
||||
maxFiles: 73,
|
||||
forbid: [
|
||||
"packages/ai/src/index.ts",
|
||||
"packages/ai/src/utils/typebox-helpers.ts",
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
* exact internal dependency versions in all workspace packages, including private ones.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { findPackageDirectories } from "./package-workspaces.mjs";
|
||||
|
||||
const GENERATED_PACKAGE_SUFFIXES = [join("coding-agent", "install-lock")];
|
||||
|
|
@ -75,3 +75,14 @@ if (totalUpdates === 0) {
|
|||
} else {
|
||||
console.log(`\nUpdated ${totalUpdates} dependency version(s).`);
|
||||
}
|
||||
|
||||
// pi-ai reports its version in the User-Agent without reading package.json, which browsers cannot do.
|
||||
const aiPackage = workspacePackages.find((pkg) => pkg.data.name === "@earendil-works/pi-ai");
|
||||
const aiVersionPath = aiPackage && join(dirname(aiPackage.path), "src", "utils", "version.ts");
|
||||
if (aiVersionPath && existsSync(aiVersionPath)) {
|
||||
const content = `// The @earendil-works/pi-ai package version. scripts/sync-versions.js keeps it in sync with package.json.\nexport const PI_AI_VERSION = ${JSON.stringify(aiPackage.data.version)};\n`;
|
||||
if (readFileSync(aiVersionPath, "utf8") !== content) {
|
||||
writeFileSync(aiVersionPath, content);
|
||||
console.log(`\nUpdated ${aiVersionPath} to ${aiPackage.data.version}.`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,3 +91,22 @@ test("pins published and private dependencies without touching registry aliases,
|
|||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("writes the pi-ai version constant", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-sync-versions-"));
|
||||
try {
|
||||
await writeManifest(root, "packages/ai", {
|
||||
name: "@earendil-works/pi-ai",
|
||||
version: "2.0.0",
|
||||
});
|
||||
const versionPath = join(root, "packages/ai/src/utils/version.ts");
|
||||
await mkdir(join(root, "packages/ai/src/utils"), { recursive: true });
|
||||
await writeFile(versionPath, 'export const PI_AI_VERSION = "1.0.0";\n');
|
||||
|
||||
const result = runSyncVersions(root);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(await readFile(versionPath, "utf8"), /export const PI_AI_VERSION = "2\.0\.0";\n$/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue