fix(ai): stable metadata.user_id per session for Anthropic OAuth

Anthropic counts sessions by metadata.user_id. Without this fix, OMP
generated fresh random entropy on every API request, inflating the
session count and preventing backend attribution to the authenticated
account.

Changes:

packages/ai:
- resolveAnthropicMetadataUserId() now accepts JSON-format user_id
  matching real Claude Code's getAPIMetadata shape
  ({ session_id, account_uuid, ... }). Previously only the legacy
  cloaking format was accepted on OAuth, causing stable caller-supplied
  values to be silently discarded.
- AnthropicOAuthFlow.exchangeToken() and refreshAnthropicToken() now
  populate OAuthCredentials.{accountId, email} from the token response
  account block, removing the need for a separate /api/oauth/profile
  round-trip.
- AuthStorage.getOAuthAccountId(provider, sessionId) returns the OAuth
  accountId for the session-sticky credential, used to build
  account_uuid in metadata.user_id. Guards against misattribution for
  API-key, runtime-override, env-key, and fallback-resolver paths that
  do not record a session credential.

packages/agent:
- Agent.metadataForProvider(provider) resolves request metadata for
  the given provider via the installed resolver, or returns the static
  metadata value. The plain metadata getter now returns only the static
  value; provider-aware resolution is explicit.
- Agent.setMetadataResolver(fn) installs a (provider: string) resolver
  evaluated per LLM request in agent-loop, after getApiKey records the
  session-sticky credential, so account_uuid reflects the credential
  actually used.
- AgentLoopConfig.metadataResolver is called with config.model.provider
  after getApiKey, overriding the static metadata field.

packages/coding-agent:
- AgentSession.#syncAgentSessionId installs a metadata resolver that
  builds { user_id: JSON.stringify({ session_id, account_uuid? }) },
  matching the Anthropic session attribution format. account_uuid is
  only included for provider="anthropic" to avoid leaking the OAuth
  identity to third-party Anthropic-format-compatible providers.
- sessionId getter prefers providerSessionId when supplied via
  AgentSessionConfig so all API paths (getApiKey, direct calls,
  metadata resolver) share the same provider-facing session ID.
- prepareSimpleStreamOptions stamps session metadata on direct calls
  (runEphemeralTurn, compaction, branch summary, title generation) so
  they share the same session bucket as Agent.prompt requests.
- generateBranchSummary and generateSessionTitle accept a
  (provider: string) metadata resolver evaluated after their own
  getApiKey call for correct credential attribution.
This commit is contained in:
Miroslav Drbal
2026-05-09 09:48:10 +02:00
parent 5ad37428a0
commit fc70a45c46
19 changed files with 523 additions and 45 deletions
+13 -4
View File
@@ -236,7 +236,7 @@ async function runPhase1(options: {
logger.debug("Phase1 skipped: no model available");
return;
}
const phase1ApiKey = await modelRegistry.getApiKey(phase1Model, session.sessionManager.getSessionId());
const phase1ApiKey = await modelRegistry.getApiKey(phase1Model, session.sessionId);
if (!phase1ApiKey) {
logger.debug("Phase1 skipped: no API key for phase1 model", {
provider: phase1Model.provider,
@@ -274,6 +274,7 @@ async function runPhase1(options: {
apiKey: phase1ApiKey,
modelMaxTokens: computeModelTokenBudget(phase1Model, config),
config,
metadata: session.agent?.metadataForProvider(phase1Model.provider),
});
if (result.kind === "failed") {
@@ -397,7 +398,7 @@ async function runPhase2(options: {
});
return;
}
const phase2ApiKey = await modelRegistry.getApiKey(phase2Model, session.sessionManager.getSessionId());
const phase2ApiKey = await modelRegistry.getApiKey(phase2Model, session.sessionId);
if (!phase2ApiKey) {
markPhase2FailureWithFallback(db, {
claim,
@@ -428,6 +429,7 @@ async function runPhase2(options: {
memoryRoot,
model: phase2Model,
apiKey: phase2ApiKey,
metadata: session.agent?.metadataForProvider(phase2Model.provider),
});
await applyConsolidation(memoryRoot, consolidated);
if (heartbeatLostOwnership) {
@@ -575,6 +577,7 @@ async function runStage1Job(options: {
apiKey: string;
modelMaxTokens: number;
config: MemoryRuntimeConfig;
metadata?: Record<string, unknown>;
}): Promise<
| {
kind: "output";
@@ -607,6 +610,7 @@ async function runStage1Job(options: {
},
{
apiKey,
metadata: options.metadata,
maxTokens: Math.max(1024, Math.min(4096, Math.floor(modelMaxTokens * 0.2))),
reasoning: Effort.Low,
},
@@ -711,7 +715,12 @@ async function readRolloutSummaries(memoryRoot: string): Promise<string> {
return blocks.join("\n\n");
}
async function runConsolidationModel(options: { memoryRoot: string; model: Model; apiKey: string }): Promise<{
async function runConsolidationModel(options: {
memoryRoot: string;
model: Model;
apiKey: string;
metadata?: Record<string, unknown>;
}): Promise<{
memoryMd: string;
memorySummary: string;
skills: Array<{
@@ -735,7 +744,7 @@ async function runConsolidationModel(options: { memoryRoot: string; model: Model
{
messages: [{ role: "user", content: [{ type: "text", text: input }], timestamp: Date.now() }],
},
{ apiKey, maxTokens: 8192, reasoning: Effort.Medium },
{ apiKey, metadata: options.metadata, maxTokens: 8192, reasoning: Effort.Medium },
);
if (response.stopReason === "error") {
throw new Error(response.errorMessage || "phase2 model error");
@@ -362,7 +362,14 @@ export class InputController {
const hasUserMessages = this.ctx.session.messages.some((m: AgentMessage) => m.role === "user");
if (!hasUserMessages && !this.ctx.sessionManager.getSessionName() && !$env.PI_NO_TITLE) {
const registry = this.ctx.session.modelRegistry;
generateSessionTitle(text, registry, this.ctx.settings, this.ctx.session.sessionId, this.ctx.session.model)
generateSessionTitle(
text,
registry,
this.ctx.settings,
this.ctx.session.sessionId,
this.ctx.session.model,
provider => this.ctx.session.agent.metadataForProvider(provider),
)
.then(async title => {
if (title) {
const applied = await this.ctx.sessionManager.setSessionName(title, "auto");
+4 -3
View File
@@ -1675,9 +1675,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
preferWebsockets: preferOpenAICodexWebsockets,
getToolContext: tc => toolContextStore.getContext(tc),
getApiKey: async provider => {
// Use the provider-facing session id for sticky credential selection so cache keys
// and provider auth affinity stay aligned across fresh benchmark sessions.
const key = await modelRegistry.getApiKeyForProvider(provider, providerSessionId);
// Read agent.sessionId at call time so credential selection stays aligned
// with metadataResolver after /new, fork, resume, or branch switches.
const key = await modelRegistry.getApiKeyForProvider(provider, agent.sessionId);
if (!key) {
throw new Error(`No API key found for provider "${provider}"`);
}
@@ -1757,6 +1757,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
asyncJobManager,
agentId: resolvedAgentId,
agentRegistry,
providerSessionId: options.providerSessionId,
});
hasSession = true;
@@ -148,6 +148,7 @@ import { type EditMode, resolveEditMode } from "../utils/edit-mode";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
import { buildNamedToolChoice } from "../utils/tool-choice";
import type { AuthStorage } from "./auth-storage";
import {
type CompactionResult,
calculateContextTokens,
@@ -280,6 +281,13 @@ export interface AgentSessionConfig {
agentId?: string;
/** Shared agent registry (for forwarding IRC observations to the main session UI). */
agentRegistry?: AgentRegistry;
/**
* Override the provider-facing session ID for all API requests from this session.
* When absent, `sessionManager.getSessionId()` is used. Needed when benchmark or
* SDK callers issue probes / prewarming with an explicit `--provider-session-id`
* so that credential sticky selection is consistent with the session's streaming calls.
*/
providerSessionId?: string;
}
/** Options for AgentSession.prompt() */
@@ -400,6 +408,46 @@ function todoClearKey(phaseName: string, taskContent: string): string {
return `${phaseName}\u0000${taskContent}`;
}
/**
* Build the per-request `metadata` payload for the Anthropic provider, shaped
* like real Claude Code's `getAPIMetadata` output (`{ session_id, account_uuid }`)
* so the backend buckets requests under one session and attributes them to the
* authenticated OAuth account when available. Resolved at request time so token
* refreshes and login/logout transitions don't strand a stale account UUID in
* memory. `account_uuid` is omitted for non-Anthropic providers to avoid leaking
* the user's Claude identity to third-party APIs (including Anthropic-format-
* compatible proxies such as cloudflare-ai-gateway or gitlab-duo).
*
* `provider` is the target provider string (e.g. `"anthropic"`) and gates the
* `account_uuid` lookup — only `"anthropic"` requests carry it.
*
* `sessionId` is forwarded to the auth-storage session-sticky lookup so that
* multi-credential setups attribute to the same OAuth account used for the
* actual API request rather than always picking the first credential.
*
* `authStorage` is treated as optional so test fixtures that stub `modelRegistry`
* without a real storage layer still work; the resolver simply skips the lookup
* and emits `{ session_id }` alone, matching the no-OAuth-credential path.
*/
function buildSessionMetadata(
sessionId: string,
provider: string,
authStorage: AuthStorage | undefined,
): Record<string, unknown> {
const userId: Record<string, string> = { session_id: sessionId };
// Only look up account_uuid when the request is going to Anthropic. Injecting
// a Claude OAuth account_uuid into requests bound for other providers (including
// Anthropic-format-compatible proxies like cloudflare-ai-gateway or gitlab-duo)
// would leak the user's Anthropic identity to unrelated third-party APIs.
if (provider === "anthropic") {
const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId);
if (typeof accountUuid === "string" && accountUuid.length > 0) {
userId.account_uuid = accountUuid;
}
}
return { user_id: JSON.stringify(userId) };
}
const noOpUIContext: ExtensionUIContext = {
select: async (_title, _options, _dialogOptions) => undefined,
confirm: async (_title, _message, _dialogOptions) => false,
@@ -503,6 +551,7 @@ export class AgentSession {
// Agent identity + registry for IRC relay forwarding to the main session UI.
#agentId: string | undefined;
#agentRegistry: AgentRegistry | undefined;
#providerSessionId: string | undefined;
// Extension system
#extensionRunner: ExtensionRunner | undefined = undefined;
#turnIndex = 0;
@@ -652,6 +701,7 @@ export class AgentSession {
this.#obfuscator = config.obfuscator;
this.#agentId = config.agentId;
this.#agentRegistry = config.agentRegistry;
this.#providerSessionId = config.providerSessionId;
this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => {
const event: AgentEvent = {
type: "message_update",
@@ -662,6 +712,7 @@ export class AgentSession {
this.#maybeAbortStreamingEdit(event);
});
this.agent.providerSessionState = this.#providerSessionState;
this.#syncAgentSessionId();
this.#syncTodoPhasesFromBranch();
// Always subscribe to agent events for internal handling
@@ -1987,7 +2038,24 @@ export class AgentSession {
this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
}
/** Keep Hindsight metadata aligned when the underlying agent session id changes. */
/**
* Set agent.sessionId from the session manager and install a dynamic
* metadata resolver so every API request carries `metadata.user_id` shaped
* like real Claude Code's `getAPIMetadata` output: `{ session_id,
* account_uuid }` (the latter only when an Anthropic OAuth credential with
* a known account UUID is loaded). Resolving live keeps the value in sync
* with auth-state changes (login/logout, token refresh that surfaces a new
* account uuid) without needing to re-call `#syncAgentSessionId()` on every
* such event.
*/
#syncAgentSessionId(sessionId?: string): void {
const sid = this.#providerSessionId ?? sessionId ?? this.sessionManager.getSessionId();
this.agent.sessionId = sid;
this.agent.setMetadataResolver((provider: string) =>
buildSessionMetadata(sid, provider, this.#modelRegistry.authStorage),
);
}
#rekeyHindsightMemoryForCurrentSessionId(): void {
if (resolveMemoryBackend(this.settings).id !== "hindsight") return;
const sid = this.agent.sessionId;
@@ -2692,13 +2760,21 @@ export class AgentSession {
}
/** Apply session-level stream hooks to a direct side request. */
prepareSimpleStreamOptions(options: SimpleStreamOptions): SimpleStreamOptions {
prepareSimpleStreamOptions(options: SimpleStreamOptions, provider = "anthropic"): SimpleStreamOptions {
const sessionOnPayload = this.#onPayload;
const sessionOnResponse = this.#onResponse;
if (!sessionOnPayload && !sessionOnResponse) return options;
const sessionMetadata = this.agent.metadataForProvider(provider);
if (!sessionOnPayload && !sessionOnResponse && !sessionMetadata) return options;
const preparedOptions: SimpleStreamOptions = { ...options };
// Stamp session metadata (e.g. user_id={session_id}) onto direct-call requests so
// they share the same session bucket as Agent.prompt-routed requests on Anthropic
// OAuth. Caller-provided metadata wins so explicit overrides are respected.
if (sessionMetadata && !options.metadata) {
preparedOptions.metadata = sessionMetadata;
}
if (sessionOnPayload) {
if (!options.onPayload) {
preparedOptions.onPayload = sessionOnPayload;
@@ -2750,7 +2826,7 @@ export class AgentSession {
/** Current session ID */
get sessionId(): string {
return this.sessionManager.getSessionId();
return this.#providerSessionId ?? this.sessionManager.getSessionId();
}
/** Current session display name, if set */
@@ -3810,7 +3886,7 @@ export class AgentSession {
}
await this.sessionManager.newSession(options);
this.setTodoPhases([]);
this.agent.sessionId = this.sessionManager.getSessionId();
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#resetHindsightConversationTrackingIfHindsight();
this.#steeringMessages = [];
@@ -3905,7 +3981,7 @@ export class AgentSession {
}
// Update agent session ID
this.agent.sessionId = this.sessionManager.getSessionId();
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
// Emit session_switch event with reason "fork" to hooks
@@ -4373,6 +4449,7 @@ export class AgentSession {
promptOverride: hookPrompt,
extraContext: hookContext,
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
metadata: this.agent.metadataForProvider(compactionModel.provider),
},
);
summary = result.summary;
@@ -4616,7 +4693,7 @@ export class AgentSession {
this.#asyncJobManager?.cancelAll();
await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
this.agent.reset();
this.agent.sessionId = this.sessionManager.getSessionId();
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#resetHindsightConversationTrackingIfHindsight();
this.#steeringMessages = [];
@@ -5487,6 +5564,7 @@ export class AgentSession {
promptOverride: hookPrompt,
extraContext: hookContext,
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
metadata: this.agent.metadataForProvider(candidate.provider),
initiatorOverride: "agent",
});
break;
@@ -6606,15 +6684,18 @@ export class AgentSession {
systemPrompt: this.systemPrompt,
messages: llmMessages,
};
const options = this.prepareSimpleStreamOptions({
apiKey,
sessionId: this.sessionId,
reasoning: toReasoningEffort(this.thinkingLevel),
hideThinkingSummary: this.agent.hideThinkingSummary,
serviceTier: this.serviceTier,
signal: args.signal,
toolChoice: "none",
});
const options = this.prepareSimpleStreamOptions(
{
apiKey,
sessionId: this.sessionId,
reasoning: toReasoningEffort(this.thinkingLevel),
hideThinkingSummary: this.agent.hideThinkingSummary,
serviceTier: this.serviceTier,
signal: args.signal,
toolChoice: "none",
},
model.provider,
);
let replyText = "";
let assistantMessage: AssistantMessage | undefined;
@@ -6791,7 +6872,7 @@ export class AgentSession {
try {
await this.sessionManager.setSessionFile(sessionPath);
this.agent.sessionId = this.sessionManager.getSessionId();
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
const sessionContext = this.buildDisplaySessionContext();
@@ -6869,7 +6950,7 @@ export class AgentSession {
return true;
} catch (error) {
this.sessionManager.restoreState(previousSessionState);
this.agent.sessionId = previousSessionState.sessionId;
this.#syncAgentSessionId(previousSessionState.sessionId);
this.#rekeyHindsightMemoryForCurrentSessionId();
let restoreMcpError: unknown;
try {
@@ -6961,7 +7042,7 @@ export class AgentSession {
this.sessionManager.createBranchedSession(selectedEntry.parentId);
}
this.#syncTodoPhasesFromBranch();
this.agent.sessionId = this.sessionManager.getSessionId();
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#resetHindsightConversationTrackingIfHindsight();
@@ -7082,6 +7163,7 @@ export class AgentSession {
signal: this.#branchSummaryAbortController.signal,
customInstructions: options.customInstructions,
reserveTokens: branchSummarySettings.reserveTokens,
metadata: this.agent.metadataForProvider(model.provider),
});
this.#branchSummaryAbortController = undefined;
if (result.aborted) {
@@ -75,6 +75,8 @@ export interface GenerateBranchSummaryOptions {
customInstructions?: string;
/** Tokens reserved for prompt + LLM response (default 16384) */
reserveTokens?: number;
/** Optional metadata forwarded to the underlying API request (e.g. user_id for session attribution). */
metadata?: Record<string, unknown>;
}
// ============================================================================
@@ -258,7 +260,7 @@ export async function generateBranchSummary(
entries: SessionEntry[],
options: GenerateBranchSummaryOptions,
): Promise<BranchSummaryResult> {
const { model, apiKey, signal, customInstructions, reserveTokens = 16384 } = options;
const { model, apiKey, signal, customInstructions, reserveTokens = 16384, metadata } = options;
// Token budget = context window minus reserved space for prompt + response
const contextWindow = model.contextWindow || 128000;
@@ -291,7 +293,7 @@ export async function generateBranchSummary(
const response = await completeSimple(
model,
{ systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages },
{ apiKey, signal, maxTokens: 2048 },
{ apiKey, signal, maxTokens: 2048, metadata },
);
// Check if aborted or errored
@@ -965,6 +965,7 @@ export interface SummaryOptions {
remoteEndpoint?: string;
remoteInstructions?: string;
initiatorOverride?: MessageAttribution;
metadata?: Record<string, unknown>;
}
export async function generateSummary(
@@ -1020,7 +1021,14 @@ export async function generateSummary(
const response = await completeSimple(
model,
{ systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages },
{ maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride: options?.initiatorOverride },
{
maxTokens,
signal,
apiKey,
reasoning: Effort.High,
initiatorOverride: options?.initiatorOverride,
metadata: options?.metadata,
},
);
if (response.stopReason === "error") {
@@ -1069,7 +1077,14 @@ async function generateShortSummary(
systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT],
messages: [{ role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() }],
},
{ maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride: options?.initiatorOverride },
{
maxTokens,
signal,
apiKey,
reasoning: Effort.High,
initiatorOverride: options?.initiatorOverride,
metadata: options?.metadata,
},
);
if (response.stopReason === "error") {
@@ -1249,6 +1264,7 @@ export async function compact(
remoteEndpoint: settings.remoteEnabled === false ? undefined : settings.remoteEndpoint,
remoteInstructions: options?.remoteInstructions,
initiatorOverride: options?.initiatorOverride,
metadata: options?.metadata,
};
let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
@@ -1304,6 +1320,7 @@ export async function compact(
apiKey,
signal,
summaryOptions.initiatorOverride,
summaryOptions.metadata,
),
]);
// Merge into single summary
@@ -1339,6 +1356,7 @@ export async function compact(
extraContext: options?.extraContext,
remoteEndpoint: summaryOptions.remoteEndpoint,
initiatorOverride: summaryOptions.initiatorOverride,
metadata: summaryOptions.metadata,
},
);
@@ -1370,6 +1388,7 @@ async function generateTurnPrefixSummary(
apiKey: string,
signal?: AbortSignal,
initiatorOverride?: MessageAttribution,
metadata?: Record<string, unknown>,
): Promise<string> {
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
@@ -1387,7 +1406,7 @@ async function generateTurnPrefixSummary(
const response = await completeSimple(
model,
{ systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages },
{ maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride },
{ maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride, metadata },
);
if (response.stopReason === "error") {
@@ -36,6 +36,11 @@ function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel
* @param registry Model registry
* @param settings Settings used to resolve the smol role
* @param sessionId Optional session id for sticky API key selection
* @param currentModel Current model (used to derive title model)
* @param metadataResolver Optional resolver evaluated after credential selection
* to produce request metadata (e.g. user_id for session attribution). Using a
* resolver instead of a pre-evaluated value ensures the metadata's account_uuid
* reflects the credential actually selected for this request.
*/
export async function generateSessionTitle(
firstMessage: string,
@@ -43,6 +48,7 @@ export async function generateSessionTitle(
settings: Settings,
sessionId?: string,
currentModel?: Model<Api>,
metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
): Promise<string | null> {
const model = getTitleModel(registry, settings, currentModel);
if (!model) {
@@ -65,6 +71,10 @@ ${truncatedMessage}
});
return null;
}
// Resolve metadata after getApiKey so the session-sticky credential for this
// request is already recorded; metadataResolver can then return the correct
// account_uuid rather than the snapshot-at-call-site value.
const metadata = metadataResolver?.(model.provider);
// Title generation is a 3-6 word task; force reasoning off so reasoning models
// don't burn the entire output budget on internal thinking and return an empty
@@ -88,6 +98,7 @@ ${truncatedMessage}
apiKey,
maxTokens: 30,
disableReasoning: true,
metadata,
},
);