Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 0 additions & 78 deletions packages/agent/src/utils/gateway.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
buildGatewayPropertyHeaders,
getLlmGatewayUrl,
resolveGatewayProduct,
resolveLlmGatewayUrl,
Expand Down Expand Up @@ -60,83 +59,6 @@ describe("resolveGatewayProduct", () => {
);
});

describe("buildGatewayPropertyHeaders", () => {
it("renders each property as an x-posthog-property header line", () => {
expect(
buildGatewayPropertyHeaders({
task_origin_product: "signal_report",
task_internal: true,
}),
).toBe(
"x-posthog-property-task_origin_product: signal_report\nx-posthog-property-task_internal: true",
);
});

it("drops null and undefined values but keeps falsy primitives", () => {
expect(
buildGatewayPropertyHeaders({
task_origin_product: null,
task_internal: false,
task_count: 0,
}),
).toBe(
"x-posthog-property-task_internal: false\nx-posthog-property-task_count: 0",
);
});

it("returns an empty string when no usable properties remain", () => {
expect(
buildGatewayPropertyHeaders({
task_origin_product: null,
task_internal: undefined,
}),
).toBe("");
});

it.each([
{
description: "LF",
title: "Fix the bug\nx-posthog-property-task_internal: true",
},
{
description: "CRLF",
title: "Fix the bug\r\nx-posthog-property-task_internal: true",
},
{
description: "CR",
title: "Fix the bug\rx-posthog-property-task_internal: true",
},
{
description: "consecutive newlines",
title: "Fix the bug\n\nx-posthog-property-task_internal: true",
},
])(
"collapses $description in values so they cannot inject extra headers",
({ title }) => {
expect(
buildGatewayPropertyHeaders({
task_title: title,
task_id: "task-abc",
}),
).toBe(
"x-posthog-property-task_title: Fix the bug x-posthog-property-task_internal: true\nx-posthog-property-task_id: task-abc",
);
},
);

it("strips characters an HTTP header value cannot carry", () => {
expect(buildGatewayPropertyHeaders({ task_title: "don’t🚀ship" })).toBe(
"x-posthog-property-task_title: dontship",
);
});

it("keeps latin1 characters such as accents", () => {
expect(buildGatewayPropertyHeaders({ task_title: "café" })).toBe(
"x-posthog-property-task_title: café",
);
});
});

describe("resolveLlmGatewayUrl", () => {
it("appends the product slug to an env-provided base URL", () => {
expect(
Expand Down
32 changes: 1 addition & 31 deletions packages/agent/src/utils/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,37 +27,7 @@ export function resolveGatewayProduct({
return "posthog_code";
}

/**
* Make a value safe to embed in an HTTP header value. Collapses newlines to
* spaces (the header block is newline-delimited) and drops characters outside
* the valid header-byte range — control chars and code points above latin1
* (emoji, smart quotes) — which an HTTP client (e.g. undici) would otherwise
* reject before sending. ASCII is preserved.
*/
function sanitizeHeaderValue(value: string): string {
return value.replace(/[\r\n]+/g, " ").replace(/[^\x20-\x7e\x80-\xff]/g, "");
}

/**
* Build `x-posthog-property-<name>: <value>` header lines that the LLM
* gateway lifts onto the `$ai_generation` event it captures for each call
* (see `services/llm-gateway/src/llm_gateway/request_context.py`).
*
* Returns a newline-joined string ready for `ANTHROPIC_CUSTOM_HEADERS`.
* `null`/`undefined` values are dropped; values are sanitized to be HTTP-header
* safe (see {@link sanitizeHeaderValue}).
*/
export function buildGatewayPropertyHeaders(
properties: Record<string, string | number | boolean | null | undefined>,
): string {
return Object.entries(properties)
.filter(([, value]) => value !== null && value !== undefined)
.map(
([key, value]) =>
`x-posthog-property-${key}: ${sanitizeHeaderValue(String(value))}`,
)
.join("\n");
}
export { buildPosthogPropertyHeaderLines as buildGatewayPropertyHeaders } from "@posthog/shared/posthog-property-headers";

function getGatewayBaseUrl(posthogHost: string): string {
const url = new URL(posthogHost);
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/git-pr/git-pr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ describe("GitPrService.generateCommitMessage", () => {
expect(messages[0].content).toContain("modified: x.ts");
expect(messages[0].content).toContain("why context");
expect(options.system).toContain("commit message generator");
expect(options.posthogProperties).toEqual({
$ai_span_name: "commit_message",
});
});
});

Expand Down Expand Up @@ -98,6 +101,10 @@ describe("GitPrService.generatePrTitleAndBody", () => {
expect(result.title).toBe("feat: add widget");
expect(result.body).toBe("TL;DR: adds a widget.");
expect(diffSource.fetchFromRemote).toHaveBeenCalledWith("/repo");
const [, options] = (llm.prompt as ReturnType<typeof vi.fn>).mock.calls[0];
expect(options.posthogProperties).toEqual({
$ai_span_name: "pr_description",
});
});
});

Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/git-pr/git-pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,11 @@ ${truncatedDiff}${contextSection}`;

const response = await this.llm.prompt(
[{ role: "user", content: userMessage }],
{ system, model: HELPER_GATEWAY_MODEL },
{
system,
model: HELPER_GATEWAY_MODEL,
posthogProperties: { $ai_span_name: "commit_message" },
},
);

return { message: response.content.trim() };
Expand Down Expand Up @@ -211,7 +215,12 @@ ${truncatedDiff || "(no diff available)"}${contextSection}`;

const response = await this.llm.prompt(
[{ role: "user", content: userMessage }],
{ system, maxTokens: 2000, model: HELPER_GATEWAY_MODEL },
{
system,
maxTokens: 2000,
model: HELPER_GATEWAY_MODEL,
posthogProperties: { $ai_span_name: "pr_description" },
},
);

const content = response.content.trim();
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/llm-gateway/llm-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,38 @@ describe("LlmGatewayService.prompt", () => {
expect(body.stream).toBe(false);
});

it("forwards posthogProperties as x-posthog-property-* request headers and skips nulls", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(createJsonResponse(SUCCESS_BODY));
const { service } = createService(fetchMock);

await service.prompt([{ role: "user", content: "hi" }], {
posthogProperties: {
$ai_span_name: "pr_description",
task_id: 42,
is_dry_run: false,
// Null/undefined values are dropped so the gateway doesn't see
// literal "null" strings on the captured event.
unused: null,
skipped: undefined,
// Newlines and non-latin1 bytes are sanitized so an undici-backed
// fetch doesn't reject the request before it's sent.
rich: "line one\nline two — done 🎉",
},
});

const [, init] = fetchMock.mock.calls[0];
expect(init.headers).toMatchObject({
"x-posthog-property-$ai_span_name": "pr_description",
"x-posthog-property-task_id": "42",
"x-posthog-property-is_dry_run": "false",
"x-posthog-property-rich": "line one line two done ",
});
expect(init.headers).not.toHaveProperty("x-posthog-property-unused");
expect(init.headers).not.toHaveProperty("x-posthog-property-skipped");
});

it("throws a typed LlmGatewayError with parsed error fields on non-ok response", async () => {
const fetchMock = vi.fn().mockResolvedValue(
createJsonResponse(
Expand Down
23 changes: 20 additions & 3 deletions packages/core/src/llm-gateway/llm-gateway.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger";
import {
buildPosthogPropertyHeaderRecord,
type PosthogProperties,
} from "@posthog/shared/posthog-property-headers";
import { inject, injectable } from "inversify";
import {
LLM_GATEWAY_HOST,
Expand Down Expand Up @@ -58,6 +62,13 @@ export class LlmGatewayService {
model?: string;
signal?: AbortSignal;
timeoutMs?: number;
/**
* Free-form metadata forwarded as `x-posthog-property-<key>` headers.
* The gateway lifts each one onto the `$ai_generation` event it
* captures, so helper callers (commit messages, PR descriptions, etc.)
* can be told apart from the agent's main generations.
*/
posthogProperties?: PosthogProperties;
} = {},
): Promise<PromptOutput> {
const {
Expand All @@ -66,6 +77,7 @@ export class LlmGatewayService {
model = this.endpoints.defaultModel,
signal,
timeoutMs = 60_000,
posthogProperties,
} = options;

const auth = await this.auth.getValidAccessToken();
Expand Down Expand Up @@ -101,13 +113,18 @@ export class LlmGatewayService {
else signal.addEventListener("abort", onCallerAbort, { once: true });
}

const headers: Record<string, string> = {
"Content-Type": "application/json",
...(posthogProperties
? buildPosthogPropertyHeaderRecord(posthogProperties)
: {}),
};

let response: Response;
try {
response = await this.auth.authenticatedFetch(messagesUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers,
body: JSON.stringify(requestBody),
signal: timeoutController.signal,
});
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
"./mcp-sandbox-proxy": {
"types": "./dist/mcp-sandbox-proxy.d.ts",
"import": "./dist/mcp-sandbox-proxy.js"
},
"./posthog-property-headers": {
"types": "./dist/posthog-property-headers.d.ts",
"import": "./dist/posthog-property-headers.js"
}
},
"scripts": {
Expand Down
Loading
Loading