@opengeni/config 0.7.13 → 0.7.22

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/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,8 @@ const SettingsSchema = z.object({
264
269
  integrationsOauthClientsJson: z.string().default("{}"),
265
270
  slackClientId: z.string().optional(),
266
271
  slackClientSecret: z.string().optional(),
272
+ googleDriveClientId: z.string().optional(),
273
+ googleDriveClientSecret: z.string().optional(),
267
274
  // Undefined is meaningful: the migration boundary persists the product
268
275
  // default of 3 when no deployment override is supplied.
269
276
  maxNestedAgentDepth: z.coerce.number().int().nonnegative().max(MAX_NESTED_AGENT_DEPTH).optional(),
@@ -327,6 +334,41 @@ const SettingsSchema = z.object({
327
334
  openaiBaseUrl: z.string().optional(),
328
335
  openaiModel: z.string().default("gpt-5.6-sol"),
329
336
  openaiAllowedModels: z.string().default("gpt-5.6-sol,gpt-5.6-terra,gpt-5.6-luna"),
337
+ // Native composer voice input (browser MediaRecorder → API transcription).
338
+ // Provider credentials stay server-side; ClientConfig only projects availability
339
+ // and hard ceilings. Selection happens once before audio is sent — never retry
340
+ // the same clip across vendors after an upstream request may have started.
341
+ voiceInputMaxDurationSeconds: z.coerce.number().int().positive().max(600).default(60),
342
+ voiceInputMaxSizeBytes: z.coerce
343
+ .number()
344
+ .int()
345
+ .positive()
346
+ .max(100 * 1024 * 1024)
347
+ .default(25 * 1024 * 1024),
348
+ // Preferred provider order (comma-separated ids). First configured+ready wins.
349
+ // Codex subscription STT is preferred by default when subscription routing is
350
+ // enabled; operators can put openai/azure-openai first explicitly.
351
+ // Supported: openai, azure-openai, codex-subscription.
352
+ voiceInputProviderOrder: z.string().default("codex-subscription,openai,azure-openai"),
353
+ // OpenAI public /v1/audio/transcriptions path. Reuses OPENGENI_OPENAI_API_KEY
354
+ // when voiceInputOpenaiApiKey is unset. Default model is gpt-transcribe.
355
+ voiceInputOpenaiEnabled: EnvBoolean.default(true),
356
+ voiceInputOpenaiApiKey: z.string().optional(),
357
+ voiceInputOpenaiBaseUrl: z.string().optional(),
358
+ voiceInputOpenaiModel: z.string().default("gpt-transcribe"),
359
+ // Azure OpenAI deployment-scoped audio transcriptions. Reuses the turn-model
360
+ // Azure endpoint/key/AD token when voice-specific overrides are unset.
361
+ voiceInputAzureEnabled: EnvBoolean.default(true),
362
+ voiceInputAzureEndpoint: z.string().optional(),
363
+ voiceInputAzureDeployment: z.string().optional(),
364
+ voiceInputAzureApiVersion: z.string().optional(),
365
+ voiceInputAzureApiKey: z.string().optional(),
366
+ voiceInputAzureAdToken: z.string().optional(),
367
+ // Legacy opt-in for undocumented ChatGPT /backend-api/transcribe. When
368
+ // OPENGENI_CODEX_SUBSCRIPTION_ENABLED is true, Codex STT is included without
369
+ // this flag. Set false and omit codex-subscription from PROVIDER_ORDER to
370
+ // keep subscription model routing while disabling Codex voice input.
371
+ voiceInputCodexExperimentalEnabled: EnvBoolean.default(false),
330
372
  modelPricingJson: z.string().default("{}"),
331
373
  // Extra (non-built-in) model providers, declared by the host as a JSON
332
374
  // provider registry. Each entry carries its own base URL, API key, wire API
@@ -426,17 +468,17 @@ const SettingsSchema = z.object({
426
468
  // the named Secret and builds the image via `fromRegistry(tag, secret)` before the
427
469
  // first sandbox is created. Knob: OPENGENI_MODAL_IMAGE_REGISTRY_SECRET.
428
470
  modalImageRegistrySecret: z.string().optional(),
429
- // Modal's hard sandbox lifetime (timeoutMs = this * 1000), counted from each
430
- // create/resume it is the BACKSTOP that reclaims a box if the reaper/worker is
431
- // down, NOT the warm-window controller (that's sandboxIdleGraceMs). It must
432
- // comfortably exceed reaperPeriod + idleGrace so the reaper terminates a
433
- // genuinely-idle box FIRST; the boot invariant below enforces that. Default 1h
434
- // (was 900s/15min): the 15-min drain grace counts from the user's LAST release,
435
- // but Modal's clock starts at the preceding turn's resume so a 15-min grace on
436
- // top of a 900s lifetime would let Modal kill the box mid-warm-window. 3600s
437
- // leaves ~45min of headroom for the active turn before the warm window opens.
471
+ // Modal's hard sandbox lifetime (timeoutMs = this * 1000), counted from box
472
+ // creation. A resume-by-id does NOT reset that provider clock. It is the
473
+ // BACKSTOP that reclaims a box if the reaper/worker is down, NOT the warm-window
474
+ // controller (that's sandboxIdleGraceMs). It must comfortably exceed
475
+ // reaperPeriod + idleGrace so the reaper terminates a genuinely-idle box FIRST;
476
+ // the boot invariant below enforces that. Default 24h, Modal's documented
477
+ // maximum, to reduce premature active-box loss and leave headroom for the
478
+ // deadline-aware snapshot/rematerialization transition. The transition—not a
479
+ // larger timeout—is what lets a session outlive one finite provider box.
438
480
  // Knob: OPENGENI_MODAL_TIMEOUT_SECONDS.
439
- modalTimeoutSeconds: z.coerce.number().int().positive().default(3600),
481
+ modalTimeoutSeconds: z.coerce.number().int().positive().max(86_400).default(86_400),
440
482
  modalTokenId: z.string().optional(),
441
483
  modalTokenSecret: z.string().optional(),
442
484
  modalEnvironment: z.string().optional(),
@@ -469,13 +511,6 @@ const SettingsSchema = z.object({
469
511
  modalWorkspacePersistence: z
470
512
  .enum(["tar", "snapshot_filesystem", "snapshot_directory"])
471
513
  .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
514
  // Shared desktop toggle: this module reads it for the 6080 port-merge; the
480
515
  // owner module (P4.x) acts on it to launch the display stack.
481
516
  sandboxDesktopEnabled: EnvBoolean.default(false),
@@ -694,8 +729,9 @@ const SettingsSchema = z.object({
694
729
  // this whole window so a "glanced away then came back" re-arms the SAME warm box
695
730
  // (acquireLease re-arms draining->warm; the reaper's BEFORE-terminate re-read
696
731
  // 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. Knob:
698
- // OPENGENI_SANDBOX_IDLE_GRACE_MS.
732
+ // fresh EMPTY box; lower it to trade warm cost for a snappier reclaim.
733
+ // getSettings caps the default at half a shorter configured Modal lifetime so
734
+ // the entire reaper window always fits. Knob: OPENGENI_SANDBOX_IDLE_GRACE_MS.
699
735
  sandboxIdleGraceMs: z.coerce.number().int().positive().default(900_000),
700
736
  // MID-SESSION /workspace snapshot cadence (sandbox-file-persistence). The
701
737
  // reaper's drain-persist only protects boxes the reaper itself kills; a box
@@ -713,6 +749,22 @@ const SettingsSchema = z.object({
713
749
  // treated exactly like a failed best-effort snapshot. Knob:
714
750
  // OPENGENI_SANDBOX_SNAPSHOT_TIMEOUT_MS. Default 60s.
715
751
  sandboxSnapshotTimeoutMs: z.coerce.number().int().positive().default(60_000),
752
+ // Begin a controlled snapshot/quiesce/drain/rematerialize transition this far
753
+ // ahead of a finite provider deadline. Modal's 24h creation clock cannot be
754
+ // extended; the logical sandbox outlives it by moving to one successor box.
755
+ // getSettings derives the actual default as min(1h, half the configured
756
+ // provider lifetime) so short-lived test/canary boxes remain bootable without
757
+ // an extra coupled environment override. An explicit value may be larger when
758
+ // an operator deliberately wants more rotation headroom; the boot invariant
759
+ // still requires it to remain below the provider lifetime.
760
+ sandboxRotationLeadMs: z.coerce.number().int().positive().default(3_600_000),
761
+ // Bound each global reaper pass so a rollout that discovers many legacy boxes
762
+ // with unknown creation clocks cannot create a provider/API thundering herd.
763
+ // One is the safe admission default: the reaper services provider transitions
764
+ // sequentially, so claiming a wider batch would fence boxes before the same
765
+ // sweep can service them. Larger fleets may raise this only as an explicit,
766
+ // observed deployment choice.
767
+ sandboxRotationBatchSize: z.coerce.number().int().positive().max(500).default(1),
716
768
  // expires_at refresh window for a held lease (>> the turn 10s heartbeat so a
717
769
  // single missed heartbeat never TTL-reaps a live turn). The warming TTL is the
718
770
  // window a cold->warming spawner has to commit warm before a reaper resets it.
@@ -741,6 +793,7 @@ const SettingsSchema = z.object({
741
793
  sandboxPreparationProfiles: z.string().default("none"),
742
794
  sandboxEnvAllowlist: z.string().default(""),
743
795
  objectStorageEndpoint: z.string().url().optional(),
796
+ objectStorageInternalEndpoint: z.string().url().optional(),
744
797
  objectStorageSandboxEndpoint: z.string().url().optional(),
745
798
  objectStorageBackend: z
746
799
  .enum(["s3-compatible", "aws-s3", "azure-blob", "gcs"])
@@ -819,6 +872,163 @@ const SettingsSchema = z.object({
819
872
 
820
873
  export type Settings = z.infer<typeof SettingsSchema>;
821
874
  export type McpServerConfig = Settings["mcpServers"][number];
875
+
876
+ /** Declarative voice-input transcription provider ids. */
877
+ export type VoiceInputProviderId = "openai" | "azure-openai" | "codex-subscription";
878
+
879
+ export type VoiceInputProviderConfig =
880
+ | {
881
+ id: "openai";
882
+ kind: "openai";
883
+ apiKey: string;
884
+ baseUrl: string;
885
+ model: string;
886
+ }
887
+ | {
888
+ id: "azure-openai";
889
+ kind: "azure-openai";
890
+ endpoint: string;
891
+ deployment: string;
892
+ apiVersion: string;
893
+ apiKey: string | null;
894
+ adToken: string | null;
895
+ }
896
+ | {
897
+ id: "codex-subscription";
898
+ kind: "codex-subscription";
899
+ experimental: true;
900
+ };
901
+
902
+ /**
903
+ * Reject empty / template secrets so `.env.example` placeholders like
904
+ * `your-key` cannot advertise voice input as available and then 401 upstream.
905
+ */
906
+ export function isUsableVoiceInputSecret(value: string | null | undefined): value is string {
907
+ if (value == null) return false;
908
+ const trimmed = value.trim();
909
+ if (!trimmed) return false;
910
+ const normalized = trimmed.toLowerCase();
911
+ if (
912
+ normalized === "your-key" ||
913
+ normalized === "your_key" ||
914
+ normalized === "changeme" ||
915
+ normalized === "replace-me" ||
916
+ normalized === "xxx" ||
917
+ normalized.startsWith("your-") ||
918
+ normalized.startsWith("your_")
919
+ ) {
920
+ return false;
921
+ }
922
+ return true;
923
+ }
924
+
925
+ /**
926
+ * Resolve the configured voice-input provider registry in selection order.
927
+ * Credentials stay in this server-side structure; ClientConfig only projects
928
+ * whether at least one non-experimental (or probed experimental) provider exists.
929
+ */
930
+ export function resolveVoiceInputProviderRegistry(settings: Settings): VoiceInputProviderConfig[] {
931
+ const order = settings.voiceInputProviderOrder
932
+ .split(",")
933
+ .map((part) => part.trim())
934
+ .filter(
935
+ (part): part is VoiceInputProviderId =>
936
+ part === "openai" || part === "azure-openai" || part === "codex-subscription",
937
+ );
938
+ const seen = new Set<VoiceInputProviderId>();
939
+ const providers: VoiceInputProviderConfig[] = [];
940
+ for (const id of order) {
941
+ if (seen.has(id)) continue;
942
+ seen.add(id);
943
+ if (id === "openai") {
944
+ if (!settings.voiceInputOpenaiEnabled) continue;
945
+ const apiKey = settings.voiceInputOpenaiApiKey ?? settings.openaiApiKey;
946
+ if (!isUsableVoiceInputSecret(apiKey)) continue;
947
+ // When the turn provider is Azure-only and no voice-specific OpenAI key/URL
948
+ // was set, do not silently reuse a leftover OPENAI_API_KEY for voice.
949
+ if (
950
+ settings.openaiProvider === "azure" &&
951
+ !settings.voiceInputOpenaiApiKey &&
952
+ !settings.voiceInputOpenaiBaseUrl
953
+ ) {
954
+ continue;
955
+ }
956
+ providers.push({
957
+ id: "openai",
958
+ kind: "openai",
959
+ apiKey,
960
+ baseUrl: (
961
+ settings.voiceInputOpenaiBaseUrl ??
962
+ settings.openaiBaseUrl ??
963
+ "https://api.openai.com/v1"
964
+ ).replace(/\/+$/, ""),
965
+ model: settings.voiceInputOpenaiModel,
966
+ });
967
+ continue;
968
+ }
969
+ if (id === "azure-openai") {
970
+ if (!settings.voiceInputAzureEnabled) continue;
971
+ const endpoint = (
972
+ settings.voiceInputAzureEndpoint ??
973
+ settings.azureOpenaiEndpoint ??
974
+ ""
975
+ ).replace(/\/+$/, "");
976
+ const deployment = settings.voiceInputAzureDeployment ?? settings.azureOpenaiDeployment ?? "";
977
+ const apiVersion =
978
+ settings.voiceInputAzureApiVersion ??
979
+ settings.azureOpenaiApiVersion ??
980
+ "2025-04-01-preview";
981
+ const apiKey = settings.voiceInputAzureApiKey ?? settings.azureOpenaiApiKey ?? null;
982
+ const adToken = settings.voiceInputAzureAdToken ?? settings.azureOpenaiAdToken ?? null;
983
+ if (
984
+ !endpoint ||
985
+ !deployment ||
986
+ (!isUsableVoiceInputSecret(apiKey) && !isUsableVoiceInputSecret(adToken))
987
+ ) {
988
+ continue;
989
+ }
990
+ // When turn provider is OpenAI-only and no voice-specific Azure settings
991
+ // were provided, skip ambient Azure leftovers.
992
+ if (
993
+ settings.openaiProvider !== "azure" &&
994
+ !settings.voiceInputAzureEndpoint &&
995
+ !settings.voiceInputAzureDeployment &&
996
+ !settings.voiceInputAzureApiKey &&
997
+ !settings.voiceInputAzureAdToken
998
+ ) {
999
+ continue;
1000
+ }
1001
+ providers.push({
1002
+ id: "azure-openai",
1003
+ kind: "azure-openai",
1004
+ endpoint,
1005
+ deployment,
1006
+ apiVersion,
1007
+ apiKey: isUsableVoiceInputSecret(apiKey) ? apiKey : null,
1008
+ adToken: isUsableVoiceInputSecret(adToken) ? adToken : null,
1009
+ });
1010
+ continue;
1011
+ }
1012
+ if (id === "codex-subscription") {
1013
+ // Prefer Codex STT whenever subscription model routing is enabled.
1014
+ // Operators who want OpenAI/Azure first should set PROVIDER_ORDER; omit
1015
+ // `codex-subscription` from the order to disable Codex voice while keeping
1016
+ // subscription turns. VOICE_INPUT_CODEX_EXPERIMENTAL is retained for
1017
+ // back-compat docs/env but no longer gates inclusion.
1018
+ if (!settings.codexSubscriptionEnabled) continue;
1019
+ providers.push({ id: "codex-subscription", kind: "codex-subscription", experimental: true });
1020
+ }
1021
+ }
1022
+ return providers;
1023
+ }
1024
+
1025
+ /** True when the deployment has at least one supported (non-experimental) provider. */
1026
+ export function voiceInputDeploymentConfigured(settings: Settings): boolean {
1027
+ return resolveVoiceInputProviderRegistry(settings).some(
1028
+ (provider) => provider.kind !== "codex-subscription",
1029
+ );
1030
+ }
1031
+
822
1032
  export type TemporalTlsConnectionConfig = {
823
1033
  serverNameOverride?: string;
824
1034
  serverRootCACertificate?: Uint8Array;
@@ -1167,88 +1377,91 @@ export interface ConfiguredModel {
1167
1377
  hostedWebSearch: boolean;
1168
1378
  }
1169
1379
 
1170
- export const defaultModelPricing: Record<string, ModelPricing> = {
1380
+ /**
1381
+ * Built-in OpenGeni credit pricing schedules.
1382
+ *
1383
+ * Rates are provider list prices in USD micros per 1M tokens. Debit applies
1384
+ * `marginBps` (2_500 = +25%) on top. Long-context tiers follow OpenAI's
1385
+ * ">272K input tokens" rule (threshold exclusive of 272_000).
1386
+ *
1387
+ * GPT-5.4 and older families are intentionally omitted — they are no longer
1388
+ * offered. Codex / connected-subscription turns use `metering: external` and
1389
+ * never consult this map.
1390
+ *
1391
+ * When adding or changing a billed model, run `bun run check:model-pricing`
1392
+ * (see docs/model-providers.md § Price audit). That compares this map to
1393
+ * llm-prices.com as a ground-truth canary; it does not generate this table.
1394
+ */
1395
+ export const defaultModelPricing: Record<string, ModelPricingScheduleV1> = {
1171
1396
  "gpt-5.6-sol": {
1172
- inputMicrosPerMillionTokens: 5_000_000,
1173
- cachedInputMicrosPerMillionTokens: 500_000,
1174
- outputMicrosPerMillionTokens: 30_000_000,
1175
- marginBps: 2_500,
1397
+ default: {
1398
+ inputMicrosPerMillionTokens: 5_000_000,
1399
+ cachedInputMicrosPerMillionTokens: 500_000,
1400
+ outputMicrosPerMillionTokens: 30_000_000,
1401
+ marginBps: 2_500,
1402
+ },
1403
+ inputTokenTiers: [
1404
+ {
1405
+ // OpenAI: prompts with >272K input tokens use the long-context rate.
1406
+ minimumInputTokens: 272_001,
1407
+ pricing: {
1408
+ inputMicrosPerMillionTokens: 10_000_000,
1409
+ cachedInputMicrosPerMillionTokens: 1_000_000,
1410
+ outputMicrosPerMillionTokens: 45_000_000,
1411
+ marginBps: 2_500,
1412
+ },
1413
+ },
1414
+ ],
1176
1415
  },
1177
1416
  "gpt-5.6-terra": {
1178
- inputMicrosPerMillionTokens: 2_500_000,
1179
- cachedInputMicrosPerMillionTokens: 250_000,
1180
- outputMicrosPerMillionTokens: 15_000_000,
1181
- marginBps: 2_500,
1417
+ default: {
1418
+ inputMicrosPerMillionTokens: 2_000_000,
1419
+ cachedInputMicrosPerMillionTokens: 200_000,
1420
+ outputMicrosPerMillionTokens: 12_000_000,
1421
+ marginBps: 2_500,
1422
+ },
1423
+ inputTokenTiers: [
1424
+ {
1425
+ minimumInputTokens: 272_001,
1426
+ pricing: {
1427
+ inputMicrosPerMillionTokens: 4_000_000,
1428
+ cachedInputMicrosPerMillionTokens: 400_000,
1429
+ outputMicrosPerMillionTokens: 18_000_000,
1430
+ marginBps: 2_500,
1431
+ },
1432
+ },
1433
+ ],
1182
1434
  },
1183
1435
  "gpt-5.6-luna": {
1184
- inputMicrosPerMillionTokens: 1_000_000,
1185
- cachedInputMicrosPerMillionTokens: 100_000,
1186
- outputMicrosPerMillionTokens: 6_000_000,
1187
- marginBps: 2_500,
1188
- },
1189
- "gpt-5.4": {
1190
- inputMicrosPerMillionTokens: 2_500_000,
1191
- cachedInputMicrosPerMillionTokens: 250_000,
1192
- outputMicrosPerMillionTokens: 15_000_000,
1193
- marginBps: 2_500,
1194
- },
1195
- "gpt-5.4-mini": {
1196
- inputMicrosPerMillionTokens: 750_000,
1197
- cachedInputMicrosPerMillionTokens: 75_000,
1198
- outputMicrosPerMillionTokens: 4_500_000,
1199
- marginBps: 2_500,
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,
1436
+ default: {
1437
+ inputMicrosPerMillionTokens: 200_000,
1438
+ cachedInputMicrosPerMillionTokens: 20_000,
1439
+ outputMicrosPerMillionTokens: 1_200_000,
1440
+ marginBps: 2_500,
1441
+ },
1442
+ inputTokenTiers: [
1443
+ {
1444
+ minimumInputTokens: 272_001,
1445
+ pricing: {
1446
+ inputMicrosPerMillionTokens: 400_000,
1447
+ cachedInputMicrosPerMillionTokens: 40_000,
1448
+ outputMicrosPerMillionTokens: 1_800_000,
1449
+ marginBps: 2_500,
1450
+ },
1451
+ },
1452
+ ],
1242
1453
  },
1243
1454
  // Fireworks AI / GLM 5.2 — the first shipped non-OpenAI registry model. A
1244
1455
  // built-in default pricing entry makes managed billing work out of the box
1245
1456
  // for hosts that expose this model via OPENGENI_MODEL_PROVIDERS_JSON without
1246
1457
  // also setting OPENGENI_MODEL_PRICING_JSON.
1247
1458
  "accounts/fireworks/models/glm-5p2": {
1248
- inputMicrosPerMillionTokens: 1_400_000,
1249
- cachedInputMicrosPerMillionTokens: 260_000,
1250
- outputMicrosPerMillionTokens: 4_400_000,
1251
- marginBps: 2_500,
1459
+ default: {
1460
+ inputMicrosPerMillionTokens: 1_400_000,
1461
+ cachedInputMicrosPerMillionTokens: 140_000,
1462
+ outputMicrosPerMillionTokens: 4_400_000,
1463
+ marginBps: 2_500,
1464
+ },
1252
1465
  },
1253
1466
  };
1254
1467
 
@@ -1351,6 +1564,7 @@ export function getSettings(): Settings {
1351
1564
  observabilityOtlpHeaders:
1352
1565
  optional("OPENGENI_OTEL_EXPORTER_OTLP_HEADERS") ?? optional("OTEL_EXPORTER_OTLP_HEADERS"),
1353
1566
  publicBaseUrl: optional("OPENGENI_PUBLIC_BASE_URL"),
1567
+ webBaseUrl: optional("OPENGENI_WEB_BASE_URL"),
1354
1568
  agentReleasesBaseUrl: optional("OPENGENI_AGENT_RELEASES_BASE_URL"),
1355
1569
  agentStableVersion: optional("OPENGENI_AGENT_STABLE_VERSION"),
1356
1570
  productAccessMode: optional("OPENGENI_PRODUCT_ACCESS_MODE"),
@@ -1374,6 +1588,8 @@ export function getSettings(): Settings {
1374
1588
  integrationsOauthClientsJson: optional("OPENGENI_INTEGRATIONS_OAUTH_CLIENTS_JSON"),
1375
1589
  slackClientId: optional("OPENGENI_SLACK_CLIENT_ID"),
1376
1590
  slackClientSecret: optional("OPENGENI_SLACK_CLIENT_SECRET"),
1591
+ googleDriveClientId: optional("OPENGENI_GOOGLE_DRIVE_CLIENT_ID"),
1592
+ googleDriveClientSecret: optional("OPENGENI_GOOGLE_DRIVE_CLIENT_SECRET"),
1377
1593
  maxNestedAgentDepth: optional("OPENGENI_MAX_NESTED_AGENT_DEPTH"),
1378
1594
  goalMaxAutoContinuations: optional("OPENGENI_GOAL_MAX_AUTO_CONTINUATIONS"),
1379
1595
  goalNoProgressLimit: optional("OPENGENI_GOAL_NO_PROGRESS_LIMIT"),
@@ -1398,6 +1614,20 @@ export function getSettings(): Settings {
1398
1614
  openaiBaseUrl: optional("OPENGENI_OPENAI_BASE_URL") ?? optional("OPENAI_BASE_URL"),
1399
1615
  openaiModel: optional("OPENGENI_OPENAI_MODEL"),
1400
1616
  openaiAllowedModels: optional("OPENGENI_OPENAI_ALLOWED_MODELS"),
1617
+ voiceInputMaxDurationSeconds: optional("OPENGENI_VOICE_INPUT_MAX_DURATION_SECONDS"),
1618
+ voiceInputMaxSizeBytes: optional("OPENGENI_VOICE_INPUT_MAX_SIZE_BYTES"),
1619
+ voiceInputProviderOrder: optional("OPENGENI_VOICE_INPUT_PROVIDER_ORDER"),
1620
+ voiceInputOpenaiEnabled: optional("OPENGENI_VOICE_INPUT_OPENAI_ENABLED"),
1621
+ voiceInputOpenaiApiKey: optional("OPENGENI_VOICE_INPUT_OPENAI_API_KEY"),
1622
+ voiceInputOpenaiBaseUrl: optional("OPENGENI_VOICE_INPUT_OPENAI_BASE_URL"),
1623
+ voiceInputOpenaiModel: optional("OPENGENI_VOICE_INPUT_OPENAI_MODEL"),
1624
+ voiceInputAzureEnabled: optional("OPENGENI_VOICE_INPUT_AZURE_ENABLED"),
1625
+ voiceInputAzureEndpoint: optional("OPENGENI_VOICE_INPUT_AZURE_ENDPOINT"),
1626
+ voiceInputAzureDeployment: optional("OPENGENI_VOICE_INPUT_AZURE_DEPLOYMENT"),
1627
+ voiceInputAzureApiVersion: optional("OPENGENI_VOICE_INPUT_AZURE_API_VERSION"),
1628
+ voiceInputAzureApiKey: optional("OPENGENI_VOICE_INPUT_AZURE_API_KEY"),
1629
+ voiceInputAzureAdToken: optional("OPENGENI_VOICE_INPUT_AZURE_AD_TOKEN"),
1630
+ voiceInputCodexExperimentalEnabled: optional("OPENGENI_VOICE_INPUT_CODEX_EXPERIMENTAL"),
1401
1631
  modelPricingJson: optional("OPENGENI_MODEL_PRICING_JSON"),
1402
1632
  modelProvidersJson: optional("OPENGENI_MODEL_PROVIDERS_JSON"),
1403
1633
  codexSubscriptionEnabled: optional("OPENGENI_CODEX_SUBSCRIPTION_ENABLED"),
@@ -1435,7 +1665,6 @@ export function getSettings(): Settings {
1435
1665
  modalEnvironment: optional("OPENGENI_MODAL_ENVIRONMENT"),
1436
1666
  modalIdleTimeoutSeconds: optional("OPENGENI_MODAL_IDLE_TIMEOUT_SECONDS"),
1437
1667
  modalWorkspacePersistence: optional("OPENGENI_MODAL_WORKSPACE_PERSISTENCE"),
1438
- modalSnapshotRetentionSeconds: optional("OPENGENI_MODAL_SNAPSHOT_RETENTION_SECONDS"),
1439
1668
  sandboxDesktopEnabled: optional("OPENGENI_SANDBOX_DESKTOP_ENABLED"),
1440
1669
  sandboxDesktopInteractive: optional("OPENGENI_SANDBOX_DESKTOP_INTERACTIVE"),
1441
1670
  sandboxTerminalEnabled: optional("OPENGENI_SANDBOX_TERMINAL_ENABLED"),
@@ -1508,6 +1737,8 @@ export function getSettings(): Settings {
1508
1737
  sandboxIdleGraceMs: optional("OPENGENI_SANDBOX_IDLE_GRACE_MS"),
1509
1738
  sandboxSnapshotIntervalMs: optional("OPENGENI_SANDBOX_SNAPSHOT_INTERVAL_MS"),
1510
1739
  sandboxSnapshotTimeoutMs: optional("OPENGENI_SANDBOX_SNAPSHOT_TIMEOUT_MS"),
1740
+ sandboxRotationLeadMs: optional("OPENGENI_SANDBOX_ROTATION_LEAD_MS"),
1741
+ sandboxRotationBatchSize: optional("OPENGENI_SANDBOX_ROTATION_BATCH_SIZE"),
1511
1742
  sandboxLeaseTtlMs: optional("OPENGENI_SANDBOX_LEASE_TTL_MS"),
1512
1743
  sandboxLeaseWarmingTtlMs: optional("OPENGENI_SANDBOX_LEASE_WARMING_TTL_MS"),
1513
1744
  sandboxWarmingTimeoutMs: optional("OPENGENI_SANDBOX_WARMING_TIMEOUT_MS"),
@@ -1519,6 +1750,7 @@ export function getSettings(): Settings {
1519
1750
  sandboxPreparationProfiles: optional("OPENGENI_SANDBOX_PREPARATION_PROFILES"),
1520
1751
  sandboxEnvAllowlist: optional("OPENGENI_SANDBOX_ENV_ALLOWLIST"),
1521
1752
  objectStorageEndpoint: optional("OPENGENI_OBJECT_STORAGE_ENDPOINT"),
1753
+ objectStorageInternalEndpoint: optional("OPENGENI_OBJECT_STORAGE_INTERNAL_ENDPOINT"),
1522
1754
  objectStorageSandboxEndpoint: optional("OPENGENI_OBJECT_STORAGE_SANDBOX_ENDPOINT"),
1523
1755
  objectStorageBackend: optional("OPENGENI_OBJECT_STORAGE_BACKEND"),
1524
1756
  objectStorageBucket: optional("OPENGENI_OBJECT_STORAGE_BUCKET"),
@@ -1574,6 +1806,14 @@ export function getSettings(): Settings {
1574
1806
  const parsed = SettingsSchema.parse(raw);
1575
1807
  const settings = {
1576
1808
  ...parsed,
1809
+ sandboxIdleGraceMs:
1810
+ raw.sandboxIdleGraceMs === undefined
1811
+ ? Math.min(900_000, Math.floor((parsed.modalTimeoutSeconds * 1000) / 2))
1812
+ : parsed.sandboxIdleGraceMs,
1813
+ sandboxRotationLeadMs:
1814
+ raw.sandboxRotationLeadMs === undefined
1815
+ ? Math.min(3_600_000, Math.floor((parsed.modalTimeoutSeconds * 1000) / 2))
1816
+ : parsed.sandboxRotationLeadMs,
1577
1817
  mcpServers: ensureBuiltInMcpServers(parsed),
1578
1818
  };
1579
1819
  validateSettings(settings);
@@ -1595,6 +1835,23 @@ export function effectiveModalIdleTimeoutSeconds(settings: Settings): number {
1595
1835
  return settings.modalIdleTimeoutSeconds ?? settings.modalTimeoutSeconds;
1596
1836
  }
1597
1837
 
1838
+ /**
1839
+ * One shared upper bound for the durable provider-capture claim and for command
1840
+ * admission waiting behind it. The SDK request itself is bounded by
1841
+ * sandboxSnapshotTimeoutMs; the extra window lets a non-cancellable provider
1842
+ * response settle and release its exact claim without turning a normal
1843
+ * checkpoint into a visible command failure. Database validation caps both
1844
+ * consumers at one hour.
1845
+ */
1846
+ export function sandboxArchiveCaptureTimeoutMs(
1847
+ settings: Pick<Settings, "sandboxSnapshotTimeoutMs">,
1848
+ ): number {
1849
+ return Math.min(
1850
+ 60 * 60_000,
1851
+ Math.max(settings.sandboxSnapshotTimeoutMs + 30_000, settings.sandboxSnapshotTimeoutMs * 2),
1852
+ );
1853
+ }
1854
+
1598
1855
  export function collectSandboxEnvironment(
1599
1856
  settings: Settings,
1600
1857
  source: NodeJS.ProcessEnv = process.env,
@@ -1885,6 +2142,115 @@ function legacyModelCapabilities(
1885
2142
  });
1886
2143
  }
1887
2144
 
2145
+ /** OpenAI GPT-5.6 Fast mode is 2× Standard list rates (service_tier fast/priority). */
2146
+ const GPT56_FAST_BILLING_MULTIPLIER_BPS = 20_000;
2147
+
2148
+ /**
2149
+ * Product display label for catalog/picker UI.
2150
+ * Same string for OpenAI and Codex copies of a slug (`gpt-5.6-luna` and
2151
+ * `codex/gpt-5.6-luna` → `GPT-5.6 Luna`). Non-gpt ids pass through unchanged.
2152
+ */
2153
+ export function productLabelForModelId(modelId: string): string {
2154
+ const slug = modelId.startsWith(CODEX_MODEL_ID_PREFIX)
2155
+ ? modelId.slice(CODEX_MODEL_ID_PREFIX.length)
2156
+ : modelId;
2157
+ const match = /^(gpt-\d+(?:\.\d+)?)(?:-(.+))?$/i.exec(slug);
2158
+ if (!match) {
2159
+ return slug;
2160
+ }
2161
+ const family = match[1]!.replace(/^gpt/i, "GPT");
2162
+ const rest = match[2];
2163
+ if (!rest) {
2164
+ return family;
2165
+ }
2166
+ const suffix = rest
2167
+ .split("-")
2168
+ .filter((part) => part.length > 0)
2169
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
2170
+ .join(" ");
2171
+ return suffix.length > 0 ? `${family} ${suffix}` : family;
2172
+ }
2173
+
2174
+ function builtinLatencyModesForModel(modelId: string): Array<{
2175
+ id: z.infer<typeof ModelLatencyModeV1>;
2176
+ upstream: "supported" | "unsupported" | "unknown";
2177
+ runnable: boolean;
2178
+ billingMultiplierBps?: number;
2179
+ }> {
2180
+ if (
2181
+ modelId === "gpt-5.6-sol" ||
2182
+ modelId === "gpt-5.6-terra" ||
2183
+ modelId === "gpt-5.6-luna" ||
2184
+ modelId.startsWith("codex/gpt-5.6-")
2185
+ ) {
2186
+ return [
2187
+ { id: "standard", upstream: "supported", runnable: true },
2188
+ {
2189
+ id: "fast",
2190
+ upstream: "supported",
2191
+ runnable: true,
2192
+ billingMultiplierBps: GPT56_FAST_BILLING_MULTIPLIER_BPS,
2193
+ },
2194
+ ];
2195
+ }
2196
+ return [{ id: "standard", upstream: "unknown", runnable: true }];
2197
+ }
2198
+
2199
+ /**
2200
+ * Map OpenGeni latency mode to the provider `service_tier` wire value.
2201
+ * Azure and Codex ChatGPT accept `priority`; OpenAI API accepts `fast` (alias of priority).
2202
+ * Standard omits the field.
2203
+ */
2204
+ export function serviceTierForLatencyMode(
2205
+ providerId: string,
2206
+ latencyMode: LatencyMode,
2207
+ ): "fast" | "priority" | undefined {
2208
+ if (latencyMode === "standard") {
2209
+ return undefined;
2210
+ }
2211
+ if (providerId === "azure" || providerId === CODEX_PROVIDER_ID) {
2212
+ return "priority";
2213
+ }
2214
+ return "fast";
2215
+ }
2216
+
2217
+ /** True when the response tier fulfills a non-standard Fast/priority request. */
2218
+ export function responseSatisfiesLatencyMode(
2219
+ requested: LatencyMode,
2220
+ responseServiceTier: string | null | undefined,
2221
+ ): boolean {
2222
+ if (requested === "standard") {
2223
+ return true;
2224
+ }
2225
+ return responseServiceTier === "priority" || responseServiceTier === "fast";
2226
+ }
2227
+
2228
+ export function runnableLatencyModesForModel(settings: Settings, modelId: string): LatencyMode[] {
2229
+ const resolved = resolveModelProvider(
2230
+ settingsForTurnExecutionPolicy(settings, modelId),
2231
+ canonicalizeConfiguredModelId(settings, modelId),
2232
+ );
2233
+ if (!resolved) {
2234
+ return ["standard"];
2235
+ }
2236
+ return resolved.model.capabilities.latencyModes
2237
+ .filter((mode) => mode.runnable)
2238
+ .map((mode) => LatencyMode.parse(mode.id));
2239
+ }
2240
+
2241
+ function assertLatencyModeRunnable(
2242
+ settings: Settings,
2243
+ modelId: string,
2244
+ latencyMode: LatencyMode,
2245
+ ): void {
2246
+ const runnable = runnableLatencyModesForModel(settings, modelId);
2247
+ if (!runnable.includes(latencyMode)) {
2248
+ throw new Error(
2249
+ `latency mode ${latencyMode} is not runnable for model ${modelId} (allowed: ${runnable.join(", ")})`,
2250
+ );
2251
+ }
2252
+ }
2253
+
1888
2254
  function registryCredentialSource(provider: RegistryProvider): CredentialSourceV1 {
1889
2255
  return provider.kind === "codex-subscription"
1890
2256
  ? { kind: "connected_subscription", provider: "codex" }
@@ -2058,21 +2424,31 @@ export function withCodexCatalogProvider(settings: Settings): Settings {
2058
2424
  label: "Codex (ChatGPT subscription)",
2059
2425
  api: "responses",
2060
2426
  baseUrl: CODEX_PROVIDER_BASE_URL,
2061
- models: CODEX_FALLBACK_MODEL_SLUGS.map((slug) => ({
2062
- id: `${CODEX_MODEL_ID_PREFIX}${slug}`,
2063
- upstreamModelId: slug,
2064
- label: slug,
2065
- reasoningEffort: true,
2066
- // The ChatGPT/Codex Responses backend accepts the native web_search
2067
- // hosted tool (unlike hosted apply_patch/computer transports). Declaring
2068
- // this here makes provider resolution truthful; the worker still applies
2069
- // the durable session/turn policy gate before attaching it.
2070
- hostedWebSearch: true,
2071
- contextWindowTokens: CODEX_MODEL_CONTEXT_WINDOW_TOKENS,
2072
- effectiveContextWindowTokens: CODEX_MODEL_EFFECTIVE_CONTEXT_WINDOW_TOKENS,
2073
- autoCompactTokenLimit: CODEX_MODEL_AUTO_COMPACT_TOKEN_LIMIT,
2074
- toolOutputTruncationTokens: CODEX_MODEL_TOOL_OUTPUT_TRUNCATION_TOKENS,
2075
- })),
2427
+ models: CODEX_FALLBACK_MODEL_SLUGS.map((slug) => {
2428
+ const capabilities = {
2429
+ ...legacyModelCapabilities(settings, {
2430
+ reasoningEffort: true,
2431
+ hostedWebSearch: true,
2432
+ }),
2433
+ latencyModes: builtinLatencyModesForModel(`${CODEX_MODEL_ID_PREFIX}${slug}`),
2434
+ };
2435
+ return {
2436
+ id: `${CODEX_MODEL_ID_PREFIX}${slug}`,
2437
+ upstreamModelId: slug,
2438
+ label: productLabelForModelId(slug),
2439
+ reasoningEffort: true,
2440
+ // The ChatGPT/Codex Responses backend accepts the native web_search
2441
+ // hosted tool (unlike hosted apply_patch/computer transports). Declaring
2442
+ // this here makes provider resolution truthful; the worker still applies
2443
+ // the durable session/turn policy gate before attaching it.
2444
+ hostedWebSearch: true,
2445
+ capabilities,
2446
+ contextWindowTokens: CODEX_MODEL_CONTEXT_WINDOW_TOKENS,
2447
+ effectiveContextWindowTokens: CODEX_MODEL_EFFECTIVE_CONTEXT_WINDOW_TOKENS,
2448
+ autoCompactTokenLimit: CODEX_MODEL_AUTO_COMPACT_TOKEN_LIMIT,
2449
+ toolOutputTruncationTokens: CODEX_MODEL_TOOL_OUTPUT_TRUNCATION_TOKENS,
2450
+ };
2451
+ }),
2076
2452
  };
2077
2453
  return { ...settings, modelProvidersJson: JSON.stringify([...providers, provider]) };
2078
2454
  }
@@ -2219,14 +2595,17 @@ export function configuredModels(settings: Settings): ConfiguredModel[] {
2219
2595
  ])
2220
2596
  .filter((id) => !isRegistryNamespaced(id))
2221
2597
  .map((id) => {
2222
- const capabilities = legacyModelCapabilities(settings, {
2223
- reasoningEffort: true,
2224
- hostedWebSearch: settings.webSearchEnabled,
2225
- });
2598
+ const capabilities = {
2599
+ ...legacyModelCapabilities(settings, {
2600
+ reasoningEffort: true,
2601
+ hostedWebSearch: settings.webSearchEnabled,
2602
+ }),
2603
+ latencyModes: builtinLatencyModesForModel(id),
2604
+ };
2226
2605
  return finalizeConfiguredModel(settings, builtinProvider, {
2227
2606
  id,
2228
2607
  aliases: [],
2229
- label: id,
2608
+ label: productLabelForModelId(id),
2230
2609
  providerId: builtinId,
2231
2610
  providerLabel: builtinLabel,
2232
2611
  api: "responses" as const,
@@ -2260,7 +2639,7 @@ export function configuredModels(settings: Settings): ConfiguredModel[] {
2260
2639
  finalizeConfiguredModel(settings, resolvedProvider, {
2261
2640
  id: model.id,
2262
2641
  aliases: [...(model.aliases ?? [])],
2263
- label: model.label ?? model.id,
2642
+ label: model.label ?? productLabelForModelId(model.id),
2264
2643
  providerId: provider.id,
2265
2644
  providerLabel,
2266
2645
  api: provider.api,
@@ -2347,6 +2726,8 @@ export type ResolveTurnExecutionPolicyV1Input = {
2347
2726
  modelSource: TurnExecutionModelSourceV1;
2348
2727
  reasoningEffort: Settings["openaiReasoningEffort"];
2349
2728
  reasoningSource: TurnExecutionReasoningSourceV1;
2729
+ latencyMode?: LatencyMode;
2730
+ latencyModeSource?: TurnExecutionLatencyModeSourceV1;
2350
2731
  };
2351
2732
 
2352
2733
  function settingsForTurnExecutionPolicy(settings: Settings, modelId: string): Settings {
@@ -2376,6 +2757,9 @@ export function resolveTurnExecutionPolicyV1(
2376
2757
  ) {
2377
2758
  throw new Error("Turn execution policy requested model does not canonicalize to its product");
2378
2759
  }
2760
+ const latencyMode = LatencyMode.parse(input.latencyMode ?? "standard");
2761
+ const latencyModeSource = input.latencyModeSource ?? "deployment";
2762
+ assertLatencyModeRunnable(catalogSettings, productModelId, latencyMode);
2379
2763
  return TurnExecutionPolicyV1.parse({
2380
2764
  schemaVersion: 1,
2381
2765
  productModelId,
@@ -2383,6 +2767,8 @@ export function resolveTurnExecutionPolicyV1(
2383
2767
  modelSource: input.modelSource,
2384
2768
  reasoningEffort: input.reasoningEffort,
2385
2769
  reasoningSource: input.reasoningSource,
2770
+ latencyMode,
2771
+ latencyModeSource,
2386
2772
  providerId: resolved.provider.id,
2387
2773
  upstreamModelId: resolved.model.upstreamModelId,
2388
2774
  wireApi: resolved.model.api,
@@ -2403,6 +2789,7 @@ export function assertTurnExecutionPolicyMatchesConfigV1(
2403
2789
  expected: {
2404
2790
  modelId: string;
2405
2791
  reasoningEffort: Settings["openaiReasoningEffort"];
2792
+ latencyMode?: LatencyMode;
2406
2793
  },
2407
2794
  ): {
2408
2795
  policy: TurnExecutionPolicyV1;
@@ -2412,12 +2799,17 @@ export function assertTurnExecutionPolicyMatchesConfigV1(
2412
2799
  const parsed = TurnExecutionPolicyV1.parse(policy);
2413
2800
  const catalogSettings = settingsForTurnExecutionPolicy(settings, parsed.productModelId);
2414
2801
  const canonicalExpectedModel = canonicalizeConfiguredModelId(catalogSettings, expected.modelId);
2802
+ const expectedLatencyMode = expected.latencyMode ?? parsed.latencyMode;
2415
2803
  if (
2416
2804
  parsed.productModelId !== canonicalExpectedModel ||
2417
- parsed.reasoningEffort !== expected.reasoningEffort
2805
+ parsed.reasoningEffort !== expected.reasoningEffort ||
2806
+ parsed.latencyMode !== expectedLatencyMode
2418
2807
  ) {
2419
- throw new Error("Turn execution policy does not match the accepted turn model/reasoning");
2808
+ throw new Error(
2809
+ "Turn execution policy does not match the accepted turn model/reasoning/latency",
2810
+ );
2420
2811
  }
2812
+ assertLatencyModeRunnable(catalogSettings, parsed.productModelId, parsed.latencyMode);
2421
2813
  if (
2422
2814
  parsed.requestedModelId !== null &&
2423
2815
  canonicalizeConfiguredModelId(catalogSettings, parsed.requestedModelId) !==
@@ -2453,7 +2845,10 @@ export function configuredModelPricingSchedules(
2453
2845
  settings: Settings,
2454
2846
  ): Record<string, ModelPricingScheduleV1> {
2455
2847
  const defaults = Object.fromEntries(
2456
- Object.entries(defaultModelPricing).map(([model, pricing]) => [model, { default: pricing }]),
2848
+ Object.entries(defaultModelPricing).map(([model, pricing]) => [
2849
+ model,
2850
+ normalizeModelPricingSchedule(pricing),
2851
+ ]),
2457
2852
  );
2458
2853
  const registry: Record<string, ModelPricingScheduleV1> = {};
2459
2854
  for (const provider of parseModelProvidersJson(settings.modelProvidersJson)) {
@@ -2580,6 +2975,7 @@ export function calculateModelUsageCostMicros(
2580
2975
  settings: Settings,
2581
2976
  model: string,
2582
2977
  usage: ModelUsageInput,
2978
+ options?: { latencyMode?: LatencyMode },
2583
2979
  ): number {
2584
2980
  const schedule = configuredModelPricingSchedules(settings)[model];
2585
2981
  if (!schedule) {
@@ -2602,6 +2998,20 @@ export function calculateModelUsageCostMicros(
2602
2998
  const marginBps = pricing.marginBps ?? 0;
2603
2999
  total += Math.ceil((rawCost * (10_000 + marginBps)) / 10_000);
2604
3000
  }
3001
+ const latencyMode = options?.latencyMode ?? "standard";
3002
+ if (latencyMode !== "standard") {
3003
+ const catalogSettings = settingsForTurnExecutionPolicy(settings, model);
3004
+ const resolved = resolveModelProvider(
3005
+ catalogSettings,
3006
+ canonicalizeConfiguredModelId(catalogSettings, model),
3007
+ );
3008
+ const multiplierBps = resolved?.model.capabilities.latencyModes.find(
3009
+ (mode) => mode.id === latencyMode && mode.runnable,
3010
+ )?.billingMultiplierBps;
3011
+ if (multiplierBps && multiplierBps > 0) {
3012
+ total = Math.ceil((total * multiplierBps) / 10_000);
3013
+ }
3014
+ }
2605
3015
  return total;
2606
3016
  }
2607
3017
 
@@ -3430,6 +3840,31 @@ function validateSettings(settings: Settings): void {
3430
3840
  );
3431
3841
  }
3432
3842
  }
3843
+ if (Boolean(settings.googleDriveClientId) !== Boolean(settings.googleDriveClientSecret)) {
3844
+ throw new Error(
3845
+ "OPENGENI_GOOGLE_DRIVE_CLIENT_ID and OPENGENI_GOOGLE_DRIVE_CLIENT_SECRET must be configured together",
3846
+ );
3847
+ }
3848
+ if (settings.googleDriveClientId) {
3849
+ if (!settings.publicBaseUrl) {
3850
+ throw new Error(
3851
+ "OPENGENI_PUBLIC_BASE_URL is required when the Google Drive integration is configured",
3852
+ );
3853
+ }
3854
+ if (
3855
+ !settings.publicBaseUrl.startsWith("https://") &&
3856
+ !["local", "test"].includes(settings.environment)
3857
+ ) {
3858
+ throw new Error(
3859
+ "OPENGENI_PUBLIC_BASE_URL must use https when the Google Drive integration is configured outside local/test",
3860
+ );
3861
+ }
3862
+ if (!settings.integrationsStateSecret) {
3863
+ throw new Error(
3864
+ "OPENGENI_INTEGRATIONS_STATE_SECRET is required when the Google Drive integration is configured",
3865
+ );
3866
+ }
3867
+ }
3433
3868
  parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
3434
3869
  if (
3435
3870
  settings.productAccessMode === "configured" &&
@@ -3536,7 +3971,9 @@ function validateSettings(settings: Settings): void {
3536
3971
  }
3537
3972
  if (
3538
3973
  settings.objectStorageBackend === "s3-compatible" &&
3539
- (settings.objectStorageEndpoint || settings.objectStorageSandboxEndpoint) &&
3974
+ (settings.objectStorageEndpoint ||
3975
+ settings.objectStorageInternalEndpoint ||
3976
+ settings.objectStorageSandboxEndpoint) &&
3540
3977
  (!settings.objectStorageAccessKeyId || !settings.objectStorageSecretAccessKey)
3541
3978
  ) {
3542
3979
  throw new Error(
@@ -3566,6 +4003,7 @@ function validateSettings(settings: Settings): void {
3566
4003
  } else if (settings.objectStorageBackend === "azure-blob") {
3567
4004
  if (
3568
4005
  settings.objectStorageEndpoint ||
4006
+ settings.objectStorageInternalEndpoint ||
3569
4007
  settings.objectStorageSandboxEndpoint ||
3570
4008
  settings.objectStorageAccessKeyId ||
3571
4009
  settings.objectStorageSecretAccessKey
@@ -3596,6 +4034,7 @@ function validateSettings(settings: Settings): void {
3596
4034
  } else {
3597
4035
  if (
3598
4036
  settings.objectStorageEndpoint ||
4037
+ settings.objectStorageInternalEndpoint ||
3599
4038
  settings.objectStorageSandboxEndpoint ||
3600
4039
  settings.objectStorageAccessKeyId ||
3601
4040
  settings.objectStorageSecretAccessKey
@@ -3644,12 +4083,13 @@ function validateSettings(settings: Settings): void {
3644
4083
  // out from under us — the provider lifetime is the backstop, not the
3645
4084
  // warm-window controller. idleGrace counts from the user's last release;
3646
4085
  // the provider clock counts from the preceding resume, so we leave the
3647
- // active-turn headroom in modalTimeoutSeconds (default 3600s).
4086
+ // active-turn headroom in modalTimeoutSeconds (default 86400s).
3648
4087
  {
3649
4088
  const reaperPeriod = settings.sandboxLeaseReaperPeriodMs;
3650
4089
  const viewerTtl = settings.sandboxViewerHolderTtlMs;
3651
4090
  const idleGraceMs = settings.sandboxIdleGraceMs;
3652
4091
  const providerLifetimeMs = settings.modalTimeoutSeconds * 1000;
4092
+ const rotationLeadMs = settings.sandboxRotationLeadMs;
3653
4093
  // The EFFECTIVE box lifetime when it sits idle between turns is the Modal IDLE
3654
4094
  // timeout, NOT the hard lifetime (sandbox-file-persistence): a box with no
3655
4095
  // active connection is idle-reaped at idleTimeout. effectiveModalIdleTimeout
@@ -3672,6 +4112,18 @@ function validateSettings(settings: Settings): void {
3672
4112
  `floor under the hard lifetime, not above it.`,
3673
4113
  );
3674
4114
  }
4115
+ if (!(rotationLeadMs < providerLifetimeMs)) {
4116
+ throw new Error(
4117
+ `OPENGENI_SANDBOX_ROTATION_LEAD_MS (${rotationLeadMs}) must be strictly less than ` +
4118
+ `OPENGENI_MODAL_TIMEOUT_SECONDS*1000 (${providerLifetimeMs}).`,
4119
+ );
4120
+ }
4121
+ if (!(rotationLeadMs > settings.sandboxSnapshotTimeoutMs + 2 * reaperPeriod)) {
4122
+ throw new Error(
4123
+ `OPENGENI_SANDBOX_ROTATION_LEAD_MS (${rotationLeadMs}) must exceed the snapshot timeout ` +
4124
+ `plus two reaper periods (${settings.sandboxSnapshotTimeoutMs + 2 * reaperPeriod}).`,
4125
+ );
4126
+ }
3675
4127
  if (!(viewerTtl < idleTimeoutMs)) {
3676
4128
  throw new Error(
3677
4129
  `OPENGENI_SANDBOX_VIEWER_HOLDER_TTL_MS (${viewerTtl}) must be strictly less than the effective box ` +