@open-mercato/ai-assistant 0.6.1-develop.3246.1.dbef9d7392 → 0.6.1-develop.3256.1.fe3dec2464

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.
Files changed (133) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +82 -18
  3. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +370 -0
  4. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +7 -0
  5. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +194 -0
  6. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +7 -0
  7. package/dist/modules/ai_assistant/api/ai/agents/route.js +4 -0
  8. package/dist/modules/ai_assistant/api/ai/agents/route.js.map +2 -2
  9. package/dist/modules/ai_assistant/api/ai/chat/route.js +169 -5
  10. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  11. package/dist/modules/ai_assistant/api/route/route.js +38 -19
  12. package/dist/modules/ai_assistant/api/route/route.js.map +3 -3
  13. package/dist/modules/ai_assistant/api/settings/allowlist/route.js +195 -0
  14. package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +7 -0
  15. package/dist/modules/ai_assistant/api/settings/route.js +537 -22
  16. package/dist/modules/ai_assistant/api/settings/route.js.map +3 -3
  17. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +701 -147
  18. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
  19. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +338 -0
  20. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +7 -0
  21. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js +10 -0
  22. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js.map +7 -0
  23. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js +25 -0
  24. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js.map +7 -0
  25. package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js +1 -1
  26. package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js.map +2 -2
  27. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +75 -26
  28. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
  29. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js +10 -0
  30. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js.map +7 -0
  31. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js +25 -0
  32. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js.map +7 -0
  33. package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js +503 -168
  34. package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +2 -2
  35. package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js +5 -0
  36. package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js.map +7 -0
  37. package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js +5 -0
  38. package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js.map +7 -0
  39. package/dist/modules/ai_assistant/data/entities.js +123 -1
  40. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  41. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +157 -0
  42. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +7 -0
  43. package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js +77 -0
  44. package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js.map +7 -0
  45. package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js +1 -1
  46. package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js.map +2 -2
  47. package/dist/modules/ai_assistant/i18n/de.json +90 -1
  48. package/dist/modules/ai_assistant/i18n/en.json +90 -1
  49. package/dist/modules/ai_assistant/i18n/es.json +90 -1
  50. package/dist/modules/ai_assistant/i18n/pl.json +90 -1
  51. package/dist/modules/ai_assistant/lib/agent-registry.js +17 -1
  52. package/dist/modules/ai_assistant/lib/agent-registry.js.map +2 -2
  53. package/dist/modules/ai_assistant/lib/agent-runtime.js +133 -36
  54. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
  55. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  56. package/dist/modules/ai_assistant/lib/baseurl-allowlist.js +29 -0
  57. package/dist/modules/ai_assistant/lib/baseurl-allowlist.js.map +7 -0
  58. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +4 -1
  59. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
  60. package/dist/modules/ai_assistant/lib/llm-adapters/google.js +4 -1
  61. package/dist/modules/ai_assistant/lib/llm-adapters/google.js.map +2 -2
  62. package/dist/modules/ai_assistant/lib/model-allowlist.js +211 -0
  63. package/dist/modules/ai_assistant/lib/model-allowlist.js.map +7 -0
  64. package/dist/modules/ai_assistant/lib/model-factory.js +203 -31
  65. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  66. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +32 -1
  67. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
  68. package/dist/modules/ai_assistant/migrations/Migration20260508140000.js +18 -0
  69. package/dist/modules/ai_assistant/migrations/Migration20260508140000.js.map +7 -0
  70. package/dist/modules/ai_assistant/migrations/Migration20260512090000.js +16 -0
  71. package/dist/modules/ai_assistant/migrations/Migration20260512090000.js.map +7 -0
  72. package/dist/modules/ai_assistant/migrations/Migration20260512130000.js +15 -0
  73. package/dist/modules/ai_assistant/migrations/Migration20260512130000.js.map +7 -0
  74. package/generated/entities/ai_agent_runtime_override/index.ts +13 -0
  75. package/generated/entities/ai_tenant_model_allowlist/index.ts +9 -0
  76. package/generated/entities.ids.generated.ts +2 -0
  77. package/generated/entity-fields-registry.ts +26 -0
  78. package/jest.config.cjs +2 -0
  79. package/package.json +4 -4
  80. package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +477 -0
  81. package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +116 -0
  82. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +240 -0
  83. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +251 -0
  84. package/src/modules/ai_assistant/api/ai/agents/route.ts +4 -0
  85. package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +273 -0
  86. package/src/modules/ai_assistant/api/ai/chat/route.ts +211 -2
  87. package/src/modules/ai_assistant/api/route/route.ts +49 -25
  88. package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +408 -0
  89. package/src/modules/ai_assistant/api/settings/allowlist/route.ts +221 -0
  90. package/src/modules/ai_assistant/api/settings/route.ts +721 -27
  91. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +858 -177
  92. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +458 -0
  93. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.ts +23 -0
  94. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.tsx +12 -0
  95. package/src/modules/ai_assistant/backend/config/ai-assistant/legacy/page.tsx +1 -1
  96. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +89 -12
  97. package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.ts +23 -0
  98. package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.tsx +18 -0
  99. package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +617 -209
  100. package/src/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.ts +7 -0
  101. package/src/modules/ai_assistant/data/entities/AiTenantModelAllowlist.ts +2 -0
  102. package/src/modules/ai_assistant/data/entities.ts +164 -0
  103. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +227 -0
  104. package/src/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.ts +132 -0
  105. package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +337 -0
  106. package/src/modules/ai_assistant/data/repositories/__tests__/AiTenantModelAllowlistRepository.test.ts +181 -0
  107. package/src/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.tsx +1 -1
  108. package/src/modules/ai_assistant/i18n/de.json +90 -1
  109. package/src/modules/ai_assistant/i18n/en.json +90 -1
  110. package/src/modules/ai_assistant/i18n/es.json +90 -1
  111. package/src/modules/ai_assistant/i18n/pl.json +90 -1
  112. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +396 -0
  113. package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +60 -6
  114. package/src/modules/ai_assistant/lib/__tests__/ai-api-operation-runner.test.ts +4 -2
  115. package/src/modules/ai_assistant/lib/__tests__/baseurl-allowlist.test.ts +75 -0
  116. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +18 -0
  117. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-google.test.ts +18 -0
  118. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +150 -4
  119. package/src/modules/ai_assistant/lib/__tests__/model-allowlist.test.ts +290 -0
  120. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +634 -0
  121. package/src/modules/ai_assistant/lib/agent-registry.ts +20 -1
  122. package/src/modules/ai_assistant/lib/agent-runtime.ts +220 -44
  123. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +48 -0
  124. package/src/modules/ai_assistant/lib/baseurl-allowlist.ts +64 -0
  125. package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +11 -1
  126. package/src/modules/ai_assistant/lib/llm-adapters/google.ts +4 -1
  127. package/src/modules/ai_assistant/lib/model-allowlist.ts +407 -0
  128. package/src/modules/ai_assistant/lib/model-factory.ts +486 -58
  129. package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +44 -0
  130. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +704 -235
  131. package/src/modules/ai_assistant/migrations/Migration20260508140000.ts +18 -0
  132. package/src/modules/ai_assistant/migrations/Migration20260512090000.ts +16 -0
  133. package/src/modules/ai_assistant/migrations/Migration20260512130000.ts +13 -0
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Env-driven provider/model allowlist (Phase 1780-5).
3
+ *
4
+ * Two env variables govern which AI providers/models the runtime accepts. They
5
+ * are the ULTIMATE constraint — settings UI, per-tenant overrides, per-agent
6
+ * defaults, and request-time overrides are all clipped against this list. When
7
+ * a caller asks for a provider/model outside the allowlist the runtime emits a
8
+ * warning and falls back to the agent's default (or to the first allowlisted
9
+ * pair when even the default is rejected).
10
+ *
11
+ * - `OM_AI_AVAILABLE_PROVIDERS=openai,anthropic`
12
+ * Comma-separated provider ids. Unset/empty → no provider restriction.
13
+ * Whitespace-tolerant; case-insensitive comparison against the registry.
14
+ *
15
+ * - `OM_AI_AVAILABLE_MODELS_<PROVIDER>=gpt-5-mini,gpt-5`
16
+ * Per-provider comma-separated model id list. Unset/empty → no model
17
+ * restriction for that provider. PROVIDER is uppercased from the registry
18
+ * id (e.g. `openai` → `OM_AI_AVAILABLE_MODELS_OPENAI`).
19
+ *
20
+ * Both vars are read fresh on every call so hot-reload and test overrides
21
+ * work without re-creating the factory.
22
+ */
23
+
24
+ export type EnvLookup = Record<string, string | undefined>
25
+
26
+ const PROVIDERS_ENV = 'OM_AI_AVAILABLE_PROVIDERS'
27
+
28
+ function envProvidersVarName(): string {
29
+ return PROVIDERS_ENV
30
+ }
31
+
32
+ function envModelsVarName(providerId: string): string {
33
+ const envSafeProviderId = providerId.toUpperCase().replace(/[^A-Z0-9]/g, '_')
34
+ return `OM_AI_AVAILABLE_MODELS_${envSafeProviderId}`
35
+ }
36
+
37
+ function envSafeId(value: string): string {
38
+ return value.toUpperCase().replace(/[^A-Z0-9]/g, '_')
39
+ }
40
+
41
+ function agentOverrideProvidersVarName(agentId: string): string {
42
+ return `OM_AI_AGENT_${envSafeId(agentId)}_AVAILABLE_PROVIDERS`
43
+ }
44
+
45
+ function agentOverrideModelsVarName(agentId: string, providerId: string): string {
46
+ return `OM_AI_AGENT_${envSafeId(agentId)}_AVAILABLE_MODELS_${envSafeId(providerId)}`
47
+ }
48
+
49
+ export function normalizeProviderId(value: string | null | undefined): string {
50
+ return value?.trim().toLowerCase().replace(/_/g, '-') ?? ''
51
+ }
52
+
53
+ export function providerIdAliases(providerId: string): string[] {
54
+ const normalized = normalizeProviderId(providerId)
55
+ if (!normalized) return []
56
+ return Array.from(new Set([normalized, normalized.replace(/-/g, '_')]))
57
+ }
58
+
59
+ export function canonicalProviderId(
60
+ providerId: string,
61
+ knownProviderIds: readonly string[],
62
+ ): string | null {
63
+ const normalized = normalizeProviderId(providerId)
64
+ return knownProviderIds.find((id) => normalizeProviderId(id) === normalized) ?? null
65
+ }
66
+
67
+ function canonicalizeProviderList(
68
+ providerIds: string[] | null,
69
+ knownProviderIds: readonly string[],
70
+ ): string[] | null {
71
+ if (providerIds === null) return null
72
+ const result: string[] = []
73
+ const seen = new Set<string>()
74
+ for (const providerId of providerIds) {
75
+ const canonical = canonicalProviderId(providerId, knownProviderIds) ?? normalizeProviderId(providerId)
76
+ const key = normalizeProviderId(canonical)
77
+ if (seen.has(key)) continue
78
+ seen.add(key)
79
+ result.push(canonical)
80
+ }
81
+ return result
82
+ }
83
+
84
+ function parseList(raw: string | undefined): string[] | null {
85
+ if (raw === undefined) return null
86
+ const trimmed = raw.trim()
87
+ if (trimmed.length === 0) return null
88
+ const items = trimmed
89
+ .split(',')
90
+ .map((entry) => entry.trim())
91
+ .filter((entry) => entry.length > 0)
92
+ return items.length > 0 ? items : null
93
+ }
94
+
95
+ /**
96
+ * Reads the configured provider allowlist. Returns `null` when the env var is
97
+ * unset or empty (no restriction). Otherwise returns the parsed list of
98
+ * provider ids (preserving caller order; case preserved as-written).
99
+ */
100
+ export function readAllowedProviders(env: EnvLookup = process.env): string[] | null {
101
+ return parseList(env[PROVIDERS_ENV])
102
+ }
103
+
104
+ /**
105
+ * Reads the configured per-provider model allowlist. Returns `null` when the
106
+ * env var is unset or empty (no restriction for that provider). Otherwise
107
+ * returns the parsed model id list.
108
+ */
109
+ export function readAllowedModels(
110
+ env: EnvLookup,
111
+ providerId: string,
112
+ ): string[] | null {
113
+ return parseList(env[envModelsVarName(providerId)])
114
+ }
115
+
116
+ /**
117
+ * Snapshot of the env-driven allowlist suitable for the settings GET response.
118
+ * `hasRestrictions` is `true` when at least one of the env vars is set.
119
+ */
120
+ export interface AllowlistConfig {
121
+ providers: string[] | null
122
+ modelsByProvider: Record<string, string[]>
123
+ hasRestrictions: boolean
124
+ }
125
+
126
+ /**
127
+ * Reads the full allowlist for every provider in `knownProviderIds` and
128
+ * returns a snapshot. Use the settings response to feed UI dropdowns so the
129
+ * picker can only offer values the runtime would accept.
130
+ */
131
+ export function readAllowlistConfig(
132
+ env: EnvLookup = process.env,
133
+ knownProviderIds: string[] = [],
134
+ ): AllowlistConfig {
135
+ const providers = readAllowedProviders(env)
136
+ const modelsByProvider: Record<string, string[]> = {}
137
+ for (const providerId of knownProviderIds) {
138
+ const list = readAllowedModels(env, providerId)
139
+ if (list !== null) modelsByProvider[providerId] = list
140
+ }
141
+ return {
142
+ providers,
143
+ modelsByProvider,
144
+ hasRestrictions: providers !== null || Object.keys(modelsByProvider).length > 0,
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Returns `true` when the provider is permitted by the allowlist (or when no
150
+ * provider restriction is configured). Case-insensitive id comparison.
151
+ */
152
+ export function isProviderAllowed(
153
+ env: EnvLookup,
154
+ providerId: string,
155
+ ): boolean {
156
+ const allowed = readAllowedProviders(env)
157
+ if (allowed === null) return true
158
+ const needle = normalizeProviderId(providerId)
159
+ return allowed.some((id) => normalizeProviderId(id) === needle)
160
+ }
161
+
162
+ /**
163
+ * Returns `true` when the model is permitted for the provider by the
164
+ * allowlist (or when no per-provider model restriction is configured).
165
+ * Case-sensitive model id comparison — model ids are vendor-specified strings
166
+ * (e.g. `gpt-5-mini`, `claude-opus-4-20250514`).
167
+ */
168
+ export function isModelAllowedForProvider(
169
+ env: EnvLookup,
170
+ providerId: string,
171
+ modelId: string,
172
+ ): boolean {
173
+ const allowed = readAllowedModels(env, providerId)
174
+ if (allowed === null) return true
175
+ return allowed.includes(modelId)
176
+ }
177
+
178
+ /**
179
+ * Returns `true` when the (provider, model) pair satisfies BOTH the provider
180
+ * allowlist and the per-provider model allowlist. Convenience helper for
181
+ * settings PUT validators.
182
+ */
183
+ export function isProviderModelAllowed(
184
+ env: EnvLookup,
185
+ providerId: string,
186
+ modelId: string,
187
+ ): boolean {
188
+ return isProviderAllowed(env, providerId) && isModelAllowedForProvider(env, providerId, modelId)
189
+ }
190
+
191
+ /**
192
+ * Public version of {@link envModelsVarName} for docs/UI hints.
193
+ */
194
+ export function modelAllowlistEnvVarName(providerId: string): string {
195
+ return envModelsVarName(providerId)
196
+ }
197
+
198
+ /**
199
+ * Public version of {@link envProvidersVarName}.
200
+ */
201
+ export function providerAllowlistEnvVarName(): string {
202
+ return envProvidersVarName()
203
+ }
204
+
205
+ export function agentOverrideProviderAllowlistEnvVarName(agentId: string): string {
206
+ return agentOverrideProvidersVarName(agentId)
207
+ }
208
+
209
+ export function agentOverrideModelAllowlistEnvVarName(
210
+ agentId: string,
211
+ providerId: string,
212
+ ): string {
213
+ return agentOverrideModelsVarName(agentId, providerId)
214
+ }
215
+
216
+ /**
217
+ * Tenant-scoped allowlist snapshot (Phase 1780-6). Persisted by the
218
+ * `ai_tenant_model_allowlists` table and edited from the AI assistant
219
+ * settings page. The runtime intersects this with the env allowlist before
220
+ * picking which provider/model is permitted.
221
+ *
222
+ * `allowedProviders === null` means "inherit env" (no tenant-level provider
223
+ * restriction). For `allowedModelsByProvider`, a missing key means "inherit
224
+ * env" for that provider; an empty array means "no models permitted for this
225
+ * provider" — the runtime will refuse all picks for that provider.
226
+ */
227
+ export interface TenantAllowlistSnapshot {
228
+ allowedProviders: string[] | null
229
+ allowedModelsByProvider: Record<string, string[]>
230
+ }
231
+
232
+ export function hasAllowlistSnapshotRestrictions(snapshot: TenantAllowlistSnapshot | null): boolean {
233
+ return Boolean(
234
+ snapshot &&
235
+ (snapshot.allowedProviders !== null ||
236
+ Object.keys(snapshot.allowedModelsByProvider ?? {}).length > 0),
237
+ )
238
+ }
239
+
240
+ export function readAgentRuntimeOverrideAllowlist(
241
+ env: EnvLookup,
242
+ agentId: string,
243
+ knownProviderIds: string[],
244
+ ): TenantAllowlistSnapshot | null {
245
+ const providers = parseList(env[agentOverrideProvidersVarName(agentId)])
246
+ const modelsByProvider: Record<string, string[]> = {}
247
+ for (const providerId of knownProviderIds) {
248
+ const list = parseList(env[agentOverrideModelsVarName(agentId, providerId)])
249
+ if (list !== null) modelsByProvider[providerId] = list
250
+ }
251
+ const snapshot = {
252
+ allowedProviders: providers,
253
+ allowedModelsByProvider: modelsByProvider,
254
+ }
255
+ return hasAllowlistSnapshotRestrictions(snapshot) ? snapshot : null
256
+ }
257
+
258
+ /**
259
+ * Effective allowlist after intersecting env with tenant. Both axes are
260
+ * `null` when neither side imposes a restriction — semantically equivalent to
261
+ * `readAllowlistConfig` with no tenant snapshot.
262
+ */
263
+ export interface EffectiveAllowlist {
264
+ providers: string[] | null
265
+ modelsByProvider: Record<string, string[]>
266
+ hasRestrictions: boolean
267
+ /**
268
+ * `true` when the tenant snapshot contributes any narrowing on top of the
269
+ * env allowlist. Useful for telling the UI "this tenant has its own picks"
270
+ * vs "we are showing env-only restrictions".
271
+ */
272
+ tenantOverridesActive: boolean
273
+ }
274
+
275
+ function intersectIdLists(
276
+ outer: string[] | null,
277
+ inner: string[] | null,
278
+ caseInsensitive: boolean,
279
+ ): string[] | null {
280
+ if (outer === null && inner === null) return null
281
+ if (outer === null) return inner
282
+ if (inner === null) return outer
283
+ const outerSet = new Set(
284
+ caseInsensitive ? outer.map((id) => normalizeProviderId(id)) : outer,
285
+ )
286
+ const result: string[] = []
287
+ for (const id of inner) {
288
+ const needle = caseInsensitive ? normalizeProviderId(id) : id
289
+ if (outerSet.has(needle)) result.push(id)
290
+ }
291
+ return result
292
+ }
293
+
294
+ /**
295
+ * Intersects the env-driven allowlist with an optional tenant snapshot. The
296
+ * tenant allowlist may NEVER widen the env allowlist; values outside the env
297
+ * are silently dropped. Returns the effective shape the settings UI and
298
+ * model-factory should clip against.
299
+ */
300
+ export function intersectAllowlists(
301
+ env: EnvLookup,
302
+ knownProviderIds: string[],
303
+ tenant: TenantAllowlistSnapshot | null,
304
+ ): EffectiveAllowlist {
305
+ const envProviders = canonicalizeProviderList(readAllowedProviders(env), knownProviderIds)
306
+ const envModelsByProvider: Record<string, string[]> = {}
307
+ for (const providerId of knownProviderIds) {
308
+ const list = readAllowedModels(env, providerId)
309
+ if (list !== null) envModelsByProvider[providerId] = list
310
+ }
311
+
312
+ const tenantProviders = canonicalizeProviderList(tenant?.allowedProviders ?? null, knownProviderIds)
313
+ const tenantModelsByProvider = tenant?.allowedModelsByProvider ?? {}
314
+
315
+ const providers = intersectIdLists(envProviders, tenantProviders, true)
316
+
317
+ const modelsByProvider: Record<string, string[]> = { ...envModelsByProvider }
318
+ for (const rawProviderId of Object.keys(tenantModelsByProvider)) {
319
+ const providerId = canonicalProviderId(rawProviderId, knownProviderIds) ?? normalizeProviderId(rawProviderId)
320
+ const tenantList = tenantModelsByProvider[rawProviderId] ?? []
321
+ const envList = modelsByProvider[providerId] ?? null
322
+ const intersection = intersectIdLists(envList, tenantList, false)
323
+ if (intersection !== null) {
324
+ modelsByProvider[providerId] = intersection
325
+ }
326
+ }
327
+
328
+ const tenantOverridesActive =
329
+ tenantProviders !== null || Object.keys(tenantModelsByProvider).length > 0
330
+
331
+ return {
332
+ providers,
333
+ modelsByProvider,
334
+ hasRestrictions:
335
+ providers !== null || Object.keys(modelsByProvider).length > 0,
336
+ tenantOverridesActive,
337
+ }
338
+ }
339
+
340
+ export function intersectEffectiveAllowlistWithSnapshot(
341
+ outer: EffectiveAllowlist,
342
+ knownProviderIds: string[],
343
+ inner: TenantAllowlistSnapshot | null,
344
+ ): EffectiveAllowlist {
345
+ if (!hasAllowlistSnapshotRestrictions(inner)) return outer
346
+
347
+ const innerProviders = canonicalizeProviderList(inner?.allowedProviders ?? null, knownProviderIds)
348
+ const providers = intersectIdLists(outer.providers, innerProviders, true)
349
+
350
+ const modelsByProvider: Record<string, string[]> = { ...outer.modelsByProvider }
351
+ for (const rawProviderId of Object.keys(inner?.allowedModelsByProvider ?? {})) {
352
+ const providerId = canonicalProviderId(rawProviderId, knownProviderIds) ?? normalizeProviderId(rawProviderId)
353
+ const innerList = inner?.allowedModelsByProvider[rawProviderId] ?? []
354
+ const outerList = modelsByProvider[providerId] ?? null
355
+ const intersection = intersectIdLists(outerList, innerList, false)
356
+ if (intersection !== null) {
357
+ modelsByProvider[providerId] = intersection
358
+ }
359
+ }
360
+
361
+ return {
362
+ providers,
363
+ modelsByProvider,
364
+ hasRestrictions: true,
365
+ tenantOverridesActive: outer.tenantOverridesActive || hasAllowlistSnapshotRestrictions(inner),
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Effective-allowlist version of `isProviderAllowed`.
371
+ */
372
+ export function isProviderAllowedInEffective(
373
+ effective: EffectiveAllowlist,
374
+ providerId: string,
375
+ ): boolean {
376
+ if (effective.providers === null) return true
377
+ const needle = normalizeProviderId(providerId)
378
+ return effective.providers.some((id) => normalizeProviderId(id) === needle)
379
+ }
380
+
381
+ /**
382
+ * Effective-allowlist version of `isModelAllowedForProvider`.
383
+ */
384
+ export function isModelAllowedForProviderInEffective(
385
+ effective: EffectiveAllowlist,
386
+ providerId: string,
387
+ modelId: string,
388
+ ): boolean {
389
+ const list = effective.modelsByProvider[providerId] ?? effective.modelsByProvider[normalizeProviderId(providerId)]
390
+ if (list === undefined) return true
391
+ return list.includes(modelId)
392
+ }
393
+
394
+ /**
395
+ * Returns `true` when the (provider, model) pair satisfies the effective
396
+ * allowlist (both env and tenant constraints).
397
+ */
398
+ export function isProviderModelAllowedInEffective(
399
+ effective: EffectiveAllowlist,
400
+ providerId: string,
401
+ modelId: string,
402
+ ): boolean {
403
+ return (
404
+ isProviderAllowedInEffective(effective, providerId) &&
405
+ isModelAllowedForProviderInEffective(effective, providerId, modelId)
406
+ )
407
+ }