@opengeni/config 0.7.13 → 0.8.1
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.
- package/dist/index.d.ts +277 -174
- package/dist/index.js +424 -121
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/index.ts +620 -122
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
CAPABILITY_DESCRIPTORS,
|
|
4
4
|
Entitlements,
|
|
5
5
|
EntitlementsMode,
|
|
6
|
+
LatencyMode,
|
|
6
7
|
MAX_NESTED_AGENT_DEPTH,
|
|
7
8
|
ProductAccessMode,
|
|
8
9
|
ReasoningEffort,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
StaticUsageLimits,
|
|
12
13
|
TurnExecutionPolicyV1,
|
|
13
14
|
UsageLimitsMode,
|
|
15
|
+
type TurnExecutionLatencyModeSourceV1,
|
|
14
16
|
type TurnExecutionModelSourceV1,
|
|
15
17
|
type TurnExecutionReasoningSourceV1,
|
|
16
18
|
} from "@opengeni/contracts";
|
|
@@ -217,6 +219,9 @@ const SettingsSchema = z.object({
|
|
|
217
219
|
observabilityOtlpEndpoint: z.string().url().optional(),
|
|
218
220
|
observabilityOtlpHeaders: z.string().default(""),
|
|
219
221
|
publicBaseUrl: z.string().url().optional(),
|
|
222
|
+
// Browser origin when the web app and API use separate origins in local
|
|
223
|
+
// development. Production normally leaves this unset and uses publicBaseUrl.
|
|
224
|
+
webBaseUrl: z.string().url().optional(),
|
|
220
225
|
// Base URL for the bring-your-own-compute agent release assets the get.<domain>
|
|
221
226
|
// install routes redirect to. Defaults to this repo's GitHub Releases. The route
|
|
222
227
|
// appends `/download/agent-v<ver>/<asset>`.
|
|
@@ -264,6 +269,9 @@ const SettingsSchema = z.object({
|
|
|
264
269
|
integrationsOauthClientsJson: z.string().default("{}"),
|
|
265
270
|
slackClientId: z.string().optional(),
|
|
266
271
|
slackClientSecret: z.string().optional(),
|
|
272
|
+
slackSigningSecret: z.string().optional(),
|
|
273
|
+
googleDriveClientId: z.string().optional(),
|
|
274
|
+
googleDriveClientSecret: z.string().optional(),
|
|
267
275
|
// Undefined is meaningful: the migration boundary persists the product
|
|
268
276
|
// default of 3 when no deployment override is supplied.
|
|
269
277
|
maxNestedAgentDepth: z.coerce.number().int().nonnegative().max(MAX_NESTED_AGENT_DEPTH).optional(),
|
|
@@ -321,12 +329,50 @@ const SettingsSchema = z.object({
|
|
|
321
329
|
apiPort: z.coerce.number().int().positive().default(8000),
|
|
322
330
|
workerHttpPort: z.coerce.number().int().positive().default(8001),
|
|
323
331
|
opengeniMcpUrl: z.string().url().optional(),
|
|
332
|
+
// Origins allowed to send browser cookies cross-origin. Other origins may
|
|
333
|
+
// call the public API with bearer credentials, but never receive credentialed
|
|
334
|
+
// CORS responses.
|
|
324
335
|
corsAllowOriginRegex: z.string().default(String.raw`^https?://(localhost|127\.0\.0\.1)(:\d+)?$`),
|
|
325
336
|
openaiProvider: z.enum(["openai", "azure"]).default("openai"),
|
|
326
337
|
openaiApiKey: z.string().optional(),
|
|
327
338
|
openaiBaseUrl: z.string().optional(),
|
|
328
339
|
openaiModel: z.string().default("gpt-5.6-sol"),
|
|
329
340
|
openaiAllowedModels: z.string().default("gpt-5.6-sol,gpt-5.6-terra,gpt-5.6-luna"),
|
|
341
|
+
// Native composer voice input (browser MediaRecorder → API transcription).
|
|
342
|
+
// Provider credentials stay server-side; ClientConfig only projects availability
|
|
343
|
+
// and hard ceilings. Selection happens once before audio is sent — never retry
|
|
344
|
+
// the same clip across vendors after an upstream request may have started.
|
|
345
|
+
voiceInputMaxDurationSeconds: z.coerce.number().int().positive().max(600).default(60),
|
|
346
|
+
voiceInputMaxSizeBytes: z.coerce
|
|
347
|
+
.number()
|
|
348
|
+
.int()
|
|
349
|
+
.positive()
|
|
350
|
+
.max(100 * 1024 * 1024)
|
|
351
|
+
.default(25 * 1024 * 1024),
|
|
352
|
+
// Preferred provider order (comma-separated ids). First configured+ready wins.
|
|
353
|
+
// Codex subscription STT is preferred by default when subscription routing is
|
|
354
|
+
// enabled; operators can put openai/azure-openai first explicitly.
|
|
355
|
+
// Supported: openai, azure-openai, codex-subscription.
|
|
356
|
+
voiceInputProviderOrder: z.string().default("codex-subscription,openai,azure-openai"),
|
|
357
|
+
// OpenAI public /v1/audio/transcriptions path. Reuses OPENGENI_OPENAI_API_KEY
|
|
358
|
+
// when voiceInputOpenaiApiKey is unset. Default model is gpt-transcribe.
|
|
359
|
+
voiceInputOpenaiEnabled: EnvBoolean.default(true),
|
|
360
|
+
voiceInputOpenaiApiKey: z.string().optional(),
|
|
361
|
+
voiceInputOpenaiBaseUrl: z.string().optional(),
|
|
362
|
+
voiceInputOpenaiModel: z.string().default("gpt-transcribe"),
|
|
363
|
+
// Azure OpenAI deployment-scoped audio transcriptions. Reuses the turn-model
|
|
364
|
+
// Azure endpoint/key/AD token when voice-specific overrides are unset.
|
|
365
|
+
voiceInputAzureEnabled: EnvBoolean.default(true),
|
|
366
|
+
voiceInputAzureEndpoint: z.string().optional(),
|
|
367
|
+
voiceInputAzureDeployment: z.string().optional(),
|
|
368
|
+
voiceInputAzureApiVersion: z.string().optional(),
|
|
369
|
+
voiceInputAzureApiKey: z.string().optional(),
|
|
370
|
+
voiceInputAzureAdToken: z.string().optional(),
|
|
371
|
+
// Legacy opt-in for undocumented ChatGPT /backend-api/transcribe. When
|
|
372
|
+
// OPENGENI_CODEX_SUBSCRIPTION_ENABLED is true, Codex STT is included without
|
|
373
|
+
// this flag. Set false and omit codex-subscription from PROVIDER_ORDER to
|
|
374
|
+
// keep subscription model routing while disabling Codex voice input.
|
|
375
|
+
voiceInputCodexExperimentalEnabled: EnvBoolean.default(false),
|
|
330
376
|
modelPricingJson: z.string().default("{}"),
|
|
331
377
|
// Extra (non-built-in) model providers, declared by the host as a JSON
|
|
332
378
|
// provider registry. Each entry carries its own base URL, API key, wire API
|
|
@@ -426,17 +472,17 @@ const SettingsSchema = z.object({
|
|
|
426
472
|
// the named Secret and builds the image via `fromRegistry(tag, secret)` before the
|
|
427
473
|
// first sandbox is created. Knob: OPENGENI_MODAL_IMAGE_REGISTRY_SECRET.
|
|
428
474
|
modalImageRegistrySecret: z.string().optional(),
|
|
429
|
-
// Modal's hard sandbox lifetime (timeoutMs = this * 1000), counted from
|
|
430
|
-
//
|
|
431
|
-
// down, NOT the warm-window
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
//
|
|
475
|
+
// Modal's hard sandbox lifetime (timeoutMs = this * 1000), counted from box
|
|
476
|
+
// creation. A resume-by-id does NOT reset that provider clock. It is the
|
|
477
|
+
// BACKSTOP that reclaims a box if the reaper/worker is down, NOT the warm-window
|
|
478
|
+
// controller (that's sandboxIdleGraceMs). It must comfortably exceed
|
|
479
|
+
// reaperPeriod + idleGrace so the reaper terminates a genuinely-idle box FIRST;
|
|
480
|
+
// the boot invariant below enforces that. Default 24h, Modal's documented
|
|
481
|
+
// maximum, to reduce premature active-box loss and leave headroom for the
|
|
482
|
+
// deadline-aware snapshot/rematerialization transition. The transition—not a
|
|
483
|
+
// larger timeout—is what lets a session outlive one finite provider box.
|
|
438
484
|
// Knob: OPENGENI_MODAL_TIMEOUT_SECONDS.
|
|
439
|
-
modalTimeoutSeconds: z.coerce.number().int().positive().default(
|
|
485
|
+
modalTimeoutSeconds: z.coerce.number().int().positive().max(86_400).default(86_400),
|
|
440
486
|
modalTokenId: z.string().optional(),
|
|
441
487
|
modalTokenSecret: z.string().optional(),
|
|
442
488
|
modalEnvironment: z.string().optional(),
|
|
@@ -469,13 +515,6 @@ const SettingsSchema = z.object({
|
|
|
469
515
|
modalWorkspacePersistence: z
|
|
470
516
|
.enum(["tar", "snapshot_filesystem", "snapshot_directory"])
|
|
471
517
|
.default("snapshot_filesystem"),
|
|
472
|
-
// Snapshot GC backstop (sandbox-file-persistence): the reaper keeps ONE latest
|
|
473
|
-
// filesystem snapshot per lease (delete-prior-on-supersede + delete-on-teardown).
|
|
474
|
-
// This is the TTL retention floor for the periodic orphan sweep — a snapshot
|
|
475
|
-
// whose lease is cold and older than this is best-effort deleted so a crashed
|
|
476
|
-
// persist-then-no-restore never leaks a Modal image. 0 disables the TTL sweep
|
|
477
|
-
// (delete-on-supersede/teardown still run). Default 7 days.
|
|
478
|
-
modalSnapshotRetentionSeconds: z.coerce.number().int().nonnegative().default(604_800),
|
|
479
518
|
// Shared desktop toggle: this module reads it for the 6080 port-merge; the
|
|
480
519
|
// owner module (P4.x) acts on it to launch the display stack.
|
|
481
520
|
sandboxDesktopEnabled: EnvBoolean.default(false),
|
|
@@ -694,8 +733,9 @@ const SettingsSchema = z.object({
|
|
|
694
733
|
// this whole window so a "glanced away then came back" re-arms the SAME warm box
|
|
695
734
|
// (acquireLease re-arms draining->warm; the reaper's BEFORE-terminate re-read
|
|
696
735
|
// skips a re-armed box). Default 15min so a brief detour never cold-creates a
|
|
697
|
-
// fresh EMPTY box; lower it to trade warm cost for a snappier reclaim.
|
|
698
|
-
//
|
|
736
|
+
// fresh EMPTY box; lower it to trade warm cost for a snappier reclaim.
|
|
737
|
+
// getSettings caps the default at half a shorter configured Modal lifetime so
|
|
738
|
+
// the entire reaper window always fits. Knob: OPENGENI_SANDBOX_IDLE_GRACE_MS.
|
|
699
739
|
sandboxIdleGraceMs: z.coerce.number().int().positive().default(900_000),
|
|
700
740
|
// MID-SESSION /workspace snapshot cadence (sandbox-file-persistence). The
|
|
701
741
|
// reaper's drain-persist only protects boxes the reaper itself kills; a box
|
|
@@ -713,6 +753,22 @@ const SettingsSchema = z.object({
|
|
|
713
753
|
// treated exactly like a failed best-effort snapshot. Knob:
|
|
714
754
|
// OPENGENI_SANDBOX_SNAPSHOT_TIMEOUT_MS. Default 60s.
|
|
715
755
|
sandboxSnapshotTimeoutMs: z.coerce.number().int().positive().default(60_000),
|
|
756
|
+
// Begin a controlled snapshot/quiesce/drain/rematerialize transition this far
|
|
757
|
+
// ahead of a finite provider deadline. Modal's 24h creation clock cannot be
|
|
758
|
+
// extended; the logical sandbox outlives it by moving to one successor box.
|
|
759
|
+
// getSettings derives the actual default as min(1h, half the configured
|
|
760
|
+
// provider lifetime) so short-lived test/canary boxes remain bootable without
|
|
761
|
+
// an extra coupled environment override. An explicit value may be larger when
|
|
762
|
+
// an operator deliberately wants more rotation headroom; the boot invariant
|
|
763
|
+
// still requires it to remain below the provider lifetime.
|
|
764
|
+
sandboxRotationLeadMs: z.coerce.number().int().positive().default(3_600_000),
|
|
765
|
+
// Bound each global reaper pass so a rollout that discovers many legacy boxes
|
|
766
|
+
// with unknown creation clocks cannot create a provider/API thundering herd.
|
|
767
|
+
// One is the safe admission default: the reaper services provider transitions
|
|
768
|
+
// sequentially, so claiming a wider batch would fence boxes before the same
|
|
769
|
+
// sweep can service them. Larger fleets may raise this only as an explicit,
|
|
770
|
+
// observed deployment choice.
|
|
771
|
+
sandboxRotationBatchSize: z.coerce.number().int().positive().max(500).default(1),
|
|
716
772
|
// expires_at refresh window for a held lease (>> the turn 10s heartbeat so a
|
|
717
773
|
// single missed heartbeat never TTL-reaps a live turn). The warming TTL is the
|
|
718
774
|
// window a cold->warming spawner has to commit warm before a reaper resets it.
|
|
@@ -741,6 +797,7 @@ const SettingsSchema = z.object({
|
|
|
741
797
|
sandboxPreparationProfiles: z.string().default("none"),
|
|
742
798
|
sandboxEnvAllowlist: z.string().default(""),
|
|
743
799
|
objectStorageEndpoint: z.string().url().optional(),
|
|
800
|
+
objectStorageInternalEndpoint: z.string().url().optional(),
|
|
744
801
|
objectStorageSandboxEndpoint: z.string().url().optional(),
|
|
745
802
|
objectStorageBackend: z
|
|
746
803
|
.enum(["s3-compatible", "aws-s3", "azure-blob", "gcs"])
|
|
@@ -819,6 +876,167 @@ const SettingsSchema = z.object({
|
|
|
819
876
|
|
|
820
877
|
export type Settings = z.infer<typeof SettingsSchema>;
|
|
821
878
|
export type McpServerConfig = Settings["mcpServers"][number];
|
|
879
|
+
|
|
880
|
+
/** Declarative voice-input transcription provider ids. */
|
|
881
|
+
export type VoiceInputProviderId = "openai" | "azure-openai" | "codex-subscription";
|
|
882
|
+
|
|
883
|
+
export type VoiceInputProviderConfig =
|
|
884
|
+
| {
|
|
885
|
+
id: "openai";
|
|
886
|
+
kind: "openai";
|
|
887
|
+
apiKey: string;
|
|
888
|
+
baseUrl: string;
|
|
889
|
+
model: string;
|
|
890
|
+
}
|
|
891
|
+
| {
|
|
892
|
+
id: "azure-openai";
|
|
893
|
+
kind: "azure-openai";
|
|
894
|
+
endpoint: string;
|
|
895
|
+
deployment: string;
|
|
896
|
+
apiVersion: string;
|
|
897
|
+
apiKey: string | null;
|
|
898
|
+
adToken: string | null;
|
|
899
|
+
}
|
|
900
|
+
| {
|
|
901
|
+
id: "codex-subscription";
|
|
902
|
+
kind: "codex-subscription";
|
|
903
|
+
experimental: true;
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Reject empty / template secrets so `.env.example` placeholders like
|
|
908
|
+
* `your-key` cannot advertise voice input as available and then 401 upstream.
|
|
909
|
+
*/
|
|
910
|
+
export function isUsableVoiceInputSecret(value: string | null | undefined): value is string {
|
|
911
|
+
if (value == null) return false;
|
|
912
|
+
const trimmed = value.trim();
|
|
913
|
+
if (!trimmed) return false;
|
|
914
|
+
const normalized = trimmed.toLowerCase();
|
|
915
|
+
if (
|
|
916
|
+
normalized === "your-key" ||
|
|
917
|
+
normalized === "your_key" ||
|
|
918
|
+
normalized === "changeme" ||
|
|
919
|
+
normalized === "replace-me" ||
|
|
920
|
+
normalized === "xxx" ||
|
|
921
|
+
normalized.startsWith("your-") ||
|
|
922
|
+
normalized.startsWith("your_")
|
|
923
|
+
) {
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
return true;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Resolve the configured voice-input provider registry in selection order.
|
|
931
|
+
* Credentials stay in this server-side structure; ClientConfig only projects
|
|
932
|
+
* whether at least one non-experimental (or probed experimental) provider exists.
|
|
933
|
+
*/
|
|
934
|
+
export function resolveVoiceInputProviderRegistry(settings: Settings): VoiceInputProviderConfig[] {
|
|
935
|
+
const order = settings.voiceInputProviderOrder
|
|
936
|
+
.split(",")
|
|
937
|
+
.map((part) => part.trim())
|
|
938
|
+
.filter(
|
|
939
|
+
(part): part is VoiceInputProviderId =>
|
|
940
|
+
part === "openai" || part === "azure-openai" || part === "codex-subscription",
|
|
941
|
+
);
|
|
942
|
+
const seen = new Set<VoiceInputProviderId>();
|
|
943
|
+
const providers: VoiceInputProviderConfig[] = [];
|
|
944
|
+
for (const id of order) {
|
|
945
|
+
if (seen.has(id)) continue;
|
|
946
|
+
seen.add(id);
|
|
947
|
+
if (id === "openai") {
|
|
948
|
+
if (!settings.voiceInputOpenaiEnabled) continue;
|
|
949
|
+
const apiKey = settings.voiceInputOpenaiApiKey ?? settings.openaiApiKey;
|
|
950
|
+
if (!isUsableVoiceInputSecret(apiKey)) continue;
|
|
951
|
+
// When the turn provider is Azure-only and no voice-specific OpenAI key/URL
|
|
952
|
+
// was set, do not silently reuse a leftover OPENAI_API_KEY for voice.
|
|
953
|
+
if (
|
|
954
|
+
settings.openaiProvider === "azure" &&
|
|
955
|
+
!settings.voiceInputOpenaiApiKey &&
|
|
956
|
+
!settings.voiceInputOpenaiBaseUrl
|
|
957
|
+
) {
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
providers.push({
|
|
961
|
+
id: "openai",
|
|
962
|
+
kind: "openai",
|
|
963
|
+
apiKey,
|
|
964
|
+
baseUrl: (
|
|
965
|
+
settings.voiceInputOpenaiBaseUrl ??
|
|
966
|
+
settings.openaiBaseUrl ??
|
|
967
|
+
"https://api.openai.com/v1"
|
|
968
|
+
).replace(/\/+$/, ""),
|
|
969
|
+
model: settings.voiceInputOpenaiModel,
|
|
970
|
+
});
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
if (id === "azure-openai") {
|
|
974
|
+
if (!settings.voiceInputAzureEnabled) continue;
|
|
975
|
+
const endpoint = (
|
|
976
|
+
settings.voiceInputAzureEndpoint ??
|
|
977
|
+
settings.azureOpenaiEndpoint ??
|
|
978
|
+
""
|
|
979
|
+
).replace(/\/+$/, "");
|
|
980
|
+
const deployment = settings.voiceInputAzureDeployment ?? settings.azureOpenaiDeployment ?? "";
|
|
981
|
+
const apiVersion =
|
|
982
|
+
settings.voiceInputAzureApiVersion ??
|
|
983
|
+
settings.azureOpenaiApiVersion ??
|
|
984
|
+
"2025-04-01-preview";
|
|
985
|
+
const apiKey = settings.voiceInputAzureApiKey ?? settings.azureOpenaiApiKey ?? null;
|
|
986
|
+
const adToken = settings.voiceInputAzureAdToken ?? settings.azureOpenaiAdToken ?? null;
|
|
987
|
+
if (
|
|
988
|
+
!endpoint ||
|
|
989
|
+
!deployment ||
|
|
990
|
+
(!isUsableVoiceInputSecret(apiKey) && !isUsableVoiceInputSecret(adToken))
|
|
991
|
+
) {
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
// When turn provider is OpenAI-only and no voice-specific Azure settings
|
|
995
|
+
// were provided, skip ambient Azure leftovers.
|
|
996
|
+
if (
|
|
997
|
+
settings.openaiProvider !== "azure" &&
|
|
998
|
+
!settings.voiceInputAzureEndpoint &&
|
|
999
|
+
!settings.voiceInputAzureDeployment &&
|
|
1000
|
+
!settings.voiceInputAzureApiKey &&
|
|
1001
|
+
!settings.voiceInputAzureAdToken
|
|
1002
|
+
) {
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
providers.push({
|
|
1006
|
+
id: "azure-openai",
|
|
1007
|
+
kind: "azure-openai",
|
|
1008
|
+
endpoint,
|
|
1009
|
+
deployment,
|
|
1010
|
+
apiVersion,
|
|
1011
|
+
apiKey: isUsableVoiceInputSecret(apiKey) ? apiKey : null,
|
|
1012
|
+
adToken: isUsableVoiceInputSecret(adToken) ? adToken : null,
|
|
1013
|
+
});
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
if (id === "codex-subscription") {
|
|
1017
|
+
// Prefer Codex STT whenever subscription model routing is enabled.
|
|
1018
|
+
// Operators who want OpenAI/Azure first should set PROVIDER_ORDER; omit
|
|
1019
|
+
// `codex-subscription` from the order to disable Codex voice while keeping
|
|
1020
|
+
// subscription turns. VOICE_INPUT_CODEX_EXPERIMENTAL is retained for
|
|
1021
|
+
// back-compat docs/env but no longer gates inclusion.
|
|
1022
|
+
if (!settings.codexSubscriptionEnabled) continue;
|
|
1023
|
+
providers.push({
|
|
1024
|
+
id: "codex-subscription",
|
|
1025
|
+
kind: "codex-subscription",
|
|
1026
|
+
experimental: true,
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return providers;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** True when the deployment has at least one supported (non-experimental) provider. */
|
|
1034
|
+
export function voiceInputDeploymentConfigured(settings: Settings): boolean {
|
|
1035
|
+
return resolveVoiceInputProviderRegistry(settings).some(
|
|
1036
|
+
(provider) => provider.kind !== "codex-subscription",
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
822
1040
|
export type TemporalTlsConnectionConfig = {
|
|
823
1041
|
serverNameOverride?: string;
|
|
824
1042
|
serverRootCACertificate?: Uint8Array;
|
|
@@ -1167,88 +1385,91 @@ export interface ConfiguredModel {
|
|
|
1167
1385
|
hostedWebSearch: boolean;
|
|
1168
1386
|
}
|
|
1169
1387
|
|
|
1170
|
-
|
|
1388
|
+
/**
|
|
1389
|
+
* Built-in OpenGeni credit pricing schedules.
|
|
1390
|
+
*
|
|
1391
|
+
* Rates are provider list prices in USD micros per 1M tokens. Debit applies
|
|
1392
|
+
* `marginBps` (2_500 = +25%) on top. Long-context tiers follow OpenAI's
|
|
1393
|
+
* ">272K input tokens" rule (threshold exclusive of 272_000).
|
|
1394
|
+
*
|
|
1395
|
+
* GPT-5.4 and older families are intentionally omitted — they are no longer
|
|
1396
|
+
* offered. Codex / connected-subscription turns use `metering: external` and
|
|
1397
|
+
* never consult this map.
|
|
1398
|
+
*
|
|
1399
|
+
* When adding or changing a billed model, run `bun run check:model-pricing`
|
|
1400
|
+
* (see docs/model-providers.md § Price audit). That compares this map to
|
|
1401
|
+
* llm-prices.com as a ground-truth canary; it does not generate this table.
|
|
1402
|
+
*/
|
|
1403
|
+
export const defaultModelPricing: Record<string, ModelPricingScheduleV1> = {
|
|
1171
1404
|
"gpt-5.6-sol": {
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1405
|
+
default: {
|
|
1406
|
+
inputMicrosPerMillionTokens: 5_000_000,
|
|
1407
|
+
cachedInputMicrosPerMillionTokens: 500_000,
|
|
1408
|
+
outputMicrosPerMillionTokens: 30_000_000,
|
|
1409
|
+
marginBps: 2_500,
|
|
1410
|
+
},
|
|
1411
|
+
inputTokenTiers: [
|
|
1412
|
+
{
|
|
1413
|
+
// OpenAI: prompts with >272K input tokens use the long-context rate.
|
|
1414
|
+
minimumInputTokens: 272_001,
|
|
1415
|
+
pricing: {
|
|
1416
|
+
inputMicrosPerMillionTokens: 10_000_000,
|
|
1417
|
+
cachedInputMicrosPerMillionTokens: 1_000_000,
|
|
1418
|
+
outputMicrosPerMillionTokens: 45_000_000,
|
|
1419
|
+
marginBps: 2_500,
|
|
1420
|
+
},
|
|
1421
|
+
},
|
|
1422
|
+
],
|
|
1176
1423
|
},
|
|
1177
1424
|
"gpt-5.6-terra": {
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1425
|
+
default: {
|
|
1426
|
+
inputMicrosPerMillionTokens: 2_000_000,
|
|
1427
|
+
cachedInputMicrosPerMillionTokens: 200_000,
|
|
1428
|
+
outputMicrosPerMillionTokens: 12_000_000,
|
|
1429
|
+
marginBps: 2_500,
|
|
1430
|
+
},
|
|
1431
|
+
inputTokenTiers: [
|
|
1432
|
+
{
|
|
1433
|
+
minimumInputTokens: 272_001,
|
|
1434
|
+
pricing: {
|
|
1435
|
+
inputMicrosPerMillionTokens: 4_000_000,
|
|
1436
|
+
cachedInputMicrosPerMillionTokens: 400_000,
|
|
1437
|
+
outputMicrosPerMillionTokens: 18_000_000,
|
|
1438
|
+
marginBps: 2_500,
|
|
1439
|
+
},
|
|
1440
|
+
},
|
|
1441
|
+
],
|
|
1182
1442
|
},
|
|
1183
1443
|
"gpt-5.6-luna": {
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
"gpt-5.2": {
|
|
1202
|
-
inputMicrosPerMillionTokens: 1_750_000,
|
|
1203
|
-
cachedInputMicrosPerMillionTokens: 175_000,
|
|
1204
|
-
outputMicrosPerMillionTokens: 14_000_000,
|
|
1205
|
-
marginBps: 2_500,
|
|
1206
|
-
},
|
|
1207
|
-
"gpt-5.2-chat-latest": {
|
|
1208
|
-
inputMicrosPerMillionTokens: 1_750_000,
|
|
1209
|
-
cachedInputMicrosPerMillionTokens: 175_000,
|
|
1210
|
-
outputMicrosPerMillionTokens: 14_000_000,
|
|
1211
|
-
marginBps: 2_500,
|
|
1212
|
-
},
|
|
1213
|
-
"gpt-5.2-codex": {
|
|
1214
|
-
inputMicrosPerMillionTokens: 1_750_000,
|
|
1215
|
-
cachedInputMicrosPerMillionTokens: 175_000,
|
|
1216
|
-
outputMicrosPerMillionTokens: 14_000_000,
|
|
1217
|
-
marginBps: 2_500,
|
|
1218
|
-
},
|
|
1219
|
-
"gpt-5.1": {
|
|
1220
|
-
inputMicrosPerMillionTokens: 1_250_000,
|
|
1221
|
-
cachedInputMicrosPerMillionTokens: 125_000,
|
|
1222
|
-
outputMicrosPerMillionTokens: 10_000_000,
|
|
1223
|
-
marginBps: 2_500,
|
|
1224
|
-
},
|
|
1225
|
-
"gpt-5": {
|
|
1226
|
-
inputMicrosPerMillionTokens: 1_250_000,
|
|
1227
|
-
cachedInputMicrosPerMillionTokens: 125_000,
|
|
1228
|
-
outputMicrosPerMillionTokens: 10_000_000,
|
|
1229
|
-
marginBps: 2_500,
|
|
1230
|
-
},
|
|
1231
|
-
"gpt-5-mini": {
|
|
1232
|
-
inputMicrosPerMillionTokens: 250_000,
|
|
1233
|
-
cachedInputMicrosPerMillionTokens: 25_000,
|
|
1234
|
-
outputMicrosPerMillionTokens: 2_000_000,
|
|
1235
|
-
marginBps: 2_500,
|
|
1236
|
-
},
|
|
1237
|
-
"gpt-5-nano": {
|
|
1238
|
-
inputMicrosPerMillionTokens: 50_000,
|
|
1239
|
-
cachedInputMicrosPerMillionTokens: 5_000,
|
|
1240
|
-
outputMicrosPerMillionTokens: 400_000,
|
|
1241
|
-
marginBps: 2_500,
|
|
1444
|
+
default: {
|
|
1445
|
+
inputMicrosPerMillionTokens: 200_000,
|
|
1446
|
+
cachedInputMicrosPerMillionTokens: 20_000,
|
|
1447
|
+
outputMicrosPerMillionTokens: 1_200_000,
|
|
1448
|
+
marginBps: 2_500,
|
|
1449
|
+
},
|
|
1450
|
+
inputTokenTiers: [
|
|
1451
|
+
{
|
|
1452
|
+
minimumInputTokens: 272_001,
|
|
1453
|
+
pricing: {
|
|
1454
|
+
inputMicrosPerMillionTokens: 400_000,
|
|
1455
|
+
cachedInputMicrosPerMillionTokens: 40_000,
|
|
1456
|
+
outputMicrosPerMillionTokens: 1_800_000,
|
|
1457
|
+
marginBps: 2_500,
|
|
1458
|
+
},
|
|
1459
|
+
},
|
|
1460
|
+
],
|
|
1242
1461
|
},
|
|
1243
1462
|
// Fireworks AI / GLM 5.2 — the first shipped non-OpenAI registry model. A
|
|
1244
1463
|
// built-in default pricing entry makes managed billing work out of the box
|
|
1245
1464
|
// for hosts that expose this model via OPENGENI_MODEL_PROVIDERS_JSON without
|
|
1246
1465
|
// also setting OPENGENI_MODEL_PRICING_JSON.
|
|
1247
1466
|
"accounts/fireworks/models/glm-5p2": {
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1467
|
+
default: {
|
|
1468
|
+
inputMicrosPerMillionTokens: 1_400_000,
|
|
1469
|
+
cachedInputMicrosPerMillionTokens: 140_000,
|
|
1470
|
+
outputMicrosPerMillionTokens: 4_400_000,
|
|
1471
|
+
marginBps: 2_500,
|
|
1472
|
+
},
|
|
1252
1473
|
},
|
|
1253
1474
|
};
|
|
1254
1475
|
|
|
@@ -1351,6 +1572,7 @@ export function getSettings(): Settings {
|
|
|
1351
1572
|
observabilityOtlpHeaders:
|
|
1352
1573
|
optional("OPENGENI_OTEL_EXPORTER_OTLP_HEADERS") ?? optional("OTEL_EXPORTER_OTLP_HEADERS"),
|
|
1353
1574
|
publicBaseUrl: optional("OPENGENI_PUBLIC_BASE_URL"),
|
|
1575
|
+
webBaseUrl: optional("OPENGENI_WEB_BASE_URL"),
|
|
1354
1576
|
agentReleasesBaseUrl: optional("OPENGENI_AGENT_RELEASES_BASE_URL"),
|
|
1355
1577
|
agentStableVersion: optional("OPENGENI_AGENT_STABLE_VERSION"),
|
|
1356
1578
|
productAccessMode: optional("OPENGENI_PRODUCT_ACCESS_MODE"),
|
|
@@ -1374,6 +1596,9 @@ export function getSettings(): Settings {
|
|
|
1374
1596
|
integrationsOauthClientsJson: optional("OPENGENI_INTEGRATIONS_OAUTH_CLIENTS_JSON"),
|
|
1375
1597
|
slackClientId: optional("OPENGENI_SLACK_CLIENT_ID"),
|
|
1376
1598
|
slackClientSecret: optional("OPENGENI_SLACK_CLIENT_SECRET"),
|
|
1599
|
+
slackSigningSecret: optional("OPENGENI_SLACK_SIGNING_SECRET"),
|
|
1600
|
+
googleDriveClientId: optional("OPENGENI_GOOGLE_DRIVE_CLIENT_ID"),
|
|
1601
|
+
googleDriveClientSecret: optional("OPENGENI_GOOGLE_DRIVE_CLIENT_SECRET"),
|
|
1377
1602
|
maxNestedAgentDepth: optional("OPENGENI_MAX_NESTED_AGENT_DEPTH"),
|
|
1378
1603
|
goalMaxAutoContinuations: optional("OPENGENI_GOAL_MAX_AUTO_CONTINUATIONS"),
|
|
1379
1604
|
goalNoProgressLimit: optional("OPENGENI_GOAL_NO_PROGRESS_LIMIT"),
|
|
@@ -1398,6 +1623,20 @@ export function getSettings(): Settings {
|
|
|
1398
1623
|
openaiBaseUrl: optional("OPENGENI_OPENAI_BASE_URL") ?? optional("OPENAI_BASE_URL"),
|
|
1399
1624
|
openaiModel: optional("OPENGENI_OPENAI_MODEL"),
|
|
1400
1625
|
openaiAllowedModels: optional("OPENGENI_OPENAI_ALLOWED_MODELS"),
|
|
1626
|
+
voiceInputMaxDurationSeconds: optional("OPENGENI_VOICE_INPUT_MAX_DURATION_SECONDS"),
|
|
1627
|
+
voiceInputMaxSizeBytes: optional("OPENGENI_VOICE_INPUT_MAX_SIZE_BYTES"),
|
|
1628
|
+
voiceInputProviderOrder: optional("OPENGENI_VOICE_INPUT_PROVIDER_ORDER"),
|
|
1629
|
+
voiceInputOpenaiEnabled: optional("OPENGENI_VOICE_INPUT_OPENAI_ENABLED"),
|
|
1630
|
+
voiceInputOpenaiApiKey: optional("OPENGENI_VOICE_INPUT_OPENAI_API_KEY"),
|
|
1631
|
+
voiceInputOpenaiBaseUrl: optional("OPENGENI_VOICE_INPUT_OPENAI_BASE_URL"),
|
|
1632
|
+
voiceInputOpenaiModel: optional("OPENGENI_VOICE_INPUT_OPENAI_MODEL"),
|
|
1633
|
+
voiceInputAzureEnabled: optional("OPENGENI_VOICE_INPUT_AZURE_ENABLED"),
|
|
1634
|
+
voiceInputAzureEndpoint: optional("OPENGENI_VOICE_INPUT_AZURE_ENDPOINT"),
|
|
1635
|
+
voiceInputAzureDeployment: optional("OPENGENI_VOICE_INPUT_AZURE_DEPLOYMENT"),
|
|
1636
|
+
voiceInputAzureApiVersion: optional("OPENGENI_VOICE_INPUT_AZURE_API_VERSION"),
|
|
1637
|
+
voiceInputAzureApiKey: optional("OPENGENI_VOICE_INPUT_AZURE_API_KEY"),
|
|
1638
|
+
voiceInputAzureAdToken: optional("OPENGENI_VOICE_INPUT_AZURE_AD_TOKEN"),
|
|
1639
|
+
voiceInputCodexExperimentalEnabled: optional("OPENGENI_VOICE_INPUT_CODEX_EXPERIMENTAL"),
|
|
1401
1640
|
modelPricingJson: optional("OPENGENI_MODEL_PRICING_JSON"),
|
|
1402
1641
|
modelProvidersJson: optional("OPENGENI_MODEL_PROVIDERS_JSON"),
|
|
1403
1642
|
codexSubscriptionEnabled: optional("OPENGENI_CODEX_SUBSCRIPTION_ENABLED"),
|
|
@@ -1435,7 +1674,6 @@ export function getSettings(): Settings {
|
|
|
1435
1674
|
modalEnvironment: optional("OPENGENI_MODAL_ENVIRONMENT"),
|
|
1436
1675
|
modalIdleTimeoutSeconds: optional("OPENGENI_MODAL_IDLE_TIMEOUT_SECONDS"),
|
|
1437
1676
|
modalWorkspacePersistence: optional("OPENGENI_MODAL_WORKSPACE_PERSISTENCE"),
|
|
1438
|
-
modalSnapshotRetentionSeconds: optional("OPENGENI_MODAL_SNAPSHOT_RETENTION_SECONDS"),
|
|
1439
1677
|
sandboxDesktopEnabled: optional("OPENGENI_SANDBOX_DESKTOP_ENABLED"),
|
|
1440
1678
|
sandboxDesktopInteractive: optional("OPENGENI_SANDBOX_DESKTOP_INTERACTIVE"),
|
|
1441
1679
|
sandboxTerminalEnabled: optional("OPENGENI_SANDBOX_TERMINAL_ENABLED"),
|
|
@@ -1508,6 +1746,8 @@ export function getSettings(): Settings {
|
|
|
1508
1746
|
sandboxIdleGraceMs: optional("OPENGENI_SANDBOX_IDLE_GRACE_MS"),
|
|
1509
1747
|
sandboxSnapshotIntervalMs: optional("OPENGENI_SANDBOX_SNAPSHOT_INTERVAL_MS"),
|
|
1510
1748
|
sandboxSnapshotTimeoutMs: optional("OPENGENI_SANDBOX_SNAPSHOT_TIMEOUT_MS"),
|
|
1749
|
+
sandboxRotationLeadMs: optional("OPENGENI_SANDBOX_ROTATION_LEAD_MS"),
|
|
1750
|
+
sandboxRotationBatchSize: optional("OPENGENI_SANDBOX_ROTATION_BATCH_SIZE"),
|
|
1511
1751
|
sandboxLeaseTtlMs: optional("OPENGENI_SANDBOX_LEASE_TTL_MS"),
|
|
1512
1752
|
sandboxLeaseWarmingTtlMs: optional("OPENGENI_SANDBOX_LEASE_WARMING_TTL_MS"),
|
|
1513
1753
|
sandboxWarmingTimeoutMs: optional("OPENGENI_SANDBOX_WARMING_TIMEOUT_MS"),
|
|
@@ -1519,6 +1759,7 @@ export function getSettings(): Settings {
|
|
|
1519
1759
|
sandboxPreparationProfiles: optional("OPENGENI_SANDBOX_PREPARATION_PROFILES"),
|
|
1520
1760
|
sandboxEnvAllowlist: optional("OPENGENI_SANDBOX_ENV_ALLOWLIST"),
|
|
1521
1761
|
objectStorageEndpoint: optional("OPENGENI_OBJECT_STORAGE_ENDPOINT"),
|
|
1762
|
+
objectStorageInternalEndpoint: optional("OPENGENI_OBJECT_STORAGE_INTERNAL_ENDPOINT"),
|
|
1522
1763
|
objectStorageSandboxEndpoint: optional("OPENGENI_OBJECT_STORAGE_SANDBOX_ENDPOINT"),
|
|
1523
1764
|
objectStorageBackend: optional("OPENGENI_OBJECT_STORAGE_BACKEND"),
|
|
1524
1765
|
objectStorageBucket: optional("OPENGENI_OBJECT_STORAGE_BUCKET"),
|
|
@@ -1574,12 +1815,37 @@ export function getSettings(): Settings {
|
|
|
1574
1815
|
const parsed = SettingsSchema.parse(raw);
|
|
1575
1816
|
const settings = {
|
|
1576
1817
|
...parsed,
|
|
1818
|
+
sandboxIdleGraceMs:
|
|
1819
|
+
raw.sandboxIdleGraceMs === undefined
|
|
1820
|
+
? Math.min(900_000, Math.floor((parsed.modalTimeoutSeconds * 1000) / 2))
|
|
1821
|
+
: parsed.sandboxIdleGraceMs,
|
|
1822
|
+
sandboxRotationLeadMs:
|
|
1823
|
+
raw.sandboxRotationLeadMs === undefined
|
|
1824
|
+
? Math.min(3_600_000, Math.floor((parsed.modalTimeoutSeconds * 1000) / 2))
|
|
1825
|
+
: parsed.sandboxRotationLeadMs,
|
|
1577
1826
|
mcpServers: ensureBuiltInMcpServers(parsed),
|
|
1578
1827
|
};
|
|
1579
1828
|
validateSettings(settings);
|
|
1580
1829
|
return settings;
|
|
1581
1830
|
}
|
|
1582
1831
|
|
|
1832
|
+
const LOCAL_FIRST_PARTY_DELEGATION_SECRET = "opengeni-local-first-party-delegation-secret-v1";
|
|
1833
|
+
|
|
1834
|
+
/**
|
|
1835
|
+
* First-party session tools need a shared HMAC identity even in the unauthenticated
|
|
1836
|
+
* local product mode. A fixed local-only value is no broader than that mode's
|
|
1837
|
+
* existing access boundary, while configured and managed deployments continue to
|
|
1838
|
+
* require an operator-provided secret.
|
|
1839
|
+
*/
|
|
1840
|
+
export function resolveFirstPartyDelegationSecret(settings: Settings): string | undefined {
|
|
1841
|
+
const explicit = settings.delegationSecret?.trim();
|
|
1842
|
+
if (explicit) return explicit;
|
|
1843
|
+
return settings.productAccessMode === "local" &&
|
|
1844
|
+
(settings.environment === "local" || settings.environment === "test")
|
|
1845
|
+
? LOCAL_FIRST_PARTY_DELEGATION_SECRET
|
|
1846
|
+
: undefined;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1583
1849
|
/**
|
|
1584
1850
|
* The Modal sandbox idle timeout (seconds) the provider actually passes as
|
|
1585
1851
|
* idleTimeoutMs (sandbox-file-persistence). When the operator did not pin
|
|
@@ -1595,6 +1861,23 @@ export function effectiveModalIdleTimeoutSeconds(settings: Settings): number {
|
|
|
1595
1861
|
return settings.modalIdleTimeoutSeconds ?? settings.modalTimeoutSeconds;
|
|
1596
1862
|
}
|
|
1597
1863
|
|
|
1864
|
+
/**
|
|
1865
|
+
* One shared upper bound for the durable provider-capture claim and for command
|
|
1866
|
+
* admission waiting behind it. The SDK request itself is bounded by
|
|
1867
|
+
* sandboxSnapshotTimeoutMs; the extra window lets a non-cancellable provider
|
|
1868
|
+
* response settle and release its exact claim without turning a normal
|
|
1869
|
+
* checkpoint into a visible command failure. Database validation caps both
|
|
1870
|
+
* consumers at one hour.
|
|
1871
|
+
*/
|
|
1872
|
+
export function sandboxArchiveCaptureTimeoutMs(
|
|
1873
|
+
settings: Pick<Settings, "sandboxSnapshotTimeoutMs">,
|
|
1874
|
+
): number {
|
|
1875
|
+
return Math.min(
|
|
1876
|
+
60 * 60_000,
|
|
1877
|
+
Math.max(settings.sandboxSnapshotTimeoutMs + 30_000, settings.sandboxSnapshotTimeoutMs * 2),
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1598
1881
|
export function collectSandboxEnvironment(
|
|
1599
1882
|
settings: Settings,
|
|
1600
1883
|
source: NodeJS.ProcessEnv = process.env,
|
|
@@ -1885,6 +2168,115 @@ function legacyModelCapabilities(
|
|
|
1885
2168
|
});
|
|
1886
2169
|
}
|
|
1887
2170
|
|
|
2171
|
+
/** OpenAI GPT-5.6 Fast mode is 2× Standard list rates (service_tier fast/priority). */
|
|
2172
|
+
const GPT56_FAST_BILLING_MULTIPLIER_BPS = 20_000;
|
|
2173
|
+
|
|
2174
|
+
/**
|
|
2175
|
+
* Product display label for catalog/picker UI.
|
|
2176
|
+
* Same string for OpenAI and Codex copies of a slug (`gpt-5.6-luna` and
|
|
2177
|
+
* `codex/gpt-5.6-luna` → `GPT-5.6 Luna`). Non-gpt ids pass through unchanged.
|
|
2178
|
+
*/
|
|
2179
|
+
export function productLabelForModelId(modelId: string): string {
|
|
2180
|
+
const slug = modelId.startsWith(CODEX_MODEL_ID_PREFIX)
|
|
2181
|
+
? modelId.slice(CODEX_MODEL_ID_PREFIX.length)
|
|
2182
|
+
: modelId;
|
|
2183
|
+
const match = /^(gpt-\d+(?:\.\d+)?)(?:-(.+))?$/i.exec(slug);
|
|
2184
|
+
if (!match) {
|
|
2185
|
+
return slug;
|
|
2186
|
+
}
|
|
2187
|
+
const family = match[1]!.replace(/^gpt/i, "GPT");
|
|
2188
|
+
const rest = match[2];
|
|
2189
|
+
if (!rest) {
|
|
2190
|
+
return family;
|
|
2191
|
+
}
|
|
2192
|
+
const suffix = rest
|
|
2193
|
+
.split("-")
|
|
2194
|
+
.filter((part) => part.length > 0)
|
|
2195
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
|
2196
|
+
.join(" ");
|
|
2197
|
+
return suffix.length > 0 ? `${family} ${suffix}` : family;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
function builtinLatencyModesForModel(modelId: string): Array<{
|
|
2201
|
+
id: z.infer<typeof ModelLatencyModeV1>;
|
|
2202
|
+
upstream: "supported" | "unsupported" | "unknown";
|
|
2203
|
+
runnable: boolean;
|
|
2204
|
+
billingMultiplierBps?: number;
|
|
2205
|
+
}> {
|
|
2206
|
+
if (
|
|
2207
|
+
modelId === "gpt-5.6-sol" ||
|
|
2208
|
+
modelId === "gpt-5.6-terra" ||
|
|
2209
|
+
modelId === "gpt-5.6-luna" ||
|
|
2210
|
+
modelId.startsWith("codex/gpt-5.6-")
|
|
2211
|
+
) {
|
|
2212
|
+
return [
|
|
2213
|
+
{ id: "standard", upstream: "supported", runnable: true },
|
|
2214
|
+
{
|
|
2215
|
+
id: "fast",
|
|
2216
|
+
upstream: "supported",
|
|
2217
|
+
runnable: true,
|
|
2218
|
+
billingMultiplierBps: GPT56_FAST_BILLING_MULTIPLIER_BPS,
|
|
2219
|
+
},
|
|
2220
|
+
];
|
|
2221
|
+
}
|
|
2222
|
+
return [{ id: "standard", upstream: "unknown", runnable: true }];
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
/**
|
|
2226
|
+
* Map OpenGeni latency mode to the provider `service_tier` wire value.
|
|
2227
|
+
* Azure and Codex ChatGPT accept `priority`; OpenAI API accepts `fast` (alias of priority).
|
|
2228
|
+
* Standard omits the field.
|
|
2229
|
+
*/
|
|
2230
|
+
export function serviceTierForLatencyMode(
|
|
2231
|
+
providerId: string,
|
|
2232
|
+
latencyMode: LatencyMode,
|
|
2233
|
+
): "fast" | "priority" | undefined {
|
|
2234
|
+
if (latencyMode === "standard") {
|
|
2235
|
+
return undefined;
|
|
2236
|
+
}
|
|
2237
|
+
if (providerId === "azure" || providerId === CODEX_PROVIDER_ID) {
|
|
2238
|
+
return "priority";
|
|
2239
|
+
}
|
|
2240
|
+
return "fast";
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/** True when the response tier fulfills a non-standard Fast/priority request. */
|
|
2244
|
+
export function responseSatisfiesLatencyMode(
|
|
2245
|
+
requested: LatencyMode,
|
|
2246
|
+
responseServiceTier: string | null | undefined,
|
|
2247
|
+
): boolean {
|
|
2248
|
+
if (requested === "standard") {
|
|
2249
|
+
return true;
|
|
2250
|
+
}
|
|
2251
|
+
return responseServiceTier === "priority" || responseServiceTier === "fast";
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
export function runnableLatencyModesForModel(settings: Settings, modelId: string): LatencyMode[] {
|
|
2255
|
+
const resolved = resolveModelProvider(
|
|
2256
|
+
settingsForTurnExecutionPolicy(settings, modelId),
|
|
2257
|
+
canonicalizeConfiguredModelId(settings, modelId),
|
|
2258
|
+
);
|
|
2259
|
+
if (!resolved) {
|
|
2260
|
+
return ["standard"];
|
|
2261
|
+
}
|
|
2262
|
+
return resolved.model.capabilities.latencyModes
|
|
2263
|
+
.filter((mode) => mode.runnable)
|
|
2264
|
+
.map((mode) => LatencyMode.parse(mode.id));
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
function assertLatencyModeRunnable(
|
|
2268
|
+
settings: Settings,
|
|
2269
|
+
modelId: string,
|
|
2270
|
+
latencyMode: LatencyMode,
|
|
2271
|
+
): void {
|
|
2272
|
+
const runnable = runnableLatencyModesForModel(settings, modelId);
|
|
2273
|
+
if (!runnable.includes(latencyMode)) {
|
|
2274
|
+
throw new Error(
|
|
2275
|
+
`latency mode ${latencyMode} is not runnable for model ${modelId} (allowed: ${runnable.join(", ")})`,
|
|
2276
|
+
);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
|
|
1888
2280
|
function registryCredentialSource(provider: RegistryProvider): CredentialSourceV1 {
|
|
1889
2281
|
return provider.kind === "codex-subscription"
|
|
1890
2282
|
? { kind: "connected_subscription", provider: "codex" }
|
|
@@ -1905,8 +2297,16 @@ function builtinCredentialSource(settings: Settings): CredentialSourceV1 {
|
|
|
1905
2297
|
}
|
|
1906
2298
|
|
|
1907
2299
|
function staticRequestMetadataForDigest(provider: ResolvedModelProvider): {
|
|
1908
|
-
headers: Array<{
|
|
1909
|
-
|
|
2300
|
+
headers: Array<{
|
|
2301
|
+
name: string;
|
|
2302
|
+
classification: "public" | "secret";
|
|
2303
|
+
value?: string;
|
|
2304
|
+
}>;
|
|
2305
|
+
query: Array<{
|
|
2306
|
+
name: string;
|
|
2307
|
+
classification: "public" | "secret";
|
|
2308
|
+
value?: string;
|
|
2309
|
+
}>;
|
|
1910
2310
|
} {
|
|
1911
2311
|
const publicHeaders = new Set(provider.publicDefaultHeaderNames ?? []);
|
|
1912
2312
|
const publicQuery = new Set(provider.publicDefaultQueryNames ?? []);
|
|
@@ -2058,23 +2458,36 @@ export function withCodexCatalogProvider(settings: Settings): Settings {
|
|
|
2058
2458
|
label: "Codex (ChatGPT subscription)",
|
|
2059
2459
|
api: "responses",
|
|
2060
2460
|
baseUrl: CODEX_PROVIDER_BASE_URL,
|
|
2061
|
-
models: CODEX_FALLBACK_MODEL_SLUGS.map((slug) =>
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2461
|
+
models: CODEX_FALLBACK_MODEL_SLUGS.map((slug) => {
|
|
2462
|
+
const capabilities = {
|
|
2463
|
+
...legacyModelCapabilities(settings, {
|
|
2464
|
+
reasoningEffort: true,
|
|
2465
|
+
hostedWebSearch: true,
|
|
2466
|
+
}),
|
|
2467
|
+
latencyModes: builtinLatencyModesForModel(`${CODEX_MODEL_ID_PREFIX}${slug}`),
|
|
2468
|
+
};
|
|
2469
|
+
return {
|
|
2470
|
+
id: `${CODEX_MODEL_ID_PREFIX}${slug}`,
|
|
2471
|
+
upstreamModelId: slug,
|
|
2472
|
+
label: productLabelForModelId(slug),
|
|
2473
|
+
reasoningEffort: true,
|
|
2474
|
+
// The ChatGPT/Codex Responses backend accepts the native web_search
|
|
2475
|
+
// hosted tool (unlike hosted apply_patch/computer transports). Declaring
|
|
2476
|
+
// this here makes provider resolution truthful; the worker still applies
|
|
2477
|
+
// the durable session/turn policy gate before attaching it.
|
|
2478
|
+
hostedWebSearch: true,
|
|
2479
|
+
capabilities,
|
|
2480
|
+
contextWindowTokens: CODEX_MODEL_CONTEXT_WINDOW_TOKENS,
|
|
2481
|
+
effectiveContextWindowTokens: CODEX_MODEL_EFFECTIVE_CONTEXT_WINDOW_TOKENS,
|
|
2482
|
+
autoCompactTokenLimit: CODEX_MODEL_AUTO_COMPACT_TOKEN_LIMIT,
|
|
2483
|
+
toolOutputTruncationTokens: CODEX_MODEL_TOOL_OUTPUT_TRUNCATION_TOKENS,
|
|
2484
|
+
};
|
|
2485
|
+
}),
|
|
2486
|
+
};
|
|
2487
|
+
return {
|
|
2488
|
+
...settings,
|
|
2489
|
+
modelProvidersJson: JSON.stringify([...providers, provider]),
|
|
2076
2490
|
};
|
|
2077
|
-
return { ...settings, modelProvidersJson: JSON.stringify([...providers, provider]) };
|
|
2078
2491
|
}
|
|
2079
2492
|
|
|
2080
2493
|
/**
|
|
@@ -2219,14 +2632,17 @@ export function configuredModels(settings: Settings): ConfiguredModel[] {
|
|
|
2219
2632
|
])
|
|
2220
2633
|
.filter((id) => !isRegistryNamespaced(id))
|
|
2221
2634
|
.map((id) => {
|
|
2222
|
-
const capabilities =
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2635
|
+
const capabilities = {
|
|
2636
|
+
...legacyModelCapabilities(settings, {
|
|
2637
|
+
reasoningEffort: true,
|
|
2638
|
+
hostedWebSearch: settings.webSearchEnabled,
|
|
2639
|
+
}),
|
|
2640
|
+
latencyModes: builtinLatencyModesForModel(id),
|
|
2641
|
+
};
|
|
2226
2642
|
return finalizeConfiguredModel(settings, builtinProvider, {
|
|
2227
2643
|
id,
|
|
2228
2644
|
aliases: [],
|
|
2229
|
-
label: id,
|
|
2645
|
+
label: productLabelForModelId(id),
|
|
2230
2646
|
providerId: builtinId,
|
|
2231
2647
|
providerLabel: builtinLabel,
|
|
2232
2648
|
api: "responses" as const,
|
|
@@ -2260,7 +2676,7 @@ export function configuredModels(settings: Settings): ConfiguredModel[] {
|
|
|
2260
2676
|
finalizeConfiguredModel(settings, resolvedProvider, {
|
|
2261
2677
|
id: model.id,
|
|
2262
2678
|
aliases: [...(model.aliases ?? [])],
|
|
2263
|
-
label: model.label ?? model.id,
|
|
2679
|
+
label: model.label ?? productLabelForModelId(model.id),
|
|
2264
2680
|
providerId: provider.id,
|
|
2265
2681
|
providerLabel,
|
|
2266
2682
|
api: provider.api,
|
|
@@ -2277,7 +2693,9 @@ export function configuredModels(settings: Settings): ConfiguredModel[] {
|
|
|
2277
2693
|
: { contextWindowTokens: model.contextWindowTokens }),
|
|
2278
2694
|
...(model.effectiveContextWindowTokens === undefined
|
|
2279
2695
|
? {}
|
|
2280
|
-
: {
|
|
2696
|
+
: {
|
|
2697
|
+
effectiveContextWindowTokens: model.effectiveContextWindowTokens,
|
|
2698
|
+
}),
|
|
2281
2699
|
...(model.autoCompactTokenLimit === undefined
|
|
2282
2700
|
? {}
|
|
2283
2701
|
: { autoCompactTokenLimit: model.autoCompactTokenLimit }),
|
|
@@ -2347,6 +2765,8 @@ export type ResolveTurnExecutionPolicyV1Input = {
|
|
|
2347
2765
|
modelSource: TurnExecutionModelSourceV1;
|
|
2348
2766
|
reasoningEffort: Settings["openaiReasoningEffort"];
|
|
2349
2767
|
reasoningSource: TurnExecutionReasoningSourceV1;
|
|
2768
|
+
latencyMode?: LatencyMode;
|
|
2769
|
+
latencyModeSource?: TurnExecutionLatencyModeSourceV1;
|
|
2350
2770
|
};
|
|
2351
2771
|
|
|
2352
2772
|
function settingsForTurnExecutionPolicy(settings: Settings, modelId: string): Settings {
|
|
@@ -2376,6 +2796,9 @@ export function resolveTurnExecutionPolicyV1(
|
|
|
2376
2796
|
) {
|
|
2377
2797
|
throw new Error("Turn execution policy requested model does not canonicalize to its product");
|
|
2378
2798
|
}
|
|
2799
|
+
const latencyMode = LatencyMode.parse(input.latencyMode ?? "standard");
|
|
2800
|
+
const latencyModeSource = input.latencyModeSource ?? "deployment";
|
|
2801
|
+
assertLatencyModeRunnable(catalogSettings, productModelId, latencyMode);
|
|
2379
2802
|
return TurnExecutionPolicyV1.parse({
|
|
2380
2803
|
schemaVersion: 1,
|
|
2381
2804
|
productModelId,
|
|
@@ -2383,6 +2806,8 @@ export function resolveTurnExecutionPolicyV1(
|
|
|
2383
2806
|
modelSource: input.modelSource,
|
|
2384
2807
|
reasoningEffort: input.reasoningEffort,
|
|
2385
2808
|
reasoningSource: input.reasoningSource,
|
|
2809
|
+
latencyMode,
|
|
2810
|
+
latencyModeSource,
|
|
2386
2811
|
providerId: resolved.provider.id,
|
|
2387
2812
|
upstreamModelId: resolved.model.upstreamModelId,
|
|
2388
2813
|
wireApi: resolved.model.api,
|
|
@@ -2403,6 +2828,7 @@ export function assertTurnExecutionPolicyMatchesConfigV1(
|
|
|
2403
2828
|
expected: {
|
|
2404
2829
|
modelId: string;
|
|
2405
2830
|
reasoningEffort: Settings["openaiReasoningEffort"];
|
|
2831
|
+
latencyMode?: LatencyMode;
|
|
2406
2832
|
},
|
|
2407
2833
|
): {
|
|
2408
2834
|
policy: TurnExecutionPolicyV1;
|
|
@@ -2412,12 +2838,17 @@ export function assertTurnExecutionPolicyMatchesConfigV1(
|
|
|
2412
2838
|
const parsed = TurnExecutionPolicyV1.parse(policy);
|
|
2413
2839
|
const catalogSettings = settingsForTurnExecutionPolicy(settings, parsed.productModelId);
|
|
2414
2840
|
const canonicalExpectedModel = canonicalizeConfiguredModelId(catalogSettings, expected.modelId);
|
|
2841
|
+
const expectedLatencyMode = expected.latencyMode ?? parsed.latencyMode;
|
|
2415
2842
|
if (
|
|
2416
2843
|
parsed.productModelId !== canonicalExpectedModel ||
|
|
2417
|
-
parsed.reasoningEffort !== expected.reasoningEffort
|
|
2844
|
+
parsed.reasoningEffort !== expected.reasoningEffort ||
|
|
2845
|
+
parsed.latencyMode !== expectedLatencyMode
|
|
2418
2846
|
) {
|
|
2419
|
-
throw new Error(
|
|
2847
|
+
throw new Error(
|
|
2848
|
+
"Turn execution policy does not match the accepted turn model/reasoning/latency",
|
|
2849
|
+
);
|
|
2420
2850
|
}
|
|
2851
|
+
assertLatencyModeRunnable(catalogSettings, parsed.productModelId, parsed.latencyMode);
|
|
2421
2852
|
if (
|
|
2422
2853
|
parsed.requestedModelId !== null &&
|
|
2423
2854
|
canonicalizeConfiguredModelId(catalogSettings, parsed.requestedModelId) !==
|
|
@@ -2453,7 +2884,10 @@ export function configuredModelPricingSchedules(
|
|
|
2453
2884
|
settings: Settings,
|
|
2454
2885
|
): Record<string, ModelPricingScheduleV1> {
|
|
2455
2886
|
const defaults = Object.fromEntries(
|
|
2456
|
-
Object.entries(defaultModelPricing).map(([model, pricing]) => [
|
|
2887
|
+
Object.entries(defaultModelPricing).map(([model, pricing]) => [
|
|
2888
|
+
model,
|
|
2889
|
+
normalizeModelPricingSchedule(pricing),
|
|
2890
|
+
]),
|
|
2457
2891
|
);
|
|
2458
2892
|
const registry: Record<string, ModelPricingScheduleV1> = {};
|
|
2459
2893
|
for (const provider of parseModelProvidersJson(settings.modelProvidersJson)) {
|
|
@@ -2580,6 +3014,7 @@ export function calculateModelUsageCostMicros(
|
|
|
2580
3014
|
settings: Settings,
|
|
2581
3015
|
model: string,
|
|
2582
3016
|
usage: ModelUsageInput,
|
|
3017
|
+
options?: { latencyMode?: LatencyMode },
|
|
2583
3018
|
): number {
|
|
2584
3019
|
const schedule = configuredModelPricingSchedules(settings)[model];
|
|
2585
3020
|
if (!schedule) {
|
|
@@ -2602,6 +3037,20 @@ export function calculateModelUsageCostMicros(
|
|
|
2602
3037
|
const marginBps = pricing.marginBps ?? 0;
|
|
2603
3038
|
total += Math.ceil((rawCost * (10_000 + marginBps)) / 10_000);
|
|
2604
3039
|
}
|
|
3040
|
+
const latencyMode = options?.latencyMode ?? "standard";
|
|
3041
|
+
if (latencyMode !== "standard") {
|
|
3042
|
+
const catalogSettings = settingsForTurnExecutionPolicy(settings, model);
|
|
3043
|
+
const resolved = resolveModelProvider(
|
|
3044
|
+
catalogSettings,
|
|
3045
|
+
canonicalizeConfiguredModelId(catalogSettings, model),
|
|
3046
|
+
);
|
|
3047
|
+
const multiplierBps = resolved?.model.capabilities.latencyModes.find(
|
|
3048
|
+
(mode) => mode.id === latencyMode && mode.runnable,
|
|
3049
|
+
)?.billingMultiplierBps;
|
|
3050
|
+
if (multiplierBps && multiplierBps > 0) {
|
|
3051
|
+
total = Math.ceil((total * multiplierBps) / 10_000);
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
2605
3054
|
return total;
|
|
2606
3055
|
}
|
|
2607
3056
|
|
|
@@ -3025,7 +3474,9 @@ export function parseMcpServers(raw: string | undefined): unknown[] | undefined
|
|
|
3025
3474
|
return parsed;
|
|
3026
3475
|
} catch (error) {
|
|
3027
3476
|
const message = error instanceof Error ? error.message : String(error);
|
|
3028
|
-
throw new Error(`OPENGENI_MCP_SERVERS must be a JSON array: ${message}`, {
|
|
3477
|
+
throw new Error(`OPENGENI_MCP_SERVERS must be a JSON array: ${message}`, {
|
|
3478
|
+
cause: error,
|
|
3479
|
+
});
|
|
3029
3480
|
}
|
|
3030
3481
|
}
|
|
3031
3482
|
|
|
@@ -3411,6 +3862,11 @@ function validateSettings(settings: Settings): void {
|
|
|
3411
3862
|
);
|
|
3412
3863
|
}
|
|
3413
3864
|
if (settings.slackClientId) {
|
|
3865
|
+
if (!settings.slackSigningSecret) {
|
|
3866
|
+
throw new Error(
|
|
3867
|
+
"OPENGENI_SLACK_SIGNING_SECRET is required when the OpenGeni Slack app is configured",
|
|
3868
|
+
);
|
|
3869
|
+
}
|
|
3414
3870
|
if (!settings.publicBaseUrl) {
|
|
3415
3871
|
throw new Error(
|
|
3416
3872
|
"OPENGENI_PUBLIC_BASE_URL is required when the OpenGeni Slack app is configured",
|
|
@@ -3430,6 +3886,31 @@ function validateSettings(settings: Settings): void {
|
|
|
3430
3886
|
);
|
|
3431
3887
|
}
|
|
3432
3888
|
}
|
|
3889
|
+
if (Boolean(settings.googleDriveClientId) !== Boolean(settings.googleDriveClientSecret)) {
|
|
3890
|
+
throw new Error(
|
|
3891
|
+
"OPENGENI_GOOGLE_DRIVE_CLIENT_ID and OPENGENI_GOOGLE_DRIVE_CLIENT_SECRET must be configured together",
|
|
3892
|
+
);
|
|
3893
|
+
}
|
|
3894
|
+
if (settings.googleDriveClientId) {
|
|
3895
|
+
if (!settings.publicBaseUrl) {
|
|
3896
|
+
throw new Error(
|
|
3897
|
+
"OPENGENI_PUBLIC_BASE_URL is required when the Google Drive integration is configured",
|
|
3898
|
+
);
|
|
3899
|
+
}
|
|
3900
|
+
if (
|
|
3901
|
+
!settings.publicBaseUrl.startsWith("https://") &&
|
|
3902
|
+
!["local", "test"].includes(settings.environment)
|
|
3903
|
+
) {
|
|
3904
|
+
throw new Error(
|
|
3905
|
+
"OPENGENI_PUBLIC_BASE_URL must use https when the Google Drive integration is configured outside local/test",
|
|
3906
|
+
);
|
|
3907
|
+
}
|
|
3908
|
+
if (!settings.integrationsStateSecret) {
|
|
3909
|
+
throw new Error(
|
|
3910
|
+
"OPENGENI_INTEGRATIONS_STATE_SECRET is required when the Google Drive integration is configured",
|
|
3911
|
+
);
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3433
3914
|
parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
|
|
3434
3915
|
if (
|
|
3435
3916
|
settings.productAccessMode === "configured" &&
|
|
@@ -3536,7 +4017,9 @@ function validateSettings(settings: Settings): void {
|
|
|
3536
4017
|
}
|
|
3537
4018
|
if (
|
|
3538
4019
|
settings.objectStorageBackend === "s3-compatible" &&
|
|
3539
|
-
(settings.objectStorageEndpoint ||
|
|
4020
|
+
(settings.objectStorageEndpoint ||
|
|
4021
|
+
settings.objectStorageInternalEndpoint ||
|
|
4022
|
+
settings.objectStorageSandboxEndpoint) &&
|
|
3540
4023
|
(!settings.objectStorageAccessKeyId || !settings.objectStorageSecretAccessKey)
|
|
3541
4024
|
) {
|
|
3542
4025
|
throw new Error(
|
|
@@ -3566,6 +4049,7 @@ function validateSettings(settings: Settings): void {
|
|
|
3566
4049
|
} else if (settings.objectStorageBackend === "azure-blob") {
|
|
3567
4050
|
if (
|
|
3568
4051
|
settings.objectStorageEndpoint ||
|
|
4052
|
+
settings.objectStorageInternalEndpoint ||
|
|
3569
4053
|
settings.objectStorageSandboxEndpoint ||
|
|
3570
4054
|
settings.objectStorageAccessKeyId ||
|
|
3571
4055
|
settings.objectStorageSecretAccessKey
|
|
@@ -3596,6 +4080,7 @@ function validateSettings(settings: Settings): void {
|
|
|
3596
4080
|
} else {
|
|
3597
4081
|
if (
|
|
3598
4082
|
settings.objectStorageEndpoint ||
|
|
4083
|
+
settings.objectStorageInternalEndpoint ||
|
|
3599
4084
|
settings.objectStorageSandboxEndpoint ||
|
|
3600
4085
|
settings.objectStorageAccessKeyId ||
|
|
3601
4086
|
settings.objectStorageSecretAccessKey
|
|
@@ -3644,12 +4129,13 @@ function validateSettings(settings: Settings): void {
|
|
|
3644
4129
|
// out from under us — the provider lifetime is the backstop, not the
|
|
3645
4130
|
// warm-window controller. idleGrace counts from the user's last release;
|
|
3646
4131
|
// the provider clock counts from the preceding resume, so we leave the
|
|
3647
|
-
// active-turn headroom in modalTimeoutSeconds (default
|
|
4132
|
+
// active-turn headroom in modalTimeoutSeconds (default 86400s).
|
|
3648
4133
|
{
|
|
3649
4134
|
const reaperPeriod = settings.sandboxLeaseReaperPeriodMs;
|
|
3650
4135
|
const viewerTtl = settings.sandboxViewerHolderTtlMs;
|
|
3651
4136
|
const idleGraceMs = settings.sandboxIdleGraceMs;
|
|
3652
4137
|
const providerLifetimeMs = settings.modalTimeoutSeconds * 1000;
|
|
4138
|
+
const rotationLeadMs = settings.sandboxRotationLeadMs;
|
|
3653
4139
|
// The EFFECTIVE box lifetime when it sits idle between turns is the Modal IDLE
|
|
3654
4140
|
// timeout, NOT the hard lifetime (sandbox-file-persistence): a box with no
|
|
3655
4141
|
// active connection is idle-reaped at idleTimeout. effectiveModalIdleTimeout
|
|
@@ -3672,6 +4158,18 @@ function validateSettings(settings: Settings): void {
|
|
|
3672
4158
|
`floor under the hard lifetime, not above it.`,
|
|
3673
4159
|
);
|
|
3674
4160
|
}
|
|
4161
|
+
if (!(rotationLeadMs < providerLifetimeMs)) {
|
|
4162
|
+
throw new Error(
|
|
4163
|
+
`OPENGENI_SANDBOX_ROTATION_LEAD_MS (${rotationLeadMs}) must be strictly less than ` +
|
|
4164
|
+
`OPENGENI_MODAL_TIMEOUT_SECONDS*1000 (${providerLifetimeMs}).`,
|
|
4165
|
+
);
|
|
4166
|
+
}
|
|
4167
|
+
if (!(rotationLeadMs > settings.sandboxSnapshotTimeoutMs + 2 * reaperPeriod)) {
|
|
4168
|
+
throw new Error(
|
|
4169
|
+
`OPENGENI_SANDBOX_ROTATION_LEAD_MS (${rotationLeadMs}) must exceed the snapshot timeout ` +
|
|
4170
|
+
`plus two reaper periods (${settings.sandboxSnapshotTimeoutMs + 2 * reaperPeriod}).`,
|
|
4171
|
+
);
|
|
4172
|
+
}
|
|
3675
4173
|
if (!(viewerTtl < idleTimeoutMs)) {
|
|
3676
4174
|
throw new Error(
|
|
3677
4175
|
`OPENGENI_SANDBOX_VIEWER_HOLDER_TTL_MS (${viewerTtl}) must be strictly less than the effective box ` +
|