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
5 changes: 5 additions & 0 deletions .changeset/tidy-clouds-smile.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions docs/agent-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions packages/eve/src/harness/provider-safety.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
29 changes: 29 additions & 0 deletions packages/eve/src/harness/provider-safety.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> | undefined,
auth: SessionAuthContext | null,
): Record<string, unknown> | 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);
}
10 changes: 9 additions & 1 deletion packages/eve/src/harness/step-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
TypedToolCall,
TypedToolResult,
} from "ai";
import type { SessionAuthContext } from "#channel/types.js";
import {
createActionResultEvent,
createActionsRequestedEvent,
Expand All @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
84 changes: 83 additions & 1 deletion packages/eve/src/harness/tool-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<unknown[], { providerOptions?: unknown }>(
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 = {
Expand Down
15 changes: 14 additions & 1 deletion packages/eve/src/harness/tool-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -1542,6 +1546,7 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn {
);

const hooks = buildStepHooks({
auth: ctx?.get(AuthKey) ?? null,
cachePath,
emit,
emissionState,
Expand Down Expand Up @@ -3251,6 +3256,7 @@ function createNextCompactionConfig(
*/
async function maybeCompact(input: {
readonly abortSignal?: AbortSignal;
readonly auth: SessionAuthContext | null;
readonly emit?: ToolLoopHarnessConfig["handleEvent"];
readonly emissionState: ReturnType<typeof getHarnessEmissionState>;
readonly force?: boolean;
Expand Down Expand Up @@ -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<typeof compactMessages>[3];

if (emit) {
await emit(
Expand All @@ -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,
Expand Down
Loading
Loading