Agents: add OpenAI attribution headers (#48737)

This commit is contained in:
Vincent Koc 2026-03-16 21:47:16 -07:00 committed by GitHub
parent 06459ca0df
commit ad7924b0ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 286 additions and 17 deletions

View File

@ -167,6 +167,8 @@ function buildManager(opts?: ConstructorParameters<typeof OpenAIWebSocketManager
return new OpenAIWebSocketManager({
// Use faster backoff in tests to avoid slow timer waits
backoffDelaysMs: [10, 20, 40, 80, 160],
socketFactory: (url, options) =>
new MockWebSocket(url, options as Record<string, unknown>) as never,
...opts,
});
}
@ -232,6 +234,22 @@ describe("OpenAIWebSocketManager", () => {
await connectPromise;
});
it("adds OpenClaw attribution headers on the native OpenAI websocket", async () => {
const manager = buildManager();
const connectPromise = manager.connect("sk-test-key");
const sock = lastSocket();
expect(sock.options).toMatchObject({
headers: expect.objectContaining({
originator: "openclaw",
"User-Agent": expect.stringMatching(/^openclaw\//),
}),
});
sock.simulateOpen();
await connectPromise;
});
it("resolves when the connection opens", async () => {
const manager = buildManager();
const connectPromise = manager.connect("sk-test");

View File

@ -15,6 +15,7 @@
import { EventEmitter } from "node:events";
import WebSocket from "ws";
import { resolveProviderAttributionHeaders } from "./provider-attribution.js";
// ─────────────────────────────────────────────────────────────────────────────
// WebSocket Event Types (Server → Client)
@ -251,6 +252,14 @@ const MAX_RETRIES = 5;
/** Backoff delays in ms: 1s, 2s, 4s, 8s, 16s */
const BACKOFF_DELAYS_MS = [1000, 2000, 4000, 8000, 16000] as const;
function isOpenAIPublicWebSocketUrl(url: string): boolean {
try {
return new URL(url).hostname.toLowerCase() === "api.openai.com";
} catch {
return url.toLowerCase().includes("api.openai.com");
}
}
export interface OpenAIWebSocketManagerOptions {
/** Override the default WebSocket URL (useful for testing) */
url?: string;
@ -258,6 +267,8 @@ export interface OpenAIWebSocketManagerOptions {
maxRetries?: number;
/** Custom backoff delays in ms (default: [1000, 2000, 4000, 8000, 16000]) */
backoffDelaysMs?: readonly number[];
/** Custom socket factory for tests. */
socketFactory?: (url: string, options: ConstructorParameters<typeof WebSocket>[1]) => WebSocket;
}
type InternalEvents = {
@ -297,12 +308,18 @@ export class OpenAIWebSocketManager extends EventEmitter<InternalEvents> {
private readonly wsUrl: string;
private readonly maxRetries: number;
private readonly backoffDelaysMs: readonly number[];
private readonly socketFactory: (
url: string,
options: ConstructorParameters<typeof WebSocket>[1],
) => WebSocket;
constructor(options: OpenAIWebSocketManagerOptions = {}) {
super();
this.wsUrl = options.url ?? OPENAI_WS_URL;
this.maxRetries = options.maxRetries ?? MAX_RETRIES;
this.backoffDelaysMs = options.backoffDelaysMs ?? BACKOFF_DELAYS_MS;
this.socketFactory =
options.socketFactory ?? ((url, socketOptions) => new WebSocket(url, socketOptions));
}
// ─── Public API ────────────────────────────────────────────────────────────
@ -382,10 +399,13 @@ export class OpenAIWebSocketManager extends EventEmitter<InternalEvents> {
return;
}
const socket = new WebSocket(this.wsUrl, {
const socket = this.socketFactory(this.wsUrl, {
headers: {
Authorization: `Bearer ${this.apiKey}`,
"OpenAI-Beta": "responses-websocket=v1",
...(isOpenAIPublicWebSocketUrl(this.wsUrl)
? resolveProviderAttributionHeaders("openai")
: undefined),
},
});

View File

@ -0,0 +1,110 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { Context, Model } from "@mariozechner/pi-ai";
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { captureEnv } from "../../test-utils/env.js";
import { applyExtraParamsToAgent } from "./extra-params.js";
type CapturedCall = {
headers?: Record<string, string>;
};
function applyAndCapture(params: {
provider: string;
modelId: string;
baseUrl?: string;
callerHeaders?: Record<string, string>;
}): CapturedCall {
const captured: CapturedCall = {};
const baseStreamFn: StreamFn = (model, _context, options) => {
captured.headers = options?.headers;
options?.onPayload?.({}, model);
return createAssistantMessageEventStream();
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, params.provider, params.modelId);
const model = {
api: "openai-responses",
provider: params.provider,
id: params.modelId,
baseUrl: params.baseUrl,
} as Model<"openai-responses">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, { headers: params.callerHeaders });
return captured;
}
describe("extra-params: OpenAI attribution", () => {
const envSnapshot = captureEnv(["OPENCLAW_VERSION"]);
afterEach(() => {
envSnapshot.restore();
});
it("injects originator and release-based user agent for native OpenAI", () => {
process.env.OPENCLAW_VERSION = "2026.3.14";
const { headers } = applyAndCapture({
provider: "openai",
modelId: "gpt-5.4",
baseUrl: "https://api.openai.com/v1",
});
expect(headers).toEqual({
originator: "openclaw",
"User-Agent": "openclaw/2026.3.14",
});
});
it("overrides caller-supplied OpenAI attribution headers", () => {
process.env.OPENCLAW_VERSION = "2026.3.14";
const { headers } = applyAndCapture({
provider: "openai",
modelId: "gpt-5.4",
baseUrl: "https://api.openai.com/v1",
callerHeaders: {
originator: "spoofed",
"User-Agent": "spoofed/0.0.0",
"X-Custom": "1",
},
});
expect(headers).toEqual({
originator: "openclaw",
"User-Agent": "openclaw/2026.3.14",
"X-Custom": "1",
});
});
it("does not inject attribution on non-native OpenAI-compatible base URLs", () => {
process.env.OPENCLAW_VERSION = "2026.3.14";
const { headers } = applyAndCapture({
provider: "openai",
modelId: "gpt-5.4",
baseUrl: "https://proxy.example.com/v1",
});
expect(headers).toBeUndefined();
});
it("injects attribution for ChatGPT-backed OpenAI Codex traffic", () => {
process.env.OPENCLAW_VERSION = "2026.3.14";
const { headers } = applyAndCapture({
provider: "openai-codex",
modelId: "gpt-5.4",
baseUrl: "https://chatgpt.com/backend-api",
});
expect(headers).toEqual({
originator: "openclaw",
"User-Agent": "openclaw/2026.3.14",
});
});
});

View File

@ -26,6 +26,7 @@ import {
shouldApplySiliconFlowThinkingOffCompat,
} from "./moonshot-stream-wrappers.js";
import {
createOpenAIAttributionHeadersWrapper,
createOpenAIDefaultTransportWrapper,
createOpenAIFastModeWrapper,
createOpenAIResponsesContextManagementWrapper,
@ -303,9 +304,12 @@ export function applyExtraParamsToAgent(
},
}) ?? merged;
if (provider === "openai") {
// Default OpenAI Responses to WebSocket-first with transparent SSE fallback.
agent.streamFn = createOpenAIDefaultTransportWrapper(agent.streamFn);
if (provider === "openai" || provider === "openai-codex") {
if (provider === "openai") {
// Default OpenAI Responses to WebSocket-first with transparent SSE fallback.
agent.streamFn = createOpenAIDefaultTransportWrapper(agent.streamFn);
}
agent.streamFn = createOpenAIAttributionHeadersWrapper(agent.streamFn);
}
const wrappedStreamFn = createStreamFnWithExtraParams(
agent.streamFn,

View File

@ -1,6 +1,7 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { SimpleStreamOptions } from "@mariozechner/pi-ai";
import { streamSimple } from "@mariozechner/pi-ai";
import { resolveProviderAttributionHeaders } from "../provider-attribution.js";
import { log } from "./logger.js";
import { streamWithPayloadPatch } from "./stream-payload-utils.js";
@ -42,6 +43,40 @@ function isOpenAIPublicApiBaseUrl(baseUrl: unknown): boolean {
}
}
function isOpenAICodexBaseUrl(baseUrl: unknown): boolean {
if (typeof baseUrl !== "string" || !baseUrl.trim()) {
return false;
}
try {
return new URL(baseUrl).hostname.toLowerCase() === "chatgpt.com";
} catch {
return baseUrl.toLowerCase().includes("chatgpt.com");
}
}
function shouldApplyOpenAIAttributionHeaders(model: {
api?: unknown;
provider?: unknown;
baseUrl?: unknown;
}): "openai" | "openai-codex" | undefined {
if (
model.provider === "openai" &&
(model.api === "openai-completions" || model.api === "openai-responses") &&
isOpenAIPublicApiBaseUrl(model.baseUrl)
) {
return "openai";
}
if (
model.provider === "openai-codex" &&
(model.api === "openai-codex-responses" || model.api === "openai-responses") &&
isOpenAICodexBaseUrl(model.baseUrl)
) {
return "openai-codex";
}
return undefined;
}
function shouldForceResponsesStore(model: {
api?: unknown;
provider?: unknown;
@ -357,3 +392,22 @@ export function createOpenAIDefaultTransportWrapper(baseStreamFn: StreamFn | und
return underlying(model, context, mergedOptions);
};
}
export function createOpenAIAttributionHeadersWrapper(
baseStreamFn: StreamFn | undefined,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const attributionProvider = shouldApplyOpenAIAttributionHeaders(model);
if (!attributionProvider) {
return underlying(model, context, options);
}
return underlying(model, context, {
...options,
headers: {
...options?.headers,
...resolveProviderAttributionHeaders(attributionProvider),
},
});
};
}

View File

@ -52,18 +52,44 @@ describe("provider attribution", () => {
});
});
it("tracks SDK-hook-only providers without enabling them", () => {
it("returns a hidden-spec OpenAI attribution policy", () => {
expect(resolveProviderAttributionPolicy("openai", { OPENCLAW_VERSION: "2026.3.14" })).toEqual({
provider: "openai",
enabledByDefault: false,
verification: "vendor-sdk-hook-only",
hook: "default-headers",
enabledByDefault: true,
verification: "vendor-hidden-api-spec",
hook: "request-headers",
reviewNote:
"OpenAI JS SDK exposes defaultHeaders, but public app attribution support is not yet verified.",
"OpenAI native traffic supports hidden originator/User-Agent attribution. Verified against the Codex wire contract.",
product: "OpenClaw",
version: "2026.3.14",
headers: {
originator: "openclaw",
"User-Agent": "openclaw/2026.3.14",
},
});
expect(resolveProviderAttributionHeaders("openai", { OPENCLAW_VERSION: "2026.3.14" })).toEqual({
originator: "openclaw",
"User-Agent": "openclaw/2026.3.14",
});
});
it("returns a hidden-spec OpenAI Codex attribution policy", () => {
expect(
resolveProviderAttributionPolicy("openai-codex", { OPENCLAW_VERSION: "2026.3.14" }),
).toEqual({
provider: "openai-codex",
enabledByDefault: true,
verification: "vendor-hidden-api-spec",
hook: "request-headers",
reviewNote:
"OpenAI Codex ChatGPT-backed traffic supports the same hidden originator/User-Agent attribution contract.",
product: "OpenClaw",
version: "2026.3.14",
headers: {
originator: "openclaw",
"User-Agent": "openclaw/2026.3.14",
},
});
expect(resolveProviderAttributionHeaders("openai")).toBeUndefined();
});
it("lists the current attribution support matrix", () => {
@ -76,11 +102,12 @@ describe("provider attribution", () => {
]),
).toEqual([
["openrouter", true, "vendor-documented", "request-headers"],
["openai", true, "vendor-hidden-api-spec", "request-headers"],
["openai-codex", true, "vendor-hidden-api-spec", "request-headers"],
["anthropic", false, "vendor-sdk-hook-only", "default-headers"],
["google", false, "vendor-sdk-hook-only", "user-agent-extra"],
["groq", false, "vendor-sdk-hook-only", "default-headers"],
["mistral", false, "vendor-sdk-hook-only", "custom-user-agent"],
["openai", false, "vendor-sdk-hook-only", "default-headers"],
["together", false, "vendor-sdk-hook-only", "default-headers"],
]);
});

View File

@ -4,6 +4,7 @@ import { normalizeProviderId } from "./model-selection.js";
export type ProviderAttributionVerification =
| "vendor-documented"
| "vendor-hidden-api-spec"
| "vendor-sdk-hook-only"
| "internal-runtime";
@ -28,6 +29,7 @@ export type ProviderAttributionPolicy = {
export type ProviderAttributionIdentity = Pick<ProviderAttributionPolicy, "product" | "version">;
const OPENCLAW_ATTRIBUTION_PRODUCT = "OpenClaw";
const OPENCLAW_ATTRIBUTION_ORIGINATOR = "openclaw";
export function resolveProviderAttributionIdentity(
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
@ -58,6 +60,44 @@ function buildOpenRouterAttributionPolicy(
};
}
function buildOpenAIAttributionPolicy(
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
): ProviderAttributionPolicy {
const identity = resolveProviderAttributionIdentity(env);
return {
provider: "openai",
enabledByDefault: true,
verification: "vendor-hidden-api-spec",
hook: "request-headers",
reviewNote:
"OpenAI native traffic supports hidden originator/User-Agent attribution. Verified against the Codex wire contract.",
...identity,
headers: {
originator: OPENCLAW_ATTRIBUTION_ORIGINATOR,
"User-Agent": `${OPENCLAW_ATTRIBUTION_ORIGINATOR}/${identity.version}`,
},
};
}
function buildOpenAICodexAttributionPolicy(
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
): ProviderAttributionPolicy {
const identity = resolveProviderAttributionIdentity(env);
return {
provider: "openai-codex",
enabledByDefault: true,
verification: "vendor-hidden-api-spec",
hook: "request-headers",
reviewNote:
"OpenAI Codex ChatGPT-backed traffic supports the same hidden originator/User-Agent attribution contract.",
...identity,
headers: {
originator: OPENCLAW_ATTRIBUTION_ORIGINATOR,
"User-Agent": `${OPENCLAW_ATTRIBUTION_ORIGINATOR}/${identity.version}`,
},
};
}
function buildSdkHookOnlyPolicy(
provider: string,
hook: ProviderAttributionHook,
@ -79,6 +119,8 @@ export function listProviderAttributionPolicies(
): ProviderAttributionPolicy[] {
return [
buildOpenRouterAttributionPolicy(env),
buildOpenAIAttributionPolicy(env),
buildOpenAICodexAttributionPolicy(env),
buildSdkHookOnlyPolicy(
"anthropic",
"default-headers",
@ -103,12 +145,6 @@ export function listProviderAttributionPolicies(
"Mistral JS SDK exposes a custom userAgent option, but app attribution is not yet verified.",
env,
),
buildSdkHookOnlyPolicy(
"openai",
"default-headers",
"OpenAI JS SDK exposes defaultHeaders, but public app attribution support is not yet verified.",
env,
),
buildSdkHookOnlyPolicy(
"together",
"default-headers",