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:
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user