@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,408 @@
1
+ /**
2
+ * Tests for Phase 4a additions to /api/ai_assistant/settings:
3
+ * - PUT /api/ai_assistant/settings — upsert + ACL gate
4
+ * - DELETE /api/ai_assistant/settings — clear + ACL gate
5
+ *
6
+ * The GET handler is tested implicitly via integration; unit testing
7
+ * the existing GET would require mocking many opencode-provider helpers.
8
+ */
9
+
10
+ const authMock = jest.fn()
11
+ const loadAclMock = jest.fn()
12
+ const createRequestContainerMock = jest.fn()
13
+ const upsertDefaultMock = jest.fn()
14
+ const clearDefaultMock = jest.fn()
15
+
16
+ jest.mock('@open-mercato/shared/lib/auth/server', () => ({
17
+ getAuthFromRequest: (...args: unknown[]) => authMock(...args),
18
+ }))
19
+
20
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
21
+ createRequestContainer: (...args: unknown[]) => createRequestContainerMock(...args),
22
+ }))
23
+
24
+ jest.mock('../../../data/repositories/AiAgentRuntimeOverrideRepository', () => {
25
+ return {
26
+ AiAgentRuntimeOverrideRepository: jest.fn().mockImplementation(() => ({
27
+ upsertDefault: upsertDefaultMock,
28
+ clearDefault: clearDefaultMock,
29
+ })),
30
+ AiAgentRuntimeOverrideValidationError: class AiAgentRuntimeOverrideValidationError extends Error {
31
+ constructor(message: string) {
32
+ super(message)
33
+ this.name = 'AiAgentRuntimeOverrideValidationError'
34
+ }
35
+ },
36
+ }
37
+ })
38
+
39
+ const readBaseurlAllowlistMock = jest.fn()
40
+ const isBaseurlAllowlistedMock = jest.fn()
41
+
42
+ jest.mock('../../../lib/baseurl-allowlist', () => ({
43
+ readBaseurlAllowlist: (...args: unknown[]) => readBaseurlAllowlistMock(...args),
44
+ isBaseurlAllowlisted: (...args: unknown[]) => isBaseurlAllowlistedMock(...args),
45
+ }))
46
+
47
+ // The GET handler uses opencode-provider helpers — mock only what is needed
48
+ // to prevent import errors; GET tests are not included here.
49
+ jest.mock('@open-mercato/shared/lib/ai/opencode-provider', () => ({
50
+ OPEN_CODE_PROVIDER_IDS: [],
51
+ OPEN_CODE_PROVIDERS: {},
52
+ getOpenCodeProviderConfiguredEnvKey: () => null,
53
+ isOpenCodeProviderConfigured: () => false,
54
+ resolveOpenCodeModel: () => ({ modelWithProvider: 'gpt-4o-mini' }),
55
+ resolveOpenCodeProviderId: () => 'openai',
56
+ }))
57
+
58
+ jest.mock('@open-mercato/shared/lib/ai/llm-provider-registry', () => ({
59
+ llmProviderRegistry: {
60
+ get: jest.fn(),
61
+ list: jest.fn(() => []),
62
+ },
63
+ }))
64
+
65
+ jest.mock('../../../lib/agent-registry', () => ({
66
+ loadAgentRegistry: jest.fn().mockResolvedValue(undefined),
67
+ listAgents: jest.fn(() => []),
68
+ }))
69
+
70
+ jest.mock('../../../lib/model-factory', () => ({
71
+ createModelFactory: jest.fn(() => ({
72
+ resolveModel: jest.fn(() => ({
73
+ providerId: 'openai',
74
+ modelId: 'gpt-4o-mini',
75
+ baseURL: null,
76
+ source: 'provider_default',
77
+ })),
78
+ })),
79
+ }))
80
+
81
+ import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
82
+ import { GET, PUT, DELETE } from '../route'
83
+
84
+ function buildRequest(method: 'PUT' | 'DELETE', body: unknown): Request {
85
+ return new Request('http://localhost/api/ai_assistant/settings', {
86
+ method,
87
+ body: typeof body === 'string' ? body : JSON.stringify(body),
88
+ headers: { 'content-type': 'application/json' },
89
+ })
90
+ }
91
+
92
+ describe('PUT /api/ai_assistant/settings', () => {
93
+ let consoleErrorSpy: jest.SpyInstance
94
+
95
+ beforeEach(() => {
96
+ jest.clearAllMocks()
97
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
98
+ authMock.mockResolvedValue({ sub: 'user-1', tenantId: 'tenant-1', orgId: 'org-1' })
99
+ loadAclMock.mockResolvedValue({ features: ['ai_assistant.settings.manage'], isSuperAdmin: false })
100
+ createRequestContainerMock.mockResolvedValue({
101
+ resolve: (name: string) => {
102
+ if (name === 'rbacService') return { loadAcl: loadAclMock }
103
+ if (name === 'em') return {}
104
+ return null
105
+ },
106
+ })
107
+ upsertDefaultMock.mockResolvedValue({
108
+ id: 'row-1',
109
+ tenantId: 'tenant-1',
110
+ organizationId: 'org-1',
111
+ agentId: null,
112
+ providerId: 'openai',
113
+ modelId: 'gpt-5-mini',
114
+ baseUrl: null,
115
+ updatedAt: new Date('2026-05-08T00:00:00Z'),
116
+ })
117
+ readBaseurlAllowlistMock.mockReturnValue(['openrouter.ai'])
118
+ isBaseurlAllowlistedMock.mockReturnValue(true)
119
+ })
120
+
121
+ afterEach(() => {
122
+ consoleErrorSpy.mockRestore()
123
+ })
124
+
125
+ it('returns 401 when unauthenticated', async () => {
126
+ authMock.mockResolvedValueOnce(null)
127
+
128
+ const response = await PUT(buildRequest('PUT', { providerId: 'openai' }) as any)
129
+
130
+ expect(response.status).toBe(401)
131
+ })
132
+
133
+ it('returns 403 when caller lacks ai_assistant.settings.manage', async () => {
134
+ loadAclMock.mockResolvedValueOnce({ features: ['ai_assistant.view'], isSuperAdmin: false })
135
+
136
+ const response = await PUT(buildRequest('PUT', { providerId: 'openai' }) as any)
137
+
138
+ expect(response.status).toBe(403)
139
+ const json = await response.json()
140
+ expect(json.code).toBe('forbidden')
141
+ })
142
+
143
+ it('returns 400 with validation_error for invalid body', async () => {
144
+ const response = await PUT(buildRequest('PUT', 'not-json') as any)
145
+
146
+ expect(response.status).toBe(400)
147
+ })
148
+
149
+ it('upserts the override and returns the saved row on success', async () => {
150
+ const response = await PUT(
151
+ buildRequest('PUT', { providerId: 'openai', modelId: 'gpt-5-mini', agentId: null }) as any,
152
+ )
153
+
154
+ expect(response.status).toBe(200)
155
+ const json = await response.json()
156
+ expect(json.providerId).toBe('openai')
157
+ expect(json.modelId).toBe('gpt-5-mini')
158
+ expect(upsertDefaultMock).toHaveBeenCalledWith(
159
+ expect.objectContaining({ providerId: 'openai', modelId: 'gpt-5-mini' }),
160
+ expect.objectContaining({ tenantId: 'tenant-1', organizationId: 'org-1', userId: 'user-1' }),
161
+ )
162
+ })
163
+
164
+ it('saves per-agent chat override allowlist without requiring a model override', async () => {
165
+ upsertDefaultMock.mockResolvedValueOnce({
166
+ id: 'row-1',
167
+ tenantId: 'tenant-1',
168
+ organizationId: 'org-1',
169
+ agentId: 'catalog.catalog_assistant',
170
+ providerId: null,
171
+ modelId: null,
172
+ baseUrl: null,
173
+ allowedOverrideProviders: ['openai'],
174
+ allowedOverrideModelsByProvider: { openai: ['gpt-5-mini'] },
175
+ updatedAt: new Date('2026-05-08T00:00:00Z'),
176
+ })
177
+
178
+ const response = await PUT(
179
+ buildRequest('PUT', {
180
+ agentId: 'catalog.catalog_assistant',
181
+ allowedOverrideProviders: ['openai'],
182
+ allowedOverrideModelsByProvider: { openai: ['gpt-5-mini'] },
183
+ }) as any,
184
+ )
185
+
186
+ expect(response.status).toBe(200)
187
+ const json = await response.json()
188
+ expect(json.allowedOverrideProviders).toEqual(['openai'])
189
+ expect(json.allowedOverrideModelsByProvider).toEqual({ openai: ['gpt-5-mini'] })
190
+ expect(upsertDefaultMock).toHaveBeenCalledWith(
191
+ expect.objectContaining({
192
+ agentId: 'catalog.catalog_assistant',
193
+ allowedOverrideProviders: ['openai'],
194
+ allowedOverrideModelsByProvider: { openai: ['gpt-5-mini'] },
195
+ }),
196
+ expect.objectContaining({ tenantId: 'tenant-1', organizationId: 'org-1', userId: 'user-1' }),
197
+ )
198
+ })
199
+
200
+ it('saves per-agent chat override allowlist with inherited providers and no provider override', async () => {
201
+ upsertDefaultMock.mockResolvedValueOnce({
202
+ id: 'row-1',
203
+ tenantId: 'tenant-1',
204
+ organizationId: 'org-1',
205
+ agentId: 'catalog.catalog_assistant',
206
+ providerId: null,
207
+ modelId: null,
208
+ baseUrl: null,
209
+ allowedOverrideProviders: null,
210
+ allowedOverrideModelsByProvider: {},
211
+ updatedAt: new Date('2026-05-08T00:00:00Z'),
212
+ })
213
+
214
+ const response = await PUT(
215
+ buildRequest('PUT', {
216
+ agentId: 'catalog.catalog_assistant',
217
+ allowedOverrideProviders: null,
218
+ allowedOverrideModelsByProvider: {},
219
+ }) as any,
220
+ )
221
+
222
+ expect(response.status).toBe(200)
223
+ expect(upsertDefaultMock).toHaveBeenCalledWith(
224
+ expect.objectContaining({
225
+ agentId: 'catalog.catalog_assistant',
226
+ allowedOverrideProviders: null,
227
+ allowedOverrideModelsByProvider: {},
228
+ }),
229
+ expect.objectContaining({ tenantId: 'tenant-1', organizationId: 'org-1', userId: 'user-1' }),
230
+ )
231
+ })
232
+
233
+ it('requires agentId when saving per-agent chat override allowlist', async () => {
234
+ const response = await PUT(
235
+ buildRequest('PUT', { allowedOverrideProviders: ['openai'] }) as any,
236
+ )
237
+
238
+ expect(response.status).toBe(400)
239
+ const json = await response.json()
240
+ expect(json.code).toBe('agent_required')
241
+ expect(upsertDefaultMock).not.toHaveBeenCalled()
242
+ })
243
+
244
+ it('returns 400 baseurl_not_allowlisted when baseURL fails allowlist check', async () => {
245
+ isBaseurlAllowlistedMock.mockReturnValue(false)
246
+
247
+ const response = await PUT(
248
+ buildRequest('PUT', { baseURL: 'https://evil.example.com/v1' }) as any,
249
+ )
250
+
251
+ expect(response.status).toBe(400)
252
+ const json = await response.json()
253
+ expect(json.code).toBe('baseurl_not_allowlisted')
254
+ expect(upsertDefaultMock).not.toHaveBeenCalled()
255
+ })
256
+
257
+ it('returns 400 provider_unknown when upsert throws AiAgentRuntimeOverrideValidationError', async () => {
258
+ const { AiAgentRuntimeOverrideValidationError } = await import('../../../data/repositories/AiAgentRuntimeOverrideRepository')
259
+ upsertDefaultMock.mockRejectedValueOnce(
260
+ new AiAgentRuntimeOverrideValidationError('Unknown provider id "unknown"'),
261
+ )
262
+
263
+ const response = await PUT(buildRequest('PUT', { providerId: 'unknown' }) as any)
264
+
265
+ expect(response.status).toBe(400)
266
+ const json = await response.json()
267
+ expect(json.code).toBe('provider_unknown')
268
+ })
269
+
270
+ it('allows superAdmin even without the manage feature', async () => {
271
+ loadAclMock.mockResolvedValueOnce({ features: [], isSuperAdmin: true })
272
+
273
+ const response = await PUT(buildRequest('PUT', { providerId: 'openai' }) as any)
274
+
275
+ expect(response.status).toBe(200)
276
+ })
277
+ })
278
+
279
+ describe('GET /api/ai_assistant/settings', () => {
280
+ let consoleWarnSpy: jest.SpyInstance
281
+ let originalEnv: NodeJS.ProcessEnv
282
+
283
+ beforeEach(() => {
284
+ jest.clearAllMocks()
285
+ originalEnv = { ...process.env }
286
+ consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
287
+ authMock.mockResolvedValue({ sub: 'user-1', tenantId: null, orgId: null })
288
+ createRequestContainerMock.mockResolvedValue({ resolve: () => null })
289
+ })
290
+
291
+ afterEach(() => {
292
+ process.env = originalEnv
293
+ consoleWarnSpy.mockRestore()
294
+ })
295
+
296
+ it('does not run LM Studio slashy model ids through the legacy OpenCode model parser', async () => {
297
+ process.env.OM_AI_PROVIDER = 'lm_studio'
298
+ process.env.OM_AI_MODEL = 'qwen/qwen3.5-9b'
299
+ process.env.OM_AI_AVAILABLE_PROVIDERS = 'openai,lm_studio'
300
+ process.env.OM_AI_AVAILABLE_MODELS_LM_STUDIO = 'qwen/qwen3.5-9b'
301
+ process.env.LM_STUDIO_API_KEY = 'lm-studio'
302
+
303
+ const provider = {
304
+ id: 'lm-studio',
305
+ name: 'LM Studio (local)',
306
+ defaultModel: '',
307
+ defaultModels: [],
308
+ isConfigured: jest.fn(() => true),
309
+ resolveApiKey: jest.fn(() => 'lm-studio'),
310
+ getConfiguredEnvKey: jest.fn(() => 'LM_STUDIO_API_KEY'),
311
+ createModel: jest.fn(),
312
+ envKeys: ['LM_STUDIO_API_KEY'],
313
+ }
314
+ ;(llmProviderRegistry.list as jest.Mock).mockReturnValue([provider])
315
+ ;(llmProviderRegistry.get as jest.Mock).mockImplementation((id: string) =>
316
+ id === 'lm-studio' ? provider : null,
317
+ )
318
+
319
+ const response = await GET(new Request('http://localhost/api/ai_assistant/settings') as any)
320
+ const json = await response.json()
321
+
322
+ expect(response.status).toBe(200)
323
+ expect(json.provider.id).toBe('lm-studio')
324
+ expect(json.provider.model).toBe('lm-studio/qwen/qwen3.5-9b')
325
+ expect(json.allowlistProviders[0].id).toBe('lm-studio')
326
+ expect(json.allowlistProviders[0].defaultModel).toBe('qwen/qwen3.5-9b')
327
+ expect(json.allowlistProviders[0].defaultModels).toEqual([
328
+ { id: 'qwen/qwen3.5-9b', name: 'qwen/qwen3.5-9b' },
329
+ ])
330
+ })
331
+ })
332
+
333
+ describe('DELETE /api/ai_assistant/settings', () => {
334
+ let consoleErrorSpy: jest.SpyInstance
335
+
336
+ beforeEach(() => {
337
+ jest.clearAllMocks()
338
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
339
+ authMock.mockResolvedValue({ sub: 'user-1', tenantId: 'tenant-1', orgId: 'org-1' })
340
+ loadAclMock.mockResolvedValue({ features: ['ai_assistant.settings.manage'], isSuperAdmin: false })
341
+ createRequestContainerMock.mockResolvedValue({
342
+ resolve: (name: string) => {
343
+ if (name === 'rbacService') return { loadAcl: loadAclMock }
344
+ if (name === 'em') return {}
345
+ return null
346
+ },
347
+ })
348
+ clearDefaultMock.mockResolvedValue(true)
349
+ })
350
+
351
+ afterEach(() => {
352
+ consoleErrorSpy.mockRestore()
353
+ })
354
+
355
+ it('returns 401 when unauthenticated', async () => {
356
+ authMock.mockResolvedValueOnce(null)
357
+
358
+ const response = await DELETE(buildRequest('DELETE', {}) as any)
359
+
360
+ expect(response.status).toBe(401)
361
+ })
362
+
363
+ it('returns 403 when caller lacks ai_assistant.settings.manage', async () => {
364
+ loadAclMock.mockResolvedValueOnce({ features: ['ai_assistant.view'], isSuperAdmin: false })
365
+
366
+ const response = await DELETE(buildRequest('DELETE', {}) as any)
367
+
368
+ expect(response.status).toBe(403)
369
+ const json = await response.json()
370
+ expect(json.code).toBe('forbidden')
371
+ })
372
+
373
+ it('clears the tenant-wide override and returns { cleared: true }', async () => {
374
+ const response = await DELETE(buildRequest('DELETE', {}) as any)
375
+
376
+ expect(response.status).toBe(200)
377
+ const json = await response.json()
378
+ expect(json.cleared).toBe(true)
379
+ expect(clearDefaultMock).toHaveBeenCalledWith({
380
+ tenantId: 'tenant-1',
381
+ organizationId: 'org-1',
382
+ agentId: null,
383
+ })
384
+ })
385
+
386
+ it('clears an agent-specific override when agentId is given', async () => {
387
+ const response = await DELETE(buildRequest('DELETE', { agentId: 'customers.assistant' }) as any)
388
+
389
+ expect(response.status).toBe(200)
390
+ const json = await response.json()
391
+ expect(json.cleared).toBe(true)
392
+ expect(clearDefaultMock).toHaveBeenCalledWith({
393
+ tenantId: 'tenant-1',
394
+ organizationId: 'org-1',
395
+ agentId: 'customers.assistant',
396
+ })
397
+ })
398
+
399
+ it('returns { cleared: false } when no active row was found (idempotent)', async () => {
400
+ clearDefaultMock.mockResolvedValueOnce(false)
401
+
402
+ const response = await DELETE(buildRequest('DELETE', {}) as any)
403
+
404
+ expect(response.status).toBe(200)
405
+ const json = await response.json()
406
+ expect(json.cleared).toBe(false)
407
+ })
408
+ })
@@ -0,0 +1,221 @@
1
+ import { NextResponse, type NextRequest } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
+ import type { EntityManager } from '@mikro-orm/postgresql'
7
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
8
+ import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
9
+ import { AiTenantModelAllowlistRepository } from '../../../data/repositories/AiTenantModelAllowlistRepository'
10
+ import {
11
+ canonicalProviderId,
12
+ isModelAllowedForProvider,
13
+ isProviderAllowed,
14
+ modelAllowlistEnvVarName,
15
+ } from '../../../lib/model-allowlist'
16
+
17
+ const allowlistUpsertSchema = z.object({
18
+ allowedProviders: z.array(z.string().min(1).max(64)).nullable().optional(),
19
+ allowedModelsByProvider: z
20
+ .record(z.string().min(1).max(64), z.array(z.string().min(1).max(256)))
21
+ .optional(),
22
+ })
23
+
24
+ export type AllowlistUpsertBody = z.infer<typeof allowlistUpsertSchema>
25
+
26
+ export const openApi: OpenApiRouteDoc = {
27
+ tag: 'AI Assistant',
28
+ summary: 'AI assistant tenant allowlist',
29
+ methods: {
30
+ PUT: {
31
+ summary: 'Upsert per-tenant AI provider/model allowlist',
32
+ description:
33
+ 'Persists the per-tenant allowlist of providers and models. The runtime intersects this with the env allowlist (`OM_AI_AVAILABLE_*`) at resolution time. ' +
34
+ 'Tenant values that fall outside the env allowlist are rejected with `provider_not_in_env_allowlist` / `model_not_in_env_allowlist` 400 codes. ' +
35
+ 'Gated by `ai_assistant.settings.manage`.',
36
+ requestBody: {
37
+ contentType: 'application/json',
38
+ description:
39
+ 'Allowlist payload. `allowedProviders: null` clears tenant provider restriction (inherit env). Missing key in `allowedModelsByProvider` inherits env for that provider.',
40
+ schema: allowlistUpsertSchema,
41
+ },
42
+ responses: [
43
+ { status: 200, description: 'Allowlist saved. Returns the saved snapshot.' },
44
+ ],
45
+ errors: [
46
+ { status: 400, description: 'Validation error or values outside env allowlist.' },
47
+ { status: 401, description: 'Unauthenticated.' },
48
+ { status: 403, description: 'Caller lacks ai_assistant.settings.manage.' },
49
+ ],
50
+ },
51
+ DELETE: {
52
+ summary: 'Clear per-tenant AI provider/model allowlist',
53
+ description:
54
+ 'Soft-deletes the tenant allowlist row. Tenant overrides revert to env-only enforcement. Idempotent — returns `{ cleared: false }` when no active row existed.',
55
+ responses: [
56
+ { status: 200, description: 'Returns `{ cleared: boolean }`.' },
57
+ ],
58
+ errors: [
59
+ { status: 401, description: 'Unauthenticated.' },
60
+ { status: 403, description: 'Caller lacks ai_assistant.settings.manage.' },
61
+ ],
62
+ },
63
+ },
64
+ }
65
+
66
+ export const metadata = {
67
+ PUT: { requireAuth: true, requireFeatures: ['ai_assistant.settings.manage'] },
68
+ DELETE: { requireAuth: true, requireFeatures: ['ai_assistant.settings.manage'] },
69
+ }
70
+
71
+ export async function PUT(req: NextRequest) {
72
+ const auth = await getAuthFromRequest(req)
73
+ if (!auth?.sub) {
74
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
75
+ }
76
+
77
+ let parsedBody: unknown
78
+ try {
79
+ parsedBody = await req.json()
80
+ } catch {
81
+ return NextResponse.json(
82
+ { error: 'Request body must be valid JSON.', code: 'validation_error' },
83
+ { status: 400 },
84
+ )
85
+ }
86
+
87
+ const bodyResult = allowlistUpsertSchema.safeParse(parsedBody)
88
+ if (!bodyResult.success) {
89
+ return NextResponse.json(
90
+ { error: 'Invalid request body.', code: 'validation_error', issues: bodyResult.error.issues },
91
+ { status: 400 },
92
+ )
93
+ }
94
+
95
+ const env = process.env as Record<string, string | undefined>
96
+ const knownProviderIds = llmProviderRegistry.list().map((provider) => provider.id)
97
+ const canonicalize = (providerId: string) =>
98
+ canonicalProviderId(providerId, knownProviderIds) ?? providerId
99
+ const allowedProviders = bodyResult.data.allowedProviders === undefined
100
+ ? null
101
+ : bodyResult.data.allowedProviders?.map(canonicalize) ?? null
102
+ const allowedModelsByProvider = Object.fromEntries(
103
+ Object.entries(bodyResult.data.allowedModelsByProvider ?? {}).map(([providerId, models]) => [
104
+ canonicalize(providerId),
105
+ models,
106
+ ]),
107
+ )
108
+
109
+ // Reject tenant entries that escape the env allowlist. The runtime would
110
+ // intersect them away anyway; failing fast at write time keeps stored
111
+ // tenant snapshots honest and visible in admin audits.
112
+ if (Array.isArray(allowedProviders)) {
113
+ for (const providerId of allowedProviders) {
114
+ if (!isProviderAllowed(env, providerId)) {
115
+ return NextResponse.json(
116
+ {
117
+ error: `Provider "${providerId}" is not in OM_AI_AVAILABLE_PROVIDERS; tenant allowlist may not widen the env allowlist.`,
118
+ code: 'provider_not_in_env_allowlist',
119
+ },
120
+ { status: 400 },
121
+ )
122
+ }
123
+ }
124
+ }
125
+ for (const providerId of Object.keys(allowedModelsByProvider)) {
126
+ if (!isProviderAllowed(env, providerId)) {
127
+ return NextResponse.json(
128
+ {
129
+ error: `Provider "${providerId}" is not in OM_AI_AVAILABLE_PROVIDERS; cannot save tenant model allowlist for it.`,
130
+ code: 'provider_not_in_env_allowlist',
131
+ },
132
+ { status: 400 },
133
+ )
134
+ }
135
+ for (const modelId of allowedModelsByProvider[providerId] ?? []) {
136
+ if (!isModelAllowedForProvider(env, providerId, modelId)) {
137
+ return NextResponse.json(
138
+ {
139
+ error: `Model "${modelId}" is not in ${modelAllowlistEnvVarName(providerId)}; tenant allowlist may not widen the env allowlist.`,
140
+ code: 'model_not_in_env_allowlist',
141
+ },
142
+ { status: 400 },
143
+ )
144
+ }
145
+ }
146
+ }
147
+
148
+ try {
149
+ const container = await createRequestContainer()
150
+ const rbacService = container.resolve<RbacService>('rbacService')
151
+ const acl = await rbacService.loadAcl(auth.sub, {
152
+ tenantId: auth.tenantId,
153
+ organizationId: auth.orgId,
154
+ })
155
+ const canManage =
156
+ acl.isSuperAdmin || acl.features.includes('ai_assistant.settings.manage')
157
+ if (!canManage) {
158
+ return NextResponse.json({ error: 'Forbidden', code: 'forbidden' }, { status: 403 })
159
+ }
160
+
161
+ const em = container.resolve<EntityManager>('em')
162
+ const repo = new AiTenantModelAllowlistRepository(em)
163
+ const row = await repo.upsert(
164
+ { allowedProviders, allowedModelsByProvider },
165
+ {
166
+ tenantId: auth.tenantId ?? '',
167
+ organizationId: auth.orgId ?? null,
168
+ userId: auth.sub,
169
+ },
170
+ )
171
+ return NextResponse.json({
172
+ id: row.id,
173
+ tenantId: row.tenantId,
174
+ organizationId: row.organizationId,
175
+ allowedProviders: row.allowedProviders ?? null,
176
+ allowedModelsByProvider: row.allowedModelsByProvider ?? {},
177
+ updatedAt: row.updatedAt,
178
+ })
179
+ } catch (error) {
180
+ console.error('[AI Settings Allowlist] PUT error:', error)
181
+ return NextResponse.json(
182
+ { error: 'Failed to save tenant allowlist.' },
183
+ { status: 500 },
184
+ )
185
+ }
186
+ }
187
+
188
+ export async function DELETE(req: NextRequest) {
189
+ const auth = await getAuthFromRequest(req)
190
+ if (!auth?.sub) {
191
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
192
+ }
193
+
194
+ try {
195
+ const container = await createRequestContainer()
196
+ const rbacService = container.resolve<RbacService>('rbacService')
197
+ const acl = await rbacService.loadAcl(auth.sub, {
198
+ tenantId: auth.tenantId,
199
+ organizationId: auth.orgId,
200
+ })
201
+ const canManage =
202
+ acl.isSuperAdmin || acl.features.includes('ai_assistant.settings.manage')
203
+ if (!canManage) {
204
+ return NextResponse.json({ error: 'Forbidden', code: 'forbidden' }, { status: 403 })
205
+ }
206
+
207
+ const em = container.resolve<EntityManager>('em')
208
+ const repo = new AiTenantModelAllowlistRepository(em)
209
+ const cleared = await repo.clear({
210
+ tenantId: auth.tenantId ?? '',
211
+ organizationId: auth.orgId ?? null,
212
+ })
213
+ return NextResponse.json({ cleared })
214
+ } catch (error) {
215
+ console.error('[AI Settings Allowlist] DELETE error:', error)
216
+ return NextResponse.json(
217
+ { error: 'Failed to clear tenant allowlist.' },
218
+ { status: 500 },
219
+ )
220
+ }
221
+ }