fix(ai,agent,coding-agent,durable): client identity follow-ups

- ClientAttribution is { name, url, categories? } with required name and url, so
  OpenRouter always gets an HTTP-Referer. identity.ts maps the fields to each
  provider's headers; NVIDIA gets the name with its first letter upper-cased.
  attribution: null turns it off. PI_ATTRIBUTION is pi's, including the
  cli-agent category; agent-core, durable, and the coding agent use it.
- Categories are a string array, joined for OpenRouter.
- withClientIdentity() is a typed wrapper instead of a Proxy and keeps a
  MutableModels mutable. Durable no longer caches wrappers in a WeakMap.
- The Codex originator stays pi; login agentName no longer follows attribution.
- Models and the coding agent's ModelRuntime add x-opencode-session for custom
  providers pointing at OpenCode, replacing the coding agent's transformHeaders.
This commit is contained in:
Mario Zechner 2026-10-21 11:18:16 +00:00
parent adc5fa9f80
commit 04a93bad8f
20 changed files with 236 additions and 194 deletions

View file

@ -1,10 +1,8 @@
import type { Api, ClientIdentity, Model, ProviderHeaders } from "@earendil-works/pi-ai";
import { type ClientIdentity, PI_ATTRIBUTION } from "@earendil-works/pi-ai";
import { VERSION } from "../config.ts";
import type { SettingsManager } from "./settings-manager.ts";
import { isInstallTelemetryEnabled } from "./telemetry.ts";
const OPENCODE_HOST = "opencode.ai";
/**
* How pi's requests identify it to providers: a detailed `User-Agent` with pi's version, and attribution to pi as a
* CLI agent while install telemetry is enabled.
@ -13,28 +11,6 @@ export function getClientIdentity(settingsManager: SettingsManager): ClientIdent
return {
userAgent: "detailed",
version: VERSION,
attribution: isInstallTelemetryEnabled(settingsManager) ? { categories: "cli-agent" } : false,
attribution: isInstallTelemetryEnabled(settingsManager) ? PI_ATTRIBUTION : null,
};
}
function isOpenCodeModel(model: Model<Api>): boolean {
if (model.provider === "opencode" || model.provider === "opencode-go") return true;
try {
return new URL(model.baseUrl).hostname === OPENCODE_HOST;
} catch {
return false;
}
}
/**
* OpenCode's per-conversation routing header. pi-ai's OpenCode providers add it themselves; this also covers custom
* providers pointing at OpenCode. Configured and request headers override it.
*/
export function withOpenCodeSessionHeader(
model: Model<Api>,
sessionId: string | undefined,
headers: ProviderHeaders,
): ProviderHeaders {
if (!sessionId || !isOpenCodeModel(model)) return headers;
return { "x-opencode-session": sessionId, ...headers };
}

View file

@ -25,6 +25,7 @@ import {
type DeferredCancelOptions,
type DeferredFetchOptions,
type DeferredHandle,
getOpenCodeSessionHeaders,
type ImageApi,
type ImageModel,
type ImagesContext,
@ -677,13 +678,20 @@ export class ModelRuntime implements Models {
const { transformHeaders, ...rawProviderOptions } = options ?? {};
const providerOptions = rawProviderOptions as Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>;
let headers = mergeHeaders(resolution.auth.headers, providerOptions.headers);
const requestModel: TModel = resolution.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model;
const sessionId =
"sessionId" in providerOptions && typeof providerOptions.sessionId === "string"
? providerOptions.sessionId
: undefined;
let headers = mergeHeaders(
mergeHeaders(getOpenCodeSessionHeaders(requestModel, sessionId), resolution.auth.headers),
providerOptions.headers,
);
if (transformHeaders) headers = await transformHeaders(headers ?? {});
const env =
resolution.env || providerOptions.env
? { ...(resolution.env ?? {}), ...(providerOptions.env ?? {}) }
: undefined;
const requestModel: TModel = resolution.auth.baseUrl ? { ...model, baseUrl: resolution.auth.baseUrl } : model;
return {
provider,
model: requestModel,

View file

@ -7,7 +7,7 @@ import { resolvePath } from "../utils/paths.ts";
import { AgentSession } from "./agent-session.ts";
import { formatNoModelsAvailableMessage } from "./auth-guidance.ts";
import { CacheWarmer } from "./cache-warmer.ts";
import { getClientIdentity, withOpenCodeSessionHeader } from "./client-identity.ts";
import { getClientIdentity } from "./client-identity.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { createToolNameMatcher } from "./mcp-servers.ts";
@ -337,10 +337,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
() => settingsManager.getCacheWarmingMode(),
async (event) => extensionRunnerRef.current?.emitCacheWarmingDecision(event) ?? event.action,
);
const buildRequestOptions = (
requestModel: Model<any>,
options: ModelsSimpleStreamOptions = {},
): ModelsSimpleStreamOptions => {
const buildRequestOptions = (options: ModelsSimpleStreamOptions = {}): ModelsSimpleStreamOptions => {
const providerRetrySettings = settingsManager.getProviderRetrySettings();
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
const effectiveTimeoutMs = httpIdleTimeoutMs === 0 ? 2147483647 : httpIdleTimeoutMs;
@ -353,12 +350,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
maxRetryDelayMs: options.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs,
// Replaces the agent loop's default identity; read per request so telemetry changes apply at once.
identity: getClientIdentity(settingsManager),
transformHeaders: async (requestHeaders) => {
const headers = withOpenCodeSessionHeader(requestModel, options.sessionId, requestHeaders);
return headerRunner?.hasHandlers("before_provider_headers")
transformHeaders: async (headers) =>
headerRunner?.hasHandlers("before_provider_headers")
? headerRunner.emitBeforeProviderHeaders(headers)
: headers;
},
: headers,
};
};
// Warm only requests for the selected model. Requests a virtual selection routed, or that an
@ -415,7 +410,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
},
convertToLlm: convertToLlmWithBlockImages,
streamFn: async (model, context, options) => {
const requestOptions = buildRequestOptions(model, options);
const requestOptions = buildRequestOptions(options);
// Compaction and summaries use their own routing ids; only session requests
// replace the cache entry, so warming restarts from them. Keep warming while
// the current transcript still extends the request's prefix. Agent state may

View file

@ -8,6 +8,7 @@ import {
getAttributionHeaders,
type Model,
normalizeContext,
PI_ATTRIBUTION,
type SimpleStreamOptions,
} from "@earendil-works/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@ -145,7 +146,7 @@ describe("createAgentSession client identity", () => {
expect(options?.identity).toEqual({
userAgent: "detailed",
version: VERSION,
attribution: { categories: "cli-agent" },
attribution: PI_ATTRIBUTION,
});
expect(getAttributionHeaders(model, options?.identity)).toEqual({
"HTTP-Referer": "https://pi.dev",
@ -158,7 +159,7 @@ describe("createAgentSession client identity", () => {
const model = createModel("nvidia", "https://integrate.api.nvidia.com/v1");
const options = await captureOptions(model, { telemetryEnabled: false });
expect(options?.identity).toEqual({ userAgent: "detailed", version: VERSION, attribution: false });
expect(options?.identity).toEqual({ userAgent: "detailed", version: VERSION, attribution: null });
expect(getAttributionHeaders(model, options?.identity)).toEqual({});
});
@ -166,7 +167,7 @@ describe("createAgentSession client identity", () => {
process.env.PI_TELEMETRY = "0";
const options = await captureOptions(createModel("openrouter", "https://openrouter.ai/api/v1"));
expect(options?.identity?.attribution).toBe(false);
expect(options?.identity?.attribution).toBeNull();
});
it("adds OpenCode session headers for custom OpenCode providers", async () => {