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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user