@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,240 @@
1
+ import type { AiAgentDefinition } from '../../../../../../lib/ai-agent-definition'
2
+ import {
3
+ resetAgentRegistryForTests,
4
+ seedAgentRegistryForTests,
5
+ } from '../../../../../../lib/agent-registry'
6
+
7
+ const authMock = jest.fn()
8
+ const loadAclMock = jest.fn()
9
+ const createRequestContainerMock = jest.fn()
10
+ const getSnapshotMock = jest.fn()
11
+ const getDefaultMock = jest.fn()
12
+ const getExactMock = jest.fn()
13
+ const resolveModelMock = jest.fn()
14
+
15
+ jest.mock('@open-mercato/shared/lib/auth/server', () => ({
16
+ getAuthFromRequest: (...args: unknown[]) => authMock(...args),
17
+ }))
18
+
19
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
20
+ createRequestContainer: (...args: unknown[]) => createRequestContainerMock(...args),
21
+ }))
22
+
23
+ jest.mock('@open-mercato/shared/lib/ai/llm-provider-registry', () => {
24
+ const providers = [
25
+ {
26
+ id: 'openai',
27
+ name: 'OpenAI',
28
+ defaultModel: 'gpt-4o',
29
+ defaultModels: [{ id: 'gpt-4o', name: 'GPT-4o' }],
30
+ isConfigured: jest.fn(() => true),
31
+ },
32
+ {
33
+ id: 'lm-studio',
34
+ name: 'LM Studio (local)',
35
+ defaultModel: 'qwen/qwen3.5-9b',
36
+ defaultModels: [{ id: 'qwen/qwen3.5-9b', name: 'qwen/qwen3.5-9b' }],
37
+ isConfigured: jest.fn(() => true),
38
+ },
39
+ ]
40
+ return {
41
+ llmProviderRegistry: {
42
+ list: jest.fn(() => providers),
43
+ get: jest.fn((id: string) => providers.find((provider) => provider.id === id) ?? null),
44
+ },
45
+ }
46
+ })
47
+
48
+ jest.mock('../../../../../../data/repositories/AiTenantModelAllowlistRepository', () => ({
49
+ AiTenantModelAllowlistRepository: jest.fn().mockImplementation(() => ({
50
+ getSnapshot: getSnapshotMock,
51
+ })),
52
+ }))
53
+
54
+ jest.mock('../../../../../../data/repositories/AiAgentRuntimeOverrideRepository', () => ({
55
+ AiAgentRuntimeOverrideRepository: jest.fn().mockImplementation(() => ({
56
+ getDefault: getDefaultMock,
57
+ getExact: getExactMock,
58
+ })),
59
+ }))
60
+
61
+ jest.mock('../../../../../../lib/model-factory', () => ({
62
+ createModelFactory: jest.fn(() => ({
63
+ resolveModel: resolveModelMock,
64
+ })),
65
+ }))
66
+
67
+ import { GET } from '../route'
68
+
69
+ function makeAgent(
70
+ overrides: Partial<AiAgentDefinition> & Pick<AiAgentDefinition, 'id' | 'moduleId'>,
71
+ ): AiAgentDefinition {
72
+ return {
73
+ label: `${overrides.id} label`,
74
+ description: `${overrides.id} description`,
75
+ systemPrompt: 'You are a test agent.',
76
+ allowedTools: [],
77
+ requiredFeatures: ['ai_assistant.view'],
78
+ ...overrides,
79
+ }
80
+ }
81
+
82
+ function buildParams(agentId: string) {
83
+ return { params: Promise.resolve({ agentId }) }
84
+ }
85
+
86
+ describe('GET /api/ai_assistant/ai/agents/[agentId]/models', () => {
87
+ let consoleErrorSpy: jest.SpyInstance
88
+
89
+ beforeEach(() => {
90
+ jest.clearAllMocks()
91
+ resetAgentRegistryForTests()
92
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
93
+ authMock.mockResolvedValue({
94
+ sub: 'user-1',
95
+ tenantId: 'tenant-1',
96
+ orgId: 'org-1',
97
+ })
98
+ loadAclMock.mockResolvedValue({
99
+ features: ['ai_assistant.view'],
100
+ isSuperAdmin: false,
101
+ })
102
+ createRequestContainerMock.mockResolvedValue({
103
+ resolve: (name: string) => {
104
+ if (name === 'rbacService') return { loadAcl: loadAclMock }
105
+ if (name === 'em') return {}
106
+ return null
107
+ },
108
+ })
109
+ getSnapshotMock.mockResolvedValue(null)
110
+ getDefaultMock.mockResolvedValue(null)
111
+ getExactMock.mockResolvedValue(null)
112
+ resolveModelMock.mockReturnValue({
113
+ providerId: 'openai',
114
+ modelId: 'gpt-4o',
115
+ source: 'provider_default',
116
+ model: {},
117
+ })
118
+ })
119
+
120
+ afterEach(() => {
121
+ consoleErrorSpy.mockRestore()
122
+ })
123
+
124
+ afterAll(() => {
125
+ resetAgentRegistryForTests()
126
+ })
127
+
128
+ it('uses the tenant runtime override when resolving the picker default', async () => {
129
+ seedAgentRegistryForTests([
130
+ makeAgent({ id: 'catalog.assistant', moduleId: 'catalog' }),
131
+ ])
132
+ getDefaultMock.mockResolvedValueOnce({
133
+ providerId: 'lm-studio',
134
+ modelId: 'qwen/qwen3.5-9b',
135
+ baseUrl: null,
136
+ })
137
+ resolveModelMock.mockImplementation((input) => ({
138
+ providerId: input.tenantOverride?.providerId ?? 'openai',
139
+ modelId: input.tenantOverride?.modelId ?? 'gpt-4o',
140
+ source: input.tenantOverride ? 'tenant_override' : 'provider_default',
141
+ model: {},
142
+ }))
143
+
144
+ const response = await GET(
145
+ new Request('http://localhost/api/ai_assistant/ai/agents/catalog.assistant/models') as any,
146
+ buildParams('catalog.assistant'),
147
+ )
148
+
149
+ expect(response.status).toBe(200)
150
+ const json = await response.json()
151
+ expect(json.defaultProviderId).toBe('lm-studio')
152
+ expect(json.defaultModelId).toBe('qwen/qwen3.5-9b')
153
+ expect(resolveModelMock).toHaveBeenCalledWith(
154
+ expect.objectContaining({
155
+ tenantOverride: {
156
+ providerId: 'lm-studio',
157
+ modelId: 'qwen/qwen3.5-9b',
158
+ baseURL: null,
159
+ },
160
+ }),
161
+ )
162
+ })
163
+
164
+ it('returns 401 when unauthenticated', async () => {
165
+ authMock.mockResolvedValueOnce(null)
166
+ const response = await GET(
167
+ new Request('http://localhost/api/ai_assistant/ai/agents/catalog.assistant/models') as any,
168
+ buildParams('catalog.assistant'),
169
+ )
170
+ expect(response.status).toBe(401)
171
+ const json = await response.json()
172
+ expect(json.error).toBe('Unauthorized')
173
+ })
174
+
175
+ it('returns 404 when the agent id is unknown', async () => {
176
+ seedAgentRegistryForTests([])
177
+ const response = await GET(
178
+ new Request('http://localhost/api/ai_assistant/ai/agents/unknown.assistant/models') as any,
179
+ buildParams('unknown.assistant'),
180
+ )
181
+ expect(response.status).toBe(404)
182
+ const json = await response.json()
183
+ expect(json.code).toBe('agent_unknown')
184
+ })
185
+
186
+ it('returns 403 when the caller lacks the agent\'s required features', async () => {
187
+ seedAgentRegistryForTests([
188
+ makeAgent({
189
+ id: 'catalog.assistant',
190
+ moduleId: 'catalog',
191
+ requiredFeatures: ['catalog.assistant.use'],
192
+ }),
193
+ ])
194
+ loadAclMock.mockResolvedValueOnce({ features: ['ai_assistant.view'], isSuperAdmin: false })
195
+ const response = await GET(
196
+ new Request('http://localhost/api/ai_assistant/ai/agents/catalog.assistant/models') as any,
197
+ buildParams('catalog.assistant'),
198
+ )
199
+ expect(response.status).toBe(403)
200
+ const json = await response.json()
201
+ expect(json.code).toBe('agent_features_denied')
202
+ })
203
+
204
+ it('returns an empty providers array when allowRuntimeModelOverride is false', async () => {
205
+ seedAgentRegistryForTests([
206
+ makeAgent({
207
+ id: 'catalog.assistant',
208
+ moduleId: 'catalog',
209
+ allowRuntimeModelOverride: false,
210
+ }),
211
+ ])
212
+ const response = await GET(
213
+ new Request('http://localhost/api/ai_assistant/ai/agents/catalog.assistant/models') as any,
214
+ buildParams('catalog.assistant'),
215
+ )
216
+ expect(response.status).toBe(200)
217
+ const json = await response.json()
218
+ expect(json.allowRuntimeModelOverride).toBe(false)
219
+ expect(json.providers).toEqual([])
220
+ })
221
+
222
+ it('clips providers and models against the tenant allowlist intersection', async () => {
223
+ seedAgentRegistryForTests([
224
+ makeAgent({ id: 'catalog.assistant', moduleId: 'catalog' }),
225
+ ])
226
+ getSnapshotMock.mockResolvedValueOnce({
227
+ allowedProviders: ['openai'],
228
+ allowedModelsByProvider: { openai: [] },
229
+ })
230
+ const response = await GET(
231
+ new Request('http://localhost/api/ai_assistant/ai/agents/catalog.assistant/models') as any,
232
+ buildParams('catalog.assistant'),
233
+ )
234
+ expect(response.status).toBe(200)
235
+ const json = await response.json()
236
+ expect(json.providers.map((p: { id: string }) => p.id)).toEqual(['openai'])
237
+ const openaiEntry = json.providers.find((p: { id: string }) => p.id === 'openai')
238
+ expect(openaiEntry?.models ?? []).toEqual([])
239
+ })
240
+ })
@@ -0,0 +1,251 @@
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 type { EntityManager } from '@mikro-orm/postgresql'
5
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
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 { getAgent, loadAgentRegistry } from '../../../../../lib/agent-registry'
10
+ import { hasRequiredFeatures } from '../../../../../lib/auth'
11
+ import { createModelFactory } from '../../../../../lib/model-factory'
12
+ import {
13
+ hasAllowlistSnapshotRestrictions,
14
+ intersectEffectiveAllowlistWithSnapshot,
15
+ intersectAllowlists,
16
+ isModelAllowedForProviderInEffective,
17
+ isProviderAllowedInEffective,
18
+ readAgentRuntimeOverrideAllowlist,
19
+ type TenantAllowlistSnapshot,
20
+ } from '../../../../../lib/model-allowlist'
21
+ import { AiTenantModelAllowlistRepository } from '../../../../../data/repositories/AiTenantModelAllowlistRepository'
22
+ import { AiAgentRuntimeOverrideRepository } from '../../../../../data/repositories/AiAgentRuntimeOverrideRepository'
23
+
24
+ function modelsForPicker(
25
+ provider: ReturnType<typeof llmProviderRegistry.list>[number],
26
+ allowedModelIds: string[] | undefined,
27
+ ): ReadonlyArray<{ id: string; name: string; contextWindow?: number | null; tags?: readonly string[] }> {
28
+ if (provider.defaultModels.length > 0) return provider.defaultModels
29
+ return (allowedModelIds ?? []).map((id) => ({ id, name: id }))
30
+ }
31
+
32
+ const agentIdPattern = /^[a-z0-9_]+\.[a-z0-9_]+$/
33
+
34
+ const agentIdParamSchema = z.object({
35
+ agentId: z
36
+ .string()
37
+ .regex(agentIdPattern, 'agentId must match "<module>.<agent>" (lowercase, digits, underscores only)'),
38
+ })
39
+
40
+ export const openApi: OpenApiRouteDoc = {
41
+ tag: 'AI Assistant',
42
+ summary: 'Available models for an AI agent',
43
+ methods: {
44
+ GET: {
45
+ operationId: 'aiAssistantGetAgentModels',
46
+ summary: 'Get the providers and curated models available for the chat-UI picker for this agent',
47
+ description:
48
+ 'Returns all configured providers with their curated model catalogs, filtered to providers ' +
49
+ 'that have an API key configured in the current environment. When the agent declares ' +
50
+ '`allowRuntimeModelOverride: false`, the response reflects that constraint so the ' +
51
+ 'UI picker can hide itself. Includes the agent\'s resolved default provider/model so ' +
52
+ 'the picker can render a "(default)" badge next to the right entry. ' +
53
+ 'RBAC: requires the same features as the agent itself (typically `ai_assistant.view`).',
54
+ responses: [
55
+ {
56
+ status: 200,
57
+ description:
58
+ 'Providers and curated models available for the agent picker. ' +
59
+ 'Empty `providers` array when `allowRuntimeModelOverride` is false.',
60
+ },
61
+ ],
62
+ errors: [
63
+ { status: 401, description: 'Unauthenticated.' },
64
+ { status: 403, description: 'Caller lacks the agent\'s required features.' },
65
+ { status: 404, description: 'Unknown agent id.' },
66
+ ],
67
+ },
68
+ },
69
+ }
70
+
71
+ export const metadata = {
72
+ GET: { requireAuth: true, requireFeatures: ['ai_assistant.view'] },
73
+ }
74
+
75
+ export async function GET(
76
+ req: NextRequest,
77
+ { params }: { params: Promise<{ agentId: string }> },
78
+ ): Promise<Response> {
79
+ const auth = await getAuthFromRequest(req)
80
+ if (!auth?.sub) {
81
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
82
+ }
83
+
84
+ const rawParams = await params
85
+ const paramResult = agentIdParamSchema.safeParse(rawParams)
86
+ if (!paramResult.success) {
87
+ return NextResponse.json(
88
+ { error: 'Invalid agentId path parameter.', code: 'validation_error', issues: paramResult.error.issues },
89
+ { status: 400 },
90
+ )
91
+ }
92
+ const agentId = paramResult.data.agentId
93
+
94
+ try {
95
+ await loadAgentRegistry()
96
+
97
+ const container = await createRequestContainer()
98
+ const rbacService = container.resolve<RbacService>('rbacService')
99
+ const acl = await rbacService.loadAcl(auth.sub, {
100
+ tenantId: auth.tenantId,
101
+ organizationId: auth.orgId,
102
+ })
103
+
104
+ const agent = getAgent(agentId)
105
+ if (!agent) {
106
+ return NextResponse.json({ error: `Agent "${agentId}" not found.`, code: 'agent_unknown' }, { status: 404 })
107
+ }
108
+
109
+ const agentFeatures = agent.requiredFeatures ?? []
110
+ if (agentFeatures.length > 0) {
111
+ const permitted = hasRequiredFeatures(agentFeatures, acl.features, acl.isSuperAdmin)
112
+ if (!permitted) {
113
+ return NextResponse.json(
114
+ {
115
+ error: `Access to agent "${agentId}" requires features: ${agentFeatures.join(', ')}.`,
116
+ code: 'agent_features_denied',
117
+ },
118
+ { status: 403 },
119
+ )
120
+ }
121
+ }
122
+
123
+ const allowRuntimeModelOverride = agent.allowRuntimeModelOverride !== false
124
+
125
+ // Load the per-tenant allowlist snapshot so the picker reflects both env
126
+ // and admin-edited tenant constraints (Phase 1780-6).
127
+ let tenantAllowlistSnapshot: TenantAllowlistSnapshot | null = null
128
+ let agentRuntimeOverrideAllowlist: TenantAllowlistSnapshot | null = null
129
+ let tenantRuntimeOverride: {
130
+ providerId: string | null
131
+ modelId: string | null
132
+ baseURL: string | null
133
+ } | null = null
134
+ if (auth.tenantId) {
135
+ try {
136
+ const em = container.resolve<EntityManager>('em')
137
+ const allowlistRepo = new AiTenantModelAllowlistRepository(em)
138
+ tenantAllowlistSnapshot = await allowlistRepo.getSnapshot({
139
+ tenantId: auth.tenantId,
140
+ organizationId: auth.orgId ?? null,
141
+ })
142
+ const runtimeOverrideRepo = new AiAgentRuntimeOverrideRepository(em)
143
+ const runtimeOverrideDefaultRow = await runtimeOverrideRepo.getDefault({
144
+ tenantId: auth.tenantId,
145
+ organizationId: auth.orgId ?? null,
146
+ agentId,
147
+ })
148
+ tenantRuntimeOverride = runtimeOverrideDefaultRow
149
+ ? {
150
+ providerId: runtimeOverrideDefaultRow.providerId ?? null,
151
+ modelId: runtimeOverrideDefaultRow.modelId ?? null,
152
+ baseURL: runtimeOverrideDefaultRow.baseUrl ?? null,
153
+ }
154
+ : null
155
+ const runtimeOverrideRow = await runtimeOverrideRepo.getExact({
156
+ tenantId: auth.tenantId,
157
+ organizationId: auth.orgId ?? null,
158
+ agentId,
159
+ })
160
+ const tenantAgentAllowlist = runtimeOverrideRow
161
+ ? {
162
+ allowedProviders: runtimeOverrideRow.allowedOverrideProviders ?? null,
163
+ allowedModelsByProvider: runtimeOverrideRow.allowedOverrideModelsByProvider ?? {},
164
+ }
165
+ : null
166
+ agentRuntimeOverrideAllowlist = hasAllowlistSnapshotRestrictions(tenantAgentAllowlist)
167
+ ? tenantAgentAllowlist
168
+ : null
169
+ } catch (snapshotError) {
170
+ // Picker still renders against env-only so the UI does not break, but log at
171
+ // error level so an outage is operationally visible. The chat dispatcher
172
+ // refuses to dispatch when this lookup fails, so writes stay safe.
173
+ console.error('[AI Agents Models] Failed to load tenant allowlist:', snapshotError)
174
+ }
175
+ }
176
+
177
+ // Resolve the agent's current default provider/model for the "(default)" badge
178
+ const factory = createModelFactory(container)
179
+ const defaultResolution = factory.resolveModel({
180
+ moduleId: agent.moduleId,
181
+ agentDefaultModel: agent.defaultModel,
182
+ agentDefaultProvider: agent.defaultProvider,
183
+ agentDefaultBaseUrl: agent.defaultBaseUrl,
184
+ allowRuntimeModelOverride,
185
+ tenantOverride: tenantRuntimeOverride ?? undefined,
186
+ tenantAllowlist: tenantAllowlistSnapshot,
187
+ })
188
+ const defaultProviderId = defaultResolution.providerId
189
+ const defaultModelId = defaultResolution.modelId
190
+
191
+ // Build provider list — only configured providers, with curated model
192
+ // catalogs, clipped to the EFFECTIVE allowlist (env ∩ tenant) so the
193
+ // chat-UI picker can never offer a value the runtime would refuse.
194
+ const env = process.env as Record<string, string | undefined>
195
+ const knownProviderIds = llmProviderRegistry.list().map((p) => p.id)
196
+ const baseEffectiveAllowlist = intersectAllowlists(
197
+ env,
198
+ knownProviderIds,
199
+ tenantAllowlistSnapshot,
200
+ )
201
+ const envAgentAllowlist = readAgentRuntimeOverrideAllowlist(env, agentId, knownProviderIds)
202
+ const effectiveAllowlist = intersectEffectiveAllowlistWithSnapshot(
203
+ intersectEffectiveAllowlistWithSnapshot(
204
+ baseEffectiveAllowlist,
205
+ knownProviderIds,
206
+ envAgentAllowlist,
207
+ ),
208
+ knownProviderIds,
209
+ agentRuntimeOverrideAllowlist,
210
+ )
211
+ const providers = allowRuntimeModelOverride
212
+ ? llmProviderRegistry.list()
213
+ .filter((provider) => provider.isConfigured())
214
+ .filter((provider) => isProviderAllowedInEffective(effectiveAllowlist, provider.id))
215
+ .map((provider) => {
216
+ const allowedModelIds = effectiveAllowlist.modelsByProvider[provider.id]
217
+ const filteredModels = modelsForPicker(provider, allowedModelIds).filter((model) =>
218
+ isModelAllowedForProviderInEffective(effectiveAllowlist, provider.id, model.id),
219
+ )
220
+ return {
221
+ id: provider.id,
222
+ name: provider.name,
223
+ isDefault: provider.id === defaultProviderId,
224
+ models: filteredModels.map((model) => ({
225
+ id: model.id,
226
+ name: model.name,
227
+ contextWindow: model.contextWindow,
228
+ tags: model.tags,
229
+ isDefault: provider.id === defaultProviderId && model.id === defaultModelId,
230
+ })),
231
+ }
232
+ })
233
+ : []
234
+
235
+ return NextResponse.json({
236
+ agentId,
237
+ allowRuntimeModelOverride,
238
+ defaultProviderId,
239
+ defaultModelId,
240
+ defaultProviderName: llmProviderRegistry.get(defaultProviderId)?.name ?? defaultProviderId,
241
+ defaultModelName:
242
+ llmProviderRegistry
243
+ .get(defaultProviderId)
244
+ ?.defaultModels.find((model) => model.id === defaultModelId)?.name ?? defaultModelId,
245
+ providers,
246
+ })
247
+ } catch (error) {
248
+ console.error('[AI Agents Models] GET error:', error)
249
+ return NextResponse.json({ error: 'Failed to resolve agent models.' }, { status: 500 })
250
+ }
251
+ }
@@ -83,6 +83,10 @@ export async function GET(req: NextRequest) {
83
83
  description: agent.description,
84
84
  systemPrompt: agent.systemPrompt,
85
85
  executionMode: agent.executionMode ?? 'chat',
86
+ defaultProvider: agent.defaultProvider ?? null,
87
+ defaultModel: agent.defaultModel ?? null,
88
+ defaultBaseUrl: agent.defaultBaseUrl ?? null,
89
+ allowRuntimeModelOverride: agent.allowRuntimeModelOverride !== false,
86
90
  mutationPolicy: agent.mutationPolicy ?? 'read-only',
87
91
  readOnly: Boolean(agent.readOnly),
88
92
  maxSteps: agent.maxSteps ?? null,