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:
@@ -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