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 08:40:13 +02:00
parent 5ad37428a0
commit fc70a45c46
19 changed files with 523 additions and 45 deletions
+4
View File
@@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
### Added
- Added `Agent#metadata` field forwarded to every API request; callers can set arbitrary provider metadata (e.g. `metadata.user_id`) once and have it applied to all subsequent stream calls without modifying per-call options
- Added `Agent#setMetadataResolver(fn)` for installing a function that resolves request metadata at call time. The `metadata` getter dispatches through the resolver on every read (including the snapshot taken per `prompt()`), so callers reflect mutable external state (e.g. live OAuth account UUID after a token refresh) without manual re-syncs. Plain `agent.metadata = …` continues to set a static value and clears any installed resolver.
## [14.7.6] - 2026-05-07
+8 -1
View File
@@ -385,15 +385,22 @@ async function streamAssistantResponse(
const streamFunction = streamFn || streamSimple;
// Resolve API key (important for expiring tokens)
// Resolve API key (important for expiring tokens) — do this before resolving
// metadata so that the session-sticky credential recorded by getApiKey is
// visible to metadataResolver (e.g. for the correct account_uuid in metadata.user_id).
const resolvedApiKey =
(config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
// Re-resolve metadata after credential selection so the per-request value
// reflects the credential actually used, not the snapshot from AgentLoopConfig construction.
const resolvedMetadata = config.metadataResolver ? config.metadataResolver(config.model.provider) : config.metadata;
const dynamicToolChoice = config.getToolChoice?.();
const dynamicReasoning = config.getReasoning?.();
const response = await streamFunction(config.model, llmContext, {
...config,
apiKey: resolvedApiKey,
metadata: resolvedMetadata,
toolChoice: dynamicToolChoice ?? config.toolChoice,
reasoning: dynamicReasoning ?? config.reasoning,
signal,
+46
View File
@@ -233,6 +233,8 @@ export class Agent {
#followUpMode: "all" | "one-at-a-time";
#interruptMode: "immediate" | "wait";
#sessionId?: string;
#metadata?: Record<string, unknown>;
#metadataResolver?: (provider: string) => Record<string, unknown> | undefined;
#providerSessionState?: Map<string, ProviderSessionState>;
#thinkingBudgets?: ThinkingBudgets;
#temperature?: number;
@@ -313,6 +315,48 @@ export class Agent {
this.#sessionId = value;
}
/**
* Static metadata forwarded to every API request when no resolver is installed
* (e.g. `metadata.user_id` for Anthropic session attribution). Setting this
* clears any installed resolver.
*
* For live/provider-aware metadata (e.g. Anthropic OAuth `account_uuid` that
* must reflect the credential selected per-request), use
* {@link setMetadataResolver} and read via {@link metadataForProvider}.
*/
get metadata(): Record<string, unknown> | undefined {
return this.#metadata;
}
set metadata(value: Record<string, unknown> | undefined) {
this.#metadata = value;
this.#metadataResolver = undefined;
}
/**
* Resolve request metadata for the given provider at call time. When a
* resolver is installed via {@link setMetadataResolver}, it is invoked with
* the provider string so the result can be scoped (e.g. `account_uuid` is
* only included for `"anthropic"` requests). Falls back to the static
* {@link metadata} value when no resolver is set.
*/
metadataForProvider(provider: string): Record<string, unknown> | undefined {
if (this.#metadataResolver) return this.#metadataResolver(provider);
return this.#metadata;
}
/**
* Install a function that resolves request metadata at call time. The
* resolver receives the target provider string and can gate provider-specific
* fields (e.g. `account_uuid` only for `"anthropic"`). Invoked per LLM
* request by `agent-loop` after `getApiKey` selects the session-sticky
* credential. Pass `undefined` to clear and revert to the static
* {@link metadata} value.
*/
setMetadataResolver(resolver: ((provider: string) => Record<string, unknown> | undefined) | undefined): void {
this.#metadataResolver = resolver;
}
/**
* Get provider-scoped mutable session state store.
*/
@@ -777,6 +821,8 @@ export class Agent {
hideThinkingSummary: this.#hideThinkingSummary,
interruptMode: this.#interruptMode,
sessionId: this.#sessionId,
metadata: this.#metadataResolver ? undefined : this.#metadata,
metadataResolver: this.#metadataResolver,
providerSessionState: this.#providerSessionState,
thinkingBudgets: this.#thinkingBudgets,
maxRetryDelayMs: this.#maxRetryDelayMs,
+10
View File
@@ -39,6 +39,16 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
*/
sessionId?: string;
/**
* Optional resolver called per LLM request to produce request metadata.
* When set, the agent loop evaluates it **after** `getApiKey` resolves the
* session-sticky credential, ensuring the metadata's `account_uuid` reflects
* the credential actually used for the request (not the credential that was
* current when `AgentLoopConfig` was first constructed). Overrides the static
* `metadata` field when present.
*/
metadataResolver?: (provider: string) => Record<string, unknown> | undefined;
/**
* Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.
*
+56
View File
@@ -427,4 +427,60 @@ describe("Agent", () => {
expect(reasoningPerCall).toEqual([ThinkingLevel.Low, ThinkingLevel.High]);
});
it("returns static metadata via the plain setter", () => {
const agent = new Agent();
expect(agent.metadata).toBeUndefined();
const value = { user_id: "static" };
agent.metadata = value;
expect(agent.metadata).toEqual({ user_id: "static" });
agent.metadata = undefined;
expect(agent.metadata).toBeUndefined();
});
it("metadataForProvider resolves dynamic value at every call when a resolver is installed", () => {
const agent = new Agent();
let live = "alpha";
agent.setMetadataResolver(() => ({ user_id: live }));
expect(agent.metadataForProvider("anthropic")).toEqual({ user_id: "alpha" });
live = "beta";
expect(agent.metadataForProvider("anthropic")).toEqual({ user_id: "beta" });
// Static getter is unaffected by the resolver.
expect(agent.metadata).toBeUndefined();
});
it("clears any installed resolver when assigning the plain setter", () => {
const agent = new Agent();
agent.setMetadataResolver(() => ({ user_id: "from-resolver" }));
expect(agent.metadataForProvider("any")).toEqual({ user_id: "from-resolver" });
agent.metadata = { user_id: "from-static" };
expect(agent.metadata).toEqual({ user_id: "from-static" });
expect(agent.metadataForProvider("any")).toEqual({ user_id: "from-static" });
});
it("metadataForProvider returns undefined from the resolver even when a static value is set", () => {
// Pin the contract that an installed resolver wins unconditionally over
// `#metadata` in the per-provider path.
const agent = new Agent();
agent.metadata = { user_id: "static" };
agent.setMetadataResolver(() => undefined);
expect(agent.metadataForProvider("any")).toBeUndefined();
// The static getter returns the pre-set static value; the resolver does not affect it.
expect(agent.metadata).toEqual({ user_id: "static" });
});
it("reverts to the plain-setter value when the resolver is cleared via setMetadataResolver(undefined)", () => {
const agent = new Agent();
agent.metadata = { user_id: "static" };
agent.setMetadataResolver(() => ({ user_id: "from-resolver" }));
expect(agent.metadataForProvider("any")).toEqual({ user_id: "from-resolver" });
agent.setMetadataResolver(undefined);
expect(agent.metadataForProvider("any")).toEqual({ user_id: "static" });
expect(agent.metadata).toEqual({ user_id: "static" });
});
});
+8
View File
@@ -2,6 +2,14 @@
## [Unreleased]
### Added
- Added Anthropic OAuth `account.uuid` and `account.email_address` extraction from the `/v1/oauth/token` exchange and refresh responses; both `AnthropicOAuthFlow.exchangeToken()` and `refreshAnthropicToken()` now populate `OAuthCredentials.{accountId, email}` so downstream consumers can attribute requests to the authenticated account without a separate `/api/oauth/profile` round-trip.
### Fixed
- Fixed `resolveAnthropicMetadataUserId()` to accept JSON-format `user_id` values that match real Claude Code's payload shape (`{ device_id, account_uuid, session_id, ... }` from `services/api/claude.ts:getAPIMetadata`). Previously only the synthetic `user_<hex>_account_<uuid>_session_<uuid>` cloaking format was accepted on OAuth, which caused stable session-keyed metadata supplied by callers to be discarded and replaced with fresh random entropy on every request — defeating session-count attribution on the Claude OAuth path.
## [14.8.0] - 2026-05-09
### Fixed
+43 -1
View File
@@ -684,6 +684,44 @@ export class AuthStorage {
);
}
/**
* Get the OAuth `accountId` for a provider, preferring the credential that is
* session-sticky for `sessionId` when multiple OAuth credentials are configured.
* Falls back to the first OAuth credential when no session preference exists (e.g.
* first call before any `getApiKey` has been issued, or single-credential setups).
* Returns `undefined` when no OAuth credential carries an `accountId`.
*/
getOAuthAccountId(provider: string, sessionId?: string): string | undefined {
const allCredentials = this.#getCredentialsForProvider(provider);
const oauthCredentials = allCredentials.filter((c): c is OAuthCredential => c.type === "oauth");
if (oauthCredentials.length === 0) return undefined;
// Runtime override always returns before recording a session credential.
if (this.#runtimeOverrides.has(provider)) return undefined;
// Prefer the session-sticky credential when available.
const sessionPref = this.#getSessionCredential(provider, sessionId);
// If the session has been routed to a stored API key, do not inject OAuth account_uuid.
if (sessionPref !== undefined && sessionPref.type !== "oauth") return undefined;
// When no session-sticky credential is recorded yet (first call before any getApiKey,
// or all stored credentials are unavailable), the request falls through to the env-key
// or fallback-resolver path in getApiKey() — neither is OAuth-authenticated, so
// account_uuid injection would misattribute traffic. Only apply this guard when
// sessionPref is absent; a recorded OAuth sticky (sessionPref.type === "oauth") must
// NOT be blocked even if an env key also happens to exist.
if (!sessionPref && (getEnvApiKey(provider) || this.#fallbackResolver?.(provider))) return undefined;
// Resolve the sticky index against the full credential list — the index is
// recorded against the unfiltered provider array (by #recordSessionCredential /
// #tryOAuthCredential), not the OAuth-only subset, so dereferencing it into the
// filtered array would be off-by-N when any non-OAuth credential precedes the
// OAuth ones (e.g. [api_key, oauth_A, oauth_B] stored order).
const stickyCredential = sessionPref?.type === "oauth" ? allCredentials[sessionPref.index] : undefined;
const preferred = stickyCredential?.type === "oauth" ? stickyCredential : oauthCredentials[0];
const accountId = preferred?.accountId;
return typeof accountId === "string" && accountId.length > 0 ? accountId : undefined;
}
/**
* Get all credentials.
*/
@@ -1992,7 +2030,11 @@ export class AuthStorage {
return oauthKey;
}
// Fall back to environment variable
// Fall back to environment variable or custom resolver. If we reach here after
// an OAuth miss, the session sticky (if any) is stale — the request will
// authenticate via env/fallback, not OAuth, so clear the sticky now so that
// getOAuthAccountId() correctly suppresses account_uuid for this session.
if (sessionId) this.#sessionLastCredential.get(provider)?.delete(sessionId);
const envKey = getEnvApiKey(provider);
if (envKey) return envKey;
+21 -1
View File
@@ -361,6 +361,26 @@ export function isClaudeCloakingUserId(userId: string): boolean {
return CLAUDE_CLOAKING_USER_ID_REGEX.test(userId);
}
/**
* Real Claude Code sends `metadata.user_id` as a JSON-stringified object of the
* shape `{ device_id, account_uuid, session_id, ...extra }` (see
* services/api/claude.ts → getAPIMetadata). Accept that shape so callers that
* supply a stable `session_id` aren't silently overwritten with fresh entropy
* on every request, which would inflate the backend session count.
*/
function isClaudeJsonUserId(userId: string): boolean {
if (userId.length === 0 || userId[0] !== "{") return false;
let parsed: unknown;
try {
parsed = JSON.parse(userId);
} catch {
return false;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
const obj = parsed as Record<string, unknown>;
return typeof obj.session_id === "string" && obj.session_id.length > 0;
}
export function generateClaudeCloakingUserId(): string {
const userHash = nodeCrypto.randomBytes(32).toString("hex");
const accountId = nodeCrypto.randomUUID().toLowerCase();
@@ -370,7 +390,7 @@ export function generateClaudeCloakingUserId(): string {
function resolveAnthropicMetadataUserId(userId: unknown, isOAuthToken: boolean): string | undefined {
if (typeof userId === "string") {
if (!isOAuthToken || isClaudeCloakingUserId(userId)) {
if (!isOAuthToken || isClaudeCloakingUserId(userId) || isClaudeJsonUserId(userId)) {
return userId;
}
}
+38 -10
View File
@@ -48,20 +48,23 @@ async function postJson(url: string, body: Record<string, string | number>): Pro
return responseBody;
}
function parseOAuthTokenResponse(
responseBody: string,
operation: string,
): {
/**
* Decoded shape of Anthropic's `/v1/oauth/token` response (both
* `authorization_code` exchange and `refresh_token` refresh return the same
* envelope). The `account` block is inlined alongside the tokens, so we can
* surface `accountId` / `email` on {@link OAuthCredentials} without a separate
* `/api/oauth/profile` round-trip.
*/
interface AnthropicTokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
} {
account?: { uuid?: string; email_address?: string };
}
function parseOAuthTokenResponse(responseBody: string, operation: string): AnthropicTokenResponse {
try {
return JSON.parse(responseBody) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
return JSON.parse(responseBody) as AnthropicTokenResponse;
} catch (error) {
throw new Error(
`Anthropic ${operation} returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`,
@@ -69,6 +72,25 @@ function parseOAuthTokenResponse(
}
}
/**
* Lift the OAuth response's `account: { uuid, email_address }` block onto
* {@link OAuthCredentials} so downstream identity propagation (e.g.
* `metadata.user_id.account_uuid`, usage tracking) works without a separate
* `/api/oauth/profile` round-trip. Returns `undefined` for either field when
* the response omits it or carries a non-string / empty value.
*/
function extractAccountFromTokenResponse(data: AnthropicTokenResponse): {
accountId?: string;
email?: string;
} {
const accountUuid = data.account?.uuid;
const emailAddress = data.account?.email_address;
return {
accountId: typeof accountUuid === "string" && accountUuid.length > 0 ? accountUuid : undefined,
email: typeof emailAddress === "string" && emailAddress.length > 0 ? emailAddress : undefined,
};
}
export class AnthropicOAuthFlow extends OAuthCallbackFlow {
#verifier: string = "";
#challenge: string = "";
@@ -130,11 +152,14 @@ export class AnthropicOAuthFlow extends OAuthCallbackFlow {
}
const tokenData = parseOAuthTokenResponse(responseBody, "token exchange");
const { accountId, email } = extractAccountFromTokenResponse(tokenData);
return {
refresh: tokenData.refresh_token,
access: tokenData.access_token,
expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
accountId,
email,
};
}
}
@@ -163,10 +188,13 @@ export async function refreshAnthropicToken(refreshToken: string): Promise<OAuth
}
const data = parseOAuthTokenResponse(responseBody, "token refresh");
const { accountId, email } = extractAccountFromTokenResponse(data);
return {
refresh: data.refresh_token || refreshToken,
access: data.access_token,
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
accountId,
email,
};
}
@@ -291,6 +291,55 @@ describe("Anthropic request fingerprint alignment", () => {
expect(payload.metadata?.user_id).toBe(userId);
});
it("preserves real Claude Code JSON-format metadata.user_id for OAuth requests", async () => {
// Matches the shape produced by services/api/claude.ts → getAPIMetadata in
// the Claude Code source: { device_id, account_uuid, session_id, ...extra }.
const userId = JSON.stringify({
device_id: "a".repeat(64),
account_uuid: "12345678-1234-1234-1234-1234567890ab",
session_id: "abcdefab-cdef-abcd-efab-cdefabcdef12",
});
const payload = (await captureAnthropicPayload(
ANTHROPIC_MODEL,
{
systemPrompt: ["Stay concise."],
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
},
{ metadata: { user_id: userId } },
)) as { metadata?: { user_id?: string } };
expect(payload.metadata?.user_id).toBe(userId);
});
it("preserves a minimal { session_id } JSON metadata.user_id for OAuth requests", async () => {
const userId = JSON.stringify({ session_id: "0190fb1e-0000-7000-8000-000000000001" });
const payload = (await captureAnthropicPayload(
ANTHROPIC_MODEL,
{
systemPrompt: ["Stay concise."],
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
},
{ metadata: { user_id: userId } },
)) as { metadata?: { user_id?: string } };
expect(payload.metadata?.user_id).toBe(userId);
});
it("replaces JSON metadata.user_id missing session_id for OAuth requests", async () => {
const userId = JSON.stringify({ device_id: "x".repeat(64) });
const payload = (await captureAnthropicPayload(
ANTHROPIC_MODEL,
{
systemPrompt: ["Stay concise."],
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
},
{ metadata: { user_id: userId } },
)) as { metadata?: { user_id?: string } };
expect(payload.metadata?.user_id).not.toBe(userId);
expect(isClaudeCloakingUserId(payload.metadata?.user_id ?? "")).toBe(true);
});
it("replaces invalid caller metadata.user_id for OAuth requests", async () => {
const payload = (await captureAnthropicPayload(
ANTHROPIC_MODEL,
+70
View File
@@ -122,6 +122,76 @@ describe("anthropic oauth alignment", () => {
expect(result.refresh).toBe("new-refresh-token");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("extracts account uuid and email from token-exchange response", async () => {
const fetchMock = vi.fn(async () => {
return new Response(
JSON.stringify({
access_token: "access-token",
refresh_token: "refresh-token",
expires_in: 3600,
account: {
uuid: "11111111-2222-3333-4444-555555555555",
email_address: "user@example.com",
},
organization: { uuid: "99999999-8888-7777-6666-555555555555" },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
global.fetch = fetchMock as unknown as typeof fetch;
const flow = new AnthropicOAuthFlow({});
await flow.generateAuthUrl("state-123", "http://localhost:54545/callback");
const result = await flow.exchangeToken("code-123", "state-123", "http://localhost:54545/callback");
expect(result.accountId).toBe("11111111-2222-3333-4444-555555555555");
expect(result.email).toBe("user@example.com");
});
it("extracts account uuid and email from refresh response", async () => {
const fetchMock = vi.fn(async () => {
return new Response(
JSON.stringify({
access_token: "new-access-token",
refresh_token: "new-refresh-token",
expires_in: 7200,
account: {
uuid: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
email_address: "refreshed@example.com",
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
global.fetch = fetchMock as unknown as typeof fetch;
const result = await refreshAnthropicToken("refresh-123");
expect(result.accountId).toBe("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
expect(result.email).toBe("refreshed@example.com");
});
it("leaves accountId/email undefined when token response omits account block", async () => {
const fetchMock = vi.fn(async () => {
return new Response(
JSON.stringify({
access_token: "access-token",
refresh_token: "refresh-token",
expires_in: 3600,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
global.fetch = fetchMock as unknown as typeof fetch;
const flow = new AnthropicOAuthFlow({});
await flow.generateAuthUrl("state-noaccount", "http://localhost:54545/callback");
const result = await flow.exchangeToken("code-noaccount", "state-noaccount", "http://localhost:54545/callback");
expect(result.accountId).toBeUndefined();
expect(result.email).toBeUndefined();
});
});
describe("anthropic auth resolution", () => {
+7
View File
@@ -2,6 +2,13 @@
## [Unreleased]
### Fixed
- Fixed session count inflation on Anthropic backend caused by a fresh random `metadata.user_id` being generated on every API request; all requests within one conversation now share a stable `metadata.user_id` derived from the session ID, matching the expected one-session-per-conversation counting
- Fixed compaction requests (manual and auto) not carrying `metadata.user_id`, leaving them unattributed on the backend
- Fixed direct session-bound LLM calls (`/btw` ephemeral turns via `runEphemeralTurn`, branch summarization, session title generation) bypassing the agent and emitting a fresh random `metadata.user_id` per request on Anthropic OAuth: the session-level `prepareSimpleStreamOptions` helper now stamps the agent's session metadata onto direct calls, and `generateBranchSummary` plus `generateSessionTitle` accept and forward an explicit `metadata` option from the call site
- Fixed `metadata.user_id` lacking the authenticated `account_uuid` on Anthropic OAuth requests; sessions now install a dynamic resolver via `Agent#setMetadataResolver` that builds `{ session_id, account_uuid? }` per request, looking the live OAuth account UUID up from `AuthStorage` so it stays in sync with token refreshes and login/logout transitions instead of stranding a stale value
## [14.8.0] - 2026-05-09
### Added
+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,
},
);