@opengeni/api-router 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.
@@ -37,6 +37,20 @@ export declare const DATABASE_UNAVAILABLE_ERROR_DETAIL_CODE = "DATABASE_UNAVAILA
37
37
  * must reconcile by its own idempotency key before resending.
38
38
  */
39
39
  export declare function databaseUnavailableHttpError(error: unknown, method: string): ApiHttpError | null;
40
+ /** `details.code` of the retryable 503 for a deadlock/serialization victim. */
41
+ export declare const DATABASE_CONTENTION_ERROR_DETAIL_CODE = "DATABASE_CONTENTION";
42
+ /**
43
+ * PostgreSQL aborted the request's transaction as a deadlock (40P01) or
44
+ * serialization (40001) victim after any in-process retry was exhausted. The
45
+ * aborted transaction committed nothing, so this is transient contention, not
46
+ * an internal error. A read is safe to repeat. For a mutation the default is
47
+ * `outcomeUnknown` because an earlier transaction of the same request may have
48
+ * committed; a route whose only write is the aborted idempotent transaction
49
+ * passes `outcomeUnknown: false`.
50
+ */
51
+ export declare function databaseContentionHttpError(error: unknown, method: string, options?: {
52
+ outcomeUnknown?: boolean;
53
+ }): ApiHttpError | null;
40
54
  /**
41
55
  * A typed agent-configuration failure (capability unavailable, conflict,
42
56
  * widening, not enabled). Rendered as 422 validation_failed with the specific
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  startSlackInteractionPump,
13
13
  startTemporalScheduleCleanupPump,
14
14
  withAccessGrantSessionRlsContext
15
- } from "./chunk-KKEXIBQG.js";
15
+ } from "./chunk-F3LQ6VWR.js";
16
16
  import "./chunk-OGQDXVXY.js";
17
17
 
18
18
  // src/index.ts
@@ -83,8 +83,8 @@ export declare function scheduledTaskMcpSummary(task: ScheduledTask): {
83
83
  runMode: "existing_session" | "new_session_per_run" | "reusable_session";
84
84
  overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
85
85
  action: {
86
- readonly sourceId?: never;
87
86
  kind: "agent_turn";
87
+ sourceId?: never;
88
88
  sourceGeneration?: never;
89
89
  sourceLifecycleGeneration?: never;
90
90
  destination?: never;
@@ -220,8 +220,8 @@ export declare function boundScheduledTaskDetailMcp(task: ScheduledTask): {
220
220
  runMode: "existing_session" | "new_session_per_run" | "reusable_session";
221
221
  overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
222
222
  action: {
223
- readonly sourceId?: never;
224
223
  kind: "agent_turn";
224
+ sourceId?: never;
225
225
  sourceGeneration?: never;
226
226
  sourceLifecycleGeneration?: never;
227
227
  destination?: never;
@@ -405,8 +405,8 @@ export declare function boundScheduledTaskDetailMcp(task: ScheduledTask): {
405
405
  runMode: "existing_session" | "new_session_per_run" | "reusable_session";
406
406
  overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
407
407
  action: {
408
- readonly sourceId?: never;
409
408
  kind: "agent_turn";
409
+ sourceId?: never;
410
410
  sourceGeneration?: never;
411
411
  sourceLifecycleGeneration?: never;
412
412
  destination?: never;
@@ -597,8 +597,8 @@ export declare function boundScheduledTaskMcpPage(input: {
597
597
  runMode: "existing_session" | "new_session_per_run" | "reusable_session";
598
598
  overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
599
599
  action: {
600
- readonly sourceId?: never;
601
600
  kind: "agent_turn";
601
+ sourceId?: never;
602
602
  sourceGeneration?: never;
603
603
  sourceLifecycleGeneration?: never;
604
604
  destination?: never;
@@ -1,11 +1,13 @@
1
1
  import { ClientSessionEvent, SessionScopeSubjectId, SteerSessionMessageRequest, type SessionStatus, type WorkClaimSubjectFilter, type SessionGoalRevision, type SessionAuthorizationOperation } from "@opengeni/contracts";
2
2
  import { decodeSessionListCursor, type SessionDiscoveryCursor } from "@opengeni/db";
3
3
  import type { Context, Hono } from "hono";
4
+ import { HTTPException } from "hono/http-exception";
4
5
  import type { ApiRouteDeps } from "@opengeni/core";
5
6
  import { type ViewerServices } from "../sandbox/viewer.js";
6
7
  type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
7
8
  export declare function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void;
8
9
  export declare function goalProposalMatchesExpectedRevision(revision: Pick<SessionGoalRevision, "baseObjectiveRevision">, expectedObjectiveRevision: number): boolean;
10
+ export declare function sessionRealtimeHttpError(error: unknown): HTTPException;
9
11
  /**
10
12
  * Map every mounted session-addressed HTTP path to the host-neutral operation
11
13
  * the embedding port authorizes. Returning null is deliberately fail-closed in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "1.4.0",
3
+ "version": "1.4.4",
4
4
  "description": "OpenGeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -43,25 +43,25 @@
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@llamaindex/liteparse": "2.14.2",
45
45
  "@modelcontextprotocol/sdk": "^1.29.0",
46
- "@opengeni/agent-proto": "1.4.0",
47
- "@opengeni/artifact-tool": "1.4.0",
48
- "@opengeni/capabilities": "1.4.0",
49
- "@opengeni/codemode": "1.4.0",
50
- "@opengeni/codex": "1.4.0",
51
- "@opengeni/config": "1.4.0",
52
- "@opengeni/contracts": "1.4.0",
53
- "@opengeni/core": "1.4.0",
54
- "@opengeni/db": "1.4.0",
55
- "@opengeni/documents": "1.4.0",
56
- "@opengeni/events": "1.4.0",
57
- "@opengeni/github": "1.4.0",
58
- "@opengeni/interaction": "1.4.0",
59
- "@opengeni/network": "1.4.0",
60
- "@opengeni/observability": "1.4.0",
61
- "@opengeni/runtime": "1.4.0",
62
- "@opengeni/storage": "1.4.0",
63
- "@opengeni/tool-gateway": "1.4.0",
64
- "@opengeni/xai-subscription": "1.4.0",
46
+ "@opengeni/agent-proto": "1.4.4",
47
+ "@opengeni/artifact-tool": "1.4.4",
48
+ "@opengeni/capabilities": "1.4.4",
49
+ "@opengeni/codemode": "1.4.4",
50
+ "@opengeni/codex": "1.4.4",
51
+ "@opengeni/config": "1.4.4",
52
+ "@opengeni/contracts": "1.4.4",
53
+ "@opengeni/core": "1.4.4",
54
+ "@opengeni/db": "1.4.4",
55
+ "@opengeni/documents": "1.4.4",
56
+ "@opengeni/events": "1.4.4",
57
+ "@opengeni/github": "1.4.4",
58
+ "@opengeni/interaction": "1.4.4",
59
+ "@opengeni/network": "1.4.4",
60
+ "@opengeni/observability": "1.4.4",
61
+ "@opengeni/runtime": "1.4.4",
62
+ "@opengeni/storage": "1.4.4",
63
+ "@opengeni/tool-gateway": "1.4.4",
64
+ "@opengeni/xai-subscription": "1.4.4",
65
65
  "@temporalio/client": "^1.17.0",
66
66
  "better-auth": "^1.6.14",
67
67
  "hono": "^4.12.18",
package/src/app.ts CHANGED
@@ -99,7 +99,9 @@ import {
99
99
  ApiHttpError,
100
100
  agentConfigHttpError,
101
101
  allowanceExhaustedHttpError,
102
+ DATABASE_CONTENTION_ERROR_DETAIL_CODE,
102
103
  DATABASE_UNAVAILABLE_ERROR_DETAIL_CODE,
104
+ databaseContentionHttpError,
103
105
  databaseUnavailableHttpError,
104
106
  modelUnavailableHttpError,
105
107
  scheduledTaskTargetAccessHttpError,
@@ -661,6 +663,10 @@ export function createAppComposition(deps: AppDependencies): {
661
663
  "X-OpenGeni-Access-Key",
662
664
  "X-OpenGeni-Api-Contract",
663
665
  "X-OpenGeni-Actor-Epoch",
666
+ // Resumable voice-input chunk integrity/timing (cross-origin embeds).
667
+ "X-OpenGeni-Chunk-Duration-Milliseconds",
668
+ "X-OpenGeni-Chunk-Sha256",
669
+ "X-OpenGeni-Chunk-Start-Milliseconds",
664
670
  "X-OpenGeni-Correlation-Id",
665
671
  "X-OpenGeni-Session-Csrf",
666
672
  // Session scope for a session proxy; the API itself ignores it.
@@ -1958,6 +1964,7 @@ export function createAppComposition(deps: AppDependencies): {
1958
1964
  requestBodyValidationHttpError(rawError) ??
1959
1965
  invalidPathIdentifierHttpError(rawError, new URL(c.req.url).pathname) ??
1960
1966
  databaseUnavailableHttpError(rawError, c.req.method) ??
1967
+ databaseContentionHttpError(rawError, c.req.method) ??
1961
1968
  rawError);
1962
1969
  const compactionLock = codexCompactionV2ProviderLockedError(error);
1963
1970
  const apiError = error instanceof ApiHttpError ? error : null;
@@ -1966,7 +1973,10 @@ export function createAppComposition(deps: AppDependencies): {
1966
1973
  ? compactionLock.code
1967
1974
  : (apiError?.code ?? errorCodeForStatus(status));
1968
1975
  if (status >= 500) logHttpFailure(c, status, code, rawError);
1969
- if (apiError?.details?.code === DATABASE_UNAVAILABLE_ERROR_DETAIL_CODE) {
1976
+ if (
1977
+ apiError?.details?.code === DATABASE_UNAVAILABLE_ERROR_DETAIL_CODE ||
1978
+ apiError?.details?.code === DATABASE_CONTENTION_ERROR_DETAIL_CODE
1979
+ ) {
1970
1980
  c.header("retry-after", "1");
1971
1981
  }
1972
1982
  const envelope = ErrorEnvelope.parse({
@@ -2274,6 +2284,12 @@ export function assertConfiguredCodemodeSessionProxyPath(
2274
2284
  }
2275
2285
 
2276
2286
  function codemodeHttpError(error: unknown): HTTPException {
2287
+ // The journal admission is one idempotent transaction keyed by the
2288
+ // caller-owned operation id and is already retried in-process; every other
2289
+ // step is a read. An exhausted 40P01/40001 victim therefore committed
2290
+ // nothing and the client may resubmit the same operation id.
2291
+ const contention = databaseContentionHttpError(error, "POST", { outcomeUnknown: false });
2292
+ if (contention) return contention;
2277
2293
  if (error instanceof SiteSessionPathError) {
2278
2294
  // The proxied Site/SDK surface is an explicit allowlist; a route outside
2279
2295
  // it (tool policy, visibility, forks, Steer, control, ...) does not exist
@@ -72,6 +72,7 @@ export async function createGatewayRealtimeConnectionSecret(input: {
72
72
  getSessionRealtimeContinuityEntries(input.db, input.workspaceId, input.sessionId),
73
73
  mintGatewayClientSecret({
74
74
  apiKey,
75
+ source: resolved.source,
75
76
  upstreamModelId: resolved.upstreamModelId,
76
77
  fetchImpl: input.fetchImpl ?? fetch,
77
78
  }),
@@ -86,6 +87,7 @@ export async function createGatewayRealtimeConnectionSecret(input: {
86
87
 
87
88
  async function mintGatewayClientSecret(input: {
88
89
  apiKey: string;
90
+ source: "managed" | "workspace";
89
91
  upstreamModelId: string;
90
92
  fetchImpl: typeof fetch;
91
93
  }): Promise<{ token: string; url: string; expiresAt: number | null }> {
@@ -105,17 +107,19 @@ async function mintGatewayClientSecret(input: {
105
107
  } catch {
106
108
  throw new GatewayRealtimeBrokerError(
107
109
  "provider_error",
108
- "AI Gateway realtime token request failed",
110
+ "The voice provider could not be reached. Try again.",
109
111
  );
110
112
  }
111
113
  if (!response.ok) {
114
+ const rejected = response.status === 401 || response.status === 403;
115
+ // User-facing: say what to do, never which deployment credential failed.
112
116
  throw new GatewayRealtimeBrokerError(
113
- response.status === 401 || response.status === 403
114
- ? "credential_unavailable"
115
- : "provider_error",
116
- response.status === 401 || response.status === 403
117
- ? "AI Gateway credentials were rejected"
118
- : "AI Gateway realtime token request failed",
117
+ rejected ? "credential_unavailable" : "provider_error",
118
+ rejected
119
+ ? input.source === "managed"
120
+ ? "Opengeni voice is temporarily unavailable. Try another voice model."
121
+ : "Your AI Gateway key was rejected. Update it in workspace settings."
122
+ : "The voice provider could not start a call. Try again.",
119
123
  response.status,
120
124
  );
121
125
  }
@@ -5,7 +5,11 @@ import {
5
5
  ScheduledTaskTargetAccessChange,
6
6
  type ErrorCode,
7
7
  } from "@opengeni/contracts";
8
- import { isDatabaseConnectionLoss, WorkspaceControlBusyError } from "@opengeni/db";
8
+ import {
9
+ isDatabaseConnectionLoss,
10
+ nestedPostgresSqlState,
11
+ WorkspaceControlBusyError,
12
+ } from "@opengeni/db";
9
13
  import type { ContentfulStatusCode } from "hono/utils/http-status";
10
14
  import { HTTPException } from "hono/http-exception";
11
15
 
@@ -116,6 +120,37 @@ export function databaseUnavailableHttpError(error: unknown, method: string): Ap
116
120
  });
117
121
  }
118
122
 
123
+ /** `details.code` of the retryable 503 for a deadlock/serialization victim. */
124
+ export const DATABASE_CONTENTION_ERROR_DETAIL_CODE = "DATABASE_CONTENTION";
125
+
126
+ /**
127
+ * PostgreSQL aborted the request's transaction as a deadlock (40P01) or
128
+ * serialization (40001) victim after any in-process retry was exhausted. The
129
+ * aborted transaction committed nothing, so this is transient contention, not
130
+ * an internal error. A read is safe to repeat. For a mutation the default is
131
+ * `outcomeUnknown` because an earlier transaction of the same request may have
132
+ * committed; a route whose only write is the aborted idempotent transaction
133
+ * passes `outcomeUnknown: false`.
134
+ */
135
+ export function databaseContentionHttpError(
136
+ error: unknown,
137
+ method: string,
138
+ options: { outcomeUnknown?: boolean } = {},
139
+ ): ApiHttpError | null {
140
+ if (error instanceof ApiHttpError) return null;
141
+ if (error instanceof HTTPException && error.status < 500) return null;
142
+ const sqlState = nestedPostgresSqlState(error);
143
+ if (sqlState !== "40P01" && sqlState !== "40001") return null;
144
+ const safeMethod = method === "GET" || method === "HEAD" || method === "OPTIONS";
145
+ return new ApiHttpError(503, {
146
+ code: "upstream_unavailable",
147
+ message: "Opengeni hit transient database contention. Retry shortly.",
148
+ retryable: true,
149
+ outcomeUnknown: options.outcomeUnknown ?? !safeMethod,
150
+ details: { code: DATABASE_CONTENTION_ERROR_DETAIL_CODE, sqlState },
151
+ });
152
+ }
153
+
119
154
  /**
120
155
  * A typed agent-configuration failure (capability unavailable, conflict,
121
156
  * widening, not enabled). Rendered as 422 validation_failed with the specific
@@ -7,7 +7,11 @@ import {
7
7
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
8
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
9
9
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
10
- import { parseIntegrationsOauthClientsJson, type Settings } from "@opengeni/config";
10
+ import {
11
+ findIntegrationsOauthClient,
12
+ parseIntegrationsOauthClientsJson,
13
+ type Settings,
14
+ } from "@opengeni/config";
11
15
  import {
12
16
  OAuthStartResponse,
13
17
  selectCanonicalPersonalSlackConnection,
@@ -1799,7 +1803,6 @@ function operatorClientEntryFor(
1799
1803
  settings: Settings,
1800
1804
  candidates: string[],
1801
1805
  ): ReturnType<typeof parseIntegrationsOauthClientsJson>[string] | null {
1802
- const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
1803
1806
  const candidateOrigins = candidates.flatMap((candidate) => {
1804
1807
  try {
1805
1808
  return [new URL(candidate).origin];
@@ -1816,22 +1819,10 @@ function operatorClientEntryFor(
1816
1819
  return resolved;
1817
1820
  }
1818
1821
  }
1819
- const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
1820
- const exactKeys = uniqueStrings(
1821
- candidates.flatMap((candidate) => [candidate, normalizedIssuerKey(candidate)]),
1822
+ return findIntegrationsOauthClient(
1823
+ parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson),
1824
+ candidates,
1822
1825
  );
1823
- for (const key of exactKeys) {
1824
- const entry = configured[key];
1825
- if (entry) {
1826
- return entry;
1827
- }
1828
- }
1829
- for (const [key, entry] of Object.entries(configured)) {
1830
- if (normalizedCandidates.has(normalizedIssuerKey(key))) {
1831
- return entry;
1832
- }
1833
- }
1834
- return null;
1835
1826
  }
1836
1827
 
1837
1828
  /** Secret-free readiness for the exact registered client used by Gmail setup. */
@@ -7,7 +7,7 @@ import { listSessionEventSlices } from "@opengeni/db/session-event-slices";
7
7
  import * as sessionPreviewSchema from "@opengeni/db/schema";
8
8
  import { and, eq, sql } from "drizzle-orm";
9
9
  import { SessionMessageSearchRequest } from "@opengeni/contracts";
10
- import { scheduledSessionIds } from "@opengeni/db";
10
+ import { requireWorkspace, scheduledSessionIds } from "@opengeni/db";
11
11
  import { isVerifiedDelegatedHumanAuthorization, withSiteSessionOrigin } from "@opengeni/core";
12
12
  import { freezeSessionRealtimeConnectionAccounts } from "@opengeni/core";
13
13
  import {
@@ -1192,22 +1192,34 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1192
1192
  if (!session) {
1193
1193
  throw new HTTPException(404, { message: "session not found" });
1194
1194
  }
1195
- const activity = await backgroundCommandActivityForSessions(db, {
1196
- accountId: grant.accountId,
1197
- workspaceId,
1198
- sessionIds: [sessionId],
1199
- });
1200
- const scheduleTargets =
1195
+ // Independent reads of the already-authorized session: the effective
1196
+ // policy context depends only on the session row, not on the activity or
1197
+ // schedule annotations, so all three are read concurrently.
1198
+ const [activity, scheduleTargets, policy] = await Promise.all([
1199
+ backgroundCommandActivityForSessions(db, {
1200
+ accountId: grant.accountId,
1201
+ workspaceId,
1202
+ sessionIds: [sessionId],
1203
+ }),
1201
1204
  hasPermission(grant.permissions, "scheduled_tasks:run") &&
1202
1205
  hasPermission(grant.permissions, "sessions:control")
1203
- ? await scheduledSessionIds(db, workspaceId, [sessionId])
1204
- : new Set<string>();
1206
+ ? scheduledSessionIds(db, workspaceId, [sessionId])
1207
+ : Promise.resolve(new Set<string>()),
1208
+ loadEffectivePolicyContext(deps, workspaceId, grant.subjectId, [session]),
1209
+ ]);
1205
1210
  return c.json(
1206
- await withEffectivePolicy(deps, workspaceId, grant.subjectId, {
1207
- ...session,
1208
- hasSchedules: scheduleTargets.has(sessionId),
1209
- ...(activity.get(sessionId) ? { backgroundCommandActivity: activity.get(sessionId) } : {}),
1210
- }),
1211
+ sessionWithEffectiveToolPolicy(
1212
+ {
1213
+ ...session,
1214
+ hasSchedules: scheduleTargets.has(sessionId),
1215
+ ...(activity.get(sessionId)
1216
+ ? { backgroundCommandActivity: activity.get(sessionId) }
1217
+ : {}),
1218
+ },
1219
+ policy.workspaceServerIds,
1220
+ policy.workspaceDefaultServerIds,
1221
+ policy.effectiveToolsContext,
1222
+ ),
1211
1223
  );
1212
1224
  });
1213
1225
 
@@ -2057,29 +2069,37 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2057
2069
  });
2058
2070
  }
2059
2071
  try {
2060
- const result = await withWorkspaceSessionActivityRls(db, workspaceId, async (scopedDb) => {
2061
- // Acquire origin authority before inference/session locks. A deferred
2062
- // realtime session has no initial worker turn; capture its exact
2063
- // linked actor when the ledger actually admits ordinary agent work.
2064
- await beforeCommit?.(scopedDb as unknown as Database);
2065
- return syncSessionRealtimeLedgerInTransaction(
2066
- scopedDb,
2067
- {
2068
- workspaceId,
2069
- sessionId,
2070
- realtimeId,
2071
- ownerSubjectId: grant.subjectId,
2072
- ...parsed.data,
2073
- controlLockTimeoutMs: workspaceControlRequestLockTimeoutMs(),
2074
- },
2075
- captureLinked
2076
- ? {
2077
- afterDelegationAdmission: async ({ turnId }) =>
2078
- captureLinked(scopedDb as unknown as Database, sessionId, turnId),
2079
- }
2080
- : {},
2081
- );
2082
- });
2072
+ const result = await withWorkspaceSessionActivityRls(
2073
+ db,
2074
+ workspaceId,
2075
+ async (scopedDb) => {
2076
+ // Acquire origin authority before inference/session locks. A deferred
2077
+ // realtime session has no initial worker turn; capture its exact
2078
+ // linked actor when the ledger actually admits ordinary agent work.
2079
+ await beforeCommit?.(scopedDb as unknown as Database);
2080
+ return syncSessionRealtimeLedgerInTransaction(
2081
+ scopedDb,
2082
+ {
2083
+ workspaceId,
2084
+ sessionId,
2085
+ realtimeId,
2086
+ ownerSubjectId: grant.subjectId,
2087
+ ...parsed.data,
2088
+ controlLockTimeoutMs: workspaceControlRequestLockTimeoutMs(),
2089
+ },
2090
+ captureLinked
2091
+ ? {
2092
+ afterDelegationAdmission: async ({ turnId }) =>
2093
+ captureLinked(scopedDb as unknown as Database, sessionId, turnId),
2094
+ }
2095
+ : {},
2096
+ );
2097
+ },
2098
+ undefined,
2099
+ // External reauthorization takes the organization-membership lock,
2100
+ // which precedes the tenancy fence and the canonical session prefix.
2101
+ Boolean(beforeCommit),
2102
+ );
2083
2103
  await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
2084
2104
  c.header("cache-control", "private, no-store");
2085
2105
  return c.json({ accepted: result.accepted, outbound: result.outbound });
@@ -5181,11 +5201,19 @@ function codexRealtimeHttpFailure(error: CodexRealtimeBrokerError): {
5181
5201
  }
5182
5202
  }
5183
5203
 
5184
- function sessionRealtimeHttpError(error: unknown): HTTPException {
5204
+ /** Conflicts a person can hit by starting voice; the browser shows these verbatim. */
5205
+ const SESSION_REALTIME_USER_MESSAGES: Partial<Record<string, string>> = {
5206
+ REALTIME_ACTIVE:
5207
+ "Voice is already on for this session in another tab or window. End it there, or try again in a minute.",
5208
+ CONTROL_NOT_ACTIVE: "Resume this session to start voice.",
5209
+ SESSION_CANCELLED: "This session was cancelled, so voice can't start.",
5210
+ };
5211
+
5212
+ export function sessionRealtimeHttpError(error: unknown): HTTPException {
5185
5213
  if (error instanceof HTTPException) return error;
5186
5214
  if (error instanceof SessionRealtimeConflictError) {
5187
5215
  return new HTTPException(error.code === "REALTIME_NOT_FOUND" ? 404 : 409, {
5188
- message: error.message,
5216
+ message: SESSION_REALTIME_USER_MESSAGES[error.code] ?? error.message,
5189
5217
  cause: error,
5190
5218
  });
5191
5219
  }
@@ -6205,9 +6233,18 @@ async function loadEffectivePolicyContext(
6205
6233
  subjectId: string,
6206
6234
  sessions: readonly Session[],
6207
6235
  ): Promise<EffectivePolicyContext> {
6236
+ // Both projections read the same workspace row; hydrate it once.
6237
+ const workspaceRead = requireWorkspace(deps.db, workspaceId);
6238
+ void workspaceRead.catch(() => undefined);
6208
6239
  const [policy, effectiveToolsContext] = await Promise.all([
6209
- workspaceSessionToolPolicyContext(deps.db, workspaceId, deps.settings, subjectId),
6210
- workspaceSessionEffectiveToolsContext(deps, workspaceId, subjectId, sessions),
6240
+ workspaceSessionToolPolicyContext(
6241
+ deps.db,
6242
+ workspaceId,
6243
+ deps.settings,
6244
+ subjectId,
6245
+ workspaceRead,
6246
+ ),
6247
+ workspaceSessionEffectiveToolsContext(deps, workspaceId, subjectId, sessions, workspaceRead),
6211
6248
  ]);
6212
6249
  return { ...policy, effectiveToolsContext };
6213
6250
  }
@@ -980,12 +980,14 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
980
980
  ? model.id === AZURE_LIVE_MODEL_ID
981
981
  : true,
982
982
  );
983
- const hasCredits =
984
- managedCandidates.length > 0 &&
985
- (await createRealtimeVoiceBilling({
986
- db: deps.db,
987
- settings: deps.settings,
988
- }).hasSpendableCredits(grant.accountId));
983
+ const creditStanding =
984
+ managedCandidates.length > 0
985
+ ? await createRealtimeVoiceBilling({
986
+ db: deps.db,
987
+ settings: deps.settings,
988
+ }).creditStanding(grant.accountId)
989
+ : "none";
990
+ const hasCredits = creditStanding === "spendable";
989
991
  const models = [
990
992
  ...managedCandidates.map((model, index) => ({
991
993
  ...model,
@@ -994,7 +996,10 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
994
996
  ? { available: true, unavailableReason: null, unavailableCode: null }
995
997
  : {
996
998
  available: false,
997
- unavailableReason: "Add Opengeni credits to use live voice",
999
+ unavailableReason:
1000
+ creditStanding === "promotional_only"
1001
+ ? "Promotional credits don't cover live voice. Add credits to use it."
1002
+ : "Add Opengeni credits to use live voice",
998
1003
  unavailableCode: "insufficient_credits",
999
1004
  }),
1000
1005
  recommended: index === 0,