@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,458 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
5
+ import { Loader2, Save, Shield, Trash2, Info, AlertCircle, ShieldCheck } from 'lucide-react'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
8
+ import { Badge } from '@open-mercato/ui/primitives/badge'
9
+ import { Button } from '@open-mercato/ui/primitives/button'
10
+ import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
11
+ import { Label } from '@open-mercato/ui/primitives/label'
12
+ import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
13
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
14
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
15
+
16
+ type EnvAllowlistConfig = {
17
+ providers: string[] | null
18
+ modelsByProvider: Record<string, string[]>
19
+ hasRestrictions: boolean
20
+ }
21
+
22
+ type TenantAllowlist = {
23
+ allowedProviders: string[] | null
24
+ allowedModelsByProvider: Record<string, string[]>
25
+ }
26
+
27
+ type EffectiveAllowlist = {
28
+ providers: string[] | null
29
+ modelsByProvider: Record<string, string[]>
30
+ hasRestrictions: boolean
31
+ tenantOverridesActive: boolean
32
+ }
33
+
34
+ type ProviderEntry = {
35
+ id: string
36
+ name: string
37
+ defaultModel: string
38
+ envKey: string | null
39
+ configured: boolean
40
+ defaultModels: Array<{ id: string; name: string; contextWindow?: number; tags?: string[] }>
41
+ }
42
+
43
+ type SettingsResponse = {
44
+ availableProviders: ProviderEntry[]
45
+ allowlistProviders?: ProviderEntry[]
46
+ allowlist: EnvAllowlistConfig
47
+ tenantAllowlist: TenantAllowlist | null
48
+ effectiveAllowlist: EffectiveAllowlist
49
+ }
50
+
51
+ async function fetchSettings(): Promise<SettingsResponse> {
52
+ const { result, status } = await apiCallOrThrow<SettingsResponse>(
53
+ '/api/ai_assistant/settings',
54
+ { method: 'GET', credentials: 'include' },
55
+ { errorMessage: 'Failed to load AI settings' },
56
+ )
57
+ if (!result) throw new Error(`Failed to load settings (${status})`)
58
+ return result
59
+ }
60
+
61
+ type EditState = {
62
+ /** null = "no tenant restriction (inherit env)"; array = explicit tenant pick */
63
+ allowedProviders: string[] | null
64
+ allowedModelsByProvider: Record<string, string[]>
65
+ }
66
+
67
+ function snapshotToEditState(snapshot: TenantAllowlist | null): EditState {
68
+ return {
69
+ allowedProviders: snapshot?.allowedProviders ?? null,
70
+ allowedModelsByProvider: { ...(snapshot?.allowedModelsByProvider ?? {}) },
71
+ }
72
+ }
73
+
74
+ export function AiTenantAllowlistPageClient(): React.JSX.Element {
75
+ const t = useT()
76
+ const queryClient = useQueryClient()
77
+ const settingsQuery = useQuery({ queryKey: ['ai_assistant', 'settings'], queryFn: fetchSettings, staleTime: 0 })
78
+
79
+ const [editState, setEditState] = React.useState<EditState>({
80
+ allowedProviders: null,
81
+ allowedModelsByProvider: {},
82
+ })
83
+ const [dirty, setDirty] = React.useState(false)
84
+ const [saving, setSaving] = React.useState(false)
85
+ const [clearing, setClearing] = React.useState(false)
86
+ const [feedback, setFeedback] = React.useState<{ kind: 'ok' | 'error'; text: string } | null>(null)
87
+ const { runMutation: runSaveAllowlistMutation } = useGuardedMutation({
88
+ contextId: 'ai-tenant-allowlist-save',
89
+ })
90
+ const { runMutation: runClearAllowlistMutation } = useGuardedMutation({
91
+ contextId: 'ai-tenant-allowlist-clear',
92
+ })
93
+
94
+ React.useEffect(() => {
95
+ if (settingsQuery.data) {
96
+ setEditState(snapshotToEditState(settingsQuery.data.tenantAllowlist))
97
+ setDirty(false)
98
+ }
99
+ }, [settingsQuery.data])
100
+
101
+ const pageHeader = (
102
+ <div className="space-y-1">
103
+ <h1 className="flex items-center gap-2 text-2xl font-bold">
104
+ <Shield className="size-6" />
105
+ {t('ai_assistant.allowlist.title', 'AI provider & model allowlist')}
106
+ </h1>
107
+ <p className="text-muted-foreground">
108
+ {t(
109
+ 'ai_assistant.allowlist.subtitle',
110
+ 'Limit which providers and models the runtime, settings, and chat picker may use for this tenant. The env allowlist is the outer constraint — tenant picks narrow it further.',
111
+ )}
112
+ </p>
113
+ </div>
114
+ )
115
+
116
+ if (settingsQuery.isLoading) {
117
+ return (
118
+ <div className="flex max-w-3xl flex-col gap-4">
119
+ {pageHeader}
120
+ <div className="flex w-fit items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm text-muted-foreground" role="status">
121
+ <Loader2 className="size-4 animate-spin" />
122
+ {t('ai_assistant.allowlist.loading', 'Loading allowlist…')}
123
+ </div>
124
+ </div>
125
+ )
126
+ }
127
+
128
+ if (settingsQuery.isError || !settingsQuery.data) {
129
+ return (
130
+ <div className="flex max-w-3xl flex-col gap-4">
131
+ {pageHeader}
132
+ <Alert variant="destructive">
133
+ <AlertCircle className="size-4" />
134
+ <AlertTitle>{t('ai_assistant.allowlist.loadError.title', 'Failed to load allowlist')}</AlertTitle>
135
+ <AlertDescription>
136
+ {settingsQuery.error instanceof Error
137
+ ? settingsQuery.error.message
138
+ : t('ai_assistant.allowlist.loadError.body', 'Try refreshing the page.')}
139
+ </AlertDescription>
140
+ </Alert>
141
+ </div>
142
+ )
143
+ }
144
+
145
+ const settings = settingsQuery.data
146
+ const envAllowedProviders = settings.allowlist.providers
147
+ const envModelsByProvider = settings.allowlist.modelsByProvider
148
+
149
+ // Provider universe to render: env-allowed providers (or all configured if env unset).
150
+ const editableProviders = settings.allowlistProviders ?? settings.availableProviders
151
+ const candidateProviders = editableProviders.filter((p) => {
152
+ if (envAllowedProviders === null) return true
153
+ return envAllowedProviders.some((id) => id.toLowerCase() === p.id.toLowerCase())
154
+ })
155
+
156
+ const tenantPickedProviders = editState.allowedProviders
157
+ const isProviderEnabled = (id: string): boolean => {
158
+ if (tenantPickedProviders === null) return true
159
+ return tenantPickedProviders.includes(id)
160
+ }
161
+
162
+ const toggleProvider = (id: string, next: boolean): void => {
163
+ setDirty(true)
164
+ setFeedback(null)
165
+ setEditState((prev) => {
166
+ const current = prev.allowedProviders
167
+ if (next) {
168
+ const list = current === null ? [id] : Array.from(new Set([...current, id]))
169
+ return { ...prev, allowedProviders: list }
170
+ }
171
+ const list = current === null
172
+ ? candidateProviders.map((p) => p.id).filter((pid) => pid !== id)
173
+ : current.filter((pid) => pid !== id)
174
+ return { ...prev, allowedProviders: list }
175
+ })
176
+ }
177
+
178
+ const isModelEnabled = (providerId: string, modelId: string): boolean => {
179
+ const list = editState.allowedModelsByProvider[providerId]
180
+ if (list === undefined) return true
181
+ return list.includes(modelId)
182
+ }
183
+
184
+ const toggleModel = (providerId: string, modelId: string, next: boolean): void => {
185
+ setDirty(true)
186
+ setFeedback(null)
187
+ const provider = candidateProviders.find((p) => p.id === providerId)
188
+ const allModelIds = provider?.defaultModels.map((m) => m.id) ?? []
189
+ setEditState((prev) => {
190
+ const current = prev.allowedModelsByProvider[providerId]
191
+ const allowedModelsByProvider = { ...prev.allowedModelsByProvider }
192
+ if (next) {
193
+ const list = current === undefined ? [modelId] : Array.from(new Set([...current, modelId]))
194
+ allowedModelsByProvider[providerId] = list
195
+ } else {
196
+ const baseline = current === undefined ? allModelIds : current
197
+ const list = baseline.filter((id) => id !== modelId)
198
+ allowedModelsByProvider[providerId] = list
199
+ }
200
+ return { ...prev, allowedModelsByProvider }
201
+ })
202
+ }
203
+
204
+ const resetTenantPicks = (): void => {
205
+ setDirty(true)
206
+ setFeedback(null)
207
+ setEditState({ allowedProviders: null, allowedModelsByProvider: {} })
208
+ }
209
+
210
+ const handleSave = async (): Promise<void> => {
211
+ setSaving(true)
212
+ setFeedback(null)
213
+ try {
214
+ await runSaveAllowlistMutation({
215
+ operation: async () => {
216
+ const { ok, status, result } = await apiCall<{ error?: string; code?: string }>(
217
+ '/api/ai_assistant/settings/allowlist',
218
+ {
219
+ method: 'PUT',
220
+ credentials: 'include',
221
+ headers: { 'Content-Type': 'application/json' },
222
+ body: JSON.stringify({
223
+ allowedProviders: editState.allowedProviders,
224
+ allowedModelsByProvider: editState.allowedModelsByProvider,
225
+ }),
226
+ },
227
+ )
228
+ if (!ok) {
229
+ throw new Error(
230
+ result?.error ?? t('ai_assistant.allowlist.save.error', `Save failed (${status})`),
231
+ )
232
+ }
233
+ },
234
+ context: {},
235
+ })
236
+ const successText = t('ai_assistant.allowlist.save.success', 'Allowlist saved.')
237
+ setFeedback({ kind: 'ok', text: successText })
238
+ flash(successText, 'success')
239
+ setDirty(false)
240
+ await queryClient.invalidateQueries({ queryKey: ['ai_assistant', 'settings'] })
241
+ } catch (err) {
242
+ const message = err instanceof Error ? err.message : String(err)
243
+ setFeedback({ kind: 'error', text: message })
244
+ flash(message, 'error')
245
+ } finally {
246
+ setSaving(false)
247
+ }
248
+ }
249
+
250
+ const handleClear = async (): Promise<void> => {
251
+ setClearing(true)
252
+ setFeedback(null)
253
+ try {
254
+ await runClearAllowlistMutation({
255
+ operation: async () => {
256
+ const { ok, status, result } = await apiCall<{ error?: string; cleared?: boolean }>(
257
+ '/api/ai_assistant/settings/allowlist',
258
+ { method: 'DELETE', credentials: 'include' },
259
+ )
260
+ if (!ok) {
261
+ throw new Error(
262
+ result?.error ?? t('ai_assistant.allowlist.clear.error', `Clear failed (${status})`),
263
+ )
264
+ }
265
+ },
266
+ context: {},
267
+ })
268
+ const successText = t(
269
+ 'ai_assistant.allowlist.clear.success',
270
+ 'Tenant allowlist cleared. Env-only enforcement applies.',
271
+ )
272
+ setFeedback({ kind: 'ok', text: successText })
273
+ flash(successText, 'success')
274
+ setDirty(false)
275
+ await queryClient.invalidateQueries({ queryKey: ['ai_assistant', 'settings'] })
276
+ } catch (err) {
277
+ const message = err instanceof Error ? err.message : String(err)
278
+ setFeedback({ kind: 'error', text: message })
279
+ flash(message, 'error')
280
+ } finally {
281
+ setClearing(false)
282
+ }
283
+ }
284
+
285
+ const envBanner = envAllowedProviders === null && Object.keys(envModelsByProvider).length === 0
286
+ ? null
287
+ : (
288
+ <Alert>
289
+ <Info className="h-4 w-4" />
290
+ <AlertTitle>{t('ai_assistant.allowlist.envBanner.title', 'Env allowlist is in effect')}</AlertTitle>
291
+ <AlertDescription className="space-y-1">
292
+ {envAllowedProviders ? (
293
+ <div>
294
+ {t('ai_assistant.allowlist.envBanner.providers', 'OM_AI_AVAILABLE_PROVIDERS')}: <code className="font-mono text-xs">{envAllowedProviders.join(', ')}</code>
295
+ </div>
296
+ ) : null}
297
+ {Object.keys(envModelsByProvider).map((pid) => (
298
+ <div key={pid}>
299
+ <code className="font-mono text-xs">OM_AI_AVAILABLE_MODELS_{pid.toUpperCase()}</code>: {envModelsByProvider[pid].join(', ')}
300
+ </div>
301
+ ))}
302
+ <p className="text-xs text-muted-foreground mt-1">
303
+ {t('ai_assistant.allowlist.envBanner.note', 'Tenant picks may not widen the env list — values outside it are hidden.')}
304
+ </p>
305
+ </AlertDescription>
306
+ </Alert>
307
+ )
308
+
309
+ return (
310
+ <div className="flex flex-col gap-6 max-w-3xl">
311
+ {pageHeader}
312
+
313
+ {envBanner}
314
+
315
+ {feedback ? (
316
+ <Alert variant={feedback.kind === 'error' ? 'destructive' : undefined}>
317
+ {feedback.kind === 'error' ? <AlertCircle className="h-4 w-4" /> : <Info className="h-4 w-4" />}
318
+ <AlertDescription>{feedback.text}</AlertDescription>
319
+ </Alert>
320
+ ) : null}
321
+
322
+ <div className="rounded-lg border bg-card p-6 space-y-6">
323
+ <div className="flex items-start justify-between gap-3">
324
+ <div className="space-y-1">
325
+ <h2 className="text-lg font-semibold">{t('ai_assistant.allowlist.providers.title', 'Providers')}</h2>
326
+ <p className="text-sm text-muted-foreground">
327
+ {t(
328
+ 'ai_assistant.allowlist.providers.help',
329
+ 'Untick to forbid the runtime from using a provider for this tenant. Tick all to inherit the env allowlist.',
330
+ )}
331
+ </p>
332
+ </div>
333
+ <span
334
+ className={
335
+ settings.effectiveAllowlist.tenantOverridesActive
336
+ ? 'inline-flex size-8 items-center justify-center rounded-md text-status-success-icon'
337
+ : 'inline-flex size-8 items-center justify-center rounded-md text-status-warning-icon'
338
+ }
339
+ role="img"
340
+ aria-label={
341
+ settings.effectiveAllowlist.tenantOverridesActive
342
+ ? t('ai_assistant.allowlist.badge.active', 'Tenant rules active')
343
+ : t('ai_assistant.allowlist.badge.envOnly', 'Env-only')
344
+ }
345
+ title={
346
+ settings.effectiveAllowlist.tenantOverridesActive
347
+ ? t('ai_assistant.allowlist.badge.active', 'Tenant rules active')
348
+ : t('ai_assistant.allowlist.badge.envOnly', 'Env-only')
349
+ }
350
+ >
351
+ {settings.effectiveAllowlist.tenantOverridesActive ? (
352
+ <ShieldCheck className="size-5" aria-hidden />
353
+ ) : (
354
+ <Shield className="size-5" aria-hidden />
355
+ )}
356
+ </span>
357
+ </div>
358
+
359
+ {candidateProviders.length === 0 ? (
360
+ <p className="text-sm text-muted-foreground">
361
+ {t('ai_assistant.allowlist.providers.empty', 'No configured providers within the env allowlist.')}
362
+ </p>
363
+ ) : (
364
+ <div className="space-y-4">
365
+ {candidateProviders.map((provider) => {
366
+ const enabled = isProviderEnabled(provider.id)
367
+ const envModels = envModelsByProvider[provider.id]
368
+ const candidateModels = envModels
369
+ ? provider.defaultModels.filter((m) => envModels.includes(m.id))
370
+ : provider.defaultModels
371
+ return (
372
+ <div key={provider.id} className="rounded-md border p-4 space-y-3">
373
+ <div className="flex items-center justify-between gap-3">
374
+ <div className="flex items-center gap-3">
375
+ <Checkbox
376
+ id={`provider-${provider.id}`}
377
+ checked={enabled}
378
+ onCheckedChange={(value) => toggleProvider(provider.id, value === true)}
379
+ />
380
+ <Label htmlFor={`provider-${provider.id}`} className="font-medium">
381
+ {provider.name}
382
+ </Label>
383
+ {provider.configured ? (
384
+ <Badge variant="outline" className="text-xs">
385
+ {t('ai_assistant.allowlist.providers.configured', 'configured')}
386
+ </Badge>
387
+ ) : (
388
+ <Badge variant="outline" className="text-xs text-muted-foreground">
389
+ {t('ai_assistant.allowlist.providers.notConfigured', 'not configured')}
390
+ </Badge>
391
+ )}
392
+ </div>
393
+ </div>
394
+
395
+ {enabled && candidateModels.length > 0 ? (
396
+ <div className="ml-7 space-y-2">
397
+ <div className="text-xs text-muted-foreground">
398
+ {t('ai_assistant.allowlist.models.help', 'Tick the models tenants may pick. Empty = no model restriction (inherit env).')}
399
+ </div>
400
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
401
+ {candidateModels.map((model) => {
402
+ const checked = isModelEnabled(provider.id, model.id)
403
+ return (
404
+ <label
405
+ key={`${provider.id}-${model.id}`}
406
+ className="flex items-center gap-2 text-sm"
407
+ >
408
+ <Checkbox
409
+ checked={checked}
410
+ onCheckedChange={(value) => toggleModel(provider.id, model.id, value === true)}
411
+ />
412
+ <span className="font-mono text-xs">{model.id}</span>
413
+ {model.id === provider.defaultModel ? (
414
+ <Badge variant="outline" className="text-xs">
415
+ {t('ai_assistant.allowlist.models.default', 'default')}
416
+ </Badge>
417
+ ) : null}
418
+ </label>
419
+ )
420
+ })}
421
+ </div>
422
+ </div>
423
+ ) : null}
424
+ </div>
425
+ )
426
+ })}
427
+ </div>
428
+ )}
429
+ </div>
430
+
431
+ <div className="flex flex-wrap items-center gap-2">
432
+ <Button onClick={() => void handleSave()} disabled={!dirty || saving} className="gap-2">
433
+ {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
434
+ {t('ai_assistant.allowlist.actions.save', 'Save allowlist')}
435
+ </Button>
436
+ <Button
437
+ variant="outline"
438
+ onClick={resetTenantPicks}
439
+ disabled={saving || clearing}
440
+ className="gap-2"
441
+ >
442
+ {t('ai_assistant.allowlist.actions.reset', 'Reset to env defaults')}
443
+ </Button>
444
+ <Button
445
+ variant="ghost"
446
+ onClick={() => void handleClear()}
447
+ disabled={clearing || saving || !settings.tenantAllowlist}
448
+ className="gap-2"
449
+ >
450
+ {clearing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
451
+ {t('ai_assistant.allowlist.actions.clearStored', 'Clear stored allowlist')}
452
+ </Button>
453
+ </div>
454
+ </div>
455
+ )
456
+ }
457
+
458
+ export default AiTenantAllowlistPageClient
@@ -0,0 +1,23 @@
1
+ import React from 'react'
2
+
3
+ const allowlistIcon = React.createElement(
4
+ 'svg',
5
+ { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
6
+ React.createElement('path', { d: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }),
7
+ React.createElement('path', { d: 'm9 12 2 2 4-4' }),
8
+ )
9
+
10
+ export const metadata = {
11
+ requireAuth: true,
12
+ requireFeatures: ['ai_assistant.settings.manage'],
13
+ pageTitle: 'AI Allowlist',
14
+ pageTitleKey: 'ai_assistant.allowlist.navTitle',
15
+ pageGroup: 'Module Configs',
16
+ pageGroupKey: 'settings.sections.moduleConfigs',
17
+ pageOrder: 432,
18
+ icon: allowlistIcon,
19
+ pageContext: 'settings' as const,
20
+ breadcrumb: [
21
+ { label: 'AI Allowlist', labelKey: 'ai_assistant.allowlist.navTitle' },
22
+ ],
23
+ } as const
@@ -0,0 +1,12 @@
1
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
2
+ import { AiTenantAllowlistPageClient } from './AiTenantAllowlistPageClient'
3
+
4
+ export default async function AiAssistantAllowlistPage() {
5
+ return (
6
+ <Page>
7
+ <PageBody>
8
+ <AiTenantAllowlistPageClient />
9
+ </PageBody>
10
+ </Page>
11
+ )
12
+ }
@@ -5,7 +5,7 @@ export default async function AiAssistantLegacySettingsPage() {
5
5
  return (
6
6
  <Page>
7
7
  <PageBody>
8
- <AiAssistantSettingsPageClient />
8
+ <AiAssistantSettingsPageClient launchMode="legacy" showVisibilityControl />
9
9
  </PageBody>
10
10
  </Page>
11
11
  )
@@ -110,7 +110,10 @@ function PlaygroundNoAgents() {
110
110
  function AgentDetails({ agent }: { agent: PlaygroundAgent }) {
111
111
  const t = useT()
112
112
  return (
113
- <div className="rounded-md border border-border bg-muted/30 p-3 text-sm">
113
+ <div
114
+ className="rounded-md border border-border bg-muted/30 p-3 text-sm"
115
+ data-ai-playground-agent={agent.id}
116
+ >
114
117
  <div className="font-semibold">{agent.label}</div>
115
118
  <p className="mt-1 text-xs text-muted-foreground">{agent.description}</p>
116
119
  <dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
@@ -175,6 +178,77 @@ function buildDebugPromptSections(agent: PlaygroundAgent): AiChatDebugPromptSect
175
178
  return sections
176
179
  }
177
180
 
181
+ type AgentModelResolution = {
182
+ agentId: string
183
+ providerId: string
184
+ modelId: string
185
+ baseURL: string | null
186
+ source: string
187
+ }
188
+
189
+ type SettingsAgentResolutionResponse = {
190
+ agents: AgentModelResolution[]
191
+ }
192
+
193
+ async function fetchAgentResolutions(): Promise<SettingsAgentResolutionResponse> {
194
+ const result = await apiCall<SettingsAgentResolutionResponse>('/api/ai_assistant/settings')
195
+ if (!result.ok || !result.result) return { agents: [] }
196
+ return { agents: result.result.agents ?? [] }
197
+ }
198
+
199
+ function ModelResolutionPanel({ agentId }: { agentId: string }) {
200
+ const t = useT()
201
+ const { data } = useQuery({
202
+ queryKey: ['ai_assistant', 'settings', 'agents'],
203
+ queryFn: fetchAgentResolutions,
204
+ staleTime: 30000,
205
+ })
206
+
207
+ const resolution = data?.agents.find((agent) => agent.agentId === agentId)
208
+ if (!resolution) return null
209
+
210
+ return (
211
+ <dl
212
+ className="grid grid-cols-2 gap-x-4 gap-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs sm:grid-cols-4"
213
+ data-ai-playground-model-resolution={agentId}
214
+ data-ai-playground-resolution-panel={agentId}
215
+ >
216
+ <div>
217
+ <dt className="font-medium text-muted-foreground">
218
+ {t('ai_assistant.playground.resolution.provider', 'Provider')}
219
+ </dt>
220
+ <dd className="font-mono" data-ai-playground-resolution-provider>
221
+ {resolution.providerId}
222
+ </dd>
223
+ </div>
224
+ <div>
225
+ <dt className="font-medium text-muted-foreground">
226
+ {t('ai_assistant.playground.resolution.model', 'Model')}
227
+ </dt>
228
+ <dd className="font-mono" data-ai-playground-resolution-model>
229
+ {resolution.modelId}
230
+ </dd>
231
+ </div>
232
+ <div>
233
+ <dt className="font-medium text-muted-foreground">
234
+ {t('ai_assistant.playground.resolution.baseUrl', 'Base URL')}
235
+ </dt>
236
+ <dd className="font-mono" data-ai-playground-resolution-base-url>
237
+ {resolution.baseURL ?? t('ai_assistant.playground.resolution.none', '—')}
238
+ </dd>
239
+ </div>
240
+ <div>
241
+ <dt className="font-medium text-muted-foreground">
242
+ {t('ai_assistant.playground.resolution.source', 'Source')}
243
+ </dt>
244
+ <dd className="font-mono" data-ai-playground-resolution-source>
245
+ {resolution.source}
246
+ </dd>
247
+ </div>
248
+ </dl>
249
+ )
250
+ }
251
+
178
252
  type PlaygroundUiPartSeed = {
179
253
  componentId: string
180
254
  pendingActionId?: string
@@ -243,17 +317,19 @@ function ChatLane({ agent, debug }: { agent: PlaygroundAgent; debug: boolean })
243
317
  }
244
318
 
245
319
  return (
246
- <AiChat
247
- key={agent.id}
248
- agent={agent.id}
249
- pageContext={{ source: 'playground', pageId: 'ai_assistant.playground' }}
250
- debug={debug}
251
- registry={registry}
252
- className="min-h-[360px]"
253
- debugTools={debugTools}
254
- debugPromptSections={debugPromptSections}
255
- uiParts={uiParts}
256
- />
320
+ <div data-ai-playground-chat={agent.id}>
321
+ <AiChat
322
+ key={agent.id}
323
+ agent={agent.id}
324
+ pageContext={{ source: 'playground', pageId: 'ai_assistant.playground' }}
325
+ debug={debug}
326
+ registry={registry}
327
+ className="min-h-96"
328
+ debugTools={debugTools}
329
+ debugPromptSections={debugPromptSections}
330
+ uiParts={uiParts}
331
+ />
332
+ </div>
257
333
  )
258
334
  }
259
335
 
@@ -574,6 +650,7 @@ export function AiPlaygroundPageClient() {
574
650
  </div>
575
651
  </div>
576
652
  {selectedAgent ? <AgentDetails agent={selectedAgent} /> : null}
653
+ {selectedAgent ? <ModelResolutionPanel agentId={selectedAgent.id} /> : null}
577
654
  </section>
578
655
 
579
656
  {selectedAgent ? (
@@ -0,0 +1,23 @@
1
+ import React from 'react'
2
+
3
+ const settingsIcon = React.createElement(
4
+ 'svg',
5
+ { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
6
+ React.createElement('circle', { cx: 12, cy: 12, r: 3 }),
7
+ React.createElement('path', { d: 'M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z' }),
8
+ )
9
+
10
+ export const metadata = {
11
+ requireAuth: true,
12
+ requireFeatures: ['ai_assistant.settings.manage'],
13
+ pageTitle: 'AI Settings',
14
+ pageTitleKey: 'ai_assistant.config.nav.settings',
15
+ pageGroup: 'Module Configs',
16
+ pageGroupKey: 'settings.sections.moduleConfigs',
17
+ pageOrder: 430,
18
+ icon: settingsIcon,
19
+ pageContext: 'settings' as const,
20
+ breadcrumb: [
21
+ { label: 'AI Settings', labelKey: 'ai_assistant.config.nav.settings' },
22
+ ],
23
+ } as const
@@ -0,0 +1,18 @@
1
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
2
+ import { AiAssistantSettingsPageClient } from '../../../../components/AiAssistantSettingsPageClient'
3
+
4
+ /**
5
+ * Canonical AI assistant settings route. Renders the Phase 4b override
6
+ * form + per-agent resolution table. Phase 1780-6 introduced the dedicated
7
+ * `/allowlist` page on top of this one; both routes share the same
8
+ * `ai_assistant.settings.manage` feature gate.
9
+ */
10
+ export default async function AiAssistantSettingsPage() {
11
+ return (
12
+ <Page>
13
+ <PageBody>
14
+ <AiAssistantSettingsPageClient />
15
+ </PageBody>
16
+ </Page>
17
+ )
18
+ }