From fc70a45c46cfb2cd18d2e19a34bbeff4bc1ad53c Mon Sep 17 00:00:00 2001 From: Miroslav Drbal Date: Sat, 9 May 2026 08:40:13 +0200 Subject: [PATCH] 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. --- packages/agent/CHANGELOG.md | 4 + packages/agent/src/agent-loop.ts | 9 +- packages/agent/src/agent.ts | 46 +++++++ packages/agent/src/types.ts | 10 ++ packages/agent/test/agent.test.ts | 56 ++++++++ packages/ai/CHANGELOG.md | 8 ++ packages/ai/src/auth-storage.ts | 44 ++++++- packages/ai/src/providers/anthropic.ts | 22 +++- packages/ai/src/utils/oauth/anthropic.ts | 48 +++++-- packages/ai/test/anthropic-alignment.test.ts | 49 +++++++ packages/ai/test/anthropic-oauth.test.ts | 70 ++++++++++ packages/coding-agent/CHANGELOG.md | 7 + packages/coding-agent/src/memories/index.ts | 17 ++- .../src/modes/controllers/input-controller.ts | 9 +- packages/coding-agent/src/sdk.ts | 7 +- .../coding-agent/src/session/agent-session.ts | 120 +++++++++++++++--- .../compaction/branch-summarization.ts | 6 +- .../src/session/compaction/compaction.ts | 25 +++- .../coding-agent/src/utils/title-generator.ts | 11 ++ 19 files changed, 523 insertions(+), 45 deletions(-) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index b97150dd2..3d7888f92 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -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 diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index b7a739b5b..dd1611d4a 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -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, diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 72e3e69e6..50fda10c7 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -233,6 +233,8 @@ export class Agent { #followUpMode: "all" | "one-at-a-time"; #interruptMode: "immediate" | "wait"; #sessionId?: string; + #metadata?: Record; + #metadataResolver?: (provider: string) => Record | undefined; #providerSessionState?: Map; #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 | undefined { + return this.#metadata; + } + + set metadata(value: Record | 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 | 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 | 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, diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 34de7fa50..b38f6ad5e 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -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 | undefined; + /** * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call. * diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 4172c605e..b82a5e81d 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -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" }); + }); }); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index fca7bba9c..cdd61dd2c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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__account__session_` 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 diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 5add8fc54..e035148f7 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -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; diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index f591e5306..ff5fb74fa 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -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; + 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; } } diff --git a/packages/ai/src/utils/oauth/anthropic.ts b/packages/ai/src/utils/oauth/anthropic.ts index 60793d078..7174d5d79 100644 --- a/packages/ai/src/utils/oauth/anthropic.ts +++ b/packages/ai/src/utils/oauth/anthropic.ts @@ -48,20 +48,23 @@ async function postJson(url: string, body: Record): 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 { 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, diff --git a/packages/ai/test/anthropic-oauth.test.ts b/packages/ai/test/anthropic-oauth.test.ts index 95df172bf..3a66ab1d6 100644 --- a/packages/ai/test/anthropic-oauth.test.ts +++ b/packages/ai/test/anthropic-oauth.test.ts @@ -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", () => { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 12b65f872..f80035130 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/memories/index.ts b/packages/coding-agent/src/memories/index.ts index 5bc771986..bea8269f0 100644 --- a/packages/coding-agent/src/memories/index.ts +++ b/packages/coding-agent/src/memories/index.ts @@ -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; }): 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 { 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; +}): 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"); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 47d816f88..77b99fda1 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -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"); diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 47549758f..be1874c4e 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -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; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index a40269e35..ab75b1e16 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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 { + const userId: Record = { 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) { diff --git a/packages/coding-agent/src/session/compaction/branch-summarization.ts b/packages/coding-agent/src/session/compaction/branch-summarization.ts index dc920c63e..ed40dc64a 100644 --- a/packages/coding-agent/src/session/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/session/compaction/branch-summarization.ts @@ -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; } // ============================================================================ @@ -258,7 +260,7 @@ export async function generateBranchSummary( entries: SessionEntry[], options: GenerateBranchSummaryOptions, ): Promise { - 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 diff --git a/packages/coding-agent/src/session/compaction/compaction.ts b/packages/coding-agent/src/session/compaction/compaction.ts index 8e3768028..aa26fd91c 100644 --- a/packages/coding-agent/src/session/compaction/compaction.ts +++ b/packages/coding-agent/src/session/compaction/compaction.ts @@ -965,6 +965,7 @@ export interface SummaryOptions { remoteEndpoint?: string; remoteInstructions?: string; initiatorOverride?: MessageAttribution; + metadata?: Record; } 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, ): Promise { 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") { diff --git a/packages/coding-agent/src/utils/title-generator.ts b/packages/coding-agent/src/utils/title-generator.ts index c8c07856d..c87ad7a61 100644 --- a/packages/coding-agent/src/utils/title-generator.ts +++ b/packages/coding-agent/src/utils/title-generator.ts @@ -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, + metadataResolver?: (provider: string) => Record | undefined, ): Promise { 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, }, );