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
@@ -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,
},
);