@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,337 @@
1
+ import {
2
+ AiAgentRuntimeOverrideRepository,
3
+ AiAgentRuntimeOverrideValidationError,
4
+ } from '../AiAgentRuntimeOverrideRepository'
5
+ import { AiAgentRuntimeOverride } from '../../entities'
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Registry mock — only exposes `get` and `list` consumed by the repository.
9
+ // ---------------------------------------------------------------------------
10
+ jest.mock('@open-mercato/shared/lib/ai/llm-provider-registry', () => ({
11
+ llmProviderRegistry: {
12
+ get: (id: string) => {
13
+ const known: Record<string, { id: string }> = {
14
+ anthropic: { id: 'anthropic' },
15
+ openai: { id: 'openai' },
16
+ google: { id: 'google' },
17
+ }
18
+ return known[id] ?? null
19
+ },
20
+ list: () => [{ id: 'anthropic' }, { id: 'openai' }, { id: 'google' }],
21
+ },
22
+ }))
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // In-memory entity manager mock.
26
+ // ---------------------------------------------------------------------------
27
+ type Row = {
28
+ id: string
29
+ tenantId: string
30
+ organizationId: string | null
31
+ agentId: string | null
32
+ providerId: string | null
33
+ modelId: string | null
34
+ baseUrl: string | null
35
+ updatedByUserId: string | null
36
+ createdAt: Date
37
+ updatedAt: Date
38
+ deletedAt: Date | null
39
+ }
40
+
41
+ let idCounter = 0
42
+
43
+ function mockEm() {
44
+ const store: Row[] = []
45
+
46
+ function matchRow(row: Row, filter: Record<string, unknown>): boolean {
47
+ if ('tenantId' in filter && row.tenantId !== filter.tenantId) return false
48
+ if ('organizationId' in filter) {
49
+ const expected = filter.organizationId ?? null
50
+ if ((row.organizationId ?? null) !== expected) return false
51
+ }
52
+ if ('agentId' in filter) {
53
+ const expected = filter.agentId ?? null
54
+ if ((row.agentId ?? null) !== expected) return false
55
+ }
56
+ if ('deletedAt' in filter) {
57
+ const expected = filter.deletedAt ?? null
58
+ if ((row.deletedAt ?? null) !== expected) return false
59
+ }
60
+ return true
61
+ }
62
+
63
+ const em: any = {
64
+ findOne: async (_entity: unknown, where: any) => {
65
+ return store.find((row) => matchRow(row, where)) ?? null
66
+ },
67
+ persist: (row: Row) => {
68
+ em.__pendingPersist = row
69
+ return em
70
+ },
71
+ flush: async () => {
72
+ if (em.__pendingPersist) {
73
+ const existing = store.find((r) => r.id === em.__pendingPersist?.id)
74
+ if (!existing) {
75
+ store.push(em.__pendingPersist as Row)
76
+ } else {
77
+ Object.assign(existing, em.__pendingPersist)
78
+ }
79
+ em.__pendingPersist = null
80
+ }
81
+ },
82
+ create: (_entity: unknown, data: any) => {
83
+ idCounter += 1
84
+ const row: Row = {
85
+ id: `row-${idCounter}`,
86
+ tenantId: data.tenantId,
87
+ organizationId: data.organizationId ?? null,
88
+ agentId: data.agentId ?? null,
89
+ providerId: data.providerId ?? null,
90
+ modelId: data.modelId ?? null,
91
+ baseUrl: data.baseUrl ?? null,
92
+ updatedByUserId: data.updatedByUserId ?? null,
93
+ createdAt: new Date(),
94
+ updatedAt: new Date(),
95
+ deletedAt: data.deletedAt ?? null,
96
+ }
97
+ store.push(row)
98
+ return row
99
+ },
100
+ remove: (row: Row) => {
101
+ em.__pendingRemove = row
102
+ return em
103
+ },
104
+ transactional: async (fn: (tx: any) => Promise<unknown>) => {
105
+ return fn(em)
106
+ },
107
+ __pendingPersist: null as Row | null,
108
+ __pendingRemove: null as Row | null,
109
+ __store: store,
110
+ }
111
+
112
+ return em
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Tests
117
+ // ---------------------------------------------------------------------------
118
+
119
+ describe('AiAgentRuntimeOverrideRepository', () => {
120
+ describe('getDefault', () => {
121
+ it('returns null when tenantId is missing', async () => {
122
+ const em = mockEm()
123
+ const repo = new AiAgentRuntimeOverrideRepository(em)
124
+ const result = await repo.getDefault({ tenantId: '' })
125
+ expect(result).toBeNull()
126
+ })
127
+
128
+ it('returns null when no rows exist', async () => {
129
+ const em = mockEm()
130
+ const repo = new AiAgentRuntimeOverrideRepository(em)
131
+ const result = await repo.getDefault({ tenantId: 't1' })
132
+ expect(result).toBeNull()
133
+ })
134
+
135
+ it('returns agent-specific row over tenant-wide row', async () => {
136
+ const em = mockEm()
137
+ const repo = new AiAgentRuntimeOverrideRepository(em)
138
+ const ctx = { tenantId: 't1', organizationId: null, userId: 'u1' }
139
+
140
+ await repo.upsertDefault({ agentId: null, providerId: 'anthropic' }, ctx)
141
+ await repo.upsertDefault({ agentId: 'catalog.assistant', providerId: 'openai' }, ctx)
142
+
143
+ const result = await repo.getDefault({ tenantId: 't1', agentId: 'catalog.assistant' })
144
+ expect(result?.providerId).toBe('openai')
145
+ })
146
+
147
+ it('falls back to tenant-wide row when no agent-specific row exists', async () => {
148
+ const em = mockEm()
149
+ const repo = new AiAgentRuntimeOverrideRepository(em)
150
+ const ctx = { tenantId: 't1', organizationId: null, userId: 'u1' }
151
+
152
+ await repo.upsertDefault({ agentId: null, providerId: 'google' }, ctx)
153
+
154
+ const result = await repo.getDefault({ tenantId: 't1', agentId: 'customers.assistant' })
155
+ expect(result?.providerId).toBe('google')
156
+ })
157
+
158
+ it('never leaks across tenants (cross-tenant isolation)', async () => {
159
+ const em = mockEm()
160
+ const repo = new AiAgentRuntimeOverrideRepository(em)
161
+
162
+ await repo.upsertDefault(
163
+ { agentId: 'catalog.assistant', providerId: 'anthropic' },
164
+ { tenantId: 'tenant-A', organizationId: null, userId: 'u1' },
165
+ )
166
+
167
+ const resultA = await repo.getDefault({ tenantId: 'tenant-A', agentId: 'catalog.assistant' })
168
+ expect(resultA?.providerId).toBe('anthropic')
169
+
170
+ const resultB = await repo.getDefault({ tenantId: 'tenant-B', agentId: 'catalog.assistant' })
171
+ expect(resultB).toBeNull()
172
+ })
173
+
174
+ it('does not return soft-deleted rows', async () => {
175
+ const em = mockEm()
176
+ const repo = new AiAgentRuntimeOverrideRepository(em)
177
+ const ctx = { tenantId: 't1', organizationId: null, userId: 'u1' }
178
+
179
+ await repo.upsertDefault({ agentId: 'catalog.assistant', providerId: 'openai' }, ctx)
180
+ await repo.clearDefault({ tenantId: 't1', organizationId: null, agentId: 'catalog.assistant' })
181
+
182
+ const result = await repo.getDefault({ tenantId: 't1', agentId: 'catalog.assistant' })
183
+ expect(result).toBeNull()
184
+ })
185
+ })
186
+
187
+ describe('upsertDefault', () => {
188
+ it('inserts a new row on first call', async () => {
189
+ const em = mockEm()
190
+ const repo = new AiAgentRuntimeOverrideRepository(em)
191
+ const ctx = { tenantId: 't1', organizationId: null, userId: 'u1' }
192
+
193
+ const row = await repo.upsertDefault({ agentId: null, providerId: 'anthropic', modelId: 'claude-haiku' }, ctx)
194
+ expect(row.providerId).toBe('anthropic')
195
+ expect(row.modelId).toBe('claude-haiku')
196
+ expect(row.tenantId).toBe('t1')
197
+ })
198
+
199
+ it('updates existing row in place (upsert)', async () => {
200
+ const em = mockEm()
201
+ const repo = new AiAgentRuntimeOverrideRepository(em)
202
+ const ctx = { tenantId: 't1', organizationId: null, userId: 'u1' }
203
+
204
+ await repo.upsertDefault({ agentId: null, providerId: 'anthropic' }, ctx)
205
+ const updated = await repo.upsertDefault({ agentId: null, providerId: 'openai', modelId: 'gpt-5-mini' }, ctx)
206
+
207
+ expect(updated.providerId).toBe('openai')
208
+ expect(updated.modelId).toBe('gpt-5-mini')
209
+ })
210
+
211
+ it('throws for unknown provider id', async () => {
212
+ const em = mockEm()
213
+ const repo = new AiAgentRuntimeOverrideRepository(em)
214
+ const ctx = { tenantId: 't1', organizationId: null, userId: 'u1' }
215
+
216
+ await expect(
217
+ repo.upsertDefault({ agentId: null, providerId: 'unknown-provider' }, ctx),
218
+ ).rejects.toThrow(AiAgentRuntimeOverrideValidationError)
219
+ })
220
+
221
+ it('accepts null providerId (clear the axis)', async () => {
222
+ const em = mockEm()
223
+ const repo = new AiAgentRuntimeOverrideRepository(em)
224
+ const ctx = { tenantId: 't1', organizationId: null, userId: 'u1' }
225
+
226
+ const row = await repo.upsertDefault({ agentId: null, providerId: null, modelId: 'gpt-5-mini' }, ctx)
227
+ expect(row.providerId).toBeNull()
228
+ expect(row.modelId).toBe('gpt-5-mini')
229
+ })
230
+
231
+ it('requires tenantId', async () => {
232
+ const em = mockEm()
233
+ const repo = new AiAgentRuntimeOverrideRepository(em)
234
+
235
+ await expect(
236
+ repo.upsertDefault({ agentId: null }, { tenantId: '', organizationId: null }),
237
+ ).rejects.toThrow(/tenantId/)
238
+ })
239
+ })
240
+
241
+ describe('clearDefault', () => {
242
+ it('returns false when no active row exists', async () => {
243
+ const em = mockEm()
244
+ const repo = new AiAgentRuntimeOverrideRepository(em)
245
+ const cleared = await repo.clearDefault({ tenantId: 't1', organizationId: null })
246
+ expect(cleared).toBe(false)
247
+ })
248
+
249
+ it('soft-deletes the row and returns true', async () => {
250
+ const em = mockEm()
251
+ const repo = new AiAgentRuntimeOverrideRepository(em)
252
+ const ctx = { tenantId: 't1', organizationId: null, userId: 'u1' }
253
+
254
+ await repo.upsertDefault({ agentId: null, providerId: 'anthropic' }, ctx)
255
+
256
+ const cleared = await repo.clearDefault({ tenantId: 't1', organizationId: null })
257
+ expect(cleared).toBe(true)
258
+
259
+ const result = await repo.getDefault({ tenantId: 't1' })
260
+ expect(result).toBeNull()
261
+ })
262
+
263
+ it('returns false when tenantId is missing', async () => {
264
+ const em = mockEm()
265
+ const repo = new AiAgentRuntimeOverrideRepository(em)
266
+ const cleared = await repo.clearDefault({ tenantId: '' })
267
+ expect(cleared).toBe(false)
268
+ })
269
+
270
+ it('only clears the matching (org, agent) row and leaves others untouched', async () => {
271
+ const em = mockEm()
272
+ const repo = new AiAgentRuntimeOverrideRepository(em)
273
+ const ctx = { tenantId: 't1', organizationId: null, userId: 'u1' }
274
+
275
+ await repo.upsertDefault({ agentId: null, providerId: 'anthropic' }, ctx)
276
+ await repo.upsertDefault({ agentId: 'catalog.assistant', providerId: 'openai' }, ctx)
277
+
278
+ await repo.clearDefault({ tenantId: 't1', organizationId: null, agentId: 'catalog.assistant' })
279
+
280
+ const tenantDefault = await repo.getDefault({ tenantId: 't1' })
281
+ expect(tenantDefault?.providerId).toBe('anthropic')
282
+
283
+ const agentRow = await repo.getDefault({ tenantId: 't1', agentId: 'catalog.assistant' })
284
+ expect(agentRow?.providerId).toBe('anthropic')
285
+ })
286
+ })
287
+
288
+ describe('cross-tenant isolation guarantee', () => {
289
+ it('two distinct tenants share no rows', async () => {
290
+ const em = mockEm()
291
+ const repo = new AiAgentRuntimeOverrideRepository(em)
292
+
293
+ await repo.upsertDefault(
294
+ { agentId: null, providerId: 'anthropic', modelId: 'claude-haiku' },
295
+ { tenantId: 'tenant-1', organizationId: null },
296
+ )
297
+ await repo.upsertDefault(
298
+ { agentId: null, providerId: 'openai', modelId: 'gpt-5-mini' },
299
+ { tenantId: 'tenant-2', organizationId: null },
300
+ )
301
+
302
+ const r1 = await repo.getDefault({ tenantId: 'tenant-1' })
303
+ const r2 = await repo.getDefault({ tenantId: 'tenant-2' })
304
+
305
+ expect(r1?.providerId).toBe('anthropic')
306
+ expect(r2?.providerId).toBe('openai')
307
+
308
+ expect(r1?.tenantId).toBe('tenant-1')
309
+ expect(r2?.tenantId).toBe('tenant-2')
310
+ })
311
+
312
+ it('clearing for tenant-1 does not affect tenant-2', async () => {
313
+ const em = mockEm()
314
+ const repo = new AiAgentRuntimeOverrideRepository(em)
315
+
316
+ await repo.upsertDefault(
317
+ { agentId: null, providerId: 'anthropic' },
318
+ { tenantId: 'tenant-1', organizationId: null },
319
+ )
320
+ await repo.upsertDefault(
321
+ { agentId: null, providerId: 'openai' },
322
+ { tenantId: 'tenant-2', organizationId: null },
323
+ )
324
+
325
+ await repo.clearDefault({ tenantId: 'tenant-1', organizationId: null })
326
+
327
+ expect(await repo.getDefault({ tenantId: 'tenant-1' })).toBeNull()
328
+ expect((await repo.getDefault({ tenantId: 'tenant-2' }))?.providerId).toBe('openai')
329
+ })
330
+ })
331
+
332
+ it('returns the AiAgentRuntimeOverride entity type', async () => {
333
+ // Smoke test that the entity class is importable and the mock returns a
334
+ // shaped object the repository trusts to be an entity instance.
335
+ void AiAgentRuntimeOverride
336
+ })
337
+ })
@@ -0,0 +1,181 @@
1
+ import { AiTenantModelAllowlistRepository } from '../AiTenantModelAllowlistRepository'
2
+
3
+ type Row = {
4
+ id: string
5
+ tenantId: string
6
+ organizationId: string | null
7
+ allowedProviders: string[] | null
8
+ allowedModelsByProvider: Record<string, string[]>
9
+ updatedByUserId: string | null
10
+ createdAt: Date
11
+ updatedAt: Date
12
+ deletedAt: Date | null
13
+ }
14
+
15
+ let idCounter = 0
16
+
17
+ function mockEm() {
18
+ const store: Row[] = []
19
+
20
+ function matchRow(row: Row, filter: Record<string, unknown>): boolean {
21
+ if ('tenantId' in filter && row.tenantId !== filter.tenantId) return false
22
+ if ('organizationId' in filter) {
23
+ const expected = filter.organizationId ?? null
24
+ if ((row.organizationId ?? null) !== expected) return false
25
+ }
26
+ if ('deletedAt' in filter) {
27
+ const expected = filter.deletedAt ?? null
28
+ if ((row.deletedAt ?? null) !== expected) return false
29
+ }
30
+ return true
31
+ }
32
+
33
+ const em: any = {
34
+ findOne: async (_entity: unknown, where: any) => store.find((row) => matchRow(row, where)) ?? null,
35
+ persist: (row: Row) => {
36
+ em.__pendingPersist = row
37
+ return em
38
+ },
39
+ flush: async () => {
40
+ if (em.__pendingPersist) {
41
+ const existing = store.find((r) => r.id === em.__pendingPersist?.id)
42
+ if (!existing) {
43
+ store.push(em.__pendingPersist as Row)
44
+ } else {
45
+ Object.assign(existing, em.__pendingPersist)
46
+ }
47
+ em.__pendingPersist = null
48
+ }
49
+ },
50
+ create: (_entity: unknown, data: any) => {
51
+ idCounter += 1
52
+ const row: Row = {
53
+ id: `row-${idCounter}`,
54
+ tenantId: data.tenantId,
55
+ organizationId: data.organizationId ?? null,
56
+ allowedProviders: data.allowedProviders ?? null,
57
+ allowedModelsByProvider: data.allowedModelsByProvider ?? {},
58
+ updatedByUserId: data.updatedByUserId ?? null,
59
+ createdAt: new Date(),
60
+ updatedAt: new Date(),
61
+ deletedAt: data.deletedAt ?? null,
62
+ }
63
+ store.push(row)
64
+ return row
65
+ },
66
+ transactional: async (fn: (tx: any) => Promise<unknown>) => fn(em),
67
+ __pendingPersist: null as Row | null,
68
+ __store: store,
69
+ }
70
+
71
+ return em
72
+ }
73
+
74
+ describe('AiTenantModelAllowlistRepository', () => {
75
+ it('getForTenant returns null when no row exists', async () => {
76
+ const em = mockEm()
77
+ const repo = new AiTenantModelAllowlistRepository(em)
78
+ expect(await repo.getForTenant({ tenantId: 't1' })).toBeNull()
79
+ })
80
+
81
+ it('upsert creates a row with the given snapshot', async () => {
82
+ const em = mockEm()
83
+ const repo = new AiTenantModelAllowlistRepository(em)
84
+ const row = await repo.upsert(
85
+ {
86
+ allowedProviders: ['openai'],
87
+ allowedModelsByProvider: { openai: ['gpt-5-mini'] },
88
+ },
89
+ { tenantId: 't1', organizationId: null, userId: 'u1' },
90
+ )
91
+ expect(row.allowedProviders).toEqual(['openai'])
92
+ expect(row.allowedModelsByProvider).toEqual({ openai: ['gpt-5-mini'] })
93
+ expect(row.updatedByUserId).toBe('u1')
94
+ })
95
+
96
+ it('upsert updates an existing row in place', async () => {
97
+ const em = mockEm()
98
+ const repo = new AiTenantModelAllowlistRepository(em)
99
+ await repo.upsert(
100
+ { allowedProviders: ['openai'], allowedModelsByProvider: {} },
101
+ { tenantId: 't1', userId: 'u1' },
102
+ )
103
+ const updated = await repo.upsert(
104
+ {
105
+ allowedProviders: ['openai', 'anthropic'],
106
+ allowedModelsByProvider: { openai: ['gpt-5-mini'] },
107
+ },
108
+ { tenantId: 't1', userId: 'u2' },
109
+ )
110
+ expect(updated.allowedProviders).toEqual(['openai', 'anthropic'])
111
+ expect(updated.allowedModelsByProvider).toEqual({ openai: ['gpt-5-mini'] })
112
+ expect(updated.updatedByUserId).toBe('u2')
113
+ expect(em.__store.length).toBe(1)
114
+ })
115
+
116
+ it('getSnapshot returns a JSON-friendly snapshot of the persisted row', async () => {
117
+ const em = mockEm()
118
+ const repo = new AiTenantModelAllowlistRepository(em)
119
+ await repo.upsert(
120
+ {
121
+ allowedProviders: ['openai'],
122
+ allowedModelsByProvider: { openai: ['gpt-5-mini'] },
123
+ },
124
+ { tenantId: 't1', userId: 'u1' },
125
+ )
126
+ const snapshot = await repo.getSnapshot({ tenantId: 't1' })
127
+ expect(snapshot).toEqual({
128
+ allowedProviders: ['openai'],
129
+ allowedModelsByProvider: { openai: ['gpt-5-mini'] },
130
+ })
131
+ })
132
+
133
+ it('clear soft-deletes the active row', async () => {
134
+ const em = mockEm()
135
+ const repo = new AiTenantModelAllowlistRepository(em)
136
+ await repo.upsert(
137
+ { allowedProviders: ['openai'], allowedModelsByProvider: {} },
138
+ { tenantId: 't1', userId: 'u1' },
139
+ )
140
+ const cleared = await repo.clear({ tenantId: 't1' })
141
+ expect(cleared).toBe(true)
142
+ const after = await repo.getForTenant({ tenantId: 't1' })
143
+ expect(after).toBeNull()
144
+ })
145
+
146
+ it('clear returns false when no row exists', async () => {
147
+ const em = mockEm()
148
+ const repo = new AiTenantModelAllowlistRepository(em)
149
+ expect(await repo.clear({ tenantId: 't1' })).toBe(false)
150
+ })
151
+
152
+ it('isolates rows across tenants', async () => {
153
+ const em = mockEm()
154
+ const repo = new AiTenantModelAllowlistRepository(em)
155
+ await repo.upsert(
156
+ { allowedProviders: ['openai'], allowedModelsByProvider: {} },
157
+ { tenantId: 't1', userId: 'u1' },
158
+ )
159
+ await repo.upsert(
160
+ { allowedProviders: ['anthropic'], allowedModelsByProvider: {} },
161
+ { tenantId: 't2', userId: 'u2' },
162
+ )
163
+ const t1 = await repo.getSnapshot({ tenantId: 't1' })
164
+ const t2 = await repo.getSnapshot({ tenantId: 't2' })
165
+ expect(t1?.allowedProviders).toEqual(['openai'])
166
+ expect(t2?.allowedProviders).toEqual(['anthropic'])
167
+ })
168
+
169
+ it('null tenantId yields null without touching the store', async () => {
170
+ const em = mockEm()
171
+ const repo = new AiTenantModelAllowlistRepository(em)
172
+ expect(await repo.getForTenant({ tenantId: '' })).toBeNull()
173
+ await expect(
174
+ repo.upsert(
175
+ { allowedProviders: ['openai'], allowedModelsByProvider: {} },
176
+ { tenantId: '', userId: 'u1' },
177
+ ),
178
+ ).rejects.toThrow(/tenantId/)
179
+ expect(em.__store.length).toBe(0)
180
+ })
181
+ })
@@ -215,7 +215,7 @@ function AiAssistantSettingsContent() {
215
215
  )}
216
216
 
217
217
  <p className="text-xs text-muted-foreground mt-3">
218
- Set <code className="font-mono text-overline bg-muted px-1 rounded">OPENCODE_PROVIDER</code> in .env to change provider (anthropic, openai, google).
218
+ Set <code className="font-mono text-overline bg-muted px-1 rounded">OM_AI_PROVIDER</code> in .env to change provider (anthropic, openai, google).
219
219
  </p>
220
220
  </div>
221
221
 
@@ -87,6 +87,31 @@
87
87
  "ai_assistant.agents.tools.subtitle": "In Phase 2 schreibgeschützte Oberfläche. Bearbeitung pro Tool und Mutationsrichtlinie folgen in Schritt 5.4 / Phase 3.",
88
88
  "ai_assistant.agents.tools.title": "Erlaubte Tools",
89
89
  "ai_assistant.agents.tools.tooltipDisabled": "Bearbeitbar, sobald Phase 3 die Mutationsrichtlinien-Steuerung liefert.",
90
+ "ai_assistant.allowlist.actions.clearStored": "Gespeicherte Liste löschen",
91
+ "ai_assistant.allowlist.actions.reset": "Auf ENV-Standard zurücksetzen",
92
+ "ai_assistant.allowlist.actions.save": "Liste speichern",
93
+ "ai_assistant.allowlist.badge.active": "Mandantenregeln aktiv",
94
+ "ai_assistant.allowlist.badge.envOnly": "Nur ENV",
95
+ "ai_assistant.allowlist.clear.error": "Löschen fehlgeschlagen",
96
+ "ai_assistant.allowlist.clear.success": "Mandantenliste gelöscht. Es gilt nur die ENV-Erzwingung.",
97
+ "ai_assistant.allowlist.envBanner.note": "Mandantenauswahl darf die ENV-Liste nicht erweitern — Werte außerhalb sind ausgeblendet.",
98
+ "ai_assistant.allowlist.envBanner.providers": "OM_AI_AVAILABLE_PROVIDERS",
99
+ "ai_assistant.allowlist.envBanner.title": "ENV-Liste ist aktiv",
100
+ "ai_assistant.allowlist.loadError.body": "Bitte Seite neu laden.",
101
+ "ai_assistant.allowlist.loadError.title": "Liste konnte nicht geladen werden",
102
+ "ai_assistant.allowlist.loading": "Lade Liste …",
103
+ "ai_assistant.allowlist.models.default": "Standard",
104
+ "ai_assistant.allowlist.models.help": "Modelle ankreuzen, die Mandanten wählen dürfen. Leer = keine Modellbeschränkung (ENV erben).",
105
+ "ai_assistant.allowlist.navTitle": "AI Allowlist",
106
+ "ai_assistant.allowlist.providers.configured": "konfiguriert",
107
+ "ai_assistant.allowlist.providers.empty": "Keine konfigurierten Anbieter innerhalb der ENV-Liste.",
108
+ "ai_assistant.allowlist.providers.help": "Abwählen, um die Verwendung eines Anbieters für diesen Mandanten zu verbieten. Alle wählen, um ENV zu erben.",
109
+ "ai_assistant.allowlist.providers.notConfigured": "nicht konfiguriert",
110
+ "ai_assistant.allowlist.providers.title": "Anbieter",
111
+ "ai_assistant.allowlist.save.error": "Speichern fehlgeschlagen",
112
+ "ai_assistant.allowlist.save.success": "Liste gespeichert.",
113
+ "ai_assistant.allowlist.subtitle": "Schränke Anbieter und Modelle ein, die für diesen Mandanten verwendet werden dürfen. Die ENV-Liste ist die äußere Beschränkung — die Mandantenauswahl engt sie weiter ein.",
114
+ "ai_assistant.allowlist.title": "Anbieter- & Modell-Allowlist",
90
115
  "ai_assistant.chat.assistantRoleLabel": "Assistent",
91
116
  "ai_assistant.chat.attachFile": "Attach file",
92
117
  "ai_assistant.chat.betaChip": "beta",
@@ -220,7 +245,7 @@
220
245
  "ai_assistant.launcher.hint.launch": "Launch",
221
246
  "ai_assistant.launcher.hint.navigate": "Navigate",
222
247
  "ai_assistant.launcher.searchPlaceholder": "Search assistants...",
223
- "ai_assistant.launcher.setup.body": "KI-Assistenten sind installiert, aber es ist kein Anbieterschlüssel konfiguriert. Lege OPENCODE_PROVIDER und einen passenden API-Schlüssel in deiner .env-Datei fest und starte die App neu.",
248
+ "ai_assistant.launcher.setup.body": "KI-Assistenten sind installiert, aber es ist kein Anbieterschlüssel konfiguriert. Lege OM_AI_PROVIDER und einen passenden API-Schlüssel in deiner .env-Datei fest und starte die App neu.",
224
249
  "ai_assistant.launcher.setup.docs": "Dokumentation zum KI-Assistenten",
225
250
  "ai_assistant.launcher.setup.settingsDocs": "Anbietereinstellungen",
226
251
  "ai_assistant.launcher.setup.title": "Konfiguriere einen KI-Anbieter, um Assistenten zu verwenden",
@@ -244,6 +269,12 @@
244
269
  "ai_assistant.mcp.notGenerated": "Nicht generiert",
245
270
  "ai_assistant.mcp.saveKeyWarning": "Speichern Sie diesen Schlüssel jetzt — er wird nicht erneut angezeigt!",
246
271
  "ai_assistant.mcp.title": "MCP-Konfiguration",
272
+ "ai_assistant.modelPicker.activeBadge": "aktiv",
273
+ "ai_assistant.modelPicker.defaultBadge": "Standard",
274
+ "ai_assistant.modelPicker.defaultLabel": "Modell: Standard",
275
+ "ai_assistant.modelPicker.listAriaLabel": "Verfügbare Modelle",
276
+ "ai_assistant.modelPicker.triggerAriaLabel": "KI-Modell auswählen",
277
+ "ai_assistant.modelPicker.useDefault": "Standard des Agenten verwenden",
247
278
  "ai_assistant.playground.agentPickerLabel": "Agent",
248
279
  "ai_assistant.playground.chat.notSupportedBody": "Wählen Sie einen Agenten, dessen Ausführungsmodus \"chat\" ist, oder wechseln Sie zum Objekt-Modus-Tab.",
249
280
  "ai_assistant.playground.chat.notSupportedTitle": "Chat-Modus ist für diesen Agenten nicht verfügbar.",
@@ -271,6 +302,11 @@
271
302
  "ai_assistant.playground.object.run": "Objekt ausführen",
272
303
  "ai_assistant.playground.object.shortcutHint": "Drücken Sie Cmd/Ctrl+Enter zum Ausführen.",
273
304
  "ai_assistant.playground.refresh": "Agenten aktualisieren",
305
+ "ai_assistant.playground.resolution.baseUrl": "Basis-URL",
306
+ "ai_assistant.playground.resolution.model": "Modell",
307
+ "ai_assistant.playground.resolution.none": "—",
308
+ "ai_assistant.playground.resolution.provider": "Anbieter",
309
+ "ai_assistant.playground.resolution.source": "Quelle",
274
310
  "ai_assistant.playground.retry": "Wiederholen",
275
311
  "ai_assistant.playground.subtitle": "Testen Sie jeden registrierten KI-Agenten End-to-End. Verwenden Sie das Debug-Panel, um Request- und Response-Payloads zu inspizieren, und den Objekt-Modus-Tab, um strukturierte Ausgabe zu betrachten.",
276
312
  "ai_assistant.playground.tabs.chat": "Chat",
@@ -289,6 +325,59 @@
289
325
  "ai_assistant.session.llmInstructions": "LLM-Anweisungen",
290
326
  "ai_assistant.session.title": "Sitzungs-API-Schlüssel",
291
327
  "ai_assistant.session.tokenLabel": "Sitzungstoken",
328
+ "ai_assistant.settings.agentIdColumn": "Agent",
329
+ "ai_assistant.settings.agentOverridesDescription": "Aufgelöstes Modell für jeden registrierten Agenten. Agenten mit einer benutzerdefinierten Überschreibung zeigen eine Löschen-Schaltfläche.",
330
+ "ai_assistant.settings.agentOverridesTitle": "Modellauflösung pro Agent",
331
+ "ai_assistant.settings.clearAgentError": "Agenten-Überschreibung konnte nicht gelöscht werden.",
332
+ "ai_assistant.settings.clearAgentSuccess": "Agenten-Überschreibung gelöscht.",
333
+ "ai_assistant.settings.clearError": "Überschreibung konnte nicht gelöscht werden.",
334
+ "ai_assistant.settings.clearOverride": "Überschreibung löschen",
335
+ "ai_assistant.settings.clearSuccess": "Standard-Modell-Überschreibung gelöscht.",
336
+ "ai_assistant.settings.configured": "Konfiguriert",
337
+ "ai_assistant.settings.connected": "Verbunden",
338
+ "ai_assistant.settings.connecting": "Verbindet...",
339
+ "ai_assistant.settings.connectionsTitle": "Verbindungen",
340
+ "ai_assistant.settings.currentOverride": "Aktuelle Überschreibung:",
341
+ "ai_assistant.settings.defaultOverrideDescription": "Legen Sie einen mandantenweiten Standard-Anbieter und ein Modell fest. Agenten mit einer Überschreibung pro Agent oder einem bestimmten defaultModel haben Vorrang.",
342
+ "ai_assistant.settings.defaultOverrideTitle": "Standard-Modell-Überschreibung",
343
+ "ai_assistant.settings.developerToolsTitle": "Entwicklertools",
344
+ "ai_assistant.settings.disconnected": "Getrennt",
345
+ "ai_assistant.settings.envKeyConfigured": "{{key}} konfiguriert",
346
+ "ai_assistant.settings.envKeyMissing": "{{key}} nicht gesetzt",
347
+ "ai_assistant.settings.generateMcpConfig": "MCP-Konfiguration generieren",
348
+ "ai_assistant.settings.generateSessionKey": "Sitzungsschlüssel generieren",
349
+ "ai_assistant.settings.llmProviderLabel": "LLM-Anbieter:",
350
+ "ai_assistant.settings.loading": "Einstellungen werden geladen...",
351
+ "ai_assistant.settings.mcpAuthLabel": "MCP-Authentifizierung:",
352
+ "ai_assistant.settings.mcpAuthNote": "Erforderlich für den KI-Zugriff auf Plattform-Tools über den MCP-Server.",
353
+ "ai_assistant.settings.mcpConfigDescription": "Konfiguration für Claude Code oder andere MCP-Clients generieren.",
354
+ "ai_assistant.settings.mcpConfigTitle": "MCP-Konfiguration",
355
+ "ai_assistant.settings.mcpKeyConfigured": "MCP_SERVER_API_KEY konfiguriert",
356
+ "ai_assistant.settings.mcpKeyMissing": "MCP_SERVER_API_KEY nicht gesetzt",
357
+ "ai_assistant.settings.mcpToolsCount": "Tools",
358
+ "ai_assistant.settings.mcpToolsTitle": "MCP-Tools",
359
+ "ai_assistant.settings.meilisearchNote": "Meilisearch ist für die API-Endpunkt-Erkennung erforderlich. Endpunkte werden automatisch beim Start des MCP-Servers indiziert.",
360
+ "ai_assistant.settings.modelColumn": "Modell",
361
+ "ai_assistant.settings.modelLabel": "Modell",
362
+ "ai_assistant.settings.notAvailable": "Nicht verfügbar",
363
+ "ai_assistant.settings.notConfigured": "Nicht konfiguriert",
364
+ "ai_assistant.settings.openButton": "KI-Assistent öffnen",
365
+ "ai_assistant.settings.pageDescription": "KI-Assistent konfigurieren und überwachen",
366
+ "ai_assistant.settings.pageTitle": "KI-Assistent-Einstellungen",
367
+ "ai_assistant.settings.providerColumn": "Anbieter",
368
+ "ai_assistant.settings.providerLabel": "Anbieter",
369
+ "ai_assistant.settings.saveError": "Überschreibung konnte nicht gespeichert werden.",
370
+ "ai_assistant.settings.saveOverride": "Überschreibung speichern",
371
+ "ai_assistant.settings.saveSuccess": "Standard-Modell-Überschreibung gespeichert.",
372
+ "ai_assistant.settings.selectModel": "Modell auswählen",
373
+ "ai_assistant.settings.selectProvider": "Anbieter auswählen",
374
+ "ai_assistant.settings.sessionKeyDescription": "Temporären Token für programmatischen LLM-Zugriff generieren. Läuft nach 2 Stunden ab.",
375
+ "ai_assistant.settings.sessionKeyTitle": "Sitzungs-API-Schlüssel",
376
+ "ai_assistant.settings.sourceColumn": "Quelle",
377
+ "ai_assistant.settings.visibilityDisabled": "Im Header ausgeblendet. Aktivieren, um den Button und den Cmd+J-Shortcut anzuzeigen.",
378
+ "ai_assistant.settings.visibilityEnabled": "Im Header sichtbar mit aktiviertem Cmd+J-Shortcut.",
379
+ "ai_assistant.settings.visibilityTitle": "KI-Assistent",
380
+ "ai_assistant.settings.visibilityToggleLabel": "Sichtbarkeit",
292
381
  "ai_assistant.status.analyzing": "Anfrage wird analysiert...",
293
382
  "ai_assistant.status.executing": "Tools werden ausgeführt...",
294
383
  "ai_assistant.status.responding": "Antwort wird erstellt...",