@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +82 -18
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +370 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +194 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/route.js +4 -0
- package/dist/modules/ai_assistant/api/ai/agents/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js +169 -5
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/route/route.js +38 -19
- package/dist/modules/ai_assistant/api/route/route.js.map +3 -3
- package/dist/modules/ai_assistant/api/settings/allowlist/route.js +195 -0
- package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/settings/route.js +537 -22
- package/dist/modules/ai_assistant/api/settings/route.js.map +3 -3
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +701 -147
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +338 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js +25 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js +1 -1
- package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +75 -26
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js +25 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js +503 -168
- package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities.js +123 -1
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +157 -0
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +7 -0
- package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js +77 -0
- package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js.map +7 -0
- package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js +1 -1
- package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +90 -1
- package/dist/modules/ai_assistant/i18n/en.json +90 -1
- package/dist/modules/ai_assistant/i18n/es.json +90 -1
- package/dist/modules/ai_assistant/i18n/pl.json +90 -1
- package/dist/modules/ai_assistant/lib/agent-registry.js +17 -1
- package/dist/modules/ai_assistant/lib/agent-registry.js.map +2 -2
- package/dist/modules/ai_assistant/lib/agent-runtime.js +133 -36
- package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
- package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
- package/dist/modules/ai_assistant/lib/baseurl-allowlist.js +29 -0
- package/dist/modules/ai_assistant/lib/baseurl-allowlist.js.map +7 -0
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +4 -1
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
- package/dist/modules/ai_assistant/lib/llm-adapters/google.js +4 -1
- package/dist/modules/ai_assistant/lib/llm-adapters/google.js.map +2 -2
- package/dist/modules/ai_assistant/lib/model-allowlist.js +211 -0
- package/dist/modules/ai_assistant/lib/model-allowlist.js.map +7 -0
- package/dist/modules/ai_assistant/lib/model-factory.js +203 -31
- package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +32 -1
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
- package/dist/modules/ai_assistant/migrations/Migration20260508140000.js +18 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508140000.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260512090000.js +16 -0
- package/dist/modules/ai_assistant/migrations/Migration20260512090000.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260512130000.js +15 -0
- package/dist/modules/ai_assistant/migrations/Migration20260512130000.js.map +7 -0
- package/generated/entities/ai_agent_runtime_override/index.ts +13 -0
- package/generated/entities/ai_tenant_model_allowlist/index.ts +9 -0
- package/generated/entities.ids.generated.ts +2 -0
- package/generated/entity-fields-registry.ts +26 -0
- package/jest.config.cjs +2 -0
- package/package.json +4 -4
- package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +477 -0
- package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +116 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +240 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +251 -0
- package/src/modules/ai_assistant/api/ai/agents/route.ts +4 -0
- package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +273 -0
- package/src/modules/ai_assistant/api/ai/chat/route.ts +211 -2
- package/src/modules/ai_assistant/api/route/route.ts +49 -25
- package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +408 -0
- package/src/modules/ai_assistant/api/settings/allowlist/route.ts +221 -0
- package/src/modules/ai_assistant/api/settings/route.ts +721 -27
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +858 -177
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +458 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.ts +23 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.tsx +12 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/legacy/page.tsx +1 -1
- package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +89 -12
- package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.ts +23 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.tsx +18 -0
- package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +617 -209
- package/src/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.ts +7 -0
- package/src/modules/ai_assistant/data/entities/AiTenantModelAllowlist.ts +2 -0
- package/src/modules/ai_assistant/data/entities.ts +164 -0
- package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +227 -0
- package/src/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.ts +132 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +337 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiTenantModelAllowlistRepository.test.ts +181 -0
- package/src/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.tsx +1 -1
- package/src/modules/ai_assistant/i18n/de.json +90 -1
- package/src/modules/ai_assistant/i18n/en.json +90 -1
- package/src/modules/ai_assistant/i18n/es.json +90 -1
- package/src/modules/ai_assistant/i18n/pl.json +90 -1
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +396 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +60 -6
- package/src/modules/ai_assistant/lib/__tests__/ai-api-operation-runner.test.ts +4 -2
- package/src/modules/ai_assistant/lib/__tests__/baseurl-allowlist.test.ts +75 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +18 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-google.test.ts +18 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +150 -4
- package/src/modules/ai_assistant/lib/__tests__/model-allowlist.test.ts +290 -0
- package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +634 -0
- package/src/modules/ai_assistant/lib/agent-registry.ts +20 -1
- package/src/modules/ai_assistant/lib/agent-runtime.ts +220 -44
- package/src/modules/ai_assistant/lib/ai-agent-definition.ts +48 -0
- package/src/modules/ai_assistant/lib/baseurl-allowlist.ts +64 -0
- package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +11 -1
- package/src/modules/ai_assistant/lib/llm-adapters/google.ts +4 -1
- package/src/modules/ai_assistant/lib/model-allowlist.ts +407 -0
- package/src/modules/ai_assistant/lib/model-factory.ts +486 -58
- package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +44 -0
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +704 -235
- package/src/modules/ai_assistant/migrations/Migration20260508140000.ts +18 -0
- package/src/modules/ai_assistant/migrations/Migration20260512090000.ts +16 -0
- 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,
|