@opengeni/core 1.4.0 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2052,6 +2052,11 @@ export async function postUserMessageTurn(
2052
2052
  : {}),
2053
2053
  controlLockTimeoutMs: workspaceControlRequestLockTimeoutMs(),
2054
2054
  }),
2055
+ undefined,
2056
+ "shared",
2057
+ // Linked-actor capture reauthorizes under the organization-membership
2058
+ // lock; take it before the tenancy fence and the canonical prefix.
2059
+ Boolean(input.captureTurnAuthority),
2055
2060
  ),
2056
2061
  );
2057
2062
  } catch (error) {
@@ -2590,27 +2595,35 @@ async function createSessionForRequestInFileScope(
2590
2595
  payload.idempotencyKey &&
2591
2596
  (effectiveVisibility !== "user_private" || replayManagedHumanSubjectId !== null)
2592
2597
  ) {
2598
+ const createIdempotencyKey = payload.idempotencyKey;
2593
2599
  try {
2594
- const initializedReplay = await getInitializedSessionCreateReplay(db, {
2595
- bundledSkillIds,
2596
- accountId: grant.accountId,
2597
- workspaceId,
2598
- subjectId: replayManagedHumanSubjectId ?? grant.subjectId,
2599
- ...(replayManagedHumanSubjectId
2600
- ? { activeManagedHumanSubjectId: replayManagedHumanSubjectId }
2601
- : {}),
2602
- createIdempotencyKey: payload.idempotencyKey,
2603
- selectedInstalledSkillIds: payload.installedSkillIds ?? [],
2604
- initialAgentLearning: payload.agentLearning,
2605
- ...sessionScope,
2600
+ const initializedReplay = await measureSessionStartPhase(
2601
+ unresolvedDeps.observability,
2602
+ "idempotent_replay",
2603
+ () =>
2604
+ getInitializedSessionCreateReplay(db, {
2605
+ bundledSkillIds,
2606
+ accountId: grant.accountId,
2607
+ workspaceId,
2608
+ subjectId: replayManagedHumanSubjectId ?? grant.subjectId,
2609
+ ...(replayManagedHumanSubjectId
2610
+ ? { activeManagedHumanSubjectId: replayManagedHumanSubjectId }
2611
+ : {}),
2612
+ createIdempotencyKey,
2613
+ selectedInstalledSkillIds: payload.installedSkillIds ?? [],
2614
+ initialAgentLearning: payload.agentLearning,
2615
+ ...sessionScope,
2606
2616
 
2607
- ...(payload.requestedSessionId ? { requestedSessionId: payload.requestedSessionId } : {}),
2608
- visibility: effectiveVisibility,
2609
- variableSetIds: payload.variableSetIds ?? [],
2610
- initialPersonalResourceAttachmentIntent: payload.personalResourceAttachment ?? null,
2611
- deferInitialTurn: payload.startMode === "realtime",
2612
- agentConfig,
2613
- });
2617
+ ...(payload.requestedSessionId
2618
+ ? { requestedSessionId: payload.requestedSessionId }
2619
+ : {}),
2620
+ visibility: effectiveVisibility,
2621
+ variableSetIds: payload.variableSetIds ?? [],
2622
+ initialPersonalResourceAttachmentIntent: payload.personalResourceAttachment ?? null,
2623
+ deferInitialTurn: payload.startMode === "realtime",
2624
+ agentConfig,
2625
+ }),
2626
+ );
2614
2627
  if (initializedReplay) {
2615
2628
  if (initializedReplay.outcome === "denied") {
2616
2629
  throw new SessionSpawnDeniedError(SessionSpawnDenial.parse(initializedReplay.denial));
@@ -2988,36 +3001,43 @@ async function createSessionForRequestInFileScope(
2988
3001
  const tools = withFirstPartyTools(selectedTools, runtimeSettings);
2989
3002
 
2990
3003
  const captureLinkedAuthority = prepareExternalLinkTurnAdmission(authorization);
2991
- await validateGitHubRepositorySelection(db, workspaceId, resources);
2992
- if (resources.some((resource) => resource.kind === "file") && !objectStorage) {
2993
- throw new HTTPException(503, {
2994
- message: "object storage is not configured",
2995
- });
2996
- }
2997
- const attachmentOwnerContext = authorization
2998
- ? await fileOwnerContextForAccess({ db }, authorization, "sessions:create")
2999
- : grant.principalKind === "agent_attempt"
3000
- ? await fileOwnerContextForAgent({ db }, grant, "sessions:create")
3001
- : undefined;
3004
+ const { attachmentOwnerContext, variableSets } = await measureSessionStartPhase(
3005
+ unresolvedDeps.observability,
3006
+ "resource_validation",
3007
+ async () => {
3008
+ await validateGitHubRepositorySelection(db, workspaceId, resources);
3009
+ if (resources.some((resource) => resource.kind === "file") && !objectStorage) {
3010
+ throw new HTTPException(503, {
3011
+ message: "object storage is not configured",
3012
+ });
3013
+ }
3014
+ const ownerContext = authorization
3015
+ ? await fileOwnerContextForAccess({ db }, authorization, "sessions:create")
3016
+ : grant.principalKind === "agent_attempt"
3017
+ ? await fileOwnerContextForAgent({ db }, grant, "sessions:create")
3018
+ : undefined;
3019
+ await validateFileResources(
3020
+ db,
3021
+ grant.accountId,
3022
+ workspaceId,
3023
+ personalResourceSubjectId ?? grant.subjectId,
3024
+ resources,
3025
+ ownerContext,
3026
+ );
3027
+ // Every selected Variable Set is independently authorized. Scope does not
3028
+ // affect precedence: explicit order is low-to-high and later sets win name
3029
+ // collisions.
3030
+ const validatedVariableSets: VariableSet[] = [];
3031
+ for (const variableSetId of payload.variableSetIds ?? []) {
3032
+ validatedVariableSets.push(
3033
+ await validateVariableSetAttachment({ settings, db }, grant, workspaceId, variableSetId),
3034
+ );
3035
+ }
3036
+ return { attachmentOwnerContext: ownerContext, variableSets: validatedVariableSets };
3037
+ },
3038
+ );
3002
3039
  const attachmentOwner =
3003
3040
  attachmentOwnerContext?.privateFileOwnerSubjectId === grant.subjectId ? grant.subjectId : null;
3004
- await validateFileResources(
3005
- db,
3006
- grant.accountId,
3007
- workspaceId,
3008
- personalResourceSubjectId ?? grant.subjectId,
3009
- resources,
3010
- attachmentOwnerContext,
3011
- );
3012
- // Every selected Variable Set is independently authorized. Scope does not
3013
- // affect precedence: explicit order is low-to-high and later sets win name
3014
- // collisions.
3015
- const variableSets: VariableSet[] = [];
3016
- for (const variableSetId of payload.variableSetIds ?? []) {
3017
- variableSets.push(
3018
- await validateVariableSetAttachment({ settings, db }, grant, workspaceId, variableSetId),
3019
- );
3020
- }
3021
3041
  // RIG BINDING (M3). Resolve the rig this session rides — a UUID binds that
3022
3042
  // rig, null explicitly opts out, and omission inherits the workspace default
3023
3043
  // (workspaces.default_rig_id) — then FREEZE both the rig id and its currently-
@@ -3029,22 +3049,27 @@ async function createSessionForRequestInFileScope(
3029
3049
  // - A stale workspace-default rig (deleted → FK-nulled, or somehow with no
3030
3050
  // active version) degrades SILENTLY to rig-less: an operator-side default
3031
3051
  // must never brick every create in the workspace.
3032
- const requestedRigId =
3033
- payload.rigId === undefined ? await getWorkspaceDefaultRigId(db, workspaceId) : payload.rigId;
3034
- let frozenRigId: string | null = null;
3035
- let frozenRigVersionId: string | null = null;
3036
- if (requestedRigId) {
3037
- const rig = await getRig(db, grant, requestedRigId);
3038
- if (!rig || !rig.activeVersion) {
3039
- if (payload.rigId) {
3040
- throw new HTTPException(422, {
3041
- message: rig
3042
- ? `sandbox environment ${payload.rigId} has no active version to bind`
3043
- : `unknown rigId: ${payload.rigId}`,
3044
- });
3052
+ const { frozenRigId, frozenRigVersionId } = await measureSessionStartPhase(
3053
+ unresolvedDeps.observability,
3054
+ "rig_binding",
3055
+ async () => {
3056
+ const requestedRigId =
3057
+ payload.rigId === undefined
3058
+ ? await getWorkspaceDefaultRigId(db, workspaceId)
3059
+ : payload.rigId;
3060
+ if (!requestedRigId) return { frozenRigId: null, frozenRigVersionId: null };
3061
+ const rig = await getRig(db, grant, requestedRigId);
3062
+ if (!rig || !rig.activeVersion) {
3063
+ if (payload.rigId) {
3064
+ throw new HTTPException(422, {
3065
+ message: rig
3066
+ ? `sandbox environment ${payload.rigId} has no active version to bind`
3067
+ : `unknown rigId: ${payload.rigId}`,
3068
+ });
3069
+ }
3070
+ // else: workspace-default fallback that no longer resolves → rig-less.
3071
+ return { frozenRigId: null, frozenRigVersionId: null };
3045
3072
  }
3046
- // else: workspace-default fallback that no longer resolves → rig-less.
3047
- } else {
3048
3073
  for (const defaultVariableSetId of new Set(rig.activeVersion.defaultVariableSetIds)) {
3049
3074
  await validateVariableSetAttachment(
3050
3075
  { settings, db },
@@ -3053,10 +3078,12 @@ async function createSessionForRequestInFileScope(
3053
3078
  defaultVariableSetId,
3054
3079
  );
3055
3080
  }
3056
- frozenRigId = rig.id;
3057
- frozenRigVersionId = rig.activeVersion.id;
3058
- }
3059
- }
3081
+ return {
3082
+ frozenRigId: rig.id as string | null,
3083
+ frozenRigVersionId: rig.activeVersion.id as string | null,
3084
+ };
3085
+ },
3086
+ );
3060
3087
  // CHANNEL FILING. Pure rail organization: a UUID files the session into that
3061
3088
  // workspace channel, omission/null leaves it unfiled (inbox). Resolved
3062
3089
  // workspace-scoped so a foreign channel id can never attach; an explicit
@@ -3085,25 +3112,27 @@ async function createSessionForRequestInFileScope(
3085
3112
  // inherited calling-turn model, or deployment default — so the policy must
3086
3113
  // vet that effective value, not just explicit ones (a restricted workspace's
3087
3114
  // inherited/default-model session would otherwise be born blocked).
3088
- await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, model);
3089
- // Direct creation is a fresh model selection. Child inheritance and keyed
3090
- // repair preserve the existing accepted-model/authority rules.
3091
- if (retainedKeyedShellModel === null && !parentSession) {
3092
- const selections = await resolveCallerWorkspaceModelSelections(db, settings, {
3093
- accountId: grant.accountId,
3094
- workspaceId,
3095
- subjectId: personalResourceSubjectId ?? grant.subjectId,
3096
- ...(xaiProviderAccountAuthoritySnapshot
3097
- ? { xaiAuthoritySnapshot: xaiProviderAccountAuthoritySnapshot }
3098
- : {}),
3099
- ...(claudeProviderAccountAuthoritySnapshot
3100
- ? { claudeAuthoritySnapshot: claudeProviderAccountAuthoritySnapshot }
3101
- : {}),
3102
- });
3103
- if (!admissibleWorkspaceModel(selections, model)) {
3104
- throw new HTTPException(422, { message: `model is not selectable: ${model}` });
3115
+ await measureSessionStartPhase(unresolvedDeps.observability, "model_admission", async () => {
3116
+ await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, model);
3117
+ // Direct creation is a fresh model selection. Child inheritance and keyed
3118
+ // repair preserve the existing accepted-model/authority rules.
3119
+ if (retainedKeyedShellModel === null && !parentSession) {
3120
+ const selections = await resolveCallerWorkspaceModelSelections(db, settings, {
3121
+ accountId: grant.accountId,
3122
+ workspaceId,
3123
+ subjectId: personalResourceSubjectId ?? grant.subjectId,
3124
+ ...(xaiProviderAccountAuthoritySnapshot
3125
+ ? { xaiAuthoritySnapshot: xaiProviderAccountAuthoritySnapshot }
3126
+ : {}),
3127
+ ...(claudeProviderAccountAuthoritySnapshot
3128
+ ? { claudeAuthoritySnapshot: claudeProviderAccountAuthoritySnapshot }
3129
+ : {}),
3130
+ });
3131
+ if (!admissibleWorkspaceModel(selections, model)) {
3132
+ throw new HTTPException(422, { message: `model is not selectable: ${model}` });
3133
+ }
3105
3134
  }
3106
- }
3135
+ });
3107
3136
  const inheritedReasoningEffort =
3108
3137
  parentCallingTurn?.reasoningEffort ??
3109
3138
  parentSession?.reasoningEffort ??
@@ -3307,18 +3336,23 @@ async function createSessionForRequestInFileScope(
3307
3336
  workspace.settings,
3308
3337
  ),
3309
3338
  });
3310
- const { personalConnectionDelegations, mcpAccountBindings } = await freezeConnectionAccounts({
3311
- db,
3312
- accountId: grant.accountId,
3313
- workspaceId,
3314
- settings: runtimeSettings,
3315
- tools: connectionAccountTools,
3316
- resources,
3317
- source: connectionDelegationSource,
3318
- authoritySelections: payload.connectionAccounts,
3319
- googleDrivePublicationEnabled,
3320
- atlassianEnabled,
3321
- });
3339
+ const { personalConnectionDelegations, mcpAccountBindings } = await measureSessionStartPhase(
3340
+ unresolvedDeps.observability,
3341
+ "connection_freeze",
3342
+ () =>
3343
+ freezeConnectionAccounts({
3344
+ db,
3345
+ accountId: grant.accountId,
3346
+ workspaceId,
3347
+ settings: runtimeSettings,
3348
+ tools: connectionAccountTools,
3349
+ resources,
3350
+ source: connectionDelegationSource,
3351
+ authoritySelections: payload.connectionAccounts,
3352
+ googleDrivePublicationEnabled,
3353
+ atlassianEnabled,
3354
+ }),
3355
+ );
3322
3356
  if (effectiveGoal) {
3323
3357
  const missingGoalTools = ["goal_update", "goal_progress", "goal_complete", "goal_pause"].filter(
3324
3358
  (name) => !firstPartyMcpTools.includes(name as FirstPartyMcpToolName),
@@ -18,6 +18,7 @@ import {
18
18
  VOICE_TRANSCRIPTION_DEBIT_TYPE,
19
19
  VOICE_TRANSCRIPTION_SOURCE_TYPE,
20
20
  voiceTranscriptionSettlementKeys,
21
+ VOICE_CREDIT_USAGE,
21
22
  type CreditDebitAttribution,
22
23
  type Database,
23
24
  } from "@opengeni/db";
@@ -27,6 +28,7 @@ import {
27
28
  type TranscriptionBilling,
28
29
  type TranscriptionBillingContext,
29
30
  } from "../transcription";
31
+ import { voiceCreditStanding, voiceInsufficientCreditsMessage } from "./realtime-voice-billing";
30
32
 
31
33
  /** Credit debit type and usage source for deployment-funded voice input. */
32
34
  export const VOICE_INPUT_DEBIT_TYPE = VOICE_TRANSCRIPTION_DEBIT_TYPE;
@@ -86,8 +88,9 @@ function startOfUtcMonth(): Date {
86
88
  }
87
89
 
88
90
  /**
89
- * Same admission shape as knowledge-search and session admission: general
90
- * (not model-promotional) credits must be positive, the workspace/member
91
+ * Same admission shape as knowledge-search and session admission:
92
+ * voice-spendable credits (general plus signup, not model-scoped promotional
93
+ * credits) must be positive, the workspace/member
91
94
  * allowance must not be exhausted, and a static monthly cost cap applies.
92
95
  * Admission is a read, not a reservation; settlement is post-use and may take
93
96
  * an account below zero by at most the concurrent in-flight calls.
@@ -132,11 +135,11 @@ export function createVoiceInputBilling(deps: {
132
135
  },
133
136
  );
134
137
  }
135
- const balance = await getSpendableCreditBalance(deps.db, accountId);
138
+ const balance = await getSpendableCreditBalance(deps.db, accountId, VOICE_CREDIT_USAGE);
136
139
  if (balance.balanceMicros <= 0) {
137
140
  throw new TranscriptionBillingRefusedError({
138
141
  code: "insufficient_credits",
139
- message: "Voice input needs Opengeni credits. Add credits to continue.",
142
+ message: voiceInsufficientCreditsMessage("Voice input", voiceCreditStanding(balance)),
140
143
  });
141
144
  }
142
145
  const refusal = await checkWorkspaceAllowance(deps.db, {
@@ -250,6 +253,7 @@ export async function settleVoiceInputUsage(
250
253
  sourceType: VOICE_TRANSCRIPTION_SOURCE_TYPE,
251
254
  sourceId: input.billing.sourceId,
252
255
  idempotencyKey: keys.debitIdempotencyKey,
256
+ usage: VOICE_CREDIT_USAGE,
253
257
  metadata: {
254
258
  providerId: input.providerId,
255
259
  model: input.model,
@@ -298,6 +302,7 @@ export async function reconcileUnsettledVoiceInputCharges(
298
302
  workspaceId: input.workspaceId,
299
303
  sourceId: charge.sourceId,
300
304
  }).debitIdempotencyKey,
305
+ usage: VOICE_CREDIT_USAGE,
301
306
  metadata: {
302
307
  basis: "reconciled_receipt" satisfies VoiceInputBillingBasis,
303
308
  ...(charge.attribution.kind === "unknown"
@@ -39,18 +39,19 @@ import {
39
39
  import {
40
40
  listConnectionsMetadata,
41
41
  getDeploymentModelCatalog,
42
- listWorkspaceProviderCustomModels,
42
+ listWorkspaceProviderCustomModelsByKind,
43
43
  getWorkspaceProviderCustomModelForExecution,
44
44
  lockActiveWorkspaceProviderCustomModelForAdmission,
45
45
  getWorkspaceGatewayCustomModelForExecution,
46
46
  getWorkspaceOpenRouterCustomModelForExecution,
47
47
  getOrganizationModelProviderCustomModelForExecution,
48
- listWorkspaceGatewayCustomModels,
49
- listWorkspaceOpenRouterCustomModels,
50
- listOrganizationModelProviderCustomModelsForWorkspace,
48
+ getOrganizationModelProviderCatalogForWorkspace,
51
49
  lockActiveOrganizationModelProviderCustomModelForAdmission,
52
50
  type OrganizationClaudeModelAdmissionAuthority,
53
51
  type Database,
52
+ type WorkspaceCustomModelProviderKind,
53
+ type WorkspaceGatewayCustomModel,
54
+ type WorkspaceOpenRouterCustomModel,
54
55
  } from "@opengeni/db";
55
56
 
56
57
  export type ResolvedCatalogSettings = {
@@ -249,26 +250,34 @@ export async function resolveWorkspaceCatalogSettings(
249
250
  ? [productModelId.slice(ORGANIZATION_OPENROUTER_MODEL_ID_PREFIX.length)]
250
251
  : [],
251
252
  );
253
+ // One scoped read per catalog family instead of one scoped transaction per
254
+ // provider kind. The batched readers keep each provider's filters, bound and
255
+ // overflow error. The Claude subscription flag is env-only (the catalog
256
+ // document never changes it), so it decides the requested kinds up front.
257
+ const workspaceCustomModelKinds: WorkspaceCustomModelProviderKind[] = [
258
+ "vercel_gateway",
259
+ "openrouter",
260
+ ...(supportsOrganizationProviderReads
261
+ ? CLAUDE_CONNECTION_KINDS.filter(
262
+ (kind) => kind !== "claude_subscription" || envSettings.claudeSubscriptionEnabled,
263
+ )
264
+ : []),
265
+ ];
252
266
  const [
253
267
  resolved,
254
- activeGatewayCustomModels,
255
- activeOpenRouterCustomModels,
268
+ workspaceCustomModels,
256
269
  retainedGatewayCustomModels,
257
270
  retainedOpenRouterCustomModels,
258
- organizationGatewayCustomModels,
259
- organizationOpenRouterCustomModels,
271
+ organizationProviders,
260
272
  retainedOrganizationGatewayCustomModels,
261
273
  retainedOrganizationOpenRouterCustomModels,
262
274
  directConnections,
263
275
  ] = await Promise.all([
264
276
  resolveCatalogSettings(db, envSettings),
265
- listWorkspaceGatewayCustomModels(db, {
266
- accountId: input.accountId,
267
- workspaceId: input.workspaceId,
268
- }),
269
- listWorkspaceOpenRouterCustomModels(db, {
277
+ listWorkspaceProviderCustomModelsByKind(db, {
270
278
  accountId: input.accountId,
271
279
  workspaceId: input.workspaceId,
280
+ providerKinds: workspaceCustomModelKinds,
272
281
  }),
273
282
  Promise.all(
274
283
  [...new Set(retainedGatewayUpstreamModelIds)].map(
@@ -291,19 +300,12 @@ export async function resolveWorkspaceCatalogSettings(
291
300
  ),
292
301
  ),
293
302
  supportsOrganizationProviderReads
294
- ? listOrganizationModelProviderCustomModelsForWorkspace(db, {
295
- accountId: input.accountId,
296
- workspaceId: input.workspaceId,
297
- providerKind: "vercel_gateway",
298
- })
299
- : Promise.resolve([]),
300
- supportsOrganizationProviderReads
301
- ? listOrganizationModelProviderCustomModelsForWorkspace(db, {
303
+ ? getOrganizationModelProviderCatalogForWorkspace(db, {
302
304
  accountId: input.accountId,
303
305
  workspaceId: input.workspaceId,
304
- providerKind: "openrouter",
306
+ providerKinds: ["vercel_gateway", "openrouter", ...CLAUDE_CONNECTION_KINDS],
305
307
  })
306
- : Promise.resolve([]),
308
+ : null,
307
309
  supportsOrganizationProviderReads
308
310
  ? Promise.all(
309
311
  [...new Set(retainedOrganizationGatewayUpstreamModelIds)].map(
@@ -352,29 +354,25 @@ export async function resolveWorkspaceCatalogSettings(
352
354
  return customModels;
353
355
  };
354
356
  const gatewayCustomModels = includeRetainedModels(
355
- activeGatewayCustomModels,
357
+ workspaceCustomModels.vercel_gateway as WorkspaceGatewayCustomModel[],
356
358
  retainedGatewayCustomModels,
357
359
  );
358
360
  const openRouterCustomModels = includeRetainedModels(
359
- activeOpenRouterCustomModels,
361
+ workspaceCustomModels.openrouter as WorkspaceOpenRouterCustomModel[],
360
362
  retainedOpenRouterCustomModels,
361
363
  );
362
364
  const organizationGatewayModels = includeRetainedModels(
363
- organizationGatewayCustomModels,
365
+ organizationProviders?.vercel_gateway.models ?? [],
364
366
  retainedOrganizationGatewayCustomModels,
365
367
  );
366
368
  const organizationOpenRouterModels = includeRetainedModels(
367
- organizationOpenRouterCustomModels,
369
+ organizationProviders?.openrouter.models ?? [],
368
370
  retainedOrganizationOpenRouterCustomModels,
369
371
  );
370
372
  const claudeConnections: ClaudeConnectionCatalog = {};
371
- if (supportsOrganizationProviderReads)
373
+ if (organizationProviders)
372
374
  for (const kind of CLAUDE_CONNECTION_KINDS) {
373
- const models = await listOrganizationModelProviderCustomModelsForWorkspace(db, {
374
- accountId: input.accountId,
375
- workspaceId: input.workspaceId,
376
- providerKind: kind,
377
- });
375
+ const models = [...organizationProviders[kind].models];
378
376
  const prefix = claudeProviderId(kind) + "/";
379
377
  for (const productId of retainedProductModelIds)
380
378
  if (productId?.startsWith(prefix)) {
@@ -392,11 +390,7 @@ export async function resolveWorkspaceCatalogSettings(
392
390
  if (supportsOrganizationProviderReads)
393
391
  for (const kind of CLAUDE_CONNECTION_KINDS) {
394
392
  if (kind === "claude_subscription" && !resolved.settings.claudeSubscriptionEnabled) continue;
395
- const models = await listWorkspaceProviderCustomModels(db, {
396
- accountId: input.accountId,
397
- workspaceId: input.workspaceId,
398
- providerKind: kind,
399
- });
393
+ const models = [...workspaceCustomModels[kind]];
400
394
  const prefix = claudeProviderId(kind, "workspace") + "/";
401
395
  for (const productId of retainedProductModelIds) {
402
396
  if (!productId?.startsWith(prefix)) continue;