diff --git a/.changeset/tidy-clouds-smile.md b/.changeset/tidy-clouds-smile.md new file mode 100644 index 0000000000..2467b90b18 --- /dev/null +++ b/.changeset/tidy-clouds-smile.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +OpenAI and Anthropic model calls now receive privacy-preserving end-user safety identifiers derived from the active session caller when the agent has not provided one, including calls made during context compaction. diff --git a/docs/agent-config.md b/docs/agent-config.md index b783e79912..843b8a28fc 100644 --- a/docs/agent-config.md +++ b/docs/agent-config.md @@ -49,6 +49,19 @@ version uses hyphens (`claude-opus-4-8`), while the Gateway id above uses a dot Model use is subject to the terms, data-processing commitments, retention behavior, and available controls of the selected provider and routing path. Review the [AI Gateway model catalog](https://vercel.com/ai-gateway/models) for gateway-routed models, and review the provider's terms when you configure a direct `LanguageModel`. +For every OpenAI or Anthropic model call, eve fills the provider's end-user +safety identifier from the active turn's +[`auth.current`](./guides/auth-and-route-protection#what-reaches-ctxsessionauth) +principal when you have not configured it. For OpenAI, the option is +`providerOptions.openai.safetyIdentifier`; for Anthropic, it is +`providerOptions.anthropic.metadata.userId`. The default value is a SHA-256 +fingerprint of the principal's authenticator, issuer, type, id, and subject; +eve does not send the raw principal fields or attributes. The fingerprint +follows the current caller when a later turn changes users. An authored value +at either provider path takes precedence and is forwarded unchanged. When +`auth.current` is `null`, eve does not add an identifier. The same rules apply +to compaction calls. + ### Choose the model dynamically `model` also accepts `defineDynamic({ events })`. Each matching handler must diff --git a/packages/eve/src/harness/provider-safety.test.ts b/packages/eve/src/harness/provider-safety.test.ts new file mode 100644 index 0000000000..a3a6a7148f --- /dev/null +++ b/packages/eve/src/harness/provider-safety.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionAuthContext } from "#channel/types.js"; +import { mergeProviderSafetyIdentifier } from "#harness/provider-safety.js"; +import { invocationOwnerKey } from "#internal/invocation/metadata.js"; + +const auth: SessionAuthContext = { + attributes: { email: "user@example.com" }, + authenticator: "oidc", + issuer: "https://issuer.example.com", + principalId: "user_123", + principalType: "user", + subject: "subject_123", +}; + +describe("mergeProviderSafetyIdentifier", () => { + it("preserves an authored OpenAI safety identifier", () => { + const providerOptions = { + gateway: { caching: "auto" }, + openai: { safetyIdentifier: "authored", store: false }, + }; + + expect( + mergeProviderSafetyIdentifier({ id: "openai/gpt-5.6-sol" }, providerOptions, auth), + ).toEqual(providerOptions); + }); + + it("treats an authored OpenAI null as explicit", () => { + const providerOptions = { openai: { safetyIdentifier: null } }; + + expect( + mergeProviderSafetyIdentifier({ id: "openai/gpt-5.6-sol" }, providerOptions, auth), + ).toEqual(providerOptions); + }); + + it("sets the OpenAI safety identifier while preserving other options", () => { + const result = mergeProviderSafetyIdentifier( + { id: "openai/gpt-5.6-sol" }, + { + gateway: { caching: "auto" }, + openai: { store: false }, + }, + auth, + ); + + expect(result).toEqual({ + gateway: { caching: "auto" }, + openai: { + safetyIdentifier: invocationOwnerKey(auth), + store: false, + }, + }); + expect(JSON.stringify(result)).not.toContain(auth.principalId); + }); + + it("preserves an authored Anthropic user ID", () => { + const providerOptions = { + anthropic: { + metadata: { userId: "authored" }, + thinking: { type: "adaptive" }, + }, + }; + + expect( + mergeProviderSafetyIdentifier({ id: "anthropic/claude-opus-5" }, providerOptions, auth), + ).toEqual(providerOptions); + }); + + it("sets the Anthropic user ID while preserving other options", () => { + const result = mergeProviderSafetyIdentifier( + { id: "anthropic/claude-opus-5" }, + { + gateway: { caching: "auto" }, + anthropic: { thinking: { type: "adaptive" } }, + }, + auth, + ); + + expect(result).toEqual({ + gateway: { caching: "auto" }, + anthropic: { + metadata: { userId: invocationOwnerKey(auth) }, + thinking: { type: "adaptive" }, + }, + }); + expect(JSON.stringify(result)).not.toContain(auth.principalId); + }); + + it("does not add a safety identifier for another provider", () => { + const providerOptions = { google: { structuredOutputs: true } }; + + expect( + mergeProviderSafetyIdentifier({ id: "google/gemini-3.1-pro" }, providerOptions, auth), + ).toBe(providerOptions); + }); + + it("does not add a safety identifier without an active caller", () => { + const providerOptions = { anthropic: { thinking: { type: "adaptive" } } }; + + expect( + mergeProviderSafetyIdentifier({ id: "anthropic/claude-opus-5" }, providerOptions, null), + ).toBe(providerOptions); + }); +}); diff --git a/packages/eve/src/harness/provider-safety.ts b/packages/eve/src/harness/provider-safety.ts new file mode 100644 index 0000000000..3f4e1de994 --- /dev/null +++ b/packages/eve/src/harness/provider-safety.ts @@ -0,0 +1,29 @@ +import type { SessionAuthContext } from "#channel/types.js"; +import { invocationOwnerKey } from "#internal/invocation/metadata.js"; +import type { RuntimeModelReference } from "#runtime/agent/bootstrap.js"; +import { mergeObjects } from "#shared/objects.js"; + +/** + * Adds a provider-specific end-user safety identifier without disclosing the + * raw eve principal. Authored provider options take precedence over the default. + */ +export function mergeProviderSafetyIdentifier( + modelReference: RuntimeModelReference, + providerOptions: Readonly> | undefined, + auth: SessionAuthContext | null, +): Record | undefined { + if (auth === null) { + return providerOptions; + } + + const ownerKey = invocationOwnerKey(auth); + const provider = modelReference.id.split("/", 1)[0]?.toLowerCase(); + const defaults = + provider === "openai" + ? { openai: { safetyIdentifier: ownerKey } } + : provider === "anthropic" + ? { anthropic: { metadata: { userId: ownerKey } } } + : undefined; + + return defaults === undefined ? providerOptions : mergeObjects(defaults, providerOptions); +} diff --git a/packages/eve/src/harness/step-hooks.ts b/packages/eve/src/harness/step-hooks.ts index 69a3502d6e..168fb28ab3 100644 --- a/packages/eve/src/harness/step-hooks.ts +++ b/packages/eve/src/harness/step-hooks.ts @@ -10,6 +10,7 @@ import type { TypedToolCall, TypedToolResult, } from "ai"; +import type { SessionAuthContext } from "#channel/types.js"; import { createActionResultEvent, createActionsRequestedEvent, @@ -30,6 +31,7 @@ import { mergeGatewayAutoCaching, type PromptCachePath, } from "#harness/prompt-cache.js"; +import { mergeProviderSafetyIdentifier } from "#harness/provider-safety.js"; import { createRuntimeActionRequestFromToolCall } from "#harness/runtime-actions.js"; import { isInvalidToolCall } from "#harness/tool-call-input-errors.js"; import type { RuntimeToolResultActionResult } from "#shared/action-types.js"; @@ -75,6 +77,7 @@ export type HarnessStepResult = Pick< * Input for {@link buildStepHooks}. */ interface StepHooksInput { + readonly auth: SessionAuthContext | null; readonly cachePath: PromptCachePath; readonly emit?: HarnessEmitFn; readonly emissionState: HarnessEmissionState; @@ -179,7 +182,12 @@ export function buildStepHooks(input: StepHooksInput): StepHooks { messages: processed, }; - const providerOptions = requireSessionModelReference(session).providerOptions; + const modelReference = requireSessionModelReference(session); + const providerOptions = mergeProviderSafetyIdentifier( + modelReference, + modelReference.providerOptions, + input.auth, + ); if (input.cachePath.kind === "gateway-auto") { stepResult.providerOptions = mergeGatewayAutoCaching(providerOptions) as NonNullable< typeof stepResult.providerOptions diff --git a/packages/eve/src/harness/tool-loop.test.ts b/packages/eve/src/harness/tool-loop.test.ts index 5bb79ae4fa..a9d64b228f 100644 --- a/packages/eve/src/harness/tool-loop.test.ts +++ b/packages/eve/src/harness/tool-loop.test.ts @@ -31,6 +31,7 @@ import { TurnTaskStateKey, } from "#context/keys.js"; import { SCHEDULE_APP_AUTH } from "#channel/schedule-auth.js"; +import { invocationOwnerKey } from "#internal/invocation/metadata.js"; import { decodeSandboxRef, isSandboxRefUrl } from "#internal/attachments/sandbox-refs.js"; import { mockSandbox } from "#internal/testing/mocks/mock-sandbox.js"; import type { UnstampedMessageStreamEvent } from "#protocol/message.js"; @@ -9197,13 +9198,29 @@ describe("createToolLoopHarness", () => { }), ); const session = createTestSession({ + agent: { + modelReference: { + id: "openai/gpt-4", + providerOptions: { openai: { store: false } }, + }, + system: "You are a test assistant.", + tools: [{ description: "Adds numbers", name: "add", inputSchema: { type: "object" } }], + }, history: [ { content: "old message", role: "user" }, { content: "old reply", role: "assistant" }, ], }); + const auth = { + attributes: {}, + authenticator: "oidc", + principalId: "user_123", + principalType: "user", + }; + const ctx = new ContextContainer(); + ctx.set(AuthKey, auth); - await runStep(session, { message: "Hi" }); + await contextStorage.run(ctx, () => runStep(session, { message: "Hi" })); expect(getCompatibilityEventTypes(events)).toEqual([ "session.started", @@ -9232,6 +9249,12 @@ describe("createToolLoopHarness", () => { sessionId: "test-session", turnId: "turn_0", }); + expect(vi.mocked(compactMessages).mock.calls[0]?.[3]).toEqual({ + openai: { + safetyIdentifier: invocationOwnerKey(auth), + store: false, + }, + }); }); it("selects the model from the pre-compaction view and dispatches step consumers after rewrite", async () => { @@ -10051,6 +10074,65 @@ describe("createToolLoopHarness", () => { ]); }); + it("threads the active caller into OpenAI provider options across turns", async () => { + setupStopResult(); + const auth = { + attributes: {}, + authenticator: "oidc", + principalId: "user_123", + principalType: "user", + }; + const session = createTestSession({ + agent: { + modelReference: { + id: "openai/gpt-5.6-sol", + providerOptions: { + openai: { store: false }, + }, + }, + system: "", + tools: [{ description: "Adds numbers", name: "add", inputSchema: { type: "object" } }], + }, + }); + const runStep = createToolLoopHarness( + createTestConfig("conversation", undefined, { + resolveModel: vi.fn().mockResolvedValue("openai/gpt-5.6-sol"), + }), + ); + const ctx = new ContextContainer(); + ctx.set(AuthKey, auth); + + const first = await contextStorage.run(ctx, () => runStep(session, { message: "hi" })); + const nextAuth = { ...auth, principalId: "user_456" }; + ctx.set(AuthKey, nextAuth); + await contextStorage.run(ctx, () => runStep(first.session, { message: "again" })); + + const readProviderOptions = async (index: number) => { + const agentCall = vi.mocked(ToolLoopAgent).mock.calls[index]?.[0]; + const prepareStep = getPrepareStep( + agentCall?.prepareStep, + ); + return ( + await prepareStep({ + context: undefined, + messages: [], + model: null, + stepNumber: 0, + steps: [], + }) + ).providerOptions; + }; + + await expect(readProviderOptions(0)).resolves.toEqual({ + gateway: { caching: "auto" }, + openai: { safetyIdentifier: invocationOwnerKey(auth), store: false }, + }); + await expect(readProviderOptions(1)).resolves.toEqual({ + gateway: { caching: "auto" }, + openai: { safetyIdentifier: invocationOwnerKey(nextAuth), store: false }, + }); + }); + it("gateway-auto path: merges gateway.caching='auto' into providerOptions for string model ids", async () => { setupStopResult(); const config: ToolLoopHarnessConfig = { diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index 56aa8baad9..9258353cdf 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -20,6 +20,7 @@ import { type TypedToolResult, } from "ai"; import { isScheduleAppAuth } from "#channel/schedule-auth.js"; +import type { SessionAuthContext } from "#channel/types.js"; import { resolveInstalledPackageInfo } from "#internal/application/package.js"; import { resolveProviderHeaders } from "#internal/gateway.js"; import { @@ -243,6 +244,7 @@ import { isInvalidToolCall, } from "#harness/tool-call-input-errors.js"; import { buildStepHooks, emitStepActions, type HarnessStepResult } from "#harness/step-hooks.js"; +import { mergeProviderSafetyIdentifier } from "#harness/provider-safety.js"; import { buildToolApproval, buildToolSetFromDefinitions, @@ -779,6 +781,7 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { const compacted = await maybeCompact({ abortSignal: config.abortSignal, + auth: ctx?.get(AuthKey) ?? null, emit, emissionState: { ...emissionState, @@ -1279,6 +1282,7 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { const compaction = await maybeCompact({ abortSignal: config.abortSignal, + auth: ctx?.get(AuthKey) ?? null, emit, emissionState, messages: [...projectedMessages], @@ -1542,6 +1546,7 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { ); const hooks = buildStepHooks({ + auth: ctx?.get(AuthKey) ?? null, cachePath, emit, emissionState, @@ -3251,6 +3256,7 @@ function createNextCompactionConfig( */ async function maybeCompact(input: { readonly abortSignal?: AbortSignal; + readonly auth: SessionAuthContext | null; readonly emit?: ToolLoopHarnessConfig["handleEvent"]; readonly emissionState: ReturnType; readonly force?: boolean; @@ -3280,6 +3286,13 @@ async function maybeCompact(input: { modelReference: requireSessionModelReference(session), resolveModel: input.resolveModel, }); + const compactionModelReference = + session.agent.compactionModelReference ?? requireSessionModelReference(session); + const providerOptions = mergeProviderSafetyIdentifier( + compactionModelReference, + compaction.providerOptions, + input.auth, + ) as Parameters[3]; if (emit) { await emit( @@ -3297,7 +3310,7 @@ async function maybeCompact(input: { messages, compaction.model, session.compaction, - compaction.providerOptions, + providerOptions, input.telemetry, buildGatewayAttributionHeaders(compaction.model, input.runtimeIdentity), input.abortSignal, diff --git a/packages/eve/src/shared/objects.test.ts b/packages/eve/src/shared/objects.test.ts new file mode 100644 index 0000000000..fcbee9b8e0 --- /dev/null +++ b/packages/eve/src/shared/objects.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { mergeObjects } from "#shared/objects.js"; + +describe("mergeObjects", () => { + it("recursively merges disjoint keys and lets overrides win conflicts", () => { + const base = { + a: { + b: 1, + nested: { base: true, shared: "base" }, + shared: "base", + }, + }; + const overrides = { + a: { + c: 1, + nested: { override: true, shared: "override" }, + shared: "override", + }, + }; + + expect(mergeObjects(base, overrides)).toEqual({ + a: { + b: 1, + c: 1, + nested: { base: true, override: true, shared: "override" }, + shared: "override", + }, + }); + expect(base).toEqual({ + a: { + b: 1, + nested: { base: true, shared: "base" }, + shared: "base", + }, + }); + expect(overrides).toEqual({ + a: { + c: 1, + nested: { override: true, shared: "override" }, + shared: "override", + }, + }); + }); + + it("replaces arrays, primitives, nullish values, and exotic objects", () => { + const date = new Date("2026-08-25T00:00:00.000Z"); + const map = new Map([["authored", true]]); + const result = mergeObjects( + { + array: [1], + date: { default: true }, + map: { default: true }, + nullish: { default: true }, + primitive: "default", + undefinedValue: { default: true }, + }, + { + array: [2], + date, + map, + nullish: null, + primitive: 42, + undefinedValue: undefined, + }, + ); + + expect(result).toEqual({ + array: [2], + date, + map, + nullish: null, + primitive: 42, + undefinedValue: undefined, + }); + expect(result.date).toBe(date); + expect(result.map).toBe(map); + }); + + it("merges __proto__ as data without polluting object prototypes", () => { + const base = JSON.parse('{"__proto__":{"base":true}}') as Record; + const overrides = JSON.parse('{"__proto__":{"override":true}}') as Record; + const result = mergeObjects(base, overrides); + + expect(Object.hasOwn(result, "__proto__")).toBe(true); + expect(result.__proto__).toEqual({ base: true, override: true }); + expect(Object.hasOwn(Object.prototype, "base")).toBe(false); + expect(Object.hasOwn(Object.prototype, "override")).toBe(false); + }); +}); diff --git a/packages/eve/src/shared/objects.ts b/packages/eve/src/shared/objects.ts new file mode 100644 index 0000000000..92b43b63ba --- /dev/null +++ b/packages/eve/src/shared/objects.ts @@ -0,0 +1,21 @@ +import { isPlainRecord } from "#shared/guards.js"; + +/** + * Recursively merges plain records. Nested records are merged while arrays, + * primitives, and exotic objects from `overrides` replace the base value. + */ +export function mergeObjects( + base: Readonly>, + overrides: Readonly> | undefined, +): Record { + const merged: Record = { ...base, ...overrides }; + + for (const [key, overrideValue] of Object.entries(overrides ?? {})) { + const baseValue = base[key]; + if (isPlainRecord(baseValue) && isPlainRecord(overrideValue)) { + merged[key] = mergeObjects(baseValue, overrideValue); + } + } + + return merged; +}