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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions apps/api/src/config/env-boolean.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { envBoolean } from './env.js';

/**
* #325 — `LANGFUSE_ALLOW_REMOTE_PAYLOADS` was `z.coerce.boolean()`, i.e.
* `Boolean(value)`. Because an env var is ALWAYS a string, every non-empty value
* is truthy: `=false`, `=0` and `=no` all evaluated to **true**.
*
* That inverts the operator's intent on a §2.2 security gate — writing
* `LANGFUSE_ALLOW_REMOTE_PAYLOADS=false` to keep memory text on-box would have
* started shipping prompts and completions to a remote Langfuse.
*/

describe('envBoolean', () => {
const schema = envBoolean.default(false);

it('reads the falsey spellings as FALSE — the whole point of the fix', () => {
// Under `z.coerce.boolean()` every one of these was `true`.
for (const raw of ['false', '0', 'no', 'off', '']) {
expect(schema.parse(raw), `"${raw}" must be false`).toBe(false);
}
});

it('reads the truthy spellings as TRUE', () => {
for (const raw of ['true', '1', 'yes', 'on']) {
expect(schema.parse(raw), `"${raw}" must be true`).toBe(true);
}
});

it('is case- and whitespace-insensitive', () => {
expect(schema.parse(' FALSE ')).toBe(false);
expect(schema.parse('True')).toBe(true);
});

it('defaults to false when the variable is absent', () => {
expect(schema.parse(undefined)).toBe(false);
});

it('rejects an unrecognised value rather than guessing', () => {
// Failing closed at boot beats a security gate whose state nobody can predict.
for (const raw of ['maybe', 'FALSE!', 'y', '2']) {
expect(() => schema.parse(raw), `"${raw}" must throw`).toThrow();
}
});

it('names the accepted spellings in the error, so the fix is obvious', () => {
const result = z.object({ FLAG: envBoolean }).safeParse({ FLAG: 'maybe' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toContain('true');
expect(result.error.issues[0]?.message).toContain('maybe');
}
});
});
33 changes: 32 additions & 1 deletion apps/api/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,32 @@
import 'dotenv/config';
import { z } from 'zod';

/**
* A boolean read from an environment variable, where the value is always a
* string. Only the listed spellings are accepted; anything else is a hard parse
* error rather than a silent guess.
*
* Deliberately NOT `z.coerce.boolean()`, which is `Boolean(value)` and therefore
* true for EVERY non-empty string — `=false`, `=0` and `=no` would all mean true
* (#325). On a security gate that inverts the operator's intent silently.
*/
const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
const FALSE_VALUES = new Set(['0', 'false', 'no', 'off', '']);

export const envBoolean = z
.string()
.transform((raw, ctx) => {
const value = raw.trim().toLowerCase();
if (TRUE_VALUES.has(value)) return true;
if (FALSE_VALUES.has(value)) return false;
ctx.addIssue({
code: 'custom',
message: `expected one of ${[...TRUE_VALUES, ...FALSE_VALUES].filter(Boolean).join(', ')} — got "${raw}"`,
});
return z.NEVER;
})
.pipe(z.boolean());

/**
* Environment is validated once at boot. Importing `env` anywhere guarantees
* a fully-typed, present-and-correct configuration or a hard fail on startup.
Expand Down Expand Up @@ -131,7 +157,12 @@ const envSchema = z.object({
// Langfuse — an explicit, auditable opt-in; otherwise a non-loopback host
// degrades to metadata-only (model, tokens, cost, latency) rather than
// silently shipping memory text off-box (failure mode M9).
LANGFUSE_ALLOW_REMOTE_PAYLOADS: z.coerce.boolean().default(false),
//
// NOT `z.coerce.boolean()` (#325): that is `Boolean(string)`, so every
// non-empty value is true — `=false`, `=0` and `=no` would all ENABLE remote
// payloads. An operator writing `=false` to turn the escape hatch off would
// have turned it on, silently, on a security gate.
LANGFUSE_ALLOW_REMOTE_PAYLOADS: envBoolean.default(false),
});

/**
Expand Down
24 changes: 20 additions & 4 deletions apps/api/src/platform/model-router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,18 @@ export interface CompleteArgs {
* per-class estimate is used. Tests drive spend over the cap by injecting this.
*/
readonly cost?: number;
/**
* Marks this call as carrying PRIVATE memory content (#325, invariant §2.2).
* When set, the Langfuse trace's input/output are redacted unconditionally —
* even on loopback, even with the remote opt-in.
*
* Recall does not set it because it pre-filters private memories out of
* `contexts` upstream (`publicHits` in memory.service.ts), so its prompts carry
* no private text. Any FUTURE routed call that does handle private content must
* set this; without it `safePayload`'s unconditional-redaction branch is
* unreachable and the guarantee is only a comment.
*/
readonly isPrivate?: boolean;
}

/**
Expand Down Expand Up @@ -279,9 +291,11 @@ function traceGeneration(input: {
costUsd: number;
latencyMs: number;
startedAt: Date;
isPrivate?: boolean;
}): void {
const lf = getLangfuse();
if (!lf) return;
const redact = { isPrivate: input.isPrivate };
safely(() => {
const trace = lf.trace({
name: `llm.${input.modelClass}`,
Expand All @@ -290,17 +304,17 @@ function traceGeneration(input: {
// the trace root is what the UI shows first, and leaving them unset
// renders "this trace didn't receive an input or output" even though the
// generation underneath has both.
input: safePayload(input.promptText),
output: safePayload(input.answerText),
input: safePayload(input.promptText, redact),
output: safePayload(input.answerText, redact),
metadata: { route: input.route, vendor: input.vendor, costUsd: input.costUsd },
});
trace.generation({
name: input.route,
model: input.model,
startTime: input.startedAt,
endTime: new Date(input.startedAt.getTime() + input.latencyMs),
input: safePayload(input.promptText),
output: safePayload(input.answerText),
input: safePayload(input.promptText, redact),
output: safePayload(input.answerText, redact),
metadata: {
modelClass: input.modelClass,
vendor: input.vendor,
Expand Down Expand Up @@ -335,6 +349,7 @@ export async function complete(
traceGeneration({
modelClass: args.class, model, vendor, route, promptText,
answerText: result.answer, costUsd: cost, latencyMs, startedAt,
isPrivate: args.isPrivate,
});
return result;
}
Expand Down Expand Up @@ -366,6 +381,7 @@ export async function complete(
traceGeneration({
modelClass: args.class, model, vendor, route, userId: args.userId, promptText,
answerText: result.answer, costUsd: spend, latencyMs, startedAt,
isPrivate: args.isPrivate,
});

// Record spend only AFTER a successful provider call (reconciled on threshold-cross).
Expand Down
146 changes: 146 additions & 0 deletions apps/api/src/platform/model-router/langfuse-trace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import type { Langfuse } from 'langfuse';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { setLangfuseClient } from '../observability/langfuse.js';
import { complete } from './index.js';

/**
* #325 — the spy-provider half of the §2.2 proof for Langfuse tracing (#320
* stage 2). The unit tests in `observability/langfuse.test.ts` prove the gating
* FUNCTIONS behave; these prove the routed `complete()` path actually applies
* them, and that a broken Langfuse can never break a recall.
*
* Hermetic: `keys: {}` forces the deterministic FAKE provider, so nothing here
* touches the network. The Langfuse client is a spy injected through the module's
* test seam — tracing is otherwise (correctly) off in tests.
*/

/** Everything the spy was handed, so a test can search it for leaked content. */
interface Captured {
traces: unknown[];
generations: unknown[];
}

function spyClient(opts: { throwOnTrace?: boolean } = {}): {
client: Langfuse;
captured: Captured;
} {
const captured: Captured = { traces: [], generations: [] };
const client = {
trace: (body: unknown) => {
if (opts.throwOnTrace) throw new Error('langfuse is down');
captured.traces.push(body);
return {
generation: (genBody: unknown) => {
captured.generations.push(genBody);
return {};
},
};
},
} as unknown as Langfuse;
return { client, captured };
}

/** Every string the spy received, flattened — what "reached Langfuse" means. */
const everythingSent = (captured: Captured): string =>
JSON.stringify([...captured.traces, ...captured.generations]);

const PRIVATE_TEXT = 'my HIV test result came back positive';
const PUBLIC_TEXT = 'the standup moved to 9:30';

afterEach(() => {
setLangfuseClient(undefined);
vi.restoreAllMocks();
});

describe('AC3: private memory text never reaches the Langfuse client', () => {
it('redacts input AND output when the routed call is marked private', async () => {
const { client, captured } = spyClient();
setLangfuseClient(client);

await complete(
{
class: 'fast',
query: 'what did the clinic say?',
contexts: [{ id: 'm1', text: PRIVATE_TEXT }],
isPrivate: true,
},
{},
);

// The spy must have been called — otherwise this test proves nothing.
expect(captured.traces).toHaveLength(1);
expect(captured.generations).toHaveLength(1);

const sent = everythingSent(captured);
expect(sent).not.toContain('HIV');
expect(sent).not.toContain(PRIVATE_TEXT);
expect(sent).toContain('private-memory');
});

it('redacts the private prompt even though the host is loopback', async () => {
// Loopback normally ALLOWS payloads; `isPrivate` must override that.
const { client, captured } = spyClient();
setLangfuseClient(client);

await complete(
{
class: 'fast',
query: 'clinic',
contexts: [{ id: 'm1', text: PRIVATE_TEXT }],
isPrivate: true,
},
{},
);

expect(everythingSent(captured)).not.toContain('HIV');
});

it('still traces non-private content, so the redaction is not vacuous', async () => {
// If the spy saw nothing either way, the assertions above would be worthless.
const { client, captured } = spyClient();
setLangfuseClient(client);

await complete(
{ class: 'fast', query: 'when is standup?', contexts: [{ id: 'm1', text: PUBLIC_TEXT }] },
{},
);

expect(everythingSent(captured)).toContain('standup');
});
});

describe('AC4: tracing is fail-open', () => {
it('a throwing Langfuse client does not fail or reject the LLM call', async () => {
const { client } = spyClient({ throwOnTrace: true });
setLangfuseClient(client);

const result = await complete(
{ class: 'fast', query: 'when is standup?', contexts: [{ id: 'm1', text: PUBLIC_TEXT }] },
{},
);

// The answer still comes back — observability cannot break a recall.
expect(result.answer).toBeTypeOf('string');
expect(result.answer.length).toBeGreaterThan(0);
});

it('swallows the trace error rather than propagating it', async () => {
const { client } = spyClient({ throwOnTrace: true });
setLangfuseClient(client);

await expect(
complete({ class: 'fast', query: 'q', contexts: [{ id: 'm1', text: PUBLIC_TEXT }] }, {}),
).resolves.toBeDefined();
});

it('is a no-op when tracing is disabled (no client)', async () => {
setLangfuseClient(undefined);

const result = await complete(
{ class: 'fast', query: 'when is standup?', contexts: [{ id: 'm1', text: PUBLIC_TEXT }] },
{},
);

expect(result.answer).toBeTypeOf('string');
});
});
Loading
Loading