From ad067f9e2d2c4db975950e28d653bee5b4280605 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Tue, 25 Aug 2026 15:19:35 -0400 Subject: [PATCH 1/4] feat(eve): models - set OpenAI safety identifier from auth Signed-off-by: Andrew Barba --- .changeset/tidy-clouds-smile.md | 5 ++ docs/agent-config.md | 10 +++ .../eve/src/harness/openai-safety.test.ts | 52 ++++++++++++ packages/eve/src/harness/openai-safety.ts | 30 +++++++ packages/eve/src/harness/step-hooks.ts | 10 ++- packages/eve/src/harness/tool-loop.test.ts | 84 ++++++++++++++++++- packages/eve/src/harness/tool-loop.ts | 15 +++- 7 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 .changeset/tidy-clouds-smile.md create mode 100644 packages/eve/src/harness/openai-safety.test.ts create mode 100644 packages/eve/src/harness/openai-safety.ts diff --git a/.changeset/tidy-clouds-smile.md b/.changeset/tidy-clouds-smile.md new file mode 100644 index 0000000000..475c93cae4 --- /dev/null +++ b/.changeset/tidy-clouds-smile.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +OpenAI model calls now receive a privacy-preserving safety identifier derived from the active session caller, including calls made during context compaction. diff --git a/docs/agent-config.md b/docs/agent-config.md index b783e79912..ca3f3be529 100644 --- a/docs/agent-config.md +++ b/docs/agent-config.md @@ -49,6 +49,16 @@ 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 model call, eve sets `providerOptions.openai.safetyIdentifier` +from the active turn's [`auth.current`](./guides/auth-and-route-protection#what-reaches-ctxsessionauth) +principal. The 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 as the safety identifier. The fingerprint follows the current caller +when a later turn changes users. When `auth.current` is `null`, eve does not add +or replace the option. This applies to OpenAI compaction calls too. eve replaces +an authored `openai.safetyIdentifier` with the active caller's fingerprint while +preserving the other provider options. + ### Choose the model dynamically `model` also accepts `defineDynamic({ events })`. Each matching handler must diff --git a/packages/eve/src/harness/openai-safety.test.ts b/packages/eve/src/harness/openai-safety.test.ts new file mode 100644 index 0000000000..a92129621f --- /dev/null +++ b/packages/eve/src/harness/openai-safety.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionAuthContext } from "#channel/types.js"; +import { mergeOpenAISafetyIdentifier } from "#harness/openai-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("mergeOpenAISafetyIdentifier", () => { + it("sets a fingerprint of the active caller while preserving OpenAI options", () => { + const result = mergeOpenAISafetyIdentifier( + { id: "openai/gpt-5.6-sol" }, + { + gateway: { caching: "auto" }, + openai: { safetyIdentifier: "authored", store: false }, + }, + auth, + ); + + expect(result).toEqual({ + gateway: { caching: "auto" }, + openai: { + safetyIdentifier: invocationOwnerKey(auth), + store: false, + }, + }); + expect(JSON.stringify(result)).not.toContain(auth.principalId); + }); + + it("does not add the OpenAI option to another provider", () => { + const providerOptions = { anthropic: { effort: "high" } }; + + expect( + mergeOpenAISafetyIdentifier({ id: "anthropic/claude-opus-4.8" }, providerOptions, auth), + ).toBe(providerOptions); + }); + + it("does not add a safety identifier without an active caller", () => { + const providerOptions = { openai: { store: false } }; + + expect(mergeOpenAISafetyIdentifier({ id: "openai/gpt-5.6-sol" }, providerOptions, null)).toBe( + providerOptions, + ); + }); +}); diff --git a/packages/eve/src/harness/openai-safety.ts b/packages/eve/src/harness/openai-safety.ts new file mode 100644 index 0000000000..ad67c2d7e0 --- /dev/null +++ b/packages/eve/src/harness/openai-safety.ts @@ -0,0 +1,30 @@ +import type { SessionAuthContext } from "#channel/types.js"; +import { invocationOwnerKey } from "#internal/invocation/metadata.js"; +import type { RuntimeModelReference } from "#runtime/agent/bootstrap.js"; + +/** + * Adds OpenAI's end-user safety identifier without disclosing the raw eve + * principal. The framework-owned value replaces an authored value so every + * request follows the active turn's caller. + */ +export function mergeOpenAISafetyIdentifier( + modelReference: RuntimeModelReference, + providerOptions: Readonly> | undefined, + auth: SessionAuthContext | null, +): Record | undefined { + if (auth === null || modelReference.id.split("/", 1)[0]?.toLowerCase() !== "openai") { + return providerOptions; + } + + const openai = providerOptions?.openai; + const openaiOptions = + openai !== null && typeof openai === "object" && !Array.isArray(openai) ? openai : undefined; + + return { + ...providerOptions, + openai: { + ...openaiOptions, + safetyIdentifier: invocationOwnerKey(auth), + }, + }; +} diff --git a/packages/eve/src/harness/step-hooks.ts b/packages/eve/src/harness/step-hooks.ts index 69a3502d6e..cdf3e0453d 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 { mergeOpenAISafetyIdentifier } from "#harness/openai-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 = mergeOpenAISafetyIdentifier( + 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..3a7f04438d 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: { safetyIdentifier: "authored", 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..fe4faedfce 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 { mergeOpenAISafetyIdentifier } from "#harness/openai-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 = mergeOpenAISafetyIdentifier( + 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, From 24f5893b052bfa86be3f6ec2d233d9a38128035a Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Tue, 25 Aug 2026 15:28:03 -0400 Subject: [PATCH 2/4] fix(eve): models - preserve authored safety identifier Signed-off-by: Andrew Barba --- .changeset/tidy-clouds-smile.md | 2 +- docs/agent-config.md | 19 +++++++-------- .../eve/src/harness/openai-safety.test.ts | 23 +++++++++++++++++-- packages/eve/src/harness/openai-safety.ts | 6 +++-- packages/eve/src/harness/tool-loop.test.ts | 2 +- 5 files changed, 37 insertions(+), 15 deletions(-) diff --git a/.changeset/tidy-clouds-smile.md b/.changeset/tidy-clouds-smile.md index 475c93cae4..5ae5b5c3c2 100644 --- a/.changeset/tidy-clouds-smile.md +++ b/.changeset/tidy-clouds-smile.md @@ -2,4 +2,4 @@ "eve": patch --- -OpenAI model calls now receive a privacy-preserving safety identifier derived from the active session caller, including calls made during context compaction. +OpenAI model calls now receive a privacy-preserving safety identifier 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 ca3f3be529..3de34aaacd 100644 --- a/docs/agent-config.md +++ b/docs/agent-config.md @@ -49,15 +49,16 @@ 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 model call, eve sets `providerOptions.openai.safetyIdentifier` -from the active turn's [`auth.current`](./guides/auth-and-route-protection#what-reaches-ctxsessionauth) -principal. The 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 as the safety identifier. The fingerprint follows the current caller -when a later turn changes users. When `auth.current` is `null`, eve does not add -or replace the option. This applies to OpenAI compaction calls too. eve replaces -an authored `openai.safetyIdentifier` with the active caller's fingerprint while -preserving the other provider options. +For every OpenAI model call, eve fills +`providerOptions.openai.safetyIdentifier` from the active turn's +[`auth.current`](./guides/auth-and-route-protection#what-reaches-ctxsessionauth) +principal when you have not configured that option. 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 as the safety +identifier. The fingerprint follows the current caller when a later turn +changes users. An authored `openai.safetyIdentifier` takes precedence and is +forwarded unchanged. When `auth.current` is `null`, eve does not add the option. +The same rules apply to OpenAI compaction calls. ### Choose the model dynamically diff --git a/packages/eve/src/harness/openai-safety.test.ts b/packages/eve/src/harness/openai-safety.test.ts index a92129621f..b77f1b1c5d 100644 --- a/packages/eve/src/harness/openai-safety.test.ts +++ b/packages/eve/src/harness/openai-safety.test.ts @@ -14,12 +14,31 @@ const auth: SessionAuthContext = { }; describe("mergeOpenAISafetyIdentifier", () => { - it("sets a fingerprint of the active caller while preserving OpenAI options", () => { + it("preserves an authored safety identifier", () => { + const providerOptions = { + gateway: { caching: "auto" }, + openai: { safetyIdentifier: "authored", store: false }, + }; + + expect(mergeOpenAISafetyIdentifier({ id: "openai/gpt-5.6-sol" }, providerOptions, auth)).toBe( + providerOptions, + ); + }); + + it("treats an authored null as explicit", () => { + const providerOptions = { openai: { safetyIdentifier: null } }; + + expect(mergeOpenAISafetyIdentifier({ id: "openai/gpt-5.6-sol" }, providerOptions, auth)).toBe( + providerOptions, + ); + }); + + it("sets a fingerprint of the active caller while preserving other options", () => { const result = mergeOpenAISafetyIdentifier( { id: "openai/gpt-5.6-sol" }, { gateway: { caching: "auto" }, - openai: { safetyIdentifier: "authored", store: false }, + openai: { store: false }, }, auth, ); diff --git a/packages/eve/src/harness/openai-safety.ts b/packages/eve/src/harness/openai-safety.ts index ad67c2d7e0..76411de9d6 100644 --- a/packages/eve/src/harness/openai-safety.ts +++ b/packages/eve/src/harness/openai-safety.ts @@ -4,8 +4,7 @@ import type { RuntimeModelReference } from "#runtime/agent/bootstrap.js"; /** * Adds OpenAI's end-user safety identifier without disclosing the raw eve - * principal. The framework-owned value replaces an authored value so every - * request follows the active turn's caller. + * principal. An authored value takes precedence over the framework default. */ export function mergeOpenAISafetyIdentifier( modelReference: RuntimeModelReference, @@ -19,6 +18,9 @@ export function mergeOpenAISafetyIdentifier( const openai = providerOptions?.openai; const openaiOptions = openai !== null && typeof openai === "object" && !Array.isArray(openai) ? openai : undefined; + if (openaiOptions !== undefined && Object.hasOwn(openaiOptions, "safetyIdentifier")) { + return providerOptions; + } return { ...providerOptions, diff --git a/packages/eve/src/harness/tool-loop.test.ts b/packages/eve/src/harness/tool-loop.test.ts index 3a7f04438d..a9d64b228f 100644 --- a/packages/eve/src/harness/tool-loop.test.ts +++ b/packages/eve/src/harness/tool-loop.test.ts @@ -10087,7 +10087,7 @@ describe("createToolLoopHarness", () => { modelReference: { id: "openai/gpt-5.6-sol", providerOptions: { - openai: { safetyIdentifier: "authored", store: false }, + openai: { store: false }, }, }, system: "", From 94bd69f12b6c70c8406d726ebd75ee097b7c4078 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Tue, 25 Aug 2026 15:41:30 -0400 Subject: [PATCH 3/4] feat(eve): models - support provider safety identifiers Signed-off-by: Andrew Barba --- .changeset/tidy-clouds-smile.md | 2 +- docs/agent-config.md | 20 ++-- .../eve/src/harness/openai-safety.test.ts | 71 ------------ packages/eve/src/harness/openai-safety.ts | 32 ------ .../eve/src/harness/provider-safety.test.ts | 104 ++++++++++++++++++ packages/eve/src/harness/provider-safety.ts | 29 +++++ packages/eve/src/harness/step-hooks.ts | 4 +- packages/eve/src/harness/tool-loop.ts | 4 +- packages/eve/src/shared/objects.test.ts | 28 +++++ packages/eve/src/shared/objects.ts | 21 ++++ 10 files changed, 198 insertions(+), 117 deletions(-) delete mode 100644 packages/eve/src/harness/openai-safety.test.ts delete mode 100644 packages/eve/src/harness/openai-safety.ts create mode 100644 packages/eve/src/harness/provider-safety.test.ts create mode 100644 packages/eve/src/harness/provider-safety.ts create mode 100644 packages/eve/src/shared/objects.test.ts create mode 100644 packages/eve/src/shared/objects.ts diff --git a/.changeset/tidy-clouds-smile.md b/.changeset/tidy-clouds-smile.md index 5ae5b5c3c2..2467b90b18 100644 --- a/.changeset/tidy-clouds-smile.md +++ b/.changeset/tidy-clouds-smile.md @@ -2,4 +2,4 @@ "eve": patch --- -OpenAI model calls now receive a privacy-preserving safety identifier derived from the active session caller when the agent has not provided one, including calls made during context compaction. +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 3de34aaacd..843b8a28fc 100644 --- a/docs/agent-config.md +++ b/docs/agent-config.md @@ -49,16 +49,18 @@ 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 model call, eve fills -`providerOptions.openai.safetyIdentifier` from the active turn's +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 that option. 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 as the safety -identifier. The fingerprint follows the current caller when a later turn -changes users. An authored `openai.safetyIdentifier` takes precedence and is -forwarded unchanged. When `auth.current` is `null`, eve does not add the option. -The same rules apply to OpenAI compaction calls. +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 diff --git a/packages/eve/src/harness/openai-safety.test.ts b/packages/eve/src/harness/openai-safety.test.ts deleted file mode 100644 index b77f1b1c5d..0000000000 --- a/packages/eve/src/harness/openai-safety.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { SessionAuthContext } from "#channel/types.js"; -import { mergeOpenAISafetyIdentifier } from "#harness/openai-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("mergeOpenAISafetyIdentifier", () => { - it("preserves an authored safety identifier", () => { - const providerOptions = { - gateway: { caching: "auto" }, - openai: { safetyIdentifier: "authored", store: false }, - }; - - expect(mergeOpenAISafetyIdentifier({ id: "openai/gpt-5.6-sol" }, providerOptions, auth)).toBe( - providerOptions, - ); - }); - - it("treats an authored null as explicit", () => { - const providerOptions = { openai: { safetyIdentifier: null } }; - - expect(mergeOpenAISafetyIdentifier({ id: "openai/gpt-5.6-sol" }, providerOptions, auth)).toBe( - providerOptions, - ); - }); - - it("sets a fingerprint of the active caller while preserving other options", () => { - const result = mergeOpenAISafetyIdentifier( - { 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("does not add the OpenAI option to another provider", () => { - const providerOptions = { anthropic: { effort: "high" } }; - - expect( - mergeOpenAISafetyIdentifier({ id: "anthropic/claude-opus-4.8" }, providerOptions, auth), - ).toBe(providerOptions); - }); - - it("does not add a safety identifier without an active caller", () => { - const providerOptions = { openai: { store: false } }; - - expect(mergeOpenAISafetyIdentifier({ id: "openai/gpt-5.6-sol" }, providerOptions, null)).toBe( - providerOptions, - ); - }); -}); diff --git a/packages/eve/src/harness/openai-safety.ts b/packages/eve/src/harness/openai-safety.ts deleted file mode 100644 index 76411de9d6..0000000000 --- a/packages/eve/src/harness/openai-safety.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { SessionAuthContext } from "#channel/types.js"; -import { invocationOwnerKey } from "#internal/invocation/metadata.js"; -import type { RuntimeModelReference } from "#runtime/agent/bootstrap.js"; - -/** - * Adds OpenAI's end-user safety identifier without disclosing the raw eve - * principal. An authored value takes precedence over the framework default. - */ -export function mergeOpenAISafetyIdentifier( - modelReference: RuntimeModelReference, - providerOptions: Readonly> | undefined, - auth: SessionAuthContext | null, -): Record | undefined { - if (auth === null || modelReference.id.split("/", 1)[0]?.toLowerCase() !== "openai") { - return providerOptions; - } - - const openai = providerOptions?.openai; - const openaiOptions = - openai !== null && typeof openai === "object" && !Array.isArray(openai) ? openai : undefined; - if (openaiOptions !== undefined && Object.hasOwn(openaiOptions, "safetyIdentifier")) { - return providerOptions; - } - - return { - ...providerOptions, - openai: { - ...openaiOptions, - safetyIdentifier: invocationOwnerKey(auth), - }, - }; -} 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 cdf3e0453d..168fb28ab3 100644 --- a/packages/eve/src/harness/step-hooks.ts +++ b/packages/eve/src/harness/step-hooks.ts @@ -31,7 +31,7 @@ import { mergeGatewayAutoCaching, type PromptCachePath, } from "#harness/prompt-cache.js"; -import { mergeOpenAISafetyIdentifier } from "#harness/openai-safety.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"; @@ -183,7 +183,7 @@ export function buildStepHooks(input: StepHooksInput): StepHooks { }; const modelReference = requireSessionModelReference(session); - const providerOptions = mergeOpenAISafetyIdentifier( + const providerOptions = mergeProviderSafetyIdentifier( modelReference, modelReference.providerOptions, input.auth, diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index fe4faedfce..9258353cdf 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -244,7 +244,7 @@ import { isInvalidToolCall, } from "#harness/tool-call-input-errors.js"; import { buildStepHooks, emitStepActions, type HarnessStepResult } from "#harness/step-hooks.js"; -import { mergeOpenAISafetyIdentifier } from "#harness/openai-safety.js"; +import { mergeProviderSafetyIdentifier } from "#harness/provider-safety.js"; import { buildToolApproval, buildToolSetFromDefinitions, @@ -3288,7 +3288,7 @@ async function maybeCompact(input: { }); const compactionModelReference = session.agent.compactionModelReference ?? requireSessionModelReference(session); - const providerOptions = mergeOpenAISafetyIdentifier( + const providerOptions = mergeProviderSafetyIdentifier( compactionModelReference, compaction.providerOptions, input.auth, diff --git a/packages/eve/src/shared/objects.test.ts b/packages/eve/src/shared/objects.test.ts new file mode 100644 index 0000000000..9027f4e596 --- /dev/null +++ b/packages/eve/src/shared/objects.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { mergeObjects } from "#shared/objects.js"; + +describe("mergeObjects", () => { + it("recursively merges records with overrides taking precedence", () => { + expect( + mergeObjects( + { provider: { metadata: { safetyId: "default" }, setting: true } }, + { provider: { metadata: { safetyId: "authored" } } }, + ), + ).toEqual({ + provider: { + metadata: { safetyId: "authored" }, + setting: true, + }, + }); + }); + + it("replaces non-record values instead of merging them", () => { + expect( + mergeObjects( + { array: [1], object: { nested: true }, primitive: "default" }, + { array: [2], object: null, primitive: undefined }, + ), + ).toEqual({ array: [2], object: null, primitive: undefined }); + }); +}); 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; +} From d34027d971a88013e3a6977020aaac7544bed3f1 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Tue, 25 Aug 2026 15:49:04 -0400 Subject: [PATCH 4/4] test(eve): strengthen object merge coverage Signed-off-by: Andrew Barba --- packages/eve/src/shared/objects.test.ts | 96 ++++++++++++++++++++----- 1 file changed, 79 insertions(+), 17 deletions(-) diff --git a/packages/eve/src/shared/objects.test.ts b/packages/eve/src/shared/objects.test.ts index 9027f4e596..fcbee9b8e0 100644 --- a/packages/eve/src/shared/objects.test.ts +++ b/packages/eve/src/shared/objects.test.ts @@ -3,26 +3,88 @@ import { describe, expect, it } from "vitest"; import { mergeObjects } from "#shared/objects.js"; describe("mergeObjects", () => { - it("recursively merges records with overrides taking precedence", () => { - expect( - mergeObjects( - { provider: { metadata: { safetyId: "default" }, setting: true } }, - { provider: { metadata: { safetyId: "authored" } } }, - ), - ).toEqual({ - provider: { - metadata: { safetyId: "authored" }, - setting: true, + 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 non-record values instead of merging them", () => { - expect( - mergeObjects( - { array: [1], object: { nested: true }, primitive: "default" }, - { array: [2], object: null, primitive: undefined }, - ), - ).toEqual({ array: [2], object: null, primitive: undefined }); + 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); }); });