@opengeni/config 0.6.2 → 0.6.9

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
@@ -6,10 +6,24 @@ import {
6
6
  ProductAccessMode,
7
7
  ReasoningEffort,
8
8
  SandboxBackend,
9
+ SessionMcpApprovalPolicy,
9
10
  StaticUsageLimits,
11
+ TurnExecutionPolicyV1,
10
12
  UsageLimitsMode,
13
+ type TurnExecutionModelSourceV1,
14
+ type TurnExecutionReasoningSourceV1,
11
15
  } from "@opengeni/contracts";
12
- import { CODEX_MODEL_ID_PREFIX, CODEX_PROVIDER_ID } from "@opengeni/codex/constants";
16
+ import { CODEX_MODEL_TOOL_OUTPUT_TRUNCATION_TOKENS } from "@opengeni/codex";
17
+ import {
18
+ CODEX_FALLBACK_MODEL_SLUGS,
19
+ CODEX_MODEL_AUTO_COMPACT_TOKEN_LIMIT,
20
+ CODEX_MODEL_CONTEXT_WINDOW_TOKENS,
21
+ CODEX_MODEL_EFFECTIVE_CONTEXT_WINDOW_TOKENS,
22
+ CODEX_MODEL_ID_PREFIX,
23
+ CODEX_PROVIDER_BASE_URL,
24
+ CODEX_PROVIDER_ID,
25
+ } from "@opengeni/codex/constants";
26
+ import { createHash } from "node:crypto";
13
27
  import { z } from "zod";
14
28
 
15
29
  const envName = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -218,6 +232,9 @@ const SettingsSchema = z.object({
218
232
  // holder of stream:control gets 403 until this flips. Keeps stream:control a
219
233
  // declared-but-inert permission so later hardening is a flag flip.
220
234
  streamControlEnabled: EnvBoolean.default(false),
235
+ // Existing-session explicit tool replacement is gated until every API and
236
+ // worker instance understands durable tools_provided provenance.
237
+ sessionTurnToolReplacementEnabled: EnvBoolean.default(false),
221
238
  toolspaceEnabled: EnvBoolean.default(false),
222
239
  toolspaceMaxCallsPerTurn: z.coerce.number().int().positive().default(200),
223
240
  // Optional release-coherent bootstrap hint for custom rigs/connected machines
@@ -516,6 +533,15 @@ const SettingsSchema = z.object({
516
533
  // EnvBoolean (NOT z.coerce.boolean(), which would coerce "false" -> true and
517
534
  // turn the flag ON the moment anyone set the env var to disable it).
518
535
  sandboxOwnershipEnabled: EnvBoolean.default(false),
536
+ // --- standalone rig-verifier ownership rollout flag, default OFF ---
537
+ // Rig verification creates a throwaway provider sandbox outside the normal
538
+ // session-turn path. When enabled, that sandbox must first acquire the same
539
+ // durable lease lifecycle used by session boxes so the global orphan sweep
540
+ // recognizes its exact provider instance. Keep this separate from the general
541
+ // sandboxOwnershipEnabled rollout: every reaper worker must understand verifier
542
+ // leases before dispatch is enabled. When false the verifier fails closed before
543
+ // provider create; it never falls back to the legacy unowned path.
544
+ rigVerificationLeaseOwnershipEnabled: EnvBoolean.default(false),
519
545
  // --- lazy sandbox provisioning rollout flag, default OFF ---
520
546
  // Only effective when sandboxOwnershipEnabled is ALSO on (lazy provisioning is a
521
547
  // property of the owned path — the SDK never creates/resumes an injected session,
@@ -736,14 +762,8 @@ const SettingsSchema = z.object({
736
762
  allowedTools: z.array(z.string().min(1)).optional(),
737
763
  timeoutMs: z.number().int().positive().optional(),
738
764
  cacheToolsList: z.boolean().default(false),
739
- /**
740
- * Human-approval policy for this server's tools, overlaid per-run from a
741
- * session MCP server row (never from OPENGENI_MCP_SERVERS). `true` = all
742
- * tools require approval; a string[] = only the listed UNPREFIXED tool
743
- * names do; absent = auto-run (the historical default). Enforced in the
744
- * runtime by attaching `needsApproval` to the matching MCP tools.
745
- */
746
- requireApproval: z.union([z.boolean(), z.array(z.string().min(1))]).optional(),
765
+ /** Runtime approval policy, overlaid from an attempt-frozen session snapshot. */
766
+ requireApproval: SessionMcpApprovalPolicy.optional(),
747
767
  /**
748
768
  * Extra request headers sent to this MCP server (credential injection
749
769
  * for workspace-enabled capability MCPs). Populated at runtime from
@@ -778,6 +798,15 @@ export type ModelPricing = {
778
798
  outputMicrosPerMillionTokens: number;
779
799
  marginBps?: number | undefined;
780
800
  };
801
+ export type ModelPricingScheduleV1 = {
802
+ default: ModelPricing;
803
+ inputTokenTiers?:
804
+ | Array<{
805
+ minimumInputTokens: number;
806
+ pricing: ModelPricing;
807
+ }>
808
+ | undefined;
809
+ };
781
810
  export type ModelUsageInput = {
782
811
  inputTokens?: number | undefined;
783
812
  outputTokens?: number | undefined;
@@ -796,6 +825,164 @@ const ModelPricingSchema = z.object({
796
825
  marginBps: z.number().int().min(0).max(100_000).optional(),
797
826
  });
798
827
 
828
+ const ModelPricingScheduleSchema = z
829
+ .object({
830
+ default: ModelPricingSchema,
831
+ inputTokenTiers: z
832
+ .array(
833
+ z.object({
834
+ minimumInputTokens: z.number().int().nonnegative(),
835
+ pricing: ModelPricingSchema,
836
+ }),
837
+ )
838
+ .optional(),
839
+ })
840
+ .superRefine((schedule, ctx) => {
841
+ let previous = -1;
842
+ for (const [index, tier] of (schedule.inputTokenTiers ?? []).entries()) {
843
+ if (tier.minimumInputTokens <= previous) {
844
+ ctx.addIssue({
845
+ code: "custom",
846
+ path: ["inputTokenTiers", index, "minimumInputTokens"],
847
+ message: "input-token tier thresholds must be strictly increasing",
848
+ });
849
+ }
850
+ previous = tier.minimumInputTokens;
851
+ }
852
+ });
853
+
854
+ export const CapabilitySupportV1 = z.enum(["supported", "unsupported", "unknown"]);
855
+ export type CapabilitySupportV1 = z.infer<typeof CapabilitySupportV1>;
856
+
857
+ export const CapabilityStateV1Schema = z
858
+ .object({
859
+ upstream: CapabilitySupportV1,
860
+ runnable: z.boolean(),
861
+ })
862
+ .superRefine((state, ctx) => {
863
+ if (state.upstream === "unsupported" && state.runnable) {
864
+ ctx.addIssue({
865
+ code: "custom",
866
+ path: ["runnable"],
867
+ message: "an upstream-unsupported capability cannot be runnable",
868
+ });
869
+ }
870
+ });
871
+ export type CapabilityStateV1 = z.infer<typeof CapabilityStateV1Schema>;
872
+
873
+ const ModelModalityV1 = z.enum(["text", "image", "audio"]);
874
+ const ModelLatencyModeV1 = z.enum(["standard", "priority", "fast"]);
875
+
876
+ export const ModelCapabilitiesV1Schema = z
877
+ .object({
878
+ reasoning: CapabilityStateV1Schema.extend({
879
+ efforts: z.array(ReasoningEffort),
880
+ defaultEffort: ReasoningEffort.nullable(),
881
+ required: z.boolean(),
882
+ }),
883
+ functionCalling: CapabilityStateV1Schema,
884
+ structuredOutput: CapabilityStateV1Schema,
885
+ hostedTools: z.object({
886
+ webSearch: CapabilityStateV1Schema,
887
+ xSearch: CapabilityStateV1Schema,
888
+ codeExecution: CapabilityStateV1Schema,
889
+ }),
890
+ inputModalities: z.array(ModelModalityV1).min(1),
891
+ outputModalities: z.array(ModelModalityV1).min(1),
892
+ transports: z.object({
893
+ sse: CapabilityStateV1Schema,
894
+ responsesWebSocket: CapabilityStateV1Schema,
895
+ realtimeAudio: CapabilityStateV1Schema,
896
+ }),
897
+ latencyModes: z
898
+ .array(
899
+ z.object({
900
+ id: ModelLatencyModeV1,
901
+ upstream: CapabilitySupportV1,
902
+ runnable: z.boolean(),
903
+ billingMultiplierBps: z.number().int().positive().optional(),
904
+ }),
905
+ )
906
+ .min(1),
907
+ })
908
+ .superRefine((capabilities, ctx) => {
909
+ const efforts = new Set(capabilities.reasoning.efforts);
910
+ if (efforts.size !== capabilities.reasoning.efforts.length) {
911
+ ctx.addIssue({
912
+ code: "custom",
913
+ path: ["reasoning", "efforts"],
914
+ message: "reasoning efforts must be unique",
915
+ });
916
+ }
917
+ if (
918
+ capabilities.reasoning.defaultEffort !== null &&
919
+ !efforts.has(capabilities.reasoning.defaultEffort)
920
+ ) {
921
+ ctx.addIssue({
922
+ code: "custom",
923
+ path: ["reasoning", "defaultEffort"],
924
+ message: "the default reasoning effort must be one of the supported efforts",
925
+ });
926
+ }
927
+ if (capabilities.reasoning.runnable && capabilities.reasoning.efforts.length === 0) {
928
+ ctx.addIssue({
929
+ code: "custom",
930
+ path: ["reasoning", "efforts"],
931
+ message: "a runnable reasoning capability must declare at least one effort",
932
+ });
933
+ }
934
+ for (const field of ["inputModalities", "outputModalities"] as const) {
935
+ if (new Set(capabilities[field]).size !== capabilities[field].length) {
936
+ ctx.addIssue({
937
+ code: "custom",
938
+ path: [field],
939
+ message: `${field} must be unique`,
940
+ });
941
+ }
942
+ }
943
+ const latencyIds = new Set<string>();
944
+ for (const [index, mode] of capabilities.latencyModes.entries()) {
945
+ if (latencyIds.has(mode.id)) {
946
+ ctx.addIssue({
947
+ code: "custom",
948
+ path: ["latencyModes", index, "id"],
949
+ message: "latency mode ids must be unique",
950
+ });
951
+ }
952
+ latencyIds.add(mode.id);
953
+ if (mode.upstream === "unsupported" && mode.runnable) {
954
+ ctx.addIssue({
955
+ code: "custom",
956
+ path: ["latencyModes", index, "runnable"],
957
+ message: "an upstream-unsupported latency mode cannot be runnable",
958
+ });
959
+ }
960
+ }
961
+ });
962
+ export type ModelCapabilitiesV1 = z.infer<typeof ModelCapabilitiesV1Schema>;
963
+
964
+ export type ModelDeploymentV1 = {
965
+ upstreamModelId: string;
966
+ wireApi: ModelProviderApi;
967
+ };
968
+
969
+ export type ModelExecutionLimitsV1 = {
970
+ contextWindowTokens: number | null;
971
+ effectiveContextWindowTokens: number | null;
972
+ autoCompactTokenLimit: number | null;
973
+ toolOutputTruncationTokens: number | null;
974
+ };
975
+
976
+ export type CredentialSourceV1 =
977
+ | { kind: "deployment"; mechanism: "api_key" | "azure_ad_bearer" }
978
+ | { kind: "connected_subscription"; provider: "codex" }
979
+ | { kind: "workspace_connection"; mechanism: "api_key" };
980
+
981
+ export type BillingAttributionV1 = {
982
+ upstreamPayer: "deployment" | "workspace" | "connected_subscription";
983
+ metering: "opengeni_credits" | "external";
984
+ };
985
+
799
986
  /**
800
987
  * Wire API a provider speaks. The built-in OpenAI/Azure provider always uses
801
988
  * "responses" (the OpenAI Responses API). Extra registry providers default to
@@ -815,19 +1002,52 @@ export const RegistryProviderKind = z.enum(["api-key", "codex-subscription"]);
815
1002
  export type RegistryProviderKind = z.infer<typeof RegistryProviderKind>;
816
1003
 
817
1004
  /** A single model exposed by a registry provider. */
818
- const RegistryModelSchema = z.object({
819
- id: z.string().min(1), // model id sent to the provider, e.g. "accounts/fireworks/models/glm-5p2"
820
- label: z.string().min(1).optional(), // display name; defaults to id
821
- contextWindowTokens: z.number().int().positive().optional(),
822
- effectiveContextWindowTokens: z.number().int().positive().optional(),
823
- autoCompactTokenLimit: z.number().int().positive().optional(),
824
- // Canonical model-facing function/tool-result policy. The runtime applies
825
- // the same 1.2x serialization allowance as Codex when materializing output.
826
- toolOutputTruncationTokens: z.number().int().positive().optional(),
827
- reasoningEffort: z.boolean().optional(), // model accepts a reasoning-effort control
828
- hostedWebSearch: z.boolean().optional(), // provider executes the hosted web_search tool for this model
829
- pricing: ModelPricingSchema.optional(),
830
- });
1005
+ const RegistryModelSchema = z
1006
+ .object({
1007
+ id: z.string().min(1), // canonical OpenGeni product id
1008
+ upstreamModelId: z.string().min(1).optional(), // exact provider slug; defaults to id
1009
+ aliases: z.array(z.string().min(1)).optional(), // accepted input only; never sent upstream
1010
+ label: z.string().min(1).optional(), // display name; defaults to id
1011
+ contextWindowTokens: z.number().int().positive().optional(),
1012
+ effectiveContextWindowTokens: z.number().int().positive().optional(),
1013
+ autoCompactTokenLimit: z.number().int().positive().optional(),
1014
+ // Canonical model-facing function/tool-result policy. The runtime applies
1015
+ // the same 1.2x serialization allowance as Codex when materializing output.
1016
+ toolOutputTruncationTokens: z.number().int().positive().optional(),
1017
+ reasoningEffort: z.boolean().optional(), // legacy compatibility input/projection
1018
+ hostedWebSearch: z.boolean().optional(), // legacy compatibility input/projection
1019
+ capabilities: ModelCapabilitiesV1Schema.optional(),
1020
+ pricing: z.union([ModelPricingSchema, ModelPricingScheduleSchema]).optional(),
1021
+ // Reserved normalized contracts are derived by OpenGeni in V1. Generic
1022
+ // registry JSON must not opt itself into workspace BYOK or reattribute cost.
1023
+ credentialSource: z.never().optional(),
1024
+ billing: z.never().optional(),
1025
+ })
1026
+ .superRefine((model, ctx) => {
1027
+ if (
1028
+ model.capabilities &&
1029
+ model.reasoningEffort !== undefined &&
1030
+ model.reasoningEffort !== model.capabilities.reasoning.runnable
1031
+ ) {
1032
+ ctx.addIssue({
1033
+ code: "custom",
1034
+ path: ["reasoningEffort"],
1035
+ message: "legacy reasoningEffort must agree with capabilities.reasoning.runnable",
1036
+ });
1037
+ }
1038
+ if (
1039
+ model.capabilities &&
1040
+ model.hostedWebSearch !== undefined &&
1041
+ model.hostedWebSearch !== model.capabilities.hostedTools.webSearch.runnable
1042
+ ) {
1043
+ ctx.addIssue({
1044
+ code: "custom",
1045
+ path: ["hostedWebSearch"],
1046
+ message:
1047
+ "legacy hostedWebSearch must agree with capabilities.hostedTools.webSearch.runnable",
1048
+ });
1049
+ }
1050
+ });
831
1051
 
832
1052
  /** A non-built-in provider declared by the host via OPENGENI_MODEL_PROVIDERS_JSON. */
833
1053
  const RegistryProviderSchema = z.object({
@@ -840,6 +1060,12 @@ const RegistryProviderSchema = z.object({
840
1060
  apiKeyEnv: z.string().optional(), // ... OR name of the env var holding the key (preferred)
841
1061
  defaultQuery: z.record(z.string(), z.string()).optional(),
842
1062
  defaultHeaders: z.record(z.string(), z.string()).optional(),
1063
+ publicDefaultQueryNames: z.array(z.string().min(1)).optional(),
1064
+ publicDefaultHeaderNames: z.array(z.string().min(1)).optional(),
1065
+ // V1 derives these from provider kind. Workspace BYOK is deliberately not a
1066
+ // registry switch and requires a separately reviewed encrypted broker.
1067
+ credentialSource: z.never().optional(),
1068
+ billing: z.never().optional(),
843
1069
  models: z.array(RegistryModelSchema).min(1),
844
1070
  });
845
1071
  export type RegistryProvider = z.infer<typeof RegistryProviderSchema>;
@@ -870,15 +1096,29 @@ export interface ResolvedModelProvider {
870
1096
  apiKey?: string | undefined;
871
1097
  defaultQuery?: Record<string, string> | undefined;
872
1098
  defaultHeaders?: Record<string, string> | undefined;
1099
+ publicDefaultQueryNames?: string[] | undefined;
1100
+ publicDefaultHeaderNames?: string[] | undefined;
1101
+ credentialSource: CredentialSourceV1;
1102
+ billing: BillingAttributionV1;
873
1103
  }
874
1104
 
875
1105
  /** A single exposed model + the provider that serves it. */
876
1106
  export interface ConfiguredModel {
1107
+ schemaVersion: 1;
877
1108
  id: string;
1109
+ aliases: string[];
878
1110
  label: string;
879
1111
  providerId: string;
880
1112
  providerLabel: string;
881
1113
  api: ModelProviderApi;
1114
+ upstreamModelId: string;
1115
+ deployment: ModelDeploymentV1;
1116
+ executionLimits: ModelExecutionLimitsV1;
1117
+ credentialSource: CredentialSourceV1;
1118
+ billing: BillingAttributionV1;
1119
+ capabilities: ModelCapabilitiesV1;
1120
+ pricing?: ModelPricingScheduleV1 | undefined;
1121
+ definitionVersion: string;
882
1122
  contextWindowTokens?: number | undefined;
883
1123
  effectiveContextWindowTokens?: number | undefined;
884
1124
  autoCompactTokenLimit?: number | undefined;
@@ -1076,6 +1316,7 @@ export function getSettings(): Settings {
1076
1316
  delegationSecret: optional("OPENGENI_DELEGATION_SECRET"),
1077
1317
  streamTokenSecret: optional("OPENGENI_STREAM_TOKEN_SECRET"),
1078
1318
  streamControlEnabled: optional("OPENGENI_STREAM_CONTROL_ENABLED"),
1319
+ sessionTurnToolReplacementEnabled: optional("OPENGENI_SESSION_TURN_TOOL_REPLACEMENT_ENABLED"),
1079
1320
  toolspaceEnabled: optional("OPENGENI_TOOLSPACE_ENABLED"),
1080
1321
  toolspaceMaxCallsPerTurn: optional("OPENGENI_TOOLSPACE_MAX_CALLS_PER_TURN"),
1081
1322
  ogtoolPackageSpec: optional("OPENGENI_OGTOOL_PACKAGE_SPEC"),
@@ -1192,6 +1433,9 @@ export function getSettings(): Settings {
1192
1433
  vercelTeamId: optional("OPENGENI_VERCEL_TEAM_ID"),
1193
1434
  vercelRuntime: optional("OPENGENI_VERCEL_RUNTIME"),
1194
1435
  sandboxOwnershipEnabled: optional("OPENGENI_SANDBOX_OWNERSHIP_ENABLED"),
1436
+ rigVerificationLeaseOwnershipEnabled: optional(
1437
+ "OPENGENI_RIG_VERIFICATION_LEASE_OWNERSHIP_ENABLED",
1438
+ ),
1195
1439
  sandboxLazyProvisionEnabled: optional("OPENGENI_SANDBOX_LAZY_PROVISION"),
1196
1440
  sandboxSelfhostedEnabled: optional("OPENGENI_SANDBOX_SELFHOSTED_ENABLED"),
1197
1441
  agentOpStreamEnabled: optional("OPENGENI_AGENT_OP_STREAM_ENABLED"),
@@ -1331,6 +1575,353 @@ export function resolveProviderApiKey(
1331
1575
  return undefined;
1332
1576
  }
1333
1577
 
1578
+ const HTTP_FIELD_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
1579
+ const CREDENTIAL_LIKE_NAME_PARTS = new Set([
1580
+ "apikey",
1581
+ "auth",
1582
+ "authorization",
1583
+ "bearer",
1584
+ "credential",
1585
+ "cookie",
1586
+ "key",
1587
+ "password",
1588
+ "secret",
1589
+ "session",
1590
+ "signature",
1591
+ "token",
1592
+ ]);
1593
+ const REASONING_EFFORT_ORDER = new Map(
1594
+ ReasoningEffort.options.map((effort, index) => [effort, index]),
1595
+ );
1596
+ const MODALITY_ORDER = new Map(["text", "image", "audio"].map((value, index) => [value, index]));
1597
+ const LATENCY_MODE_ORDER = new Map(
1598
+ ["standard", "priority", "fast"].map((value, index) => [value, index]),
1599
+ );
1600
+
1601
+ function normalizeRegistryBaseUrl(value: string, providerId: string): string {
1602
+ const url = new URL(value);
1603
+ if (url.username || url.password) {
1604
+ throw new Error(`provider ${providerId} baseUrl must not contain userinfo`);
1605
+ }
1606
+ if (url.search) {
1607
+ throw new Error(
1608
+ `provider ${providerId} baseUrl must not contain a query; move query entries to defaultQuery`,
1609
+ );
1610
+ }
1611
+ if (url.hash) {
1612
+ throw new Error(`provider ${providerId} baseUrl must not contain a fragment`);
1613
+ }
1614
+ return url.toString();
1615
+ }
1616
+
1617
+ function isCredentialLikeMetadataName(name: string): boolean {
1618
+ return name
1619
+ .toLowerCase()
1620
+ .split(/[-_.]/u)
1621
+ .some((part) => CREDENTIAL_LIKE_NAME_PARTS.has(part));
1622
+ }
1623
+
1624
+ function normalizeHeaderMap(
1625
+ providerId: string,
1626
+ headers: Record<string, string> | undefined,
1627
+ ): Record<string, string> | undefined {
1628
+ if (!headers) {
1629
+ return undefined;
1630
+ }
1631
+ const normalized: Record<string, string> = {};
1632
+ const rawByNormalized = new Map<string, string>();
1633
+ for (const [rawName, value] of Object.entries(headers)) {
1634
+ if (!HTTP_FIELD_NAME.test(rawName)) {
1635
+ throw new Error(
1636
+ `provider ${providerId} defaultHeaders contains invalid HTTP field name ${JSON.stringify(rawName)}`,
1637
+ );
1638
+ }
1639
+ const name = rawName.toLowerCase();
1640
+ const previous = rawByNormalized.get(name);
1641
+ if (previous !== undefined) {
1642
+ throw new Error(
1643
+ `provider ${providerId} defaultHeaders names ${JSON.stringify(previous)} and ${JSON.stringify(rawName)} collide after lowercase normalization`,
1644
+ );
1645
+ }
1646
+ if (name === "authorization") {
1647
+ throw new Error(
1648
+ `provider ${providerId} defaultHeaders must not override SDK-managed Authorization`,
1649
+ );
1650
+ }
1651
+ rawByNormalized.set(name, rawName);
1652
+ normalized[name] = value;
1653
+ }
1654
+ return normalized;
1655
+ }
1656
+
1657
+ function normalizePublicHeaderNames(
1658
+ providerId: string,
1659
+ names: string[] | undefined,
1660
+ headers: Record<string, string> | undefined,
1661
+ ): string[] | undefined {
1662
+ if (!names) {
1663
+ return undefined;
1664
+ }
1665
+ const normalized: string[] = [];
1666
+ const seen = new Set<string>();
1667
+ for (const rawName of names) {
1668
+ if (!HTTP_FIELD_NAME.test(rawName)) {
1669
+ throw new Error(
1670
+ `provider ${providerId} publicDefaultHeaderNames contains invalid HTTP field name ${JSON.stringify(rawName)}`,
1671
+ );
1672
+ }
1673
+ const name = rawName.toLowerCase();
1674
+ if (seen.has(name)) {
1675
+ throw new Error(
1676
+ `provider ${providerId} publicDefaultHeaderNames contains duplicate normalized name ${JSON.stringify(name)}`,
1677
+ );
1678
+ }
1679
+ if (!(name in (headers ?? {}))) {
1680
+ throw new Error(
1681
+ `provider ${providerId} publicDefaultHeaderNames declares absent defaultHeaders entry ${JSON.stringify(name)}`,
1682
+ );
1683
+ }
1684
+ if (isCredentialLikeMetadataName(name)) {
1685
+ throw new Error(
1686
+ `provider ${providerId} publicDefaultHeaderNames cannot classify credential-like name ${JSON.stringify(name)} as public`,
1687
+ );
1688
+ }
1689
+ seen.add(name);
1690
+ normalized.push(name);
1691
+ }
1692
+ return normalized;
1693
+ }
1694
+
1695
+ function normalizeQueryMap(
1696
+ providerId: string,
1697
+ query: Record<string, string> | undefined,
1698
+ ): Record<string, string> | undefined {
1699
+ if (!query) {
1700
+ return undefined;
1701
+ }
1702
+ for (const name of Object.keys(query)) {
1703
+ if (!name) {
1704
+ throw new Error(`provider ${providerId} defaultQuery contains an empty name`);
1705
+ }
1706
+ }
1707
+ return { ...query };
1708
+ }
1709
+
1710
+ function normalizePublicQueryNames(
1711
+ providerId: string,
1712
+ names: string[] | undefined,
1713
+ query: Record<string, string> | undefined,
1714
+ ): string[] | undefined {
1715
+ if (!names) {
1716
+ return undefined;
1717
+ }
1718
+ const seen = new Set<string>();
1719
+ for (const name of names) {
1720
+ if (seen.has(name)) {
1721
+ throw new Error(
1722
+ `provider ${providerId} publicDefaultQueryNames contains duplicate name ${JSON.stringify(name)}`,
1723
+ );
1724
+ }
1725
+ if (!(name in (query ?? {}))) {
1726
+ throw new Error(
1727
+ `provider ${providerId} publicDefaultQueryNames declares absent defaultQuery entry ${JSON.stringify(name)}`,
1728
+ );
1729
+ }
1730
+ if (isCredentialLikeMetadataName(name)) {
1731
+ throw new Error(
1732
+ `provider ${providerId} publicDefaultQueryNames cannot classify credential-like name ${JSON.stringify(name)} as public`,
1733
+ );
1734
+ }
1735
+ seen.add(name);
1736
+ }
1737
+ return [...names];
1738
+ }
1739
+
1740
+ function normalizeRegistryProvider(provider: RegistryProvider): RegistryProvider {
1741
+ const defaultHeaders = normalizeHeaderMap(provider.id, provider.defaultHeaders);
1742
+ const defaultQuery = normalizeQueryMap(provider.id, provider.defaultQuery);
1743
+ return {
1744
+ ...provider,
1745
+ baseUrl: normalizeRegistryBaseUrl(provider.baseUrl, provider.id),
1746
+ ...(defaultHeaders === undefined ? {} : { defaultHeaders }),
1747
+ ...(defaultQuery === undefined ? {} : { defaultQuery }),
1748
+ ...(provider.publicDefaultHeaderNames === undefined
1749
+ ? {}
1750
+ : {
1751
+ publicDefaultHeaderNames: normalizePublicHeaderNames(
1752
+ provider.id,
1753
+ provider.publicDefaultHeaderNames,
1754
+ defaultHeaders,
1755
+ ),
1756
+ }),
1757
+ ...(provider.publicDefaultQueryNames === undefined
1758
+ ? {}
1759
+ : {
1760
+ publicDefaultQueryNames: normalizePublicQueryNames(
1761
+ provider.id,
1762
+ provider.publicDefaultQueryNames,
1763
+ defaultQuery,
1764
+ ),
1765
+ }),
1766
+ };
1767
+ }
1768
+
1769
+ function normalizeModelPricingSchedule(
1770
+ pricing: ModelPricing | ModelPricingScheduleV1,
1771
+ ): ModelPricingScheduleV1 {
1772
+ return "default" in pricing ? pricing : { default: pricing };
1773
+ }
1774
+
1775
+ function normalizeCapabilities(capabilities: ModelCapabilitiesV1): ModelCapabilitiesV1 {
1776
+ const parsed = ModelCapabilitiesV1Schema.parse(capabilities);
1777
+ return {
1778
+ ...parsed,
1779
+ reasoning: {
1780
+ ...parsed.reasoning,
1781
+ efforts: [...parsed.reasoning.efforts].sort(
1782
+ (left, right) =>
1783
+ (REASONING_EFFORT_ORDER.get(left) ?? 0) - (REASONING_EFFORT_ORDER.get(right) ?? 0),
1784
+ ),
1785
+ },
1786
+ inputModalities: [...parsed.inputModalities].sort(
1787
+ (left, right) => (MODALITY_ORDER.get(left) ?? 0) - (MODALITY_ORDER.get(right) ?? 0),
1788
+ ),
1789
+ outputModalities: [...parsed.outputModalities].sort(
1790
+ (left, right) => (MODALITY_ORDER.get(left) ?? 0) - (MODALITY_ORDER.get(right) ?? 0),
1791
+ ),
1792
+ latencyModes: [...parsed.latencyModes].sort(
1793
+ (left, right) =>
1794
+ (LATENCY_MODE_ORDER.get(left.id) ?? 0) - (LATENCY_MODE_ORDER.get(right.id) ?? 0),
1795
+ ),
1796
+ };
1797
+ }
1798
+
1799
+ function legacyModelCapabilities(
1800
+ settings: Settings,
1801
+ input: { reasoningEffort: boolean; hostedWebSearch: boolean },
1802
+ ): ModelCapabilitiesV1 {
1803
+ const reasoningEfforts = input.reasoningEffort ? configuredAllowedReasoningEfforts(settings) : [];
1804
+ return normalizeCapabilities({
1805
+ reasoning: {
1806
+ upstream: input.reasoningEffort ? "supported" : "unknown",
1807
+ runnable: input.reasoningEffort,
1808
+ efforts: reasoningEfforts,
1809
+ defaultEffort: input.reasoningEffort ? settings.openaiReasoningEffort : null,
1810
+ required: false,
1811
+ },
1812
+ functionCalling: { upstream: "unknown", runnable: true },
1813
+ structuredOutput: { upstream: "unknown", runnable: false },
1814
+ hostedTools: {
1815
+ webSearch: {
1816
+ upstream: input.hostedWebSearch ? "supported" : "unknown",
1817
+ runnable: input.hostedWebSearch,
1818
+ },
1819
+ xSearch: { upstream: "unknown", runnable: false },
1820
+ codeExecution: { upstream: "unknown", runnable: false },
1821
+ },
1822
+ inputModalities: ["text"],
1823
+ outputModalities: ["text"],
1824
+ transports: {
1825
+ sse: { upstream: "unknown", runnable: true },
1826
+ responsesWebSocket: { upstream: "unknown", runnable: false },
1827
+ realtimeAudio: { upstream: "unknown", runnable: false },
1828
+ },
1829
+ latencyModes: [{ id: "standard", upstream: "unknown", runnable: true }],
1830
+ });
1831
+ }
1832
+
1833
+ function registryCredentialSource(provider: RegistryProvider): CredentialSourceV1 {
1834
+ return provider.kind === "codex-subscription"
1835
+ ? { kind: "connected_subscription", provider: "codex" }
1836
+ : { kind: "deployment", mechanism: "api_key" };
1837
+ }
1838
+
1839
+ function registryBilling(provider: RegistryProvider): BillingAttributionV1 {
1840
+ return provider.kind === "codex-subscription"
1841
+ ? { upstreamPayer: "connected_subscription", metering: "external" }
1842
+ : { upstreamPayer: "deployment", metering: "opengeni_credits" };
1843
+ }
1844
+
1845
+ function builtinCredentialSource(settings: Settings): CredentialSourceV1 {
1846
+ if (settings.openaiProvider === "azure" && !settings.azureOpenaiApiKey) {
1847
+ return { kind: "deployment", mechanism: "azure_ad_bearer" };
1848
+ }
1849
+ return { kind: "deployment", mechanism: "api_key" };
1850
+ }
1851
+
1852
+ function staticRequestMetadataForDigest(provider: ResolvedModelProvider): {
1853
+ headers: Array<{ name: string; classification: "public" | "secret"; value?: string }>;
1854
+ query: Array<{ name: string; classification: "public" | "secret"; value?: string }>;
1855
+ } {
1856
+ const publicHeaders = new Set(provider.publicDefaultHeaderNames ?? []);
1857
+ const publicQuery = new Set(provider.publicDefaultQueryNames ?? []);
1858
+ return {
1859
+ headers: Object.entries(provider.defaultHeaders ?? {})
1860
+ .sort(([left], [right]) => left.localeCompare(right))
1861
+ .map(([name, value]) =>
1862
+ publicHeaders.has(name)
1863
+ ? { name, classification: "public" as const, value }
1864
+ : { name, classification: "secret" as const },
1865
+ ),
1866
+ query: Object.entries(provider.defaultQuery ?? {})
1867
+ .sort(([left], [right]) => left.localeCompare(right))
1868
+ .map(([name, value]) =>
1869
+ publicQuery.has(name)
1870
+ ? { name, classification: "public" as const, value }
1871
+ : { name, classification: "secret" as const },
1872
+ ),
1873
+ };
1874
+ }
1875
+
1876
+ function canonicalJson(value: unknown): string {
1877
+ const normalize = (input: unknown): unknown => {
1878
+ if (Array.isArray(input)) {
1879
+ return input.map((entry) => normalize(entry));
1880
+ }
1881
+ if (input && typeof input === "object") {
1882
+ const out: Record<string, unknown> = {};
1883
+ for (const key of Object.keys(input).sort()) {
1884
+ const child = (input as Record<string, unknown>)[key];
1885
+ if (child !== undefined) {
1886
+ out[key] = normalize(child);
1887
+ }
1888
+ }
1889
+ return out;
1890
+ }
1891
+ return input;
1892
+ };
1893
+ return JSON.stringify(normalize(value));
1894
+ }
1895
+
1896
+ function definitionVersionFor(
1897
+ model: Omit<ConfiguredModel, "definitionVersion">,
1898
+ provider: ResolvedModelProvider,
1899
+ ): string {
1900
+ const requestMetadata = staticRequestMetadataForDigest(provider);
1901
+ const digestInput = canonicalJson({
1902
+ schemaVersion: model.schemaVersion,
1903
+ id: model.id,
1904
+ providerId: model.providerId,
1905
+ deployment: model.deployment,
1906
+ provider: {
1907
+ adapterKind: provider.kind,
1908
+ wireApi: provider.api,
1909
+ baseUrl: provider.baseUrl ?? null,
1910
+ defaultHeaders: requestMetadata.headers,
1911
+ defaultQuery: requestMetadata.query,
1912
+ },
1913
+ credentialSource: model.credentialSource,
1914
+ billing: model.billing,
1915
+ executionLimits: model.executionLimits,
1916
+ capabilities: model.capabilities,
1917
+ pricing: model.pricing ?? null,
1918
+ });
1919
+ return `sha256:${createHash("sha256")
1920
+ .update("opengeni:model-definition:v1\n", "utf8")
1921
+ .update(digestInput, "utf8")
1922
+ .digest("hex")}`;
1923
+ }
1924
+
1334
1925
  /**
1335
1926
  * The built-in provider's stable id: "openai" on the OpenAI platform, "azure"
1336
1927
  * on Azure. Exported because the workspace model-policy gate must attribute
@@ -1355,18 +1946,24 @@ function builtinProviderLabel(settings: Pick<Settings, "openaiProvider">): strin
1355
1946
  * id — validateSettings rejects that at boot.
1356
1947
  */
1357
1948
  export function configuredProviders(settings: Settings): ResolvedModelProvider[] {
1949
+ const credentialSource = builtinCredentialSource(settings);
1358
1950
  const builtin: ResolvedModelProvider = {
1359
1951
  id: builtinProviderId(settings),
1360
1952
  label: builtinProviderLabel(settings),
1361
1953
  kind: "api-key",
1362
1954
  api: "responses",
1363
1955
  builtin: true,
1956
+ credentialSource,
1957
+ billing: { upstreamPayer: "deployment", metering: "opengeni_credits" },
1364
1958
  };
1365
1959
  if (settings.openaiProvider === "azure") {
1366
- builtin.baseUrl = settings.azureOpenaiBaseUrl ?? settings.azureOpenaiEndpoint;
1960
+ const baseUrl = settings.azureOpenaiBaseUrl ?? settings.azureOpenaiEndpoint;
1961
+ builtin.baseUrl = baseUrl ? normalizeRegistryBaseUrl(baseUrl, builtin.id) : undefined;
1367
1962
  builtin.apiKey = settings.azureOpenaiApiKey ?? settings.azureOpenaiAdToken;
1368
1963
  } else {
1369
- builtin.baseUrl = settings.openaiBaseUrl;
1964
+ builtin.baseUrl = settings.openaiBaseUrl
1965
+ ? normalizeRegistryBaseUrl(settings.openaiBaseUrl, builtin.id)
1966
+ : undefined;
1370
1967
  builtin.apiKey = settings.openaiApiKey;
1371
1968
  }
1372
1969
  const registry = parseModelProvidersJson(settings.modelProvidersJson).map(
@@ -1380,11 +1977,46 @@ export function configuredProviders(settings: Settings): ResolvedModelProvider[]
1380
1977
  apiKey: resolveProviderApiKey(provider),
1381
1978
  defaultQuery: provider.defaultQuery,
1382
1979
  defaultHeaders: provider.defaultHeaders,
1980
+ publicDefaultQueryNames: provider.publicDefaultQueryNames,
1981
+ publicDefaultHeaderNames: provider.publicDefaultHeaderNames,
1982
+ credentialSource: registryCredentialSource(provider),
1983
+ billing: registryBilling(provider),
1383
1984
  }),
1384
1985
  );
1385
1986
  return [builtin, ...registry];
1386
1987
  }
1387
1988
 
1989
+ /**
1990
+ * Pure catalog overlay for a workspace whose existing Codex connection seam
1991
+ * reports ready. This describes product/provider identity only; it does not
1992
+ * select, lease, refresh, or expose a concrete credential; those runtime
1993
+ * operations remain owned by the credential allocator.
1994
+ */
1995
+ export function withCodexCatalogProvider(settings: Settings): Settings {
1996
+ const providers = parseModelProvidersJson(settings.modelProvidersJson);
1997
+ if (providers.some((provider) => provider.id === CODEX_PROVIDER_ID)) {
1998
+ return settings;
1999
+ }
2000
+ const provider: RegistryProvider = {
2001
+ kind: "codex-subscription",
2002
+ id: CODEX_PROVIDER_ID,
2003
+ label: "Codex (ChatGPT subscription)",
2004
+ api: "responses",
2005
+ baseUrl: CODEX_PROVIDER_BASE_URL,
2006
+ models: CODEX_FALLBACK_MODEL_SLUGS.map((slug) => ({
2007
+ id: `${CODEX_MODEL_ID_PREFIX}${slug}`,
2008
+ upstreamModelId: slug,
2009
+ label: slug,
2010
+ reasoningEffort: true,
2011
+ contextWindowTokens: CODEX_MODEL_CONTEXT_WINDOW_TOKENS,
2012
+ effectiveContextWindowTokens: CODEX_MODEL_EFFECTIVE_CONTEXT_WINDOW_TOKENS,
2013
+ autoCompactTokenLimit: CODEX_MODEL_AUTO_COMPACT_TOKEN_LIMIT,
2014
+ toolOutputTruncationTokens: CODEX_MODEL_TOOL_OUTPUT_TRUNCATION_TOKENS,
2015
+ })),
2016
+ };
2017
+ return { ...settings, modelProvidersJson: JSON.stringify([...providers, provider]) };
2018
+ }
2019
+
1388
2020
  /**
1389
2021
  * The provider identity a model id resolves to, for workspace model-policy
1390
2022
  * evaluation — MUST agree with the real router (resolveTurnModel /
@@ -1399,13 +2031,83 @@ export function configuredProviders(settings: Settings): ResolvedModelProvider[]
1399
2031
  * serves. A policy blocking the built-in must block this path too.
1400
2032
  */
1401
2033
  export function policyProviderIdForModel(settings: Settings, modelId: string): string {
1402
- if (modelId.startsWith(CODEX_MODEL_ID_PREFIX)) {
2034
+ const canonicalModelId = canonicalizeConfiguredModelId(settings, modelId);
2035
+ if (canonicalModelId.startsWith(CODEX_MODEL_ID_PREFIX)) {
1403
2036
  return CODEX_PROVIDER_ID;
1404
2037
  }
1405
- const configured = configuredModels(settings).find((model) => model.id === modelId);
2038
+ const configured = configuredModels(settings).find((model) => model.id === canonicalModelId);
1406
2039
  return configured?.providerId ?? builtinProviderId(settings);
1407
2040
  }
1408
2041
 
2042
+ function resolvedExecutionLimits(
2043
+ settings: Settings,
2044
+ model: {
2045
+ contextWindowTokens?: number | undefined;
2046
+ effectiveContextWindowTokens?: number | undefined;
2047
+ autoCompactTokenLimit?: number | undefined;
2048
+ toolOutputTruncationTokens?: number | undefined;
2049
+ },
2050
+ ): ModelExecutionLimitsV1 {
2051
+ return {
2052
+ contextWindowTokens: model.contextWindowTokens ?? settings.contextWindowTokens,
2053
+ effectiveContextWindowTokens:
2054
+ model.effectiveContextWindowTokens ?? settings.contextEffectiveWindowTokens ?? null,
2055
+ autoCompactTokenLimit:
2056
+ model.autoCompactTokenLimit ?? settings.contextAutoCompactThresholdTokens ?? null,
2057
+ toolOutputTruncationTokens:
2058
+ model.toolOutputTruncationTokens ?? settings.modelToolOutputTruncationTokens ?? null,
2059
+ };
2060
+ }
2061
+
2062
+ function finalizeConfiguredModel(
2063
+ settings: Settings,
2064
+ provider: ResolvedModelProvider,
2065
+ input: Omit<ConfiguredModel, "schemaVersion" | "definitionVersion" | "executionLimits">,
2066
+ ): ConfiguredModel {
2067
+ const modelWithoutVersion: Omit<ConfiguredModel, "definitionVersion"> = {
2068
+ schemaVersion: 1,
2069
+ ...input,
2070
+ executionLimits: resolvedExecutionLimits(settings, input),
2071
+ };
2072
+ return {
2073
+ ...modelWithoutVersion,
2074
+ definitionVersion: definitionVersionFor(modelWithoutVersion, provider),
2075
+ };
2076
+ }
2077
+
2078
+ function assertUniqueModelIdentities(models: ConfiguredModel[]): void {
2079
+ const canonicalOwners = new Map<string, string>();
2080
+ for (const model of models) {
2081
+ const previous = canonicalOwners.get(model.id);
2082
+ if (previous !== undefined) {
2083
+ throw new Error(
2084
+ `OPENGENI_MODEL_PROVIDERS_JSON model id ${JSON.stringify(model.id)} is declared by both ${previous} and ${model.providerId}`,
2085
+ );
2086
+ }
2087
+ canonicalOwners.set(model.id, model.providerId);
2088
+ }
2089
+
2090
+ const acceptedInputs = new Map(canonicalOwners);
2091
+ for (const model of models) {
2092
+ const ownAliases = new Set<string>();
2093
+ for (const alias of model.aliases) {
2094
+ if (ownAliases.has(alias)) {
2095
+ throw new Error(
2096
+ `OPENGENI_MODEL_PROVIDERS_JSON model ${JSON.stringify(model.id)} contains duplicate alias ${JSON.stringify(alias)}`,
2097
+ );
2098
+ }
2099
+ ownAliases.add(alias);
2100
+ const previous = acceptedInputs.get(alias);
2101
+ if (previous !== undefined) {
2102
+ throw new Error(
2103
+ `OPENGENI_MODEL_PROVIDERS_JSON alias ${JSON.stringify(alias)} for model ${JSON.stringify(model.id)} collides with model/provider ${previous}`,
2104
+ );
2105
+ }
2106
+ acceptedInputs.set(alias, model.id);
2107
+ }
2108
+ }
2109
+ }
2110
+
1409
2111
  /**
1410
2112
  * Every model a client may use, the built-in provider's models first
1411
2113
  * (configuredAllowedModels-from-openai, mapped to "responses" with
@@ -1417,6 +2119,9 @@ export function policyProviderIdForModel(settings: Settings, modelId: string): s
1417
2119
  export function configuredModels(settings: Settings): ConfiguredModel[] {
1418
2120
  const builtinId = builtinProviderId(settings);
1419
2121
  const builtinLabel = builtinProviderLabel(settings);
2122
+ const providers = configuredProviders(settings);
2123
+ const providerById = new Map(providers.map((provider) => [provider.id, provider]));
2124
+ const pricingSchedules = configuredModelPricingSchedules(settings);
1420
2125
  // The built-in (OpenAI/Azure) provider must NEVER claim a registry-namespaced
1421
2126
  // model id. The worker overwrites settings.openaiModel with the turn's model
1422
2127
  // (apps/worker agent-turn runSettings) — including a `codex/<slug>` id, or a
@@ -1433,63 +2138,110 @@ export function configuredModels(settings: Settings): ConfiguredModel[] {
1433
2138
  // a codex/ id has NO codex provider injected (no active subscription) it then
1434
2139
  // resolves to nothing and getModel fails loud with
1435
2140
  // CodexSubscriptionUnavailableError instead of mis-routing to Azure.
2141
+ const parsedRegistry = parseModelProvidersJson(settings.modelProvidersJson);
1436
2142
  const registryOwnedIds = new Set(
1437
- parseModelProvidersJson(settings.modelProvidersJson).flatMap((provider) =>
1438
- provider.models.map((model) => model.id),
1439
- ),
2143
+ parsedRegistry.flatMap((provider) => provider.models.map((model) => model.id)),
2144
+ );
2145
+ const registryAliases = new Set(
2146
+ parsedRegistry.flatMap((provider) => provider.models.flatMap((model) => model.aliases ?? [])),
1440
2147
  );
1441
2148
  const isRegistryNamespaced = (id: string): boolean =>
1442
- id.startsWith(CODEX_MODEL_ID_PREFIX) || (id.includes("/") && registryOwnedIds.has(id));
2149
+ id.startsWith(CODEX_MODEL_ID_PREFIX) ||
2150
+ registryAliases.has(id) ||
2151
+ (id.includes("/") && registryOwnedIds.has(id));
2152
+ const builtinProvider = providerById.get(builtinId);
2153
+ if (!builtinProvider) {
2154
+ throw new Error(`Built-in model provider ${builtinId} is not configured`);
2155
+ }
1443
2156
  const out: ConfiguredModel[] = uniqueValues([
1444
2157
  settings.openaiModel,
1445
2158
  ...splitCsv(settings.openaiAllowedModels),
1446
2159
  ])
1447
2160
  .filter((id) => !isRegistryNamespaced(id))
1448
- .map((id) => ({
1449
- id,
1450
- label: id,
1451
- providerId: builtinId,
1452
- providerLabel: builtinLabel,
1453
- api: "responses" as const,
1454
- contextWindowTokens: settings.contextWindowTokens,
1455
- toolOutputTruncationTokens: settings.modelToolOutputTruncationTokens,
1456
- reasoningEffort: true,
1457
- hostedWebSearch: settings.webSearchEnabled,
1458
- }));
1459
- for (const provider of parseModelProvidersJson(settings.modelProvidersJson)) {
2161
+ .map((id) => {
2162
+ const capabilities = legacyModelCapabilities(settings, {
2163
+ reasoningEffort: true,
2164
+ hostedWebSearch: settings.webSearchEnabled,
2165
+ });
2166
+ return finalizeConfiguredModel(settings, builtinProvider, {
2167
+ id,
2168
+ aliases: [],
2169
+ label: id,
2170
+ providerId: builtinId,
2171
+ providerLabel: builtinLabel,
2172
+ api: "responses" as const,
2173
+ upstreamModelId: id,
2174
+ deployment: { upstreamModelId: id, wireApi: "responses" },
2175
+ credentialSource: builtinProvider.credentialSource,
2176
+ billing: builtinProvider.billing,
2177
+ capabilities,
2178
+ ...(pricingSchedules[id] === undefined ? {} : { pricing: pricingSchedules[id] }),
2179
+ contextWindowTokens: settings.contextWindowTokens,
2180
+ toolOutputTruncationTokens: settings.modelToolOutputTruncationTokens,
2181
+ reasoningEffort: capabilities.reasoning.runnable,
2182
+ hostedWebSearch: capabilities.hostedTools.webSearch.runnable,
2183
+ });
2184
+ });
2185
+ for (const provider of parsedRegistry) {
1460
2186
  const providerLabel = provider.label ?? provider.id;
2187
+ const resolvedProvider = providerById.get(provider.id);
2188
+ if (!resolvedProvider) {
2189
+ throw new Error(`Registry model provider ${provider.id} is not configured`);
2190
+ }
1461
2191
  for (const model of provider.models) {
1462
- out.push({
1463
- id: model.id,
1464
- label: model.label ?? model.id,
1465
- providerId: provider.id,
1466
- providerLabel,
1467
- api: provider.api,
1468
- ...(model.contextWindowTokens === undefined
1469
- ? {}
1470
- : { contextWindowTokens: model.contextWindowTokens }),
1471
- ...(model.effectiveContextWindowTokens === undefined
1472
- ? {}
1473
- : { effectiveContextWindowTokens: model.effectiveContextWindowTokens }),
1474
- ...(model.autoCompactTokenLimit === undefined
1475
- ? {}
1476
- : { autoCompactTokenLimit: model.autoCompactTokenLimit }),
1477
- ...(model.toolOutputTruncationTokens === undefined
1478
- ? {}
1479
- : { toolOutputTruncationTokens: model.toolOutputTruncationTokens }),
1480
- reasoningEffort: model.reasoningEffort ?? false,
1481
- hostedWebSearch: model.hostedWebSearch ?? false,
1482
- });
2192
+ const capabilities = model.capabilities
2193
+ ? normalizeCapabilities(model.capabilities)
2194
+ : legacyModelCapabilities(settings, {
2195
+ reasoningEffort: model.reasoningEffort ?? false,
2196
+ hostedWebSearch: model.hostedWebSearch ?? false,
2197
+ });
2198
+ const upstreamModelId = model.upstreamModelId ?? model.id;
2199
+ out.push(
2200
+ finalizeConfiguredModel(settings, resolvedProvider, {
2201
+ id: model.id,
2202
+ aliases: [...(model.aliases ?? [])],
2203
+ label: model.label ?? model.id,
2204
+ providerId: provider.id,
2205
+ providerLabel,
2206
+ api: provider.api,
2207
+ upstreamModelId,
2208
+ deployment: { upstreamModelId, wireApi: provider.api },
2209
+ credentialSource: resolvedProvider.credentialSource,
2210
+ billing: resolvedProvider.billing,
2211
+ capabilities,
2212
+ ...(pricingSchedules[model.id] === undefined
2213
+ ? {}
2214
+ : { pricing: pricingSchedules[model.id] }),
2215
+ ...(model.contextWindowTokens === undefined
2216
+ ? {}
2217
+ : { contextWindowTokens: model.contextWindowTokens }),
2218
+ ...(model.effectiveContextWindowTokens === undefined
2219
+ ? {}
2220
+ : { effectiveContextWindowTokens: model.effectiveContextWindowTokens }),
2221
+ ...(model.autoCompactTokenLimit === undefined
2222
+ ? {}
2223
+ : { autoCompactTokenLimit: model.autoCompactTokenLimit }),
2224
+ ...(model.toolOutputTruncationTokens === undefined
2225
+ ? {}
2226
+ : { toolOutputTruncationTokens: model.toolOutputTruncationTokens }),
2227
+ reasoningEffort: capabilities.reasoning.runnable,
2228
+ hostedWebSearch: capabilities.hostedTools.webSearch.runnable,
2229
+ }),
2230
+ );
1483
2231
  }
1484
2232
  }
1485
- const seen = new Set<string>();
1486
- return out.filter((model) => {
1487
- if (seen.has(model.id)) {
1488
- return false;
1489
- }
1490
- seen.add(model.id);
1491
- return true;
1492
- });
2233
+ assertUniqueModelIdentities(out);
2234
+ return out;
2235
+ }
2236
+
2237
+ /** Resolve a known canonical id or alias. Unknown strings are returned unchanged. */
2238
+ export function canonicalizeConfiguredModelId(settings: Settings, modelId: string): string {
2239
+ const models = configuredModels(settings);
2240
+ const canonical = models.find((model) => model.id === modelId);
2241
+ if (canonical) {
2242
+ return canonical.id;
2243
+ }
2244
+ return models.find((model) => model.aliases.includes(modelId))?.id ?? modelId;
1493
2245
  }
1494
2246
 
1495
2247
  /**
@@ -1513,7 +2265,8 @@ export function resolveModelProvider(
1513
2265
  settings: Settings,
1514
2266
  modelId: string,
1515
2267
  ): { provider: ResolvedModelProvider; model: ConfiguredModel } | undefined {
1516
- const model = configuredModels(settings).find((candidate) => candidate.id === modelId);
2268
+ const canonicalModelId = canonicalizeConfiguredModelId(settings, modelId);
2269
+ const model = configuredModels(settings).find((candidate) => candidate.id === canonicalModelId);
1517
2270
  if (!model) {
1518
2271
  return undefined;
1519
2272
  }
@@ -1526,28 +2279,169 @@ export function resolveModelProvider(
1526
2279
  return { provider, model };
1527
2280
  }
1528
2281
 
2282
+ export type ResolveTurnExecutionPolicyV1Input = {
2283
+ /** Effective persisted turn model. Aliases are accepted and canonicalized. */
2284
+ modelId: string;
2285
+ /** Exact caller-supplied input before canonicalization, only for explicit switches. */
2286
+ requestedModelId: string | null;
2287
+ modelSource: TurnExecutionModelSourceV1;
2288
+ reasoningEffort: Settings["openaiReasoningEffort"];
2289
+ reasoningSource: TurnExecutionReasoningSourceV1;
2290
+ };
2291
+
2292
+ function settingsForTurnExecutionPolicy(settings: Settings, modelId: string): Settings {
2293
+ return settings.codexSubscriptionEnabled && modelId.startsWith(CODEX_MODEL_ID_PREFIX)
2294
+ ? withCodexCatalogProvider(settings)
2295
+ : settings;
2296
+ }
2297
+
1529
2298
  /**
1530
- * Effective per-model pricing. Merge order (later wins):
1531
- * defaultModelPricing registry model `pricing` entries (keyed by model id)
1532
- * parseModelPricingJson(settings.modelPricingJson) (explicit JSON wins).
2299
+ * Build a trusted, secret-safe execution policy from the normalized catalog.
2300
+ * The Codex overlay here contains static product/provider identity only; it
2301
+ * neither proves readiness nor chooses, decrypts, leases, or exposes an account.
1533
2302
  */
1534
- export function configuredModelPricing(settings: Settings): Record<string, ModelPricing> {
1535
- const registry: Record<string, ModelPricing> = {};
2303
+ export function resolveTurnExecutionPolicyV1(
2304
+ settings: Settings,
2305
+ input: ResolveTurnExecutionPolicyV1Input,
2306
+ ): TurnExecutionPolicyV1 {
2307
+ const catalogSettings = settingsForTurnExecutionPolicy(settings, input.modelId);
2308
+ const productModelId = canonicalizeConfiguredModelId(catalogSettings, input.modelId);
2309
+ const resolved = resolveModelProvider(catalogSettings, productModelId);
2310
+ if (!resolved) {
2311
+ throw new Error("Turn execution policy model is not present in the configured catalog");
2312
+ }
2313
+ if (
2314
+ input.requestedModelId !== null &&
2315
+ canonicalizeConfiguredModelId(catalogSettings, input.requestedModelId) !== productModelId
2316
+ ) {
2317
+ throw new Error("Turn execution policy requested model does not canonicalize to its product");
2318
+ }
2319
+ return TurnExecutionPolicyV1.parse({
2320
+ schemaVersion: 1,
2321
+ productModelId,
2322
+ requestedModelId: input.requestedModelId,
2323
+ modelSource: input.modelSource,
2324
+ reasoningEffort: input.reasoningEffort,
2325
+ reasoningSource: input.reasoningSource,
2326
+ providerId: resolved.provider.id,
2327
+ upstreamModelId: resolved.model.upstreamModelId,
2328
+ wireApi: resolved.model.api,
2329
+ credentialSource: resolved.model.credentialSource,
2330
+ billing: resolved.model.billing,
2331
+ definitionVersion: resolved.model.definitionVersion,
2332
+ });
2333
+ }
2334
+
2335
+ /**
2336
+ * Parse-time validation lives in @opengeni/contracts; this verifier binds a
2337
+ * present snapshot to the current executable definition and exact turn row.
2338
+ * Any deployment/provider drift fails before a provider or compaction call.
2339
+ */
2340
+ export function assertTurnExecutionPolicyMatchesConfigV1(
2341
+ settings: Settings,
2342
+ policy: TurnExecutionPolicyV1,
2343
+ expected: {
2344
+ modelId: string;
2345
+ reasoningEffort: Settings["openaiReasoningEffort"];
2346
+ },
2347
+ ): {
2348
+ policy: TurnExecutionPolicyV1;
2349
+ provider: ResolvedModelProvider;
2350
+ model: ConfiguredModel;
2351
+ } {
2352
+ const parsed = TurnExecutionPolicyV1.parse(policy);
2353
+ const catalogSettings = settingsForTurnExecutionPolicy(settings, parsed.productModelId);
2354
+ const canonicalExpectedModel = canonicalizeConfiguredModelId(catalogSettings, expected.modelId);
2355
+ if (
2356
+ parsed.productModelId !== canonicalExpectedModel ||
2357
+ parsed.reasoningEffort !== expected.reasoningEffort
2358
+ ) {
2359
+ throw new Error("Turn execution policy does not match the accepted turn model/reasoning");
2360
+ }
2361
+ if (
2362
+ parsed.requestedModelId !== null &&
2363
+ canonicalizeConfiguredModelId(catalogSettings, parsed.requestedModelId) !==
2364
+ parsed.productModelId
2365
+ ) {
2366
+ throw new Error("Turn execution policy requested model does not match its product model");
2367
+ }
2368
+ const resolved = resolveModelProvider(catalogSettings, parsed.productModelId);
2369
+ if (!resolved) {
2370
+ throw new Error("Turn execution policy model is no longer configured");
2371
+ }
2372
+ const mismatched =
2373
+ parsed.providerId !== resolved.provider.id ||
2374
+ parsed.upstreamModelId !== resolved.model.upstreamModelId ||
2375
+ parsed.wireApi !== resolved.model.api ||
2376
+ parsed.definitionVersion !== resolved.model.definitionVersion ||
2377
+ canonicalJson(parsed.credentialSource) !== canonicalJson(resolved.model.credentialSource) ||
2378
+ canonicalJson(parsed.billing) !== canonicalJson(resolved.model.billing);
2379
+ if (mismatched) {
2380
+ throw new Error("Turn execution policy does not match the current provider definition");
2381
+ }
2382
+ return { policy: parsed, provider: resolved.provider, model: resolved.model };
2383
+ }
2384
+
2385
+ /**
2386
+ * Effective per-model pricing schedules. Merge order (later wins): built-in
2387
+ * flat defaults → registry model flat/scheduled pricing → explicit legacy flat
2388
+ * OPENGENI_MODEL_PRICING_JSON. The explicit legacy map intentionally replaces
2389
+ * a registry schedule with one flat default so its historical precedence stays
2390
+ * exact.
2391
+ */
2392
+ export function configuredModelPricingSchedules(
2393
+ settings: Settings,
2394
+ ): Record<string, ModelPricingScheduleV1> {
2395
+ const defaults = Object.fromEntries(
2396
+ Object.entries(defaultModelPricing).map(([model, pricing]) => [model, { default: pricing }]),
2397
+ );
2398
+ const registry: Record<string, ModelPricingScheduleV1> = {};
1536
2399
  for (const provider of parseModelProvidersJson(settings.modelProvidersJson)) {
1537
2400
  for (const model of provider.models) {
1538
2401
  if (model.pricing) {
1539
- registry[model.id] = model.pricing;
2402
+ registry[model.id] = normalizeModelPricingSchedule(model.pricing);
1540
2403
  }
1541
2404
  }
1542
2405
  }
1543
- const configured = parseModelPricingJson(settings.modelPricingJson);
2406
+ const configured = Object.fromEntries(
2407
+ Object.entries(parseModelPricingJson(settings.modelPricingJson)).map(([model, pricing]) => [
2408
+ model,
2409
+ { default: pricing },
2410
+ ]),
2411
+ );
1544
2412
  return {
1545
- ...defaultModelPricing,
2413
+ ...defaults,
1546
2414
  ...registry,
1547
2415
  ...configured,
1548
2416
  };
1549
2417
  }
1550
2418
 
2419
+ /** Legacy flat projection: returns the default/below-threshold price. */
2420
+ export function configuredModelPricing(settings: Settings): Record<string, ModelPricing> {
2421
+ return Object.fromEntries(
2422
+ Object.entries(configuredModelPricingSchedules(settings)).map(([model, schedule]) => [
2423
+ model,
2424
+ schedule.default,
2425
+ ]),
2426
+ );
2427
+ }
2428
+
2429
+ /** Select the per-provider-request price at an exact input-token threshold. */
2430
+ export function selectModelPricing(
2431
+ schedule: ModelPricingScheduleV1,
2432
+ inputTokens: number,
2433
+ ): ModelPricing {
2434
+ const normalizedInputTokens = Math.max(0, Math.floor(inputTokens));
2435
+ let selected = schedule.default;
2436
+ for (const tier of schedule.inputTokenTiers ?? []) {
2437
+ if (normalizedInputTokens < tier.minimumInputTokens) {
2438
+ break;
2439
+ }
2440
+ selected = tier.pricing;
2441
+ }
2442
+ return selected;
2443
+ }
2444
+
1551
2445
  /**
1552
2446
  * Usable input-token budget: an explicit model-catalog effective window when
1553
2447
  * available, otherwise the deployment window minus its output reserve.
@@ -1627,17 +2521,28 @@ export function calculateModelUsageCostMicros(
1627
2521
  model: string,
1628
2522
  usage: ModelUsageInput,
1629
2523
  ): number {
1630
- const pricing = configuredModelPricing(settings)[model];
1631
- if (!pricing) {
2524
+ const schedule = configuredModelPricingSchedules(settings)[model];
2525
+ if (!schedule) {
1632
2526
  throw new Error(`Missing model pricing for ${model}`);
1633
2527
  }
1634
2528
  const entries =
1635
2529
  usage.requestUsageEntries && usage.requestUsageEntries.length > 0
1636
2530
  ? usage.requestUsageEntries
1637
2531
  : [usage];
1638
- const rawCost = entries.reduce((sum, entry) => sum + calculateEntryCostMicros(pricing, entry), 0);
1639
- const marginBps = pricing.marginBps ?? 0;
1640
- return Math.ceil((rawCost * (10_000 + marginBps)) / 10_000);
2532
+ const rawCostByPricing = new Map<ModelPricing, number>();
2533
+ for (const entry of entries) {
2534
+ const pricing = selectModelPricing(schedule, positiveInt(entry.inputTokens));
2535
+ rawCostByPricing.set(
2536
+ pricing,
2537
+ (rawCostByPricing.get(pricing) ?? 0) + calculateEntryCostMicros(pricing, entry),
2538
+ );
2539
+ }
2540
+ let total = 0;
2541
+ for (const [pricing, rawCost] of rawCostByPricing) {
2542
+ const marginBps = pricing.marginBps ?? 0;
2543
+ total += Math.ceil((rawCost * (10_000 + marginBps)) / 10_000);
2544
+ }
2545
+ return total;
1641
2546
  }
1642
2547
 
1643
2548
  export function configuredAllowedReasoningEfforts(
@@ -2151,7 +3056,14 @@ export function parseModelProvidersJson(raw: string): RegistryProvider[] {
2151
3056
  `OPENGENI_MODEL_PROVIDERS_JSON provider[${index}] is invalid: ${result.error.message}`,
2152
3057
  );
2153
3058
  }
2154
- return result.data;
3059
+ try {
3060
+ return normalizeRegistryProvider(result.data);
3061
+ } catch (error) {
3062
+ const message = error instanceof Error ? error.message : String(error);
3063
+ throw new Error(`OPENGENI_MODEL_PROVIDERS_JSON provider[${index}] is invalid: ${message}`, {
3064
+ cause: error,
3065
+ });
3066
+ }
2155
3067
  });
2156
3068
  }
2157
3069
 
@@ -2720,6 +3632,10 @@ function validateSettings(settings: Settings): void {
2720
3632
  );
2721
3633
  }
2722
3634
  }
3635
+ // Materialize the normalized catalog at boot so canonical product ids,
3636
+ // aliases, definition digests, and capability/pricing normalization are
3637
+ // validated even when managed billing is disabled.
3638
+ configuredModels(settings);
2723
3639
  }
2724
3640
 
2725
3641
  /**