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
@@ -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") {