From 4f7afd33a388164cd984c797cfd656fc23e54404 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Sat, 5 Sep 2026 20:26:58 -0400 Subject: [PATCH 01/10] fix(gateway): show the server's reason when the mint refuses a run A refused mint carried a fixed message per status, so a blocklisted account read "Your access to this project has changed" instead of the server's detail naming the contact address. Prefer the response detail when it is a short string; keep the fixed message otherwise. Generated-By: PostHog Desktop Task-Id: ecbe6b2f-c266-41d5-be54-272f86314c88 --- src/lib/__tests__/gateway-session.test.ts | 31 +++++++++++++++++++++++ src/lib/gateway-session.ts | 24 ++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index 4ce613a9..0e9df02e 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -221,6 +221,37 @@ describe('gatewayAuth', () => { }, ); + it('shows the server detail on a refusal when it sends one', async () => { + // The blocklist's 403 names the contact address; the fixed message would + // tell a banned user to re-authenticate instead. + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + json: () => + Promise.resolve({ + detail: 'This account is blocked. Contact wizard@posthog.com.', + }), + }); + await expect(gatewayAuth(host, 'pha_oauth', 'integration')).rejects.toThrow( + 'Contact wizard@posthog.com', + ); + }); + + it.each([ + ['not an object', () => Promise.resolve('nope')], + ['an empty detail', () => Promise.resolve({ detail: ' ' })], + ['an unparseable body', () => Promise.reject(new SyntaxError('bad json'))], + ['an oversized detail', () => Promise.resolve({ detail: 'x'.repeat(501) })], + ])( + 'keeps the fixed message when the refusal body is %s', + async (_label, json) => { + fetchMock.mockResolvedValue({ ok: false, status: 403, json }); + await expect( + gatewayAuth(host, 'pha_oauth', 'integration'), + ).rejects.toThrow(/access to this project/i); + }, + ); + it.each([404, 401])( 'stays on the existing gateway on HTTP %i', async (status) => { diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index 6b1c6d00..303224cc 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -50,6 +50,8 @@ const LEGACY_RETRY_MS = 10 * 60 * 1000; // Exceeds the backend's own 10s gateway timeout: a slow mint that lands after the // CLI hangs up spends a daily mint and orphans a live token. const MINT_TIMEOUT_MS = 20_000; +/** Longer than any refusal the mint writes; a body past this is not a message. */ +const MAX_REFUSAL_DETAIL_LENGTH = 500; /** Resolve this run's gateway auth, minting and re-minting near expiry. */ export async function gatewayAuth( @@ -218,7 +220,25 @@ function isMintRefusal(status: number): boolean { return status === 400 || status === 403 || status === 429; } -function mintRefusalMessage(status: number): string { +/** + * The server's own reason for a refusal, when it sent one. DRF answers every + * refusal as `{"detail": "..."}`; the blocklist's detail names the contact + * address, which the fixed messages below cannot. + */ +async function readRefusalDetail(resp: Response): Promise { + try { + const body = (await resp.json()) as { detail?: unknown }; + const detail = typeof body?.detail === 'string' ? body.detail.trim() : ''; + return detail.length > 0 && detail.length <= MAX_REFUSAL_DETAIL_LENGTH + ? detail + : undefined; + } catch { + return undefined; + } +} + +function mintRefusalMessage(status: number, detail?: string): string { + if (detail) return detail; switch (status) { case 429: return 'This wizard program has used its daily run limit. Try again tomorrow.'; @@ -255,7 +275,7 @@ async function mintGatewayToken( ); throw new GatewayMintRefused( resp.status, - mintRefusalMessage(resp.status), + mintRefusalMessage(resp.status, await readRefusalDetail(resp)), ); } if (resp.status === 404 || resp.status === 401) { From 06e33283e453cebb1770ea3c4e5840db14571dc9 Mon Sep 17 00:00:00 2001 From: Brandon Leung Date: Tue, 8 Sep 2026 13:24:08 -0400 Subject: [PATCH 02/10] feat(gateway): stop falling back to the legacy gateway Co-Authored-By: Claude Fable 5.1 --- src/lib/__tests__/agent-interface.test.ts | 40 ++++----- src/lib/__tests__/gateway-session.test.ts | 82 ++++++------------- src/lib/__tests__/host-resolution.test.ts | 3 - .../agent/__tests__/triage-provider.test.ts | 38 ++++----- .../agent/__tests__/tutorial-run-tags.test.ts | 18 ++-- src/lib/agent/agent-interface.ts | 61 ++++---------- src/lib/agent/mcp-prompt-streaming.ts | 22 ++--- src/lib/agent/runner/harness/pi/README.md | 10 +-- .../harness/pi/__tests__/gateway.test.ts | 50 +++++++++-- src/lib/agent/runner/harness/pi/gateway.ts | 81 ++++++------------ src/lib/agent/runner/harness/pi/index.ts | 6 +- src/lib/agent/runner/harness/pi/task.ts | 1 - src/lib/agent/runner/shared/bootstrap.ts | 4 +- src/lib/agent/triage-provider.ts | 8 +- src/lib/constants.ts | 7 -- src/lib/gateway-session.ts | 66 +++++---------- src/lib/host-resolution.ts | 7 -- src/utils/__tests__/ci-region.test.ts | 1 - src/utils/custom-headers.ts | 9 -- src/utils/urls.ts | 16 ---- 20 files changed, 189 insertions(+), 341 deletions(-) diff --git a/src/lib/__tests__/agent-interface.test.ts b/src/lib/__tests__/agent-interface.test.ts index a8188984..de75ea35 100644 --- a/src/lib/__tests__/agent-interface.test.ts +++ b/src/lib/__tests__/agent-interface.test.ts @@ -94,7 +94,6 @@ describe('runAgent', () => { // would make either source pass. gatewayUrl: 'https://gateway.test', token: 'phe_run_scoped_token', - edition: 'legacy' as const, }, }; @@ -598,31 +597,20 @@ describe('buildAgentEnv header shape', () => { const metadata = { run_id: 'r1', integration: 'nextjs' }; const flags = { 'wizard-orchestrator': 'test' }; - it('sends per-key headers and the bedrock opt-in on the legacy gateway', () => { - const encoded = buildAgentEnv(metadata, flags, { - gatewayUrl: 'https://gateway.us.posthog.com/wizard', - token: 'pha_oauth', - edition: 'legacy', + it('sends one properties blob and no per-key or bedrock headers', () => { + const encoded = buildAgentEnv(metadata, flags, 42); + const [name, json] = encoded.split(': ', 2); + expect(name).toBe('X-PostHog-Properties'); + // Fallback is native in the gateway's routing chain, and the run tags ride + // the blob rather than per-key headers. + expect(JSON.parse(json)).toEqual({ + team_id: 42, + run_id: 'r1', + integration: 'nextjs', + 'wizard_flag_wizard-orchestrator': 'test', }); - expect(encoded).toContain('x-posthog-use-bedrock-fallback'); - expect(encoded).toContain('X-POSTHOG-PROPERTY-run_id'); - expect(encoded).not.toContain('X-PostHog-Properties'); - }); - - it('sends one properties blob and no bedrock opt-in on the new gateway', () => { - const encoded = buildAgentEnv(metadata, flags, { - gatewayUrl: 'https://ai-gateway.us.posthog.com', - token: 'phe_minted', - edition: 'v2', - teamId: 42, - }); - expect(encoded).toContain('X-PostHog-Properties'); expect(encoded).not.toContain('x-posthog-use-bedrock-fallback'); - expect(encoded).not.toContain('X-POSTHOG-PROPERTY-run_id'); - // Fallback is native in the new gateway's routing chain, and the run tags - // ride the blob rather than per-key headers. - expect(encoded).toContain('run_id'); - expect(encoded).toContain('team_id'); + expect(encoded).not.toContain('X-POSTHOG-PROPERTY-'); }); }); @@ -642,7 +630,6 @@ describe('subprocess gateway credentials', () => { gatewayAuth: { gatewayUrl: 'https://ai-gateway.us.posthog.com', token: 'phe_run_scoped_token', - edition: 'v2' as const, teamId: 42, }, }; @@ -689,8 +676,9 @@ describe('subprocess gateway credentials', () => { // The MCP token is the user's own OAuth key and must not be swapped for // the gateway bearer. expect(env.POSTHOG_MCP_TOKEN).toBe('phx_user_oauth_token'); - // v2 carries one properties blob, not the per-key legacy headers. + // The run tags ride one properties blob, with the minted team on it. expect(env.ANTHROPIC_CUSTOM_HEADERS).toContain('X-PostHog-Properties'); + expect(env.ANTHROPIC_CUSTOM_HEADERS).toContain('"team_id":42'); }); }); diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index 0e9df02e..cc0f59a7 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -8,13 +8,8 @@ import { resetGatewaySession, } from '@lib/gateway-session'; import type { HostResolution } from '@lib/host-resolution'; -import { analytics } from '@utils/analytics'; import { logToFile } from '@utils/debug'; -vi.mock('@utils/analytics', () => ({ - analytics: { setTag: vi.fn(), captureException: vi.fn() }, -})); - vi.mock('@utils/debug', () => ({ logToFile: vi.fn() })); // logToFile is variadic, so a leak in any argument is a leak. Rendered every way the @@ -34,10 +29,7 @@ const renderArg = (a: unknown): string => { const loggedLines = () => vi.mocked(logToFile).mock.calls.map((call) => call.map(renderArg).join(' ')); -const host = { - apiHost: 'https://us.posthog.com', - gatewayUrl: 'https://gateway.us.posthog.com/wizard', -} as unknown as HostResolution; +const host = { apiHost: 'https://us.posthog.com' } as unknown as HostResolution; describe('gatewayAuth', () => { const fetchMock = vi.fn(); @@ -45,7 +37,6 @@ describe('gatewayAuth', () => { beforeEach(() => { resetGatewaySession(); fetchMock.mockReset(); - vi.mocked(analytics.setTag).mockClear(); vi.mocked(logToFile).mockClear(); vi.stubGlobal('fetch', fetchMock); }); @@ -54,7 +45,7 @@ describe('gatewayAuth', () => { vi.unstubAllGlobals(); }); - it('resolves the v2 posture from a mint response and caches it', async () => { + it('resolves auth from a mint response and caches it', async () => { fetchMock.mockResolvedValue({ ok: true, json: () => @@ -70,10 +61,8 @@ describe('gatewayAuth', () => { expect(auth).toEqual({ gatewayUrl: 'https://gateway.us.posthog.com', token: 'phe_minted', - edition: 'v2', teamId: 42, }); - expect(analytics.setTag).toHaveBeenCalledWith('gateway_edition', 'v2'); expect(fetchMock).toHaveBeenCalledWith( 'https://us.posthog.com/api/wizard/gateway_token/', expect.objectContaining({ @@ -155,14 +144,6 @@ describe('gatewayAuth', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('caches the legacy fallback instead of re-minting per caller', async () => { - fetchMock.mockResolvedValue({ ok: false, status: 404 }); - - await gatewayAuth(host, 'pha_oauth', 'integration'); - await gatewayAuth(host, 'pha_oauth', 'integration'); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - // Every field except the one under test is valid, so the named guard is the // sole reason the call fails. Filling the others with junk (an unparseable // expiry, say) makes the TTL guard throw first and every case pass for the @@ -209,17 +190,14 @@ describe('gatewayAuth', () => { [429, 'daily run limit'], [400, 'exactly one project'], [403, 'access to this project'], - ])( - 'refuses rather than falling back on HTTP %i', - async (status, fragment) => { - fetchMock.mockResolvedValue({ ok: false, status }); - // Falling back would put the run on the legacy gateway, which enforces none - // of the limits these statuses represent. - await expect( - gatewayAuth(host, 'pha_oauth', 'integration'), - ).rejects.toThrow(new RegExp(String(fragment), 'i')); - }, - ); + ])('refuses the run on HTTP %i', async (status, fragment) => { + fetchMock.mockResolvedValue({ ok: false, status }); + // A refusal is the mint enforcing a limit; the run must not proceed + // without it. + await expect(gatewayAuth(host, 'pha_oauth', 'integration')).rejects.toThrow( + new RegExp(String(fragment), 'i'), + ); + }); it('shows the server detail on a refusal when it sends one', async () => { // The blocklist's 403 names the contact address; the fixed message would @@ -252,19 +230,24 @@ describe('gatewayAuth', () => { }, ); - it.each([404, 401])( - 'stays on the existing gateway on HTTP %i', - async (status) => { + it.each([ + [401, /re-authenticate with `npx @posthog\/wizard@latest`/i], + [404, /does not issue gateway tokens/i], + ])( + 'refuses with a status-specific message on HTTP %i', + async (status, message) => { fetchMock.mockResolvedValue({ ok: false, status }); - // 404 is the staged-rollout switch, so removing it would make the flip - // all-or-nothing. 401 covers a credential the mint cannot authenticate, - // such as the API key CI runs with. - const auth = await gatewayAuth(host, 'pha_oauth', 'integration'); - expect(auth.edition).toBe('legacy'); - expect(analytics.setTag).toHaveBeenCalledWith( - 'gateway_edition', - 'legacy', - ); + // Neither status has a fallback: 401 is a credential the mint does not + // accept, 404 an instance without the mint endpoint. Both used to put the + // run on the legacy gateway, which enforced none of the mint's limits. + const err: unknown = await gatewayAuth( + host, + 'pha_oauth', + 'integration', + ).catch((e: unknown) => e); + expect(err).toBeInstanceOf(GatewayMintRefused); + expect((err as GatewayMintRefused).status).toBe(status); + expect((err as GatewayMintRefused).message).toMatch(message); }, ); @@ -456,17 +439,6 @@ describe('gatewayAuth', () => { ).rejects.toBeInstanceOf(GatewayMintFailed); }); - it('falls back to the legacy posture when the backend does not mint', async () => { - fetchMock.mockResolvedValue({ ok: false, status: 404 }); - - const auth = await gatewayAuth(host, 'pha_oauth', 'integration'); - expect(auth).toEqual({ - gatewayUrl: host.gatewayUrl, - token: 'pha_oauth', - edition: 'legacy', - }); - }); - it('fails the run on a transport failure', async () => { fetchMock.mockRejectedValue(new Error('network down')); diff --git a/src/lib/__tests__/host-resolution.test.ts b/src/lib/__tests__/host-resolution.test.ts index ffe18667..a22d5515 100644 --- a/src/lib/__tests__/host-resolution.test.ts +++ b/src/lib/__tests__/host-resolution.test.ts @@ -14,7 +14,6 @@ describe('HostResolution.fromApiHost', () => { expect(h.apiHost).toBe('https://us.i.posthog.com'); expect(h.appHost).toBe('https://us.posthog.com'); expect(h.assetHost).toBe('https://us-assets.i.posthog.com'); - expect(h.gatewayUrl).toBe('https://gateway.us.posthog.com/wizard'); }); it('derives the full EU host family from the EU ingestion host', () => { @@ -23,7 +22,6 @@ describe('HostResolution.fromApiHost', () => { expect(h.apiHost).toBe('https://eu.i.posthog.com'); expect(h.appHost).toBe('https://eu.posthog.com'); expect(h.assetHost).toBe('https://eu-assets.i.posthog.com'); - expect(h.gatewayUrl).toBe('https://gateway.eu.posthog.com/wizard'); }); it('preserves the given apiHost verbatim (provisioning may return a non-canonical host)', () => { @@ -38,7 +36,6 @@ describe('HostResolution.fromApiHost', () => { expect(h.region).toBe('us'); expect(h.apiHost).toBe('http://localhost:8010'); expect(h.appHost).toBe('http://localhost:8010'); - expect(h.gatewayUrl).toBe('http://localhost:3308/wizard'); }); }); diff --git a/src/lib/agent/__tests__/triage-provider.test.ts b/src/lib/agent/__tests__/triage-provider.test.ts index ffd6c190..2d47e992 100644 --- a/src/lib/agent/__tests__/triage-provider.test.ts +++ b/src/lib/agent/__tests__/triage-provider.test.ts @@ -42,7 +42,7 @@ describe('createTriageLLMProvider', () => { }); }); - it('triages a legacy pi run on luna at the table effort, over openai-completions', async () => { + it('triages a pi run on luna at the table effort, over openai-responses', async () => { complete.mockResolvedValue(reply('true_positive')); const provider = createTriageLLMProvider(AUTH, Harness.pi); @@ -50,27 +50,13 @@ describe('createTriageLLMProvider', () => { const [model, context, options] = complete.mock.calls[0]; expect(model.id).toBe(GPT5_6_LUNA_MODEL); - expect(model.api).toBe('openai-completions'); + expect(model.api).toBe('openai-responses'); expect(model.baseUrl).toBe('https://gw.posthog.test/v1'); // Luna rejects the request without an effort it recognises. expect(options?.reasoning).toBe('low'); expect(context.messages[0].content).toBe('verdict?'); }); - it('triages a v2 pi run over openai-responses', async () => { - complete.mockResolvedValue(reply('true_positive')); - const provider = createTriageLLMProvider( - { ...AUTH, edition: 'v2' as const }, - Harness.pi, - ); - - await expect(provider('verdict?')).resolves.toBe('true_positive'); - - const [model] = complete.mock.calls[0]; - expect(model.api).toBe('openai-responses'); - expect(model.baseUrl).toBe('https://gw.posthog.test/v1'); - }); - it('triages an anthropic run on haiku over anthropic-messages', async () => { complete.mockResolvedValue(reply('false_positive')); const provider = createTriageLLMProvider(AUTH, Harness.anthropic); @@ -83,11 +69,12 @@ describe('createTriageLLMProvider', () => { expect(model.baseUrl).toBe('https://gw.posthog.test'); }); - it('carries the same gateway trace headers as every other model call', async () => { + it('carries the same gateway properties blob as every other model call', async () => { complete.mockResolvedValue(reply('')); const provider = createTriageLLMProvider( { ...AUTH, + teamId: 42, wizardMetadata: { run_id: 'r1' }, wizardFlags: { 'wizard-orchestrator': 'true' }, }, @@ -96,11 +83,13 @@ describe('createTriageLLMProvider', () => { await provider('verdict?'); - expect(complete.mock.calls[0][0].headers).toMatchObject({ - 'x-posthog-use-bedrock-fallback': 'true', - 'X-POSTHOG-PROPERTY-run_id': 'r1', - 'X-POSTHOG-FLAG-WIZARD-ORCHESTRATOR': 'true', + const headers = complete.mock.calls[0][0].headers ?? {}; + expect(JSON.parse(headers['X-PostHog-Properties'])).toEqual({ + team_id: 42, + run_id: 'r1', + 'wizard_flag_wizard-orchestrator': 'true', }); + expect(headers['x-posthog-use-bedrock-fallback']).toBeUndefined(); }); it('attributes its spend to the program that triggered the scan', async () => { @@ -120,9 +109,10 @@ describe('createTriageLLMProvider', () => { await provider('verdict?'); - expect(complete.mock.calls[0][0].headers).toMatchObject({ - 'X-POSTHOG-PROPERTY-program_id': 'posthog-integration', - 'X-POSTHOG-PROPERTY-call_type': 'yara-triage', + const headers = complete.mock.calls[0][0].headers ?? {}; + expect(JSON.parse(headers['X-PostHog-Properties'])).toMatchObject({ + program_id: 'posthog-integration', + call_type: 'yara-triage', }); }); diff --git a/src/lib/agent/__tests__/tutorial-run-tags.test.ts b/src/lib/agent/__tests__/tutorial-run-tags.test.ts index 118f9146..976057bc 100644 --- a/src/lib/agent/__tests__/tutorial-run-tags.test.ts +++ b/src/lib/agent/__tests__/tutorial-run-tags.test.ts @@ -2,9 +2,9 @@ * Cost-attribution contract for the MCP tutorial. * * The gateway builds each `$ai_generation`'s `program_id` from the - * `X-POSTHOG-PROPERTY-*` headers on the request. The tutorial shipped without - * them, so every generation it produced landed in the unattributed bucket — - * these tests pin the tags so that can't silently return. + * `X-PostHog-Properties` header on the request. The tutorial shipped without + * it, so every generation it produced landed in the unattributed bucket. These + * tests pin the tags so that can't silently return. */ import { buildTutorialRunTags } from '@lib/agent/mcp-prompt-streaming'; @@ -42,21 +42,21 @@ describe('buildTutorialRunTags', () => { expect(buildTutorialRunTags({})).toEqual({}); }); - it('reaches the gateway as property headers, not just an object', () => { - // The object is only useful if buildAgentEnv actually encodes it — that + it('reaches the gateway in the properties header, not just an object', () => { + // The object is only useful if buildAgentEnv actually encodes it; that // join is the part that was missing in production. const encoded = buildAgentEnv( buildTutorialRunTags({ programId: 'mcp-tutorial' }), {}, ); - expect(encoded).toContain('X-POSTHOG-PROPERTY-program_id: mcp-tutorial'); - expect(encoded).toContain('x-posthog-use-bedrock-fallback: true'); + expect(encoded).toContain('X-PostHog-Properties: '); + expect(encoded).toContain('"program_id":"mcp-tutorial"'); }); - it('sends only the bedrock header when there is no program — the pre-fix shape', () => { + it('sends an empty blob when there is no program, the pre-fix shape', () => { const encoded = buildAgentEnv(buildTutorialRunTags({}), {}); - expect(encoded).not.toContain('X-POSTHOG-PROPERTY'); + expect(encoded).toBe('X-PostHog-Properties: {}'); }); }); diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 9b5cbfdc..270f1a9b 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -19,7 +19,6 @@ import { CallType, Sequence, WIZARD_REMARK_EVENT_NAME, - POSTHOG_PROPERTY_HEADER_PREFIX, wizardUserAgentForProgram, DEFAULT_AGENT_MODEL, } from '@lib/constants'; @@ -336,10 +335,7 @@ type AgentRunConfig = { capture?: AioCapture; /** Scan-triage classifier, built from this run's gateway auth. */ triageProvider: LLMProvider; - /** - * Resolved gateway posture for this run (v2 scoped token or legacy OAuth); - * selects the ANTHROPIC_CUSTOM_HEADERS shape for the SDK subprocess. - */ + /** The run's minted gateway auth: base url, bearer and team for the subprocess. */ gatewayAuth: GatewayAuth; /** Program id, for the program-axis commandments. */ program?: string; @@ -349,10 +345,10 @@ type AgentRunConfig = { /** * Global identifiers attached to every LLM gateway trace for a run. They ride on - * each `$ai_generation` the gateway emits (as `X-POSTHOG-PROPERTY-*` headers via - * `buildAgentEnv`), so traces are filterable by program, framework, run, and build - * type for cost attribution and dashboards. `skill_id` is omitted when the run has - * none. + * each `$ai_generation` the gateway emits (in the `X-PostHog-Properties` blob + * `buildAgentEnv` builds), so traces are filterable by program, framework, run, + * and build type for cost attribution and dashboards. `skill_id` is omitted when + * the run has none. */ export function buildRunTags(args: { programId: string; @@ -385,39 +381,20 @@ export function isWarlockDisabled(): boolean { } /** - * Build env for the SDK subprocess: process.env plus ANTHROPIC_CUSTOM_HEADERS. - * The header shape follows the gateway edition. Legacy (Python gateway): - * per-key `X-POSTHOG-PROPERTY-*`/`X-POSTHOG-FLAG-*` plus the explicit - * `x-posthog-use-bedrock-fallback` opt-in. v2 (Go ai-gateway): one - * `X-PostHog-Properties` JSON blob. Bedrock fallback is native there, and - * per-key metadata headers are not read. + * Build ANTHROPIC_CUSTOM_HEADERS for the SDK subprocess: the run's metadata and + * flags as one `X-PostHog-Properties` JSON blob. Bedrock fallback is native to + * the gateway, so there is no opt-in header. */ export function buildAgentEnv( wizardMetadata: Record, wizardFlags: Record, - auth?: GatewayAuth, + teamId?: number, ): string { const headers = createCustomHeaders(); - if (auth?.edition === 'v2') { - headers.add( - 'X-PostHog-Properties', - buildWizardPropertiesBlob(wizardMetadata, wizardFlags, auth.teamId), - ); - } else { - headers.add('x-posthog-use-bedrock-fallback', 'true'); - for (const [key, value] of Object.entries(wizardMetadata)) { - headers.add( - key.startsWith(POSTHOG_PROPERTY_HEADER_PREFIX) - ? key - : `${POSTHOG_PROPERTY_HEADER_PREFIX}${key}`, - value, - ); - } - for (const [flagKey, variant] of Object.entries(wizardFlags)) { - if (!flagKey.toLowerCase().startsWith('wizard')) continue; - headers.addFlag(flagKey, variant); - } - } + headers.add( + 'X-PostHog-Properties', + buildWizardPropertiesBlob(wizardMetadata, wizardFlags, teamId), + ); const encoded = headers.encode(); logToFile('ANTHROPIC_CUSTOM_HEADERS', encoded); return encoded; @@ -544,10 +521,8 @@ export async function initializeAgent( try { // Configure model routing (inherited by the SDK subprocess). All model - // calls route through the PostHog LLM gateway. gatewayAuth resolves the - // v2 posture (a server-minted scoped token + the Go gateway URL) and - // falls back to the legacy posture (the user's OAuth token + the Python - // gateway) when the backend doesn't mint. + // calls route through the PostHog AI gateway with the scoped token + // gatewayAuth mints for this run. // Disable experimental betas (like input_examples) the gateway doesn't support. process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = 'true'; const auth = await gatewayAuth( @@ -561,7 +536,6 @@ export async function initializeAgent( // Use CLAUDE_CODE_OAUTH_TOKEN to override any stored /login credentials process.env.CLAUDE_CODE_OAUTH_TOKEN = auth.token; - logToFile('Gateway edition:', auth.edition); // Same values the env vars above carry, handed over explicitly so triage // never has to read them back out of the environment. The run tags ride @@ -571,7 +545,6 @@ export async function initializeAgent( { baseURL: gatewayUrl, authToken: auth.token, - edition: auth.edition, teamId: auth.teamId, wizardMetadata: { ...(config.wizardMetadata ?? {}), @@ -1049,11 +1022,11 @@ export async function runAgent( // blocking behavior so the SDK waits up to 5s for MCP connect before // turn 1. MCP_CONNECTION_NONBLOCKING: '0', - // PostHog gateway headers, shaped for the run's gateway edition. + // PostHog gateway headers: this run's properties blob. ANTHROPIC_CUSTOM_HEADERS: buildAgentEnv( agentConfig.wizardMetadata ?? {}, agentConfig.wizardFlags ?? {}, - agentConfig.gatewayAuth, + agentConfig.gatewayAuth.teamId, ), }, canUseTool: (toolName: string, input: unknown) => { diff --git a/src/lib/agent/mcp-prompt-streaming.ts b/src/lib/agent/mcp-prompt-streaming.ts index 986c59f6..1b9e5518 100644 --- a/src/lib/agent/mcp-prompt-streaming.ts +++ b/src/lib/agent/mcp-prompt-streaming.ts @@ -215,8 +215,8 @@ export async function* runMcpPromptViaSdk(args: { // have to import this module's dependencies. const wizardMetadata = buildTutorialRunTags(args); - // Route the SDK's LLM calls through the PostHog LLM gateway, authed - // with the user's OAuth access token. Set BEFORE loading the SDK in + // Route the SDK's LLM calls through the PostHog AI gateway, authed + // with the run's minted token. Set BEFORE loading the SDK in // case any in-process code reads env at module init (cached base // URLs, OAuth setup, etc.) — same reason `initializeAgent` does this // before its query() call. Without these the SDK tries to @@ -224,8 +224,8 @@ export async function* runMcpPromptViaSdk(args: { // authentication credentials". process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = 'true'; - // The url, the bearer and the header edition are one unit: a run must take - // all three from the same resolved posture. + // The url and the bearer are one unit: a run must take both from the same + // mint. const auth = await gatewayAuth( credentials.host, credentials.accessToken, @@ -237,9 +237,9 @@ export async function* runMcpPromptViaSdk(args: { process.env.CLAUDE_CODE_OAUTH_TOKEN = auth.token; logToFile( - `[runMcpPromptViaSdk] gatewayUrl=${gatewayUrl} edition=${ - auth.edition - } tokenPrefix=${auth.token ? auth.token.slice(0, 4) + '***' : '(missing)'}`, + `[runMcpPromptViaSdk] gatewayUrl=${gatewayUrl} tokenPrefix=${ + auth.token ? auth.token.slice(0, 4) + '***' : '(missing)' + }`, ); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -379,13 +379,13 @@ export async function* runMcpPromptViaSdk(args: { // default; without this the agent may try to call tools // before posthog-wizard is connected on turn 1. MCP_CONNECTION_NONBLOCKING: '0', - // Bedrock fallback plus this run's trace tags — the gateway reads - // these to attribute its `$ai_generation` events. Flags stay empty: - // the tutorial doesn't fork on any. + // This run's trace tags, which the gateway reads to attribute its + // `$ai_generation` events. Flags stay empty: the tutorial doesn't + // fork on any. ANTHROPIC_CUSTOM_HEADERS: buildAgentEnv( wizardMetadata ?? {}, {}, - auth, + auth.teamId, ), }, }, diff --git a/src/lib/agent/runner/harness/pi/README.md b/src/lib/agent/runner/harness/pi/README.md index cd0bc450..0142954e 100644 --- a/src/lib/agent/runner/harness/pi/README.md +++ b/src/lib/agent/runner/harness/pi/README.md @@ -23,13 +23,11 @@ Entry points: ## Core characteristics -- **Model transport:** the PostHog LLM gateway is registered as an +- **Model transport:** the PostHog AI gateway is registered as an `anthropic-messages` provider on pi's in-memory `ModelRegistry`, authed - bearer-style with the user's OAuth token. Same Bedrock fallback + - wizard-flag/metadata headers as the anthropic path. OpenAI-class models (e.g. - `GPT5_6_TERRA_MODEL`) route to `/v1/responses` via `openai-responses` on - the v2 gateway, and to `/v1/chat/completions` via `openai-completions` on legacy - shape automatically. + bearer-style with the run's minted scoped token. Same wizard properties + header as the anthropic path. OpenAI-class models (e.g. `GPT5_6_TERRA_MODEL`) + route to `/v1/responses` via `openai-responses` automatically. - **Context window:** 1M-context beta enabled (`anthropic-beta: context-1m-2025-08-07`) — otherwise runs at 200k and compaction fails on larger projects. diff --git a/src/lib/agent/runner/harness/pi/__tests__/gateway.test.ts b/src/lib/agent/runner/harness/pi/__tests__/gateway.test.ts index 7948dcee..c0fb457f 100644 --- a/src/lib/agent/runner/harness/pi/__tests__/gateway.test.ts +++ b/src/lib/agent/runner/harness/pi/__tests__/gateway.test.ts @@ -32,16 +32,48 @@ describe('buildGatewayProvider effort', () => { }); }); -describe('buildGatewayHeaders', () => { - it('carries one blob and no bedrock opt-in for v2', () => { - const headers = buildGatewayHeaders({ run_id: 'r1' }, {}, 'v2', 42); - expect(headers['X-PostHog-Properties']).toContain('run_id'); - expect(headers['x-posthog-use-bedrock-fallback']).toBeUndefined(); +describe('buildGatewayProvider transport', () => { + const base = { + gatewayUrl: 'https://ai-gateway.us.posthog.com', + accessToken: 'phe_x', + wizardMetadata: {}, + wizardFlags: {}, + }; + + it('routes openai models over the Responses API', () => { + // Chat completions rejects function tools combined with reasoning_effort, + // and every task sends both. + const { api, baseUrl } = buildGatewayProvider({ + ...base, + modelId: 'openai/gpt-5.6-terra', + }); + expect(api).toBe('openai-responses'); + expect(baseUrl).toBe('https://ai-gateway.us.posthog.com/v1'); + }); + + it('routes anthropic models over anthropic-messages without /v1', () => { + const { api, baseUrl } = buildGatewayProvider({ + ...base, + modelId: 'claude-sonnet-4-6', + }); + expect(api).toBe('anthropic-messages'); + expect(baseUrl).toBe('https://ai-gateway.us.posthog.com'); }); +}); - it('carries per-key headers for legacy', () => { - const headers = buildGatewayHeaders({ run_id: 'r1' }, {}, 'legacy'); - expect(headers['X-POSTHOG-PROPERTY-run_id']).toBe('r1'); - expect(headers['x-posthog-use-bedrock-fallback']).toBe('true'); +describe('buildGatewayHeaders', () => { + it('carries one properties blob and no per-key or bedrock headers', () => { + const headers = buildGatewayHeaders( + { run_id: 'r1' }, + { 'wizard-orchestrator': 'test' }, + 42, + ); + expect(JSON.parse(headers['X-PostHog-Properties'])).toEqual({ + team_id: 42, + run_id: 'r1', + 'wizard_flag_wizard-orchestrator': 'test', + }); + expect(headers['x-posthog-use-bedrock-fallback']).toBeUndefined(); + expect(headers['X-POSTHOG-PROPERTY-run_id']).toBeUndefined(); }); }); diff --git a/src/lib/agent/runner/harness/pi/gateway.ts b/src/lib/agent/runner/harness/pi/gateway.ts index 723ad828..d1c76ae0 100644 --- a/src/lib/agent/runner/harness/pi/gateway.ts +++ b/src/lib/agent/runner/harness/pi/gateway.ts @@ -1,19 +1,12 @@ /** - * PostHog LLM gateway provider spec for pi sessions — shared by the linear run + * PostHog AI gateway provider spec for pi sessions, shared by the linear run * and the orchestrator's per-task runs so both speak to the gateway - * identically: bearer auth, Bedrock-fallback + wizard metadata/flag headers, - * transport shape inferred from the model id. The caller registers the spec on - * its own (lazily imported, properly typed) pi ModelRegistry. + * identically: bearer auth, the wizard properties header, transport shape + * inferred from the model id. The caller registers the spec on its own + * (lazily imported, properly typed) pi ModelRegistry. */ -import { - POSTHOG_FLAG_HEADER_PREFIX, - POSTHOG_PROPERTY_HEADER_PREFIX, -} from '@lib/constants'; -import { - buildWizardPropertiesBlob, - type GatewayEdition, -} from '@lib/gateway-session'; +import { buildWizardPropertiesBlob } from '@lib/gateway-session'; import { modelCapabilities, type ThinkingLevel, @@ -35,63 +28,40 @@ export type GatewayApi = * `/v1/responses` or `/v1/chat/completions` (base URL keeps `/v1`). Infer the * shape from the model id so a pair's model selects the right transport. * - * v2 takes the Responses API because OpenAI rejects function tools combined - * with `reasoning_effort` on chat completions and every task sends both. Legacy - * stays on chat completions, where litellm does that routing itself. + * OpenAI models take the Responses API because OpenAI rejects function tools + * combined with `reasoning_effort` on chat completions and every task sends both. */ -export function gatewayApiFor( - modelId: string, - edition: GatewayEdition = 'legacy', -): GatewayApi { - if (!modelId.startsWith('openai/')) return 'anthropic-messages'; - return edition === 'v2' ? 'openai-responses' : 'openai-completions'; +export function gatewayApiFor(modelId: string): GatewayApi { + return modelId.startsWith('openai/') + ? 'openai-responses' + : 'anthropic-messages'; } /** - * Gateway HTTP headers, mirroring `buildAgentEnv` on the anthropic path. The - * shape follows the gateway edition: legacy sends per-key metadata/flag - * headers plus the explicit Bedrock-fallback opt-in; v2 (the Go ai-gateway) - * takes one `X-PostHog-Properties` JSON blob and falls back natively. The 1M - * context beta rides both, since pi otherwise runs at 200k and overflows on larger - * projects (the post-run compaction failures). + * Gateway HTTP headers, mirroring `buildAgentEnv` on the anthropic path: one + * `X-PostHog-Properties` JSON blob (Bedrock fallback is native, so no opt-in) + * plus the 1M context beta, since pi otherwise runs at 200k and overflows on + * larger projects (the post-run compaction failures). */ export function buildGatewayHeaders( wizardMetadata: Record, wizardFlags: Record, - edition: GatewayEdition = 'legacy', teamId?: number, ): Record { - const headers: Record = { + return { 'anthropic-beta': 'context-1m-2025-08-07', - }; - if (edition === 'v2') { - headers['X-PostHog-Properties'] = buildWizardPropertiesBlob( + 'X-PostHog-Properties': buildWizardPropertiesBlob( wizardMetadata, wizardFlags, teamId, - ); - return headers; - } - headers['x-posthog-use-bedrock-fallback'] = 'true'; - for (const [key, value] of Object.entries(wizardMetadata)) { - const name = key.startsWith(POSTHOG_PROPERTY_HEADER_PREFIX) - ? key - : `${POSTHOG_PROPERTY_HEADER_PREFIX}${key}`; - headers[name] = value; - } - for (const [flagKey, variant] of Object.entries(wizardFlags)) { - if (!flagKey.toLowerCase().startsWith('wizard')) continue; - headers[POSTHOG_FLAG_HEADER_PREFIX + flagKey.toUpperCase()] = variant; - } - return headers; + ), + }; } export interface GatewayProviderInputs { gatewayUrl: string; accessToken: string; - /** Gateway contract in play; selects the header shape. Default legacy. */ - edition?: GatewayEdition; - /** Customer team for the v2 properties blob (from the mint response). */ + /** Customer team for the properties blob (from the mint response). */ teamId?: number; wizardMetadata: Record; wizardFlags: Record; @@ -108,9 +78,8 @@ export interface GatewayProviderInputs { * callers (scan triage) hand it straight to `completeSimple`. */ export function buildGatewayModel(inputs: GatewayProviderInputs) { - const { gatewayUrl, wizardMetadata, wizardFlags, modelId, edition, teamId } = - inputs; - const api = gatewayApiFor(modelId, edition); + const { gatewayUrl, wizardMetadata, wizardFlags, modelId, teamId } = inputs; + const api = gatewayApiFor(modelId); return { id: modelId, name: `${modelId} (PostHog Gateway)`, @@ -126,7 +95,7 @@ export function buildGatewayModel(inputs: GatewayProviderInputs) { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1_000_000, maxTokens: 64_000, - headers: buildGatewayHeaders(wizardMetadata, wizardFlags, edition, teamId), + headers: buildGatewayHeaders(wizardMetadata, wizardFlags, teamId), }; } @@ -141,8 +110,8 @@ export function buildGatewayProvider(inputs: GatewayProviderInputs): { gatewayUrl: string; baseUrl: string; } { - const { gatewayUrl, accessToken, modelId, effort, edition } = inputs; - const api = gatewayApiFor(modelId, edition); + const { gatewayUrl, accessToken, modelId, effort } = inputs; + const api = gatewayApiFor(modelId); // One resolution point for the model's traits and the run's effort override. // pi clamps whatever comes out of here against the levels this spec declares, // so a level the spec doesn't carry is silently reduced by the session. diff --git a/src/lib/agent/runner/harness/pi/index.ts b/src/lib/agent/runner/harness/pi/index.ts index 772e39b3..1ee59d1a 100644 --- a/src/lib/agent/runner/harness/pi/index.ts +++ b/src/lib/agent/runner/harness/pi/index.ts @@ -246,9 +246,8 @@ export const piBackend: AgentHarness = { } = await import('@earendil-works/pi-coding-agent'); // the claude-agent-sdk path. The provider spec is shared with the - // orchestrator's per-task sessions (gateway.ts). gatewayAuth resolves - // the v2 scoped-token posture, or the legacy OAuth posture when the - // backend doesn't mint. + // orchestrator's per-task sessions (gateway.ts). gatewayAuth mints the + // run's scoped token. const auth = await gatewayAuth( boot.credentials.host, boot.credentials.accessToken, @@ -257,7 +256,6 @@ export const piBackend: AgentHarness = { const { provider, caps } = buildGatewayProvider({ gatewayUrl: auth.gatewayUrl, accessToken: auth.token, - edition: auth.edition, teamId: auth.teamId, wizardMetadata: boot.wizardMetadata, wizardFlags: boot.wizardFlags, diff --git a/src/lib/agent/runner/harness/pi/task.ts b/src/lib/agent/runner/harness/pi/task.ts index 826c9fbf..091fccff 100644 --- a/src/lib/agent/runner/harness/pi/task.ts +++ b/src/lib/agent/runner/harness/pi/task.ts @@ -223,7 +223,6 @@ export async function runPiTask(inputs: TaskRunInputs): Promise { const { provider, caps } = buildGatewayProvider({ gatewayUrl: auth.gatewayUrl, accessToken: auth.token, - edition: auth.edition, teamId: auth.teamId, wizardMetadata: boot.wizardMetadata, wizardFlags: boot.wizardFlags, diff --git a/src/lib/agent/runner/shared/bootstrap.ts b/src/lib/agent/runner/shared/bootstrap.ts index a9e751e9..152e4895 100644 --- a/src/lib/agent/runner/shared/bootstrap.ts +++ b/src/lib/agent/runner/shared/bootstrap.ts @@ -307,8 +307,7 @@ export async function bootstrapProgram( // set them — so downstream readers get a non-null type without asserting. const credentials = session.credentials!; - // Resolve the gateway posture once for the boot: v2 scoped token when the - // backend mints, legacy OAuth otherwise. + // Mint the run's scoped gateway token once for the boot. const auth = await gatewayAuth( credentials.host, credentials.accessToken, @@ -331,7 +330,6 @@ export async function bootstrapProgram( { baseURL: auth.gatewayUrl, authToken: auth.token, - edition: auth.edition, teamId: auth.teamId, // `call_type` splits scan spend out of the program's agent cost — // same tag the in-run triage provider carries. diff --git a/src/lib/agent/triage-provider.ts b/src/lib/agent/triage-provider.ts index 72a3fe4e..293c3826 100644 --- a/src/lib/agent/triage-provider.ts +++ b/src/lib/agent/triage-provider.ts @@ -7,7 +7,6 @@ import { Harness } from '@lib/constants'; import { logToFile } from '@utils/debug'; -import type { GatewayEdition } from '@lib/gateway-session'; import { buildGatewayModel } from '@lib/agent/runner/harness/pi/gateway'; import { modelCapabilities, @@ -23,11 +22,9 @@ const TRIAGE_TIMEOUT_MS = 20_000; export interface TriageGatewayAuth { /** Gateway base url, from the run's resolved gateway auth. */ baseURL: string; - /** The run's gateway bearer (minted phe_ on v2, OAuth token on legacy). */ + /** The run's minted gateway bearer. */ authToken: string; - /** Gateway contract in play; selects the header shape. Default legacy. */ - edition?: GatewayEdition; - /** Customer team for the v2 properties blob. */ + /** Customer team for the properties blob. */ teamId?: number; /** The run's trace tags, with `call_type` overridden to * `CallType.yaraTriage` so scan spend is separable from agent work. */ @@ -50,7 +47,6 @@ export function createTriageLLMProvider( const model = buildGatewayModel({ gatewayUrl: baseURL, accessToken: authToken, - edition: auth.edition, teamId: auth.teamId, wizardMetadata: auth?.wizardMetadata ?? {}, wizardFlags: auth?.wizardFlags ?? {}, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 6fb97cf5..13494b96 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -308,13 +308,6 @@ export function wizardUserAgentForProgram(programId?: string): string { : WIZARD_USER_AGENT; } -// ── HTTP headers ───────────────────────────────────────────────────── - -/** Header prefix for PostHog properties (e.g. X-POSTHOG-PROPERTY-VARIANT). */ -export const POSTHOG_PROPERTY_HEADER_PREFIX = 'X-POSTHOG-PROPERTY-'; -/** Header prefix for PostHog feature flags. */ -export const POSTHOG_FLAG_HEADER_PREFIX = 'X-POSTHOG-FLAG-'; - // ── Timeouts ───────────────────────────────────────────────────────── /** Timeout for framework / project detection probes (ms). */ diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index 303224cc..d6f00abb 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -2,24 +2,18 @@ * Gateway auth for a wizard run: a `phe_` scoped token the backend mints, with * pinned attribution, a spend cap and an expiry. * - * A 404 resolves to the legacy posture (OAuth token, Python gateway); every - * other failure throws, because the legacy path enforces none of those, so a - * silent downgrade spends unattributed money to hide an outage. + * Every mint failure throws. There is no other gateway to fall back to, and a + * silent downgrade would spend uncapped, unattributed money to hide an outage. */ import { logToFile } from '@utils/debug'; -import { analytics } from '@utils/analytics'; import type { HostResolution } from '@lib/host-resolution'; -export type GatewayEdition = 'legacy' | 'v2'; - export interface GatewayAuth { /** Base URL for model calls (no `/v1`; transports append their route). */ gatewayUrl: string; - /** Bearer for the gateway: a minted `phe_` (v2) or the OAuth token (legacy). */ + /** Bearer for the gateway: the minted `phe_`. */ token: string; - /** Selects the header shape: one properties blob (v2) or per-key headers. */ - edition: GatewayEdition; /** The team the mint verified; rides the blob so dashboards keep a breakdown. */ teamId?: number; } @@ -45,8 +39,6 @@ let inFlight: { key: string; promise: Promise } | null = null; const MIN_USABLE_TTL_MS = 2 * 60 * 1000; /** Re-resolve at this fraction of the token's life, leaving a usable remainder. */ const REFRESH_AT_FRACTION = 0.8; -/** How long a legacy fallback sticks before the mint endpoint is retried. */ -const LEGACY_RETRY_MS = 10 * 60 * 1000; // Exceeds the backend's own 10s gateway timeout: a slow mint that lands after the // CLI hangs up spends a daily mint and orphans a live token. const MINT_TIMEOUT_MS = 20_000; @@ -90,13 +82,6 @@ async function resolveGatewayAuth( ); } const minted = await mintGatewayToken(host, accessToken, program); - if (!minted) { - // The mint is not enabled here, or does not recognise this credential. - analytics.setTag('gateway_edition', 'legacy'); - const auth = legacyAuth(host, accessToken); - cached = { key, auth, staleAtMs: Date.now() + LEGACY_RETRY_MS }; - return auth; - } const expiresAtMs = Date.parse(minted.expiresAt); const ttlMs = expiresAtMs - Date.now(); if (!Number.isFinite(expiresAtMs) || ttlMs < MIN_USABLE_TTL_MS) { @@ -110,7 +95,6 @@ async function resolveGatewayAuth( ); } const staleAtMs = Date.now() + ttlMs * REFRESH_AT_FRACTION; - analytics.setTag('gateway_edition', 'v2'); // Only failures and fallbacks are logged otherwise, so a successful run leaves no // local trace. Never log the token itself. logToFile( @@ -121,18 +105,12 @@ async function resolveGatewayAuth( const auth: GatewayAuth = { gatewayUrl: minted.gatewayUrl, token: minted.token, - edition: 'v2', teamId: minted.teamId, }; cached = { key, auth, staleAtMs }; return auth; } -/** The legacy posture: the user's OAuth token against the Python gateway. */ -function legacyAuth(host: HostResolution, accessToken: string): GatewayAuth { - return { gatewayUrl: host.gatewayUrl, token: accessToken, edition: 'legacy' }; -} - /** Test hook: drop the cached auth so the next call re-resolves. */ export function resetGatewaySession(): void { cached = null; @@ -186,8 +164,8 @@ interface MintedToken { /** * A deliberate refusal from the mint endpoint, as opposed to the mint being - * unavailable. Thrown rather than folded into the legacy fallback, so the run - * stops instead of proceeding without the controls the refusal was enforcing. + * unavailable. Thrown so the run stops instead of proceeding without the + * controls the refusal was enforcing. */ export class GatewayMintRefused extends Error { readonly status: number; @@ -212,12 +190,18 @@ export class GatewayMintFailed extends Error { /** * Whether a mint status means "refused this run" rather than "not available". - * These are the statuses the endpoint returns after it has authenticated the - * caller: 429 the daily run limit, 403 revoked project access, 400 a login - * covering more than one project. 404 and 401 fall back instead. + * 429 the daily run limit, 403 revoked project access, 400 a login covering + * more than one project, 401 a credential the mint does not accept, 404 an + * instance without the mint endpoint. */ function isMintRefusal(status: number): boolean { - return status === 400 || status === 403 || status === 429; + return ( + status === 400 || + status === 401 || + status === 403 || + status === 404 || + status === 429 + ); } /** @@ -245,9 +229,12 @@ function mintRefusalMessage(status: number, detail?: string): string { case 403: return 'Your access to this project has changed. Re-authenticate and try again.'; case 400: - // The only 400 the mint answers is the exactly-one-project check; an - // unrecognised program is a 404 and falls back instead. + // The only 400 the mint answers is the exactly-one-project check. return 'Your PostHog login must cover exactly one project. Re-authenticate and try again.'; + case 401: + return 'PostHog did not accept this login. Re-authenticate with `npx @posthog/wizard@latest`.'; + case 404: + return 'This PostHog instance does not issue gateway tokens. Upgrade with `npx @posthog/wizard@latest` and try again.'; default: return 'The PostHog gateway refused this run.'; } @@ -257,7 +244,7 @@ async function mintGatewayToken( host: HostResolution, accessToken: string, program: string, -): Promise { +): Promise { try { const resp = await fetch(`${host.apiHost}/api/wizard/gateway_token/`, { method: 'POST', @@ -278,15 +265,6 @@ async function mintGatewayToken( mintRefusalMessage(resp.status, await readRefusalDetail(resp)), ); } - if (resp.status === 404 || resp.status === 401) { - // 404 is the rollout switch. 401 is a credential the mint does not - // recognise, an API key rather than an OAuth login; the legacy gateway - // authenticates it separately, so falling back grants nothing. - logToFile( - `[gateway] mint unavailable for this credential (HTTP ${resp.status}); staying on the existing gateway`, - ); - return null; - } logToFile( `[gateway] mint failed with HTTP ${resp.status}; failing the run`, ); @@ -328,7 +306,7 @@ async function mintGatewayToken( }; } catch (e) { // Decisions and failures both pass through: this catch exists for transport - // errors, and folding the others into it would restore the downgrade. + // errors, and folding the others into it would lose the reason. if (e instanceof GatewayMintRefused || e instanceof GatewayMintFailed) throw e; logToFile( diff --git a/src/lib/host-resolution.ts b/src/lib/host-resolution.ts index 72ee1eb3..f55f1b4b 100644 --- a/src/lib/host-resolution.ts +++ b/src/lib/host-resolution.ts @@ -19,7 +19,6 @@ import { getHost, getCloudUrl, - getLlmGatewayUrl, getUiHostFromHost, detectRegion, resolveBaseUrl, @@ -88,8 +87,6 @@ export class HostResolution { readonly appHost: string; /** CDN asset host (e.g. `https://us-assets.i.posthog.com`). */ readonly assetHost: string; - /** PostHog LLM gateway URL the agent SDK authenticates its model calls against. */ - readonly gatewayUrl: string; /** * PostHog MCP server URL the agent connects to. Region-independent — the * server resolves the user's region from the bearer token — so this is driven @@ -103,14 +100,12 @@ export class HostResolution { apiHost: string; appHost: string; assetHost: string; - gatewayUrl: string; mcpUrl: string; }) { this.region = fields.region; this.apiHost = fields.apiHost; this.appHost = fields.appHost; this.assetHost = fields.assetHost; - this.gatewayUrl = fields.gatewayUrl; this.mcpUrl = fields.mcpUrl; Object.freeze(this); } @@ -130,7 +125,6 @@ export class HostResolution { apiHost, appHost: getCloudUrl(region, opts.baseUrl), assetHost: assetHostFor(region, opts.baseUrl), - gatewayUrl: getLlmGatewayUrl(apiHost), mcpUrl: mcpUrlFor(opts.localMcp ?? false), }); } @@ -150,7 +144,6 @@ export class HostResolution { apiHost, appHost: getUiHostFromHost(apiHost), assetHost: assetHostFromApiHost(apiHost), - gatewayUrl: getLlmGatewayUrl(apiHost), mcpUrl: mcpUrlFor(opts.localMcp ?? false), }); } diff --git a/src/utils/__tests__/ci-region.test.ts b/src/utils/__tests__/ci-region.test.ts index 45664fff..2f7cf9ac 100644 --- a/src/utils/__tests__/ci-region.test.ts +++ b/src/utils/__tests__/ci-region.test.ts @@ -12,7 +12,6 @@ vi.mock('@utils/urls', () => ({ detectRegion: vi.fn(), getHost: (r: string) => `https://${r}.posthog.com`, getCloudUrl: (r: string) => `https://${r}.posthog.com`, - getLlmGatewayUrl: (host: string) => `${host}/llm-gateway`, getUiHostFromHost: (host: string) => host, resolveBaseUrl: (baseUrl?: string) => baseUrl, })); diff --git a/src/utils/custom-headers.ts b/src/utils/custom-headers.ts index 1eeee09e..abee896c 100644 --- a/src/utils/custom-headers.ts +++ b/src/utils/custom-headers.ts @@ -1,12 +1,8 @@ -import { POSTHOG_FLAG_HEADER_PREFIX } from '@lib/constants'; - /** * Builds a list of custom headers for ANTHROPIC_CUSTOM_HEADERS. */ export function createCustomHeaders(): { add(key: string, value: string): void; - /** Add a feature flag for PostHog ($feature/: variant). */ - addFlag(flagKey: string, variant: string): void; encode(): string; } { const entries: Array<{ key: string; value: string }> = []; @@ -18,11 +14,6 @@ export function createCustomHeaders(): { entries.push({ key: name, value }); }, - addFlag(flagKey: string, variant: string): void { - const headerName = POSTHOG_FLAG_HEADER_PREFIX + flagKey.toUpperCase(); - entries.push({ key: headerName, value: variant }); - }, - encode(): string { return entries.map(({ key, value }) => `${key}: ${value}`).join('\n'); }, diff --git a/src/utils/urls.ts b/src/utils/urls.ts index 7620850d..3a0b02d9 100644 --- a/src/utils/urls.ts +++ b/src/utils/urls.ts @@ -89,22 +89,6 @@ export async function detectRegion( ); } -export const getLlmGatewayUrl = (host: string) => { - if (host.includes('host.docker.internal')) { - return 'http://host.docker.internal:3308/wizard'; - } - - if (host.includes('localhost')) { - return 'http://localhost:3308/wizard'; - } - - if (host.includes('eu.posthog.com') || host.includes('eu.i.posthog.com')) { - return 'https://gateway.eu.posthog.com/wizard'; - } - - return 'https://gateway.us.posthog.com/wizard'; -}; - /** Region-agnostic prod OAuth server. Resolves to the right region server-side. */ const PROD_OAUTH_URL = 'https://oauth.posthog.com'; From f3b8562259083a9f088b8488af2a3b9b11836853 Mon Sep 17 00:00:00 2001 From: Brandon Leung Date: Tue, 8 Sep 2026 15:23:27 -0400 Subject: [PATCH 03/10] fix(gateway): declare that this build reads a refusal, and clean the detail Dropping the legacy fallback means a 404 is no longer useful here, but the server still owes one to builds that do fall back. The mint now says which this is, so the server can answer a refusal with its reason without breaking every older client. The detail it sends is printed to a terminal, so control characters and the escapes an ANSI sequence is built from are stripped first. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/__tests__/gateway-session.test.ts | 19 +++++++++++++++++-- src/lib/gateway-session.ts | 11 +++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index cc0f59a7..f5d2015d 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -215,6 +215,18 @@ describe('gatewayAuth', () => { ); }); + it('strips control characters before the detail reaches the terminal', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + json: () => + Promise.resolve({ detail: 'Upgrade\u001b[2J\u0007 the wizard.' }), + }); + await expect(gatewayAuth(host, 'pha_oauth', 'integration')).rejects.toThrow( + 'Upgrade [2J the wizard.', + ); + }); + it.each([ ['not an object', () => Promise.resolve('nope')], ['an empty detail', () => Promise.resolve({ detail: ' ' })], @@ -286,10 +298,13 @@ describe('gatewayAuth', () => { await gatewayAuth(host, 'pha_oauth', 'audit'); // The backend pins `wizard:` from this field; without it the mint - // has nothing to attribute the run to and refuses. + // has nothing to attribute the run to and refuses. The flag is what tells + // it this build reads a refusal rather than falling back on a 404. expect(fetchMock).toHaveBeenCalledWith( expect.any(String), - expect.objectContaining({ body: JSON.stringify({ program: 'audit' }) }), + expect.objectContaining({ + body: JSON.stringify({ program: 'audit', reads_refusal_reason: true }), + }), ); }); diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index d6f00abb..c6d74763 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -212,7 +212,11 @@ function isMintRefusal(status: number): boolean { async function readRefusalDetail(resp: Response): Promise { try { const body = (await resp.json()) as { detail?: unknown }; - const detail = typeof body?.detail === 'string' ? body.detail.trim() : ''; + const raw = typeof body?.detail === 'string' ? body.detail : ''; + // Server text printed straight to a terminal: strip C0/C1 and the escapes + // an ANSI sequence is built from before anything renders it. + // eslint-disable-next-line no-control-regex + const detail = raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim(); return detail.length > 0 && detail.length <= MAX_REFUSAL_DETAIL_LENGTH ? detail : undefined; @@ -252,7 +256,10 @@ async function mintGatewayToken( Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, - body: JSON.stringify({ program }), + // This build no longer falls back to the legacy gateway, so the server + // may answer a refusal with its reason rather than the 404 that used to + // mean "fall back". An older build omits the flag and still gets the 404. + body: JSON.stringify({ program, reads_refusal_reason: true }), signal: AbortSignal.timeout(MINT_TIMEOUT_MS), }); if (!resp.ok) { From 9422855094a5853df1c9ec7002084c0a391e205c Mon Sep 17 00:00:00 2001 From: Brandon Leung Date: Tue, 8 Sep 2026 16:18:44 -0400 Subject: [PATCH 04/10] fix(gateway): keep the actionable copy on the two stock-detail statuses 401 and 404 come from layers with no wizard-specific message, so their detail is DRF's "Invalid token." or "Not found.", which replaced the copy naming the upgrade command. A detail that cleans to nothing also falls back now, and a control-only detail pins both the C1 arm and the trim running after the substitution. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/__tests__/gateway-session.test.ts | 27 +++++++++++++++++++++++ src/lib/gateway-session.ts | 7 +++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index f5d2015d..af577e1e 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -215,6 +215,33 @@ describe('gatewayAuth', () => { ); }); + it.each([401, 404])( + 'keeps its own copy on HTTP %i, where the detail is DRF boilerplate', + async (status) => { + fetchMock.mockResolvedValue({ + ok: false, + status, + json: () => Promise.resolve({ detail: 'Not found.' }), + }); + await expect( + gatewayAuth(host, 'pha_oauth', 'integration'), + ).rejects.toThrow(/npx @posthog\/wizard@latest/); + }, + ); + + it('keeps the fixed message when the detail is only control characters', async () => { + // Pins both the C1 arm and the trim running after the substitution: either + // one reverted leaves a run of spaces as the user-facing message. + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + json: () => Promise.resolve({ detail: '\u0007\u009b\u001b' }), + }); + await expect(gatewayAuth(host, 'pha_oauth', 'integration')).rejects.toThrow( + /access to this project/i, + ); + }); + it('strips control characters before the detail reaches the terminal', async () => { fetchMock.mockResolvedValue({ ok: false, diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index c6d74763..9c3797ab 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -225,8 +225,13 @@ async function readRefusalDetail(resp: Response): Promise { } } +// 401 and 404 come from layers with no wizard-specific message, so their +// detail is DRF's stock "Invalid token." or "Not found.", which says less than +// the copy below. Every other refusal the mint itself writes. +const STOCK_DETAIL_STATUSES = new Set([401, 404]); + function mintRefusalMessage(status: number, detail?: string): string { - if (detail) return detail; + if (detail && !STOCK_DETAIL_STATUSES.has(status)) return detail; switch (status) { case 429: return 'This wizard program has used its daily run limit. Try again tomorrow.'; From 4b2a9b368fa28864bd3f6aabb499cddcf78ccc3b Mon Sep 17 00:00:00 2001 From: Brandon Leung Date: Tue, 8 Sep 2026 16:41:45 -0400 Subject: [PATCH 05/10] fix(gateway): show the server's refusal reason on every status Suppressing it on 401 and 404 assumed those carry DRF boilerplate, but the mint answers 404 with the reason a client that does not declare itself gets: the rollout is off, or the program is unrecognised. Dropping that text left the user with generic copy on the refusals that explain themselves best. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/__tests__/gateway-session.test.ts | 14 -------------- src/lib/gateway-session.ts | 7 +------ 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index af577e1e..123b9afa 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -215,20 +215,6 @@ describe('gatewayAuth', () => { ); }); - it.each([401, 404])( - 'keeps its own copy on HTTP %i, where the detail is DRF boilerplate', - async (status) => { - fetchMock.mockResolvedValue({ - ok: false, - status, - json: () => Promise.resolve({ detail: 'Not found.' }), - }); - await expect( - gatewayAuth(host, 'pha_oauth', 'integration'), - ).rejects.toThrow(/npx @posthog\/wizard@latest/); - }, - ); - it('keeps the fixed message when the detail is only control characters', async () => { // Pins both the C1 arm and the trim running after the substitution: either // one reverted leaves a run of spaces as the user-facing message. diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index 9c3797ab..c6d74763 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -225,13 +225,8 @@ async function readRefusalDetail(resp: Response): Promise { } } -// 401 and 404 come from layers with no wizard-specific message, so their -// detail is DRF's stock "Invalid token." or "Not found.", which says less than -// the copy below. Every other refusal the mint itself writes. -const STOCK_DETAIL_STATUSES = new Set([401, 404]); - function mintRefusalMessage(status: number, detail?: string): string { - if (detail && !STOCK_DETAIL_STATUSES.has(status)) return detail; + if (detail) return detail; switch (status) { case 429: return 'This wizard program has used its daily run limit. Try again tomorrow.'; From 283d89836df14e4093694b223d96be45ddae20b4 Mon Sep 17 00:00:00 2001 From: Brandon Leung Date: Tue, 8 Sep 2026 16:48:36 -0400 Subject: [PATCH 06/10] docs(gateway): describe the capability flag, not the posture it replaced Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/__tests__/gateway-session.test.ts | 4 ++-- src/lib/gateway-session.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index 123b9afa..f5064331 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -263,8 +263,8 @@ describe('gatewayAuth', () => { async (status, message) => { fetchMock.mockResolvedValue({ ok: false, status }); // Neither status has a fallback: 401 is a credential the mint does not - // accept, 404 an instance without the mint endpoint. Both used to put the - // run on the legacy gateway, which enforced none of the mint's limits. + // accept, 404 an instance without the mint endpoint. A run that proceeded + // past either would be on a path enforcing none of the mint's limits. const err: unknown = await gatewayAuth( host, 'pha_oauth', diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index c6d74763..e7c6bffe 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -256,9 +256,9 @@ async function mintGatewayToken( Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, - // This build no longer falls back to the legacy gateway, so the server - // may answer a refusal with its reason rather than the 404 that used to - // mean "fall back". An older build omits the flag and still gets the 404. + // The flag tells the server this build reads a refusal, so it may answer + // with the reason. A build that omits it gets a 404, which is its signal + // to fall back to the legacy gateway. body: JSON.stringify({ program, reads_refusal_reason: true }), signal: AbortSignal.timeout(MINT_TIMEOUT_MS), }); From c1572fc179bf1239a9d3d3c1e09a13bbd1a7134c Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Tue, 8 Sep 2026 15:01:13 -0700 Subject: [PATCH 07/10] chore: remove old gw readiness check --- README.md | 8 +- .../__tests__/health-checks.test.ts | 130 +++++++----------- src/lib/health-checks/endpoints.ts | 10 +- src/lib/health-checks/index.ts | 6 +- src/lib/health-checks/readiness.ts | 36 ++--- src/lib/health-checks/testme.md | 8 -- src/lib/health-checks/types.ts | 1 - .../tui/playground/demos/HealthCheckDemo.tsx | 5 - src/ui/tui/store.ts | 2 +- 9 files changed, 71 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index 32de4c18..19d24918 100644 --- a/README.md +++ b/README.md @@ -614,7 +614,7 @@ point is `evaluateWizardReadiness()`, which returns one of three values: | --- | --- | | `types.ts` | Enums, interfaces (`ServiceHealthStatus`, `AllServicesHealth`, etc.) | | `statuspage.ts` | Statuspage.io v2 API helpers + checks for Anthropic, PostHog, GitHub, npm, Cloudflare | -| `endpoints.ts` | Direct endpoint checks for LLM Gateway (`/_liveness`), MCP (`/`), and the skills origins (`skill-menu.json` on GitHub Releases + the AWS mirror) | +| `endpoints.ts` | Direct endpoint checks for MCP (`/`) and the skills origins (`skill-menu.json` on GitHub Releases + the AWS mirror) | | `readiness.ts` | `checkAllExternalServices`, `evaluateWizardReadiness`, readiness config | | `index.ts` | Barrel re-export | | `testme.md` | Test running instructions and endpoint reference | @@ -632,10 +632,14 @@ two arrays: ### Current defaults ```ts -downBlocksRun: ['anthropic', 'npmOverall', 'llmGateway', 'mcp', 'skillsOrigin'], +downBlocksRun: ['anthropic', 'npmOverall', 'mcp', 'skillsOrigin'], degradedBlocksRun: ['anthropic'], ``` +The AI gateway is deliberately absent: its URL is only known from the run's +token mint at bootstrap, so there is nothing static to probe — and a failed +mint already stops the run with the server's reason. + `skillsOrigin` is one entry covering two origins: skills are published to GitHub Releases and an AWS mirror under the same filenames, and downloads fail over between them (`src/lib/fetch-retry.ts`). Both are probed in parallel, so diff --git a/src/lib/health-checks/__tests__/health-checks.test.ts b/src/lib/health-checks/__tests__/health-checks.test.ts index 66ebca37..cbafb207 100644 --- a/src/lib/health-checks/__tests__/health-checks.test.ts +++ b/src/lib/health-checks/__tests__/health-checks.test.ts @@ -8,9 +8,7 @@ * summary.json – same rollup plus component list; component statuses: * operational | degraded_performance | partial_outage | major_outage | under_maintenance * https://support.atlassian.com/statuspage/docs/show-service-status-with-components - * - * LLM Gateway – FastAPI service, GET /_liveness returns {"status":"alive"} (200) - * Source: posthog/services/llm-gateway/src/llm_gateway/api/health.py + * * MCP – Cloudflare Worker, GET / returns an HTML landing page (200) * Source: posthog/services/mcp/src/index.ts @@ -23,7 +21,6 @@ import { checkCloudflareOverallHealth, checkGithubHealth, checkSkillsOriginHealth, - checkLlmGatewayHealth, checkMcpHealth, checkNpmComponentHealth, checkNpmOverallHealth, @@ -35,6 +32,7 @@ import { ServiceHealthStatus, WizardReadiness, } from '@lib/health-checks/index'; +import { fetchEndpointHealth } from '@lib/health-checks/endpoints'; // --------------------------------------------------------------------------- // Real-world Statuspage.io v2 response factories @@ -191,9 +189,6 @@ const POSTHOG_INCIDENTIO_HEALTHY = { scheduled_maintenances: [], }; -// LLM Gateway /_liveness response (from posthog/services/llm-gateway/src/llm_gateway/api/health.py) -const LLM_GATEWAY_LIVENESS_BODY = JSON.stringify({ status: 'alive' }); - // MCP / landing page (from posthog/services/mcp/src/index.ts + src/static/landing.html) const MCP_LANDING_HTML = 'PostHog MCP Server'; @@ -210,7 +205,6 @@ const URLS = { npmSummary: 'https://status.npmjs.org/api/v2/summary.json', cloudflareStatus: 'https://www.cloudflarestatus.com/api/v2/status.json', cloudflareSummary: 'https://www.cloudflarestatus.com/api/v2/summary.json', - llmGatewayLiveness: 'https://gateway.us.posthog.com/_liveness', mcpLanding: 'https://mcp.posthog.com/', githubSkillMenu: 'https://github.com/PostHog/context-mill/releases/latest/download/skill-menu.json', @@ -251,10 +245,6 @@ const HEALTHY_RESPONSES: Record = body: JSON.stringify(CLOUDFLARE_SUMMARY_HEALTHY), contentType: 'application/json', }, - [URLS.llmGatewayLiveness]: { - body: LLM_GATEWAY_LIVENESS_BODY, - contentType: 'application/json', - }, [URLS.mcpLanding]: { body: MCP_LANDING_HTML, contentType: 'text/html; charset=utf-8', @@ -722,54 +712,64 @@ describe('health-checks', () => { }); // ----------------------------------------------------------------------- - // LLM Gateway (fetchEndpointHealth – /_liveness) + // fetchEndpointHealth (retry + status-taxonomy machinery, probed directly + // against a synthetic URL — no production probe uses the strict defaults + // any more, but every endpoint check shares this loop) // ----------------------------------------------------------------------- - describe('checkLlmGatewayHealth', () => { - it('returns healthy when gateway responds 200 with {"status":"alive"}', async () => { - const result = await checkLlmGatewayHealth(); + describe('fetchEndpointHealth', () => { + const PROBE_URL = 'https://probe.posthog.test/_liveness'; + + it('returns healthy on a 200', async () => { + (global.fetch as Mock).mockImplementation( + overrideFetch({ + [PROBE_URL]: () => + Promise.resolve(new Response('ok', { status: 200 })), + }), + ); + const result = await fetchEndpointHealth(PROBE_URL); expect(result.status).toBe(ServiceHealthStatus.Healthy); expect(result.rawIndicator).toBe('HTTP 200'); expect(global.fetch).toHaveBeenCalledWith( - URLS.llmGatewayLiveness, + PROBE_URL, expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); - it('returns down on 302 — the gateway probe stays strict, redirects are not OK here', async () => { + it('returns down on 302 — the default predicate stays strict, redirects are not OK', async () => { (global.fetch as Mock).mockImplementation( overrideFetch({ - [URLS.llmGatewayLiveness]: () => + [PROBE_URL]: () => Promise.resolve(new Response(null, { status: 302 })), }), ); - const result = await checkLlmGatewayHealth(); + const result = await fetchEndpointHealth(PROBE_URL); expect(result.status).toBe(ServiceHealthStatus.Down); expect(result.error).toContain('HTTP 302'); }); - it('returns down when gateway responds 503 (e.g. deploying)', async () => { + it('returns down when the endpoint responds 503 (e.g. deploying)', async () => { (global.fetch as Mock).mockImplementation( overrideFetch({ - [URLS.llmGatewayLiveness]: () => + [PROBE_URL]: () => Promise.resolve( new Response('Service Unavailable', { status: 503 }), ), }), ); - const result = await checkLlmGatewayHealth(); + const result = await fetchEndpointHealth(PROBE_URL); expect(result.status).toBe(ServiceHealthStatus.Down); expect(result.error).toContain('HTTP 503'); }); - it('returns down when gateway responds 502 (bad gateway)', async () => { + it('returns down when the endpoint responds 502 (bad gateway)', async () => { (global.fetch as Mock).mockImplementation( overrideFetch({ - [URLS.llmGatewayLiveness]: () => + [PROBE_URL]: () => Promise.resolve(new Response('Bad Gateway', { status: 502 })), }), ); - const result = await checkLlmGatewayHealth(); + const result = await fetchEndpointHealth(PROBE_URL); expect(result.status).toBe(ServiceHealthStatus.Down); expect(result.error).toContain('HTTP 502'); }); @@ -777,15 +777,15 @@ describe('health-checks', () => { it('returns no-connection on DNS resolution failure (no status-page corroboration)', async () => { (global.fetch as Mock).mockImplementation( overrideFetch({ - [URLS.llmGatewayLiveness]: () => + [PROBE_URL]: () => Promise.reject( - new Error('getaddrinfo ENOTFOUND gateway.us.posthog.com'), + new Error('getaddrinfo ENOTFOUND probe.posthog.test'), ), }), ); - const result = await checkLlmGatewayHealth(); + const result = await fetchEndpointHealth(PROBE_URL); expect(result.status).toBe(ServiceHealthStatus.NoConnection); - expect(result.error).toBe('getaddrinfo ENOTFOUND gateway.us.posthog.com'); + expect(result.error).toBe('getaddrinfo ENOTFOUND probe.posthog.test'); }); it('returns no-connection on timeout (AbortError)', async () => { @@ -793,10 +793,10 @@ describe('health-checks', () => { abortError.name = 'AbortError'; (global.fetch as Mock).mockImplementation( overrideFetch({ - [URLS.llmGatewayLiveness]: () => Promise.reject(abortError), + [PROBE_URL]: () => Promise.reject(abortError), }), ); - const result = await checkLlmGatewayHealth(); + const result = await fetchEndpointHealth(PROBE_URL); expect(result.status).toBe(ServiceHealthStatus.NoConnection); expect(result.error).toBe('Request timed out after 5000ms'); }); @@ -805,18 +805,16 @@ describe('health-checks', () => { let calls = 0; (global.fetch as Mock).mockImplementation( overrideFetch({ - [URLS.llmGatewayLiveness]: () => { + [PROBE_URL]: () => { calls++; if (calls < 3) { return Promise.reject(new Error('ECONNRESET')); } - return Promise.resolve( - new Response(LLM_GATEWAY_LIVENESS_BODY, { status: 200 }), - ); + return Promise.resolve(new Response('ok', { status: 200 })); }, }), ); - const result = await checkLlmGatewayHealth(); + const result = await fetchEndpointHealth(PROBE_URL); expect(result.status).toBe(ServiceHealthStatus.Healthy); expect(result.rawIndicator).toContain('attempts=3'); expect(calls).toBe(3); @@ -826,7 +824,7 @@ describe('health-checks', () => { let calls = 0; (global.fetch as Mock).mockImplementation( overrideFetch({ - [URLS.llmGatewayLiveness]: () => { + [PROBE_URL]: () => { calls++; return Promise.resolve( new Response('Service Unavailable', { status: 503 }), @@ -834,7 +832,7 @@ describe('health-checks', () => { }, }), ); - const result = await checkLlmGatewayHealth(); + const result = await fetchEndpointHealth(PROBE_URL); expect(result.status).toBe(ServiceHealthStatus.Down); expect(calls).toBe(3); expect(result.error).toContain('HTTP 503'); @@ -845,20 +843,18 @@ describe('health-checks', () => { let calls = 0; (global.fetch as Mock).mockImplementation( overrideFetch({ - [URLS.llmGatewayLiveness]: () => { + [PROBE_URL]: () => { calls++; if (calls < 3) { return Promise.resolve( new Response('Bad Gateway', { status: 502 }), ); } - return Promise.resolve( - new Response(LLM_GATEWAY_LIVENESS_BODY, { status: 200 }), - ); + return Promise.resolve(new Response('ok', { status: 200 })); }, }), ); - const result = await checkLlmGatewayHealth(); + const result = await fetchEndpointHealth(PROBE_URL); expect(result.status).toBe(ServiceHealthStatus.Healthy); expect(result.rawIndicator).toContain('attempts=3'); expect(calls).toBe(3); @@ -868,7 +864,7 @@ describe('health-checks', () => { let calls = 0; (global.fetch as Mock).mockImplementation( overrideFetch({ - [URLS.llmGatewayLiveness]: () => { + [PROBE_URL]: () => { calls++; if (calls < 3) return Promise.reject(new Error('ECONNRESET')); return Promise.resolve( @@ -877,7 +873,7 @@ describe('health-checks', () => { }, }), ); - const result = await checkLlmGatewayHealth(); + const result = await fetchEndpointHealth(PROBE_URL); expect(result.status).toBe(ServiceHealthStatus.Down); expect(result.error).toContain('HTTP 502'); }); @@ -1082,7 +1078,7 @@ describe('health-checks', () => { // ----------------------------------------------------------------------- describe('checkAllExternalServices', () => { - it('returns all 11 service keys when everything is healthy', async () => { + it('returns all 10 service keys when everything is healthy', async () => { const health = await checkAllExternalServices(); const keys = Object.keys(health); expect(keys).toEqual( @@ -1095,18 +1091,17 @@ describe('health-checks', () => { 'npmComponents', 'cloudflareOverall', 'cloudflareComponents', - 'llmGateway', 'mcp', 'skillsOrigin', ]), ); - expect(keys).toHaveLength(11); + expect(keys).toHaveLength(10); for (const val of Object.values(health)) { expect(val.status).toBe(ServiceHealthStatus.Healthy); } }); - it('upgrades NoConnection llmGateway/mcp to Down when status page reports an outage', async () => { + it('upgrades NoConnection mcp to Down when status page reports an outage', async () => { const incidentBody = { ...POSTHOG_INCIDENTIO_HEALTHY, ongoing_incidents: [ @@ -1128,20 +1123,17 @@ describe('health-checks', () => { Promise.resolve( new Response(JSON.stringify(incidentBody), { status: 200 }), ), - [URLS.llmGatewayLiveness]: () => - Promise.reject(new Error('ECONNRESET')), [URLS.mcpLanding]: () => Promise.reject(new Error('ECONNRESET')), }), ); const health = await checkAllExternalServices(); expect(health.posthogOverall.status).toBe(ServiceHealthStatus.Down); - expect(health.llmGateway.status).toBe(ServiceHealthStatus.Down); - expect(health.llmGateway.error).toContain('corroborated by status page'); expect(health.mcp.status).toBe(ServiceHealthStatus.Down); + expect(health.mcp.error).toContain('corroborated by status page'); }); - it('keeps llmGateway/mcp as NoConnection when posthogstatus.com itself is unreachable (the bug-fix scenario)', async () => { + it('keeps mcp as NoConnection when posthogstatus.com itself is unreachable (the bug-fix scenario)', async () => { // User on flaky wifi: every PostHog-owned URL fetch fails at the // network layer, including posthogstatus.com. Previously // incidentio.ts returned Degraded for fetch failures, which @@ -1152,8 +1144,6 @@ describe('health-checks', () => { overrideFetch({ [URLS.posthogIncidentIo]: () => Promise.reject(new Error('ECONNRESET')), - [URLS.llmGatewayLiveness]: () => - Promise.reject(new Error('ECONNRESET')), [URLS.mcpLanding]: () => Promise.reject(new Error('ECONNRESET')), }), ); @@ -1162,22 +1152,18 @@ describe('health-checks', () => { expect(health.posthogOverall.status).toBe( ServiceHealthStatus.NoConnection, ); - expect(health.llmGateway.status).toBe(ServiceHealthStatus.NoConnection); expect(health.mcp.status).toBe(ServiceHealthStatus.NoConnection); }); - it('keeps llmGateway/mcp as NoConnection when status page reports no incident', async () => { + it('keeps mcp as NoConnection when status page reports no incident', async () => { (global.fetch as Mock).mockImplementation( overrideFetch({ - [URLS.llmGatewayLiveness]: () => - Promise.reject(new Error('ETIMEDOUT')), [URLS.mcpLanding]: () => Promise.reject(new Error('ETIMEDOUT')), }), ); const health = await checkAllExternalServices(); expect(health.posthogOverall.status).toBe(ServiceHealthStatus.Healthy); - expect(health.llmGateway.status).toBe(ServiceHealthStatus.NoConnection); expect(health.mcp.status).toBe(ServiceHealthStatus.NoConnection); }); @@ -1187,9 +1173,8 @@ describe('health-checks', () => { typeof c[0] === 'string' ? c[0] : (c[0] as URL).toString(), ); // PostHog uses a single incident.io endpoint for both overall + components - expect(calledUrls).toHaveLength(11); + expect(calledUrls).toHaveLength(10); expect(calledUrls).toContain(URLS.posthogIncidentIo); - expect(calledUrls).toContain(URLS.llmGatewayLiveness); expect(calledUrls).toContain(URLS.mcpLanding); expect(calledUrls).toContain(URLS.githubSkillMenu); expect(calledUrls).toContain(URLS.awsSkillMenu); @@ -1231,22 +1216,6 @@ describe('health-checks', () => { expect(result.health.anthropic.status).toBe(ServiceHealthStatus.Degraded); }); - it('returns No when LLM Gateway is down (downBlocksRun)', async () => { - (global.fetch as Mock).mockImplementation( - overrideFetch({ - [URLS.llmGatewayLiveness]: () => - Promise.resolve( - new Response('Service Unavailable', { status: 503 }), - ), - }), - ); - const result = await evaluateWizardReadiness( - DEFAULT_WIZARD_READINESS_CONFIG, - ); - expect(result.decision).toBe(WizardReadiness.No); - expect(result.health.llmGateway.status).toBe(ServiceHealthStatus.Down); - }); - it('returns No when MCP is down (downBlocksRun)', async () => { (global.fetch as Mock).mockImplementation( overrideFetch({ @@ -1316,7 +1285,6 @@ describe('health-checks', () => { expect(result.reasons.some((r) => r.includes('GitHub'))).toBe(true); expect(result.reasons.some((r) => r.includes('npm'))).toBe(true); expect(result.reasons.some((r) => r.includes('Cloudflare'))).toBe(true); - expect(result.reasons.some((r) => r.includes('LLM Gateway'))).toBe(true); expect(result.reasons.some((r) => r.includes('MCP'))).toBe(true); }); }); diff --git a/src/lib/health-checks/endpoints.ts b/src/lib/health-checks/endpoints.ts index ccb2fa16..2be4c5df 100644 --- a/src/lib/health-checks/endpoints.ts +++ b/src/lib/health-checks/endpoints.ts @@ -13,10 +13,6 @@ import { ServiceHealthStatus, type BaseHealthResult } from './types'; // NoConnection means we don't know whose fault it is; readiness reconciles // against the status page before deciding how to surface it to the user. // -// LLM Gateway – FastAPI service -// Source: posthog/services/llm-gateway/src/llm_gateway/api/health.py -// GET /_liveness → 200 {"status":"alive"} -// // MCP – Cloudflare Worker // Source: posthog/services/mcp/src/index.ts // GET / → 302 to posthog.com docs. The redirect proves the worker is up. @@ -65,7 +61,8 @@ async function attemptFetch( } } -async function fetchEndpointHealth( +// Exported so tests can pin the retry/taxonomy machinery directly. +export async function fetchEndpointHealth( url: string, timeoutMs = 5000, isExpectedStatus: (status: number) => boolean = (s) => s === 200, @@ -137,9 +134,6 @@ async function fetchEndpointHealth( return result; } -export const checkLlmGatewayHealth = (): Promise => - fetchEndpointHealth('https://gateway.us.posthog.com/_liveness'); - export const checkMcpHealth = (): Promise => fetchEndpointHealth( 'https://mcp.posthog.com/', diff --git a/src/lib/health-checks/index.ts b/src/lib/health-checks/index.ts index be2d40e8..b4c3f604 100644 --- a/src/lib/health-checks/index.ts +++ b/src/lib/health-checks/index.ts @@ -22,11 +22,7 @@ export { resetPosthogHealthCache, } from './incidentio'; -export { - checkLlmGatewayHealth, - checkMcpHealth, - checkSkillsOriginHealth, -} from './endpoints'; +export { checkMcpHealth, checkSkillsOriginHealth } from './endpoints'; export { type WizardReadinessConfig, diff --git a/src/lib/health-checks/readiness.ts b/src/lib/health-checks/readiness.ts index 018cf84d..6cd88367 100644 --- a/src/lib/health-checks/readiness.ts +++ b/src/lib/health-checks/readiness.ts @@ -17,11 +17,7 @@ import { checkPosthogOverallHealth, checkPosthogComponentHealth, } from './incidentio'; -import { - checkLlmGatewayHealth, - checkMcpHealth, - checkSkillsOriginHealth, -} from './endpoints'; +import { checkMcpHealth, checkSkillsOriginHealth } from './endpoints'; import { logToFile } from '@utils/debug'; // --------------------------------------------------------------------------- @@ -37,7 +33,6 @@ export const SERVICE_LABELS: Record = { npmComponents: 'npm (components)', cloudflareOverall: 'Cloudflare', cloudflareComponents: 'Cloudflare (components)', - llmGateway: 'LLM Gateway', mcp: 'MCP', skillsOrigin: 'Skills download', }; @@ -56,26 +51,24 @@ export interface WizardReadinessConfig { /** * See README section "Health checks" for the full rationale. * Adjust these arrays to change what blocks a wizard run. + * + * The AI gateway is not probed: its URL is only known from the run's token + * mint, and a failed mint already stops the run at bootstrap with the + * server's reason. */ export const DEFAULT_WIZARD_READINESS_CONFIG: WizardReadinessConfig = { - downBlocksRun: [ - 'anthropic', - 'npmOverall', - 'llmGateway', - 'mcp', - 'skillsOrigin', - ], + downBlocksRun: ['anthropic', 'npmOverall', 'mcp', 'skillsOrigin'], degradedBlocksRun: ['anthropic'], }; /** * Reduced readiness config for --signup provisioning flows. * - * Provisioning only needs PostHog and the LLM Gateway - it doesn't - * use Anthropic directly, npm, the skills origins, or MCP. + * Provisioning only needs PostHog - it doesn't use Anthropic directly, npm, + * the skills origins, or MCP. */ export const SIGNUP_WIZARD_READINESS_CONFIG: WizardReadinessConfig = { - downBlocksRun: ['posthogOverall', 'llmGateway'], + downBlocksRun: ['posthogOverall'], }; // --------------------------------------------------------------------------- @@ -92,7 +85,6 @@ export async function checkAllExternalServices(): Promise { npmComponents, cloudflareOverall, cloudflareComponents, - llmGateway, mcp, skillsOrigin, ] = await Promise.all([ @@ -104,7 +96,6 @@ export async function checkAllExternalServices(): Promise { checkNpmComponentHealth(), checkCloudflareOverallHealth(), checkCloudflareComponentHealth(), - checkLlmGatewayHealth(), checkMcpHealth(), checkSkillsOriginHealth(), ]); @@ -118,7 +109,6 @@ export async function checkAllExternalServices(): Promise { npmComponents, cloudflareOverall, cloudflareComponents, - llmGateway, mcp, skillsOrigin, }; @@ -131,7 +121,7 @@ export async function checkAllExternalServices(): Promise { * official status page (`posthogstatus.com`): * * - Status page says PostHog is `Down` / `Degraded` → upgrade - * llmGateway / mcp to `Down`. The status page corroborates. + * mcp to `Down`. The status page corroborates. * - Status page is `Healthy` → keep `NoConnection`. The status page * contradicts; this is probably the user's network. * - Status page is also `NoConnection` → keep `NoConnection`. User @@ -145,11 +135,11 @@ export async function checkAllExternalServices(): Promise { * when incident.io's API parsed successfully and reported a real * `partial_outage` or `degraded_performance` for some component. That's * PostHog acknowledging an issue, even if narrower than a full outage. - * If our gateway probe is also failing, those two signals together + * If our MCP probe is also failing, those two signals together * justify pointing at PostHog rather than the user. * * A narrower variant — only corroborate when the affected component is - * gateway-related (LLM, US/EU Cloud, app) — would be more precise. We + * MCP-related (US/EU Cloud, app) — would be more precise. We * have the data in `posthogComponents` but don't use it here. If the * analytics show false positives concentrated in this case, it's a * cheap follow-up. @@ -179,7 +169,6 @@ export function reconcilePosthogReachability( return { ...health, - llmGateway: upgrade(health.llmGateway), mcp: upgrade(health.mcp), }; } @@ -346,7 +335,6 @@ function allUnknown(error: string): AllServicesHealth { npmComponents: { ...base }, cloudflareOverall: base, cloudflareComponents: { ...base }, - llmGateway: base, mcp: base, skillsOrigin: base, }; diff --git a/src/lib/health-checks/testme.md b/src/lib/health-checks/testme.md index 7eb5c32a..9cf6a720 100644 --- a/src/lib/health-checks/testme.md +++ b/src/lib/health-checks/testme.md @@ -40,7 +40,6 @@ responses captured from production endpoints on 2026-03-05. | npm (components) | `https://status.npmjs.org/api/v2/summary.json` | Adds `components[]` array | | Cloudflare | `https://www.cloudflarestatus.com/api/v2/status.json` | Same shape | | Cloudflare (components) | `https://www.cloudflarestatus.com/api/v2/summary.json` | Adds `components[]` array | -| LLM Gateway | `https://gateway.us.posthog.com/_liveness` | `{"status":"alive"}` (HTTP 200) | | MCP | `https://mcp.posthog.com/` | HTML landing page (HTTP 200) | ### Statuspage.io API v2 reference @@ -54,13 +53,6 @@ responses captured from production endpoints on 2026-03-05. - Component docs: -### LLM Gateway - -- Source: `posthog/services/llm-gateway/src/llm_gateway/api/health.py` -- `GET /` → `{"service":"llm-gateway","status":"running"}` -- `GET /_liveness` → `{"status":"alive"}` (no DB dependency) -- `GET /_readiness` → `{"status":"ready"}` (checks Postgres with `SELECT 1`) - ### MCP - Source: `posthog/services/mcp/src/index.ts` diff --git a/src/lib/health-checks/types.ts b/src/lib/health-checks/types.ts index 02aeed48..f838bbf9 100644 --- a/src/lib/health-checks/types.ts +++ b/src/lib/health-checks/types.ts @@ -37,7 +37,6 @@ export interface AllServicesHealth { npmComponents: ComponentHealthResult; cloudflareOverall: BaseHealthResult; cloudflareComponents: ComponentHealthResult; - llmGateway: BaseHealthResult; mcp: BaseHealthResult; skillsOrigin: BaseHealthResult; } diff --git a/src/ui/tui/playground/demos/HealthCheckDemo.tsx b/src/ui/tui/playground/demos/HealthCheckDemo.tsx index 03c7064d..501f6972 100644 --- a/src/ui/tui/playground/demos/HealthCheckDemo.tsx +++ b/src/ui/tui/playground/demos/HealthCheckDemo.tsx @@ -44,7 +44,6 @@ const MOCK_CONFIRMED_OUTAGE: AllServicesHealth = { }, cloudflareOverall: HEALTHY, cloudflareComponents: { status: ServiceHealthStatus.Healthy }, - llmGateway: HEALTHY, mcp: HEALTHY, skillsOrigin: HEALTHY, }; @@ -58,10 +57,6 @@ const MOCK_NO_CONNECTION: AllServicesHealth = { npmComponents: { status: ServiceHealthStatus.Healthy }, cloudflareOverall: HEALTHY, cloudflareComponents: { status: ServiceHealthStatus.Healthy }, - llmGateway: { - status: ServiceHealthStatus.NoConnection, - error: 'getaddrinfo ENOTFOUND gateway.us.posthog.com', - }, mcp: { status: ServiceHealthStatus.NoConnection, error: 'fetch failed', diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index 1f2f8a16..12cf174a 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -156,7 +156,7 @@ function captureHealthCheckBlocked(result: WizardReadinessResult): void { const posthogStatus = health.posthogOverall?.status; const retriesUsed = Math.max( 0, - ...(['llmGateway', 'mcp', 'skillsOrigin'] as const).map((k) => { + ...(['mcp', 'skillsOrigin'] as const).map((k) => { const ind = health[k]?.rawIndicator ?? ''; const m = ind.match(/attempts=(\d+)/); return m ? Number(m[1]) - 1 : 0; From d38f0803124f416ea5d03e5dde1f6bfc4e4695e6 Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Tue, 8 Sep 2026 15:08:40 -0700 Subject: [PATCH 08/10] remove --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 19d24918..aa5e7500 100644 --- a/README.md +++ b/README.md @@ -636,10 +636,6 @@ downBlocksRun: ['anthropic', 'npmOverall', 'mcp', 'skillsOrigin'], degradedBlocksRun: ['anthropic'], ``` -The AI gateway is deliberately absent: its URL is only known from the run's -token mint at bootstrap, so there is nothing static to probe — and a failed -mint already stops the run with the server's reason. - `skillsOrigin` is one entry covering two origins: skills are published to GitHub Releases and an AWS mirror under the same filenames, and downloads fail over between them (`src/lib/fetch-retry.ts`). Both are probed in parallel, so From 8eb9b88a8c7e083cded5f8c9ac31d0f9fb974665 Mon Sep 17 00:00:00 2001 From: Brandon Leung Date: Tue, 8 Sep 2026 19:16:07 -0400 Subject: [PATCH 09/10] docs(gateway): say why the refusal detail is sanitized Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/gateway-session.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index e7c6bffe..7bf698b6 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -213,8 +213,9 @@ async function readRefusalDetail(resp: Response): Promise { try { const body = (await resp.json()) as { detail?: unknown }; const raw = typeof body?.detail === 'string' ? body.detail : ''; - // Server text printed straight to a terminal: strip C0/C1 and the escapes - // an ANSI sequence is built from before anything renders it. + // Not because the server sends escapes, but because this string is printed + // straight to a terminal: sanitizing at the boundary means no later message + // can move the cursor or repaint the screen. // eslint-disable-next-line no-control-regex const detail = raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim(); return detail.length > 0 && detail.length <= MAX_REFUSAL_DETAIL_LENGTH From 905c2b0945b323ff6b25d3b01c5dd53ea488eb87 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Wed, 9 Sep 2026 00:10:44 -0400 Subject: [PATCH 10/10] test(gateway): expect wizard product attribution --- src/lib/__tests__/agent-interface.test.ts | 1 + src/lib/agent/__tests__/triage-provider.test.ts | 1 + src/lib/agent/__tests__/tutorial-run-tags.test.ts | 4 ++-- src/lib/agent/runner/harness/pi/__tests__/gateway.test.ts | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/__tests__/agent-interface.test.ts b/src/lib/__tests__/agent-interface.test.ts index de75ea35..e3f2926f 100644 --- a/src/lib/__tests__/agent-interface.test.ts +++ b/src/lib/__tests__/agent-interface.test.ts @@ -604,6 +604,7 @@ describe('buildAgentEnv header shape', () => { // Fallback is native in the gateway's routing chain, and the run tags ride // the blob rather than per-key headers. expect(JSON.parse(json)).toEqual({ + ai_product: 'wizard', team_id: 42, run_id: 'r1', integration: 'nextjs', diff --git a/src/lib/agent/__tests__/triage-provider.test.ts b/src/lib/agent/__tests__/triage-provider.test.ts index 2d47e992..5eb63683 100644 --- a/src/lib/agent/__tests__/triage-provider.test.ts +++ b/src/lib/agent/__tests__/triage-provider.test.ts @@ -85,6 +85,7 @@ describe('createTriageLLMProvider', () => { const headers = complete.mock.calls[0][0].headers ?? {}; expect(JSON.parse(headers['X-PostHog-Properties'])).toEqual({ + ai_product: 'wizard', team_id: 42, run_id: 'r1', 'wizard_flag_wizard-orchestrator': 'true', diff --git a/src/lib/agent/__tests__/tutorial-run-tags.test.ts b/src/lib/agent/__tests__/tutorial-run-tags.test.ts index 976057bc..e0d2c2b3 100644 --- a/src/lib/agent/__tests__/tutorial-run-tags.test.ts +++ b/src/lib/agent/__tests__/tutorial-run-tags.test.ts @@ -54,9 +54,9 @@ describe('buildTutorialRunTags', () => { expect(encoded).toContain('"program_id":"mcp-tutorial"'); }); - it('sends an empty blob when there is no program, the pre-fix shape', () => { + it('sends only product attribution when there is no program', () => { const encoded = buildAgentEnv(buildTutorialRunTags({}), {}); - expect(encoded).toBe('X-PostHog-Properties: {}'); + expect(encoded).toBe('X-PostHog-Properties: {"ai_product":"wizard"}'); }); }); diff --git a/src/lib/agent/runner/harness/pi/__tests__/gateway.test.ts b/src/lib/agent/runner/harness/pi/__tests__/gateway.test.ts index c0fb457f..09c4d1ff 100644 --- a/src/lib/agent/runner/harness/pi/__tests__/gateway.test.ts +++ b/src/lib/agent/runner/harness/pi/__tests__/gateway.test.ts @@ -69,6 +69,7 @@ describe('buildGatewayHeaders', () => { 42, ); expect(JSON.parse(headers['X-PostHog-Properties'])).toEqual({ + ai_product: 'wizard', team_id: 42, run_id: 'r1', 'wizard_flag_wizard-orchestrator': 'test',