@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,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 4a unit tests — per-turn tenant override hydration in agent-runtime.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that `resolveRuntimeModelOverride` is called once per turn (both
|
|
5
|
+
* `runAiAgentText` and `runAiAgentObject`), that its return value is threaded
|
|
6
|
+
* into `createModelFactory.resolveModel`, and that a repository failure falls
|
|
7
|
+
* open (warn + fall through) without failing the chat turn.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AiAgentDefinition } from '../ai-agent-definition'
|
|
11
|
+
|
|
12
|
+
const streamTextMock = jest.fn()
|
|
13
|
+
const convertToModelMessagesMock = jest.fn((messages: unknown) => messages)
|
|
14
|
+
const generateObjectMock = jest.fn()
|
|
15
|
+
const stepCountIsMock = jest.fn((count: number) => ({ __stopWhen: 'stepCount', count }))
|
|
16
|
+
|
|
17
|
+
jest.mock('ai', () => {
|
|
18
|
+
const actual = jest.requireActual('ai')
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
streamText: (...args: unknown[]) => streamTextMock(...args),
|
|
22
|
+
generateObject: (...args: unknown[]) => generateObjectMock(...args),
|
|
23
|
+
stepCountIs: (...args: unknown[]) => stepCountIsMock(...(args as [number])),
|
|
24
|
+
convertToModelMessages: (...args: unknown[]) => convertToModelMessagesMock(...args),
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const createModelMock = jest.fn(
|
|
29
|
+
(options: { modelId: string }) => ({ id: options.modelId }),
|
|
30
|
+
)
|
|
31
|
+
const resolveApiKeyMock = jest.fn(() => 'test-api-key')
|
|
32
|
+
|
|
33
|
+
jest.mock('@open-mercato/shared/lib/ai/llm-provider-registry', () => ({
|
|
34
|
+
llmProviderRegistry: {
|
|
35
|
+
resolveFirstConfigured: (options?: { order?: readonly string[] }) => {
|
|
36
|
+
const order = options?.order
|
|
37
|
+
if (order && order.includes('tenant-provider')) {
|
|
38
|
+
return {
|
|
39
|
+
id: 'tenant-provider',
|
|
40
|
+
defaultModel: 'tenant-default-model',
|
|
41
|
+
resolveApiKey: resolveApiKeyMock,
|
|
42
|
+
createModel: createModelMock,
|
|
43
|
+
isConfigured: () => true,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
id: 'test-provider',
|
|
48
|
+
defaultModel: 'provider-default-model',
|
|
49
|
+
resolveApiKey: resolveApiKeyMock,
|
|
50
|
+
createModel: createModelMock,
|
|
51
|
+
isConfigured: () => true,
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
get: (id: string) => {
|
|
55
|
+
if (id === 'tenant-provider') {
|
|
56
|
+
return {
|
|
57
|
+
id: 'tenant-provider',
|
|
58
|
+
defaultModel: 'tenant-default-model',
|
|
59
|
+
resolveApiKey: resolveApiKeyMock,
|
|
60
|
+
createModel: createModelMock,
|
|
61
|
+
isConfigured: () => true,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null
|
|
65
|
+
},
|
|
66
|
+
list: () => [],
|
|
67
|
+
},
|
|
68
|
+
}))
|
|
69
|
+
|
|
70
|
+
const getDefaultMock = jest.fn()
|
|
71
|
+
|
|
72
|
+
jest.mock(
|
|
73
|
+
'../../data/repositories/AiAgentRuntimeOverrideRepository',
|
|
74
|
+
() => {
|
|
75
|
+
return {
|
|
76
|
+
AiAgentRuntimeOverrideRepository: jest.fn().mockImplementation(() => ({
|
|
77
|
+
getDefault: getDefaultMock,
|
|
78
|
+
})),
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
import { z } from 'zod'
|
|
84
|
+
import { resetAgentRegistryForTests, seedAgentRegistryForTests } from '../agent-registry'
|
|
85
|
+
import { toolRegistry } from '../tool-registry'
|
|
86
|
+
import { runAiAgentText, runAiAgentObject } from '../agent-runtime'
|
|
87
|
+
|
|
88
|
+
function makeAgent(
|
|
89
|
+
overrides: Partial<AiAgentDefinition> & Pick<AiAgentDefinition, 'id' | 'moduleId'>,
|
|
90
|
+
): AiAgentDefinition {
|
|
91
|
+
return {
|
|
92
|
+
label: `${overrides.id} label`,
|
|
93
|
+
description: `${overrides.id} description`,
|
|
94
|
+
systemPrompt: 'System prompt base.',
|
|
95
|
+
allowedTools: [],
|
|
96
|
+
...overrides,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const baseAuth = {
|
|
101
|
+
tenantId: 'tenant-1',
|
|
102
|
+
organizationId: 'org-1',
|
|
103
|
+
userId: 'user-1',
|
|
104
|
+
features: ['*'],
|
|
105
|
+
isSuperAdmin: true,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const baseMessages = [{ role: 'user' as const, id: 'm1', parts: [{ type: 'text' as const, text: 'hi' }] }]
|
|
109
|
+
|
|
110
|
+
function fakeStreamResult() {
|
|
111
|
+
return {
|
|
112
|
+
toUIMessageStreamResponse: jest.fn(
|
|
113
|
+
() => new Response('streamed', { status: 200, headers: { 'Content-Type': 'text/event-stream' } }),
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function makeFakeEm() {
|
|
119
|
+
return {
|
|
120
|
+
findOne: jest.fn(),
|
|
121
|
+
fork: jest.fn(),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function makeContainer(em: ReturnType<typeof makeFakeEm>) {
|
|
126
|
+
return {
|
|
127
|
+
resolve: jest.fn((key: string) => {
|
|
128
|
+
if (key === 'em') return em
|
|
129
|
+
throw new Error(`Unknown DI key: ${key}`)
|
|
130
|
+
}),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
describe('Phase 4a — runtime model override hydration in agent-runtime', () => {
|
|
135
|
+
let warnSpy: jest.SpyInstance
|
|
136
|
+
|
|
137
|
+
beforeEach(() => {
|
|
138
|
+
jest.clearAllMocks()
|
|
139
|
+
resetAgentRegistryForTests()
|
|
140
|
+
toolRegistry.clear()
|
|
141
|
+
streamTextMock.mockImplementation(() => fakeStreamResult())
|
|
142
|
+
generateObjectMock.mockResolvedValue({ object: { result: 'ok' }, finishReason: 'stop', usage: {} })
|
|
143
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
afterEach(() => {
|
|
147
|
+
warnSpy.mockRestore()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
afterAll(() => {
|
|
151
|
+
resetAgentRegistryForTests()
|
|
152
|
+
toolRegistry.clear()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('runAiAgentText', () => {
|
|
156
|
+
it('calls getDefault exactly once per turn with agentId + tenantId + organizationId', async () => {
|
|
157
|
+
getDefaultMock.mockResolvedValue(null)
|
|
158
|
+
seedAgentRegistryForTests([
|
|
159
|
+
makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
|
|
160
|
+
])
|
|
161
|
+
|
|
162
|
+
const fakeEm = makeFakeEm()
|
|
163
|
+
const container = makeContainer(fakeEm)
|
|
164
|
+
|
|
165
|
+
await runAiAgentText({
|
|
166
|
+
agentId: 'customers.assistant',
|
|
167
|
+
messages: baseMessages as never,
|
|
168
|
+
authContext: baseAuth,
|
|
169
|
+
container: container as never,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
expect(getDefaultMock).toHaveBeenCalledTimes(1)
|
|
173
|
+
expect(getDefaultMock).toHaveBeenCalledWith({
|
|
174
|
+
tenantId: 'tenant-1',
|
|
175
|
+
organizationId: 'org-1',
|
|
176
|
+
agentId: 'customers.assistant',
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('uses model from tenantOverride row when the repo returns one', async () => {
|
|
181
|
+
getDefaultMock.mockResolvedValue({
|
|
182
|
+
providerId: 'tenant-provider',
|
|
183
|
+
modelId: 'tenant-model-from-db',
|
|
184
|
+
baseUrl: null,
|
|
185
|
+
})
|
|
186
|
+
seedAgentRegistryForTests([
|
|
187
|
+
makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
|
|
188
|
+
])
|
|
189
|
+
|
|
190
|
+
const container = makeContainer(makeFakeEm())
|
|
191
|
+
|
|
192
|
+
await runAiAgentText({
|
|
193
|
+
agentId: 'customers.assistant',
|
|
194
|
+
messages: baseMessages as never,
|
|
195
|
+
authContext: baseAuth,
|
|
196
|
+
container: container as never,
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
expect(createModelMock).toHaveBeenCalledWith(
|
|
200
|
+
expect.objectContaining({ modelId: 'tenant-model-from-db' }),
|
|
201
|
+
)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('forwards requestOverride from input through to model factory', async () => {
|
|
205
|
+
getDefaultMock.mockResolvedValue(null)
|
|
206
|
+
seedAgentRegistryForTests([
|
|
207
|
+
makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
|
|
208
|
+
])
|
|
209
|
+
|
|
210
|
+
const container = makeContainer(makeFakeEm())
|
|
211
|
+
|
|
212
|
+
await runAiAgentText({
|
|
213
|
+
agentId: 'customers.assistant',
|
|
214
|
+
messages: baseMessages as never,
|
|
215
|
+
authContext: baseAuth,
|
|
216
|
+
container: container as never,
|
|
217
|
+
requestOverride: { providerId: null, modelId: 'request-override-model', baseURL: null },
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
expect(createModelMock).toHaveBeenCalledWith(
|
|
221
|
+
expect.objectContaining({ modelId: 'request-override-model' }),
|
|
222
|
+
)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('requestOverride wins over tenantOverride when both are present', async () => {
|
|
226
|
+
getDefaultMock.mockResolvedValue({
|
|
227
|
+
providerId: null,
|
|
228
|
+
modelId: 'tenant-model',
|
|
229
|
+
baseUrl: null,
|
|
230
|
+
})
|
|
231
|
+
seedAgentRegistryForTests([
|
|
232
|
+
makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
|
|
233
|
+
])
|
|
234
|
+
|
|
235
|
+
const container = makeContainer(makeFakeEm())
|
|
236
|
+
|
|
237
|
+
await runAiAgentText({
|
|
238
|
+
agentId: 'customers.assistant',
|
|
239
|
+
messages: baseMessages as never,
|
|
240
|
+
authContext: baseAuth,
|
|
241
|
+
container: container as never,
|
|
242
|
+
requestOverride: { providerId: null, modelId: 'request-wins-model', baseURL: null },
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
expect(createModelMock).toHaveBeenCalledWith(
|
|
246
|
+
expect.objectContaining({ modelId: 'request-wins-model' }),
|
|
247
|
+
)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('falls open when the repo throws — warns and continues without override', async () => {
|
|
251
|
+
getDefaultMock.mockRejectedValue(new Error('DB connection failed'))
|
|
252
|
+
seedAgentRegistryForTests([
|
|
253
|
+
makeAgent({ id: 'customers.assistant', moduleId: 'customers', defaultModel: 'agent-default' }),
|
|
254
|
+
])
|
|
255
|
+
|
|
256
|
+
const container = makeContainer(makeFakeEm())
|
|
257
|
+
|
|
258
|
+
const response = await runAiAgentText({
|
|
259
|
+
agentId: 'customers.assistant',
|
|
260
|
+
messages: baseMessages as never,
|
|
261
|
+
authContext: baseAuth,
|
|
262
|
+
container: container as never,
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
expect(response).toBeInstanceOf(Response)
|
|
266
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
267
|
+
expect.stringContaining('Runtime model override lookup failed'),
|
|
268
|
+
expect.anything(),
|
|
269
|
+
)
|
|
270
|
+
expect(createModelMock).toHaveBeenCalledWith(
|
|
271
|
+
expect.objectContaining({ modelId: 'agent-default' }),
|
|
272
|
+
)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('skips getDefault when no container is provided', async () => {
|
|
276
|
+
seedAgentRegistryForTests([
|
|
277
|
+
makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
|
|
278
|
+
])
|
|
279
|
+
|
|
280
|
+
await runAiAgentText({
|
|
281
|
+
agentId: 'customers.assistant',
|
|
282
|
+
messages: baseMessages as never,
|
|
283
|
+
authContext: baseAuth,
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
expect(getDefaultMock).not.toHaveBeenCalled()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('suppresses both overrides when allowRuntimeModelOverride is false', async () => {
|
|
290
|
+
getDefaultMock.mockResolvedValue({
|
|
291
|
+
providerId: null,
|
|
292
|
+
modelId: 'tenant-model-should-be-suppressed',
|
|
293
|
+
baseUrl: null,
|
|
294
|
+
})
|
|
295
|
+
seedAgentRegistryForTests([
|
|
296
|
+
makeAgent({
|
|
297
|
+
id: 'customers.assistant',
|
|
298
|
+
moduleId: 'customers',
|
|
299
|
+
defaultModel: 'pinned-agent-model',
|
|
300
|
+
allowRuntimeModelOverride: false,
|
|
301
|
+
}),
|
|
302
|
+
])
|
|
303
|
+
|
|
304
|
+
const container = makeContainer(makeFakeEm())
|
|
305
|
+
|
|
306
|
+
await runAiAgentText({
|
|
307
|
+
agentId: 'customers.assistant',
|
|
308
|
+
messages: baseMessages as never,
|
|
309
|
+
authContext: baseAuth,
|
|
310
|
+
container: container as never,
|
|
311
|
+
requestOverride: { providerId: null, modelId: 'request-model-should-be-suppressed', baseURL: null },
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// tenantOverride and requestOverride are both skipped; agent default wins
|
|
315
|
+
expect(createModelMock).toHaveBeenCalledWith(
|
|
316
|
+
expect.objectContaining({ modelId: 'pinned-agent-model' }),
|
|
317
|
+
)
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
describe('runAiAgentObject', () => {
|
|
322
|
+
const objectSchema = z.object({ result: z.string() })
|
|
323
|
+
|
|
324
|
+
it('calls getDefault exactly once per turn', async () => {
|
|
325
|
+
getDefaultMock.mockResolvedValue(null)
|
|
326
|
+
seedAgentRegistryForTests([
|
|
327
|
+
makeAgent({ id: 'catalog.extractor', moduleId: 'catalog', executionMode: 'object' }),
|
|
328
|
+
])
|
|
329
|
+
|
|
330
|
+
const container = makeContainer(makeFakeEm())
|
|
331
|
+
|
|
332
|
+
await runAiAgentObject({
|
|
333
|
+
agentId: 'catalog.extractor',
|
|
334
|
+
input: 'extract data',
|
|
335
|
+
authContext: baseAuth,
|
|
336
|
+
container: container as never,
|
|
337
|
+
output: { schemaName: 'ExtractionResult', schema: objectSchema },
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
expect(getDefaultMock).toHaveBeenCalledTimes(1)
|
|
341
|
+
expect(getDefaultMock).toHaveBeenCalledWith({
|
|
342
|
+
tenantId: 'tenant-1',
|
|
343
|
+
organizationId: 'org-1',
|
|
344
|
+
agentId: 'catalog.extractor',
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('forwards requestOverride to model factory in object mode', async () => {
|
|
349
|
+
getDefaultMock.mockResolvedValue(null)
|
|
350
|
+
seedAgentRegistryForTests([
|
|
351
|
+
makeAgent({ id: 'catalog.extractor', moduleId: 'catalog', executionMode: 'object' }),
|
|
352
|
+
])
|
|
353
|
+
|
|
354
|
+
const container = makeContainer(makeFakeEm())
|
|
355
|
+
|
|
356
|
+
await runAiAgentObject({
|
|
357
|
+
agentId: 'catalog.extractor',
|
|
358
|
+
input: 'extract data',
|
|
359
|
+
authContext: baseAuth,
|
|
360
|
+
container: container as never,
|
|
361
|
+
output: { schemaName: 'ExtractionResult', schema: objectSchema },
|
|
362
|
+
requestOverride: { providerId: null, modelId: 'object-request-model', baseURL: null },
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
expect(createModelMock).toHaveBeenCalledWith(
|
|
366
|
+
expect.objectContaining({ modelId: 'object-request-model' }),
|
|
367
|
+
)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('falls open on repo failure in object mode', async () => {
|
|
371
|
+
getDefaultMock.mockRejectedValue(new Error('Table not found'))
|
|
372
|
+
seedAgentRegistryForTests([
|
|
373
|
+
makeAgent({ id: 'catalog.extractor', moduleId: 'catalog', defaultModel: 'catalog-default', executionMode: 'object' }),
|
|
374
|
+
])
|
|
375
|
+
|
|
376
|
+
const container = makeContainer(makeFakeEm())
|
|
377
|
+
|
|
378
|
+
const result = await runAiAgentObject({
|
|
379
|
+
agentId: 'catalog.extractor',
|
|
380
|
+
input: 'extract data',
|
|
381
|
+
authContext: baseAuth,
|
|
382
|
+
container: container as never,
|
|
383
|
+
output: { schemaName: 'ExtractionResult', schema: objectSchema },
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
expect(result.mode).toBe('generate')
|
|
387
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
388
|
+
expect.stringContaining('Runtime model override lookup failed'),
|
|
389
|
+
expect.anything(),
|
|
390
|
+
)
|
|
391
|
+
expect(createModelMock).toHaveBeenCalledWith(
|
|
392
|
+
expect.objectContaining({ modelId: 'catalog-default' }),
|
|
393
|
+
)
|
|
394
|
+
})
|
|
395
|
+
})
|
|
396
|
+
})
|
|
@@ -20,14 +20,44 @@ const createModelMock = jest.fn(
|
|
|
20
20
|
)
|
|
21
21
|
const resolveApiKeyMock = jest.fn(() => 'test-api-key')
|
|
22
22
|
|
|
23
|
+
const openaiCreateModelMock = jest.fn(
|
|
24
|
+
(options: { modelId: string; apiKey: string }) => ({ id: options.modelId, apiKey: options.apiKey, provider: 'openai' }),
|
|
25
|
+
)
|
|
26
|
+
const openaiResolveApiKeyMock = jest.fn(() => 'openai-test-key')
|
|
27
|
+
|
|
23
28
|
jest.mock('@open-mercato/shared/lib/ai/llm-provider-registry', () => ({
|
|
24
29
|
llmProviderRegistry: {
|
|
25
|
-
resolveFirstConfigured: () =>
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
resolveFirstConfigured: (options?: { env?: Record<string, string | undefined>; order?: readonly string[] }) => {
|
|
31
|
+
const order = options?.order
|
|
32
|
+
if (order && order.includes('openai')) {
|
|
33
|
+
return {
|
|
34
|
+
id: 'openai',
|
|
35
|
+
defaultModel: 'gpt-4o-mini',
|
|
36
|
+
resolveApiKey: openaiResolveApiKeyMock,
|
|
37
|
+
createModel: openaiCreateModelMock,
|
|
38
|
+
isConfigured: () => true,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
id: 'test-provider',
|
|
43
|
+
defaultModel: 'provider-default-model',
|
|
44
|
+
resolveApiKey: resolveApiKeyMock,
|
|
45
|
+
createModel: createModelMock,
|
|
46
|
+
isConfigured: () => true,
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
get: (id: string) => {
|
|
50
|
+
if (id === 'openai') {
|
|
51
|
+
return {
|
|
52
|
+
id: 'openai',
|
|
53
|
+
defaultModel: 'gpt-4o-mini',
|
|
54
|
+
resolveApiKey: openaiResolveApiKeyMock,
|
|
55
|
+
createModel: openaiCreateModelMock,
|
|
56
|
+
isConfigured: () => true,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null
|
|
60
|
+
},
|
|
31
61
|
},
|
|
32
62
|
}))
|
|
33
63
|
|
|
@@ -273,6 +303,30 @@ describe('runAiAgentText', () => {
|
|
|
273
303
|
expect(callArg.system).toBe('System prompt base.')
|
|
274
304
|
errorSpy.mockRestore()
|
|
275
305
|
})
|
|
306
|
+
|
|
307
|
+
it('uses openai provider when agent.defaultProvider=openai and anthropic is registration-first', async () => {
|
|
308
|
+
seedAgentRegistryForTests([
|
|
309
|
+
makeAgent({
|
|
310
|
+
id: 'customers.assistant',
|
|
311
|
+
moduleId: 'customers',
|
|
312
|
+
defaultProvider: 'openai',
|
|
313
|
+
defaultModel: 'gpt-5-mini',
|
|
314
|
+
}),
|
|
315
|
+
])
|
|
316
|
+
|
|
317
|
+
await runAiAgentText({
|
|
318
|
+
agentId: 'customers.assistant',
|
|
319
|
+
messages: baseMessages as never,
|
|
320
|
+
authContext: baseAuth,
|
|
321
|
+
container: {} as never,
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
expect(openaiCreateModelMock).toHaveBeenCalledWith(
|
|
325
|
+
expect.objectContaining({ modelId: 'gpt-5-mini' }),
|
|
326
|
+
)
|
|
327
|
+
const callArg = streamTextMock.mock.calls[0][0] as { model: { id: string; provider?: string } }
|
|
328
|
+
expect(callArg.model.provider).toBe('openai')
|
|
329
|
+
})
|
|
276
330
|
})
|
|
277
331
|
|
|
278
332
|
describe('composeSystemPrompt', () => {
|
|
@@ -421,8 +421,10 @@ describe('normalizePath (CodeQL js/polynomial-redos regression)', () => {
|
|
|
421
421
|
const out = normalizePath(huge)
|
|
422
422
|
const elapsed = Date.now() - start
|
|
423
423
|
expect(out).toBe('/')
|
|
424
|
-
//
|
|
425
|
-
|
|
424
|
+
// 1000ms is intentionally loose so slow CI runners don't flake. The linear
|
|
425
|
+
// scan typically finishes in <20ms; the polynomial-backtracking regression
|
|
426
|
+
// would take several seconds, so this budget still catches it.
|
|
427
|
+
expect(elapsed).toBeLessThan(1000)
|
|
426
428
|
})
|
|
427
429
|
|
|
428
430
|
it('only strips trailing slashes — never internal ones', () => {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readBaseurlAllowlist, isBaseurlAllowlisted } from '../baseurl-allowlist'
|
|
2
|
+
|
|
3
|
+
describe('readBaseurlAllowlist', () => {
|
|
4
|
+
it('returns empty array when env var is absent', () => {
|
|
5
|
+
expect(readBaseurlAllowlist({})).toEqual([])
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
it('returns empty array when env var is empty string', () => {
|
|
9
|
+
expect(readBaseurlAllowlist({ AI_RUNTIME_BASEURL_ALLOWLIST: '' })).toEqual([])
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('returns trimmed lowercase entries split by comma', () => {
|
|
13
|
+
expect(
|
|
14
|
+
readBaseurlAllowlist({ AI_RUNTIME_BASEURL_ALLOWLIST: 'openrouter.ai, api.myproxy.io' }),
|
|
15
|
+
).toEqual(['openrouter.ai', 'api.myproxy.io'])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('filters out blank entries from trailing commas', () => {
|
|
19
|
+
expect(
|
|
20
|
+
readBaseurlAllowlist({ AI_RUNTIME_BASEURL_ALLOWLIST: 'openrouter.ai,' }),
|
|
21
|
+
).toEqual(['openrouter.ai'])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('normalises to lowercase', () => {
|
|
25
|
+
expect(
|
|
26
|
+
readBaseurlAllowlist({ AI_RUNTIME_BASEURL_ALLOWLIST: 'OpenRouter.AI' }),
|
|
27
|
+
).toEqual(['openrouter.ai'])
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('isBaseurlAllowlisted', () => {
|
|
32
|
+
it('returns true for empty baseUrl (no override requested)', () => {
|
|
33
|
+
expect(isBaseurlAllowlisted('', [])).toBe(true)
|
|
34
|
+
expect(isBaseurlAllowlisted(' ', [])).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('returns false for non-empty baseUrl when allowlist is empty', () => {
|
|
38
|
+
expect(isBaseurlAllowlisted('https://openrouter.ai/api/v1', [])).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns true for exact hostname match', () => {
|
|
42
|
+
const allowlist = ['openrouter.ai']
|
|
43
|
+
expect(isBaseurlAllowlisted('https://openrouter.ai/api/v1', allowlist)).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns false for non-matching hostname', () => {
|
|
47
|
+
const allowlist = ['openrouter.ai']
|
|
48
|
+
expect(isBaseurlAllowlisted('https://evil.example.com/v1', allowlist)).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns true for wildcard subdomain match', () => {
|
|
52
|
+
const allowlist = ['*.openrouter.ai']
|
|
53
|
+
expect(isBaseurlAllowlisted('https://api.openrouter.ai/v1', allowlist)).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('does not match bare domain against wildcard pattern (*.example.com does not match example.com)', () => {
|
|
57
|
+
const allowlist = ['*.openrouter.ai']
|
|
58
|
+
expect(isBaseurlAllowlisted('https://openrouter.ai/v1', allowlist)).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('matches the first matching pattern in the list', () => {
|
|
62
|
+
const allowlist = ['api.myproxy.io', 'openrouter.ai']
|
|
63
|
+
expect(isBaseurlAllowlisted('https://openrouter.ai/v1', allowlist)).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('returns false when URL does not parse', () => {
|
|
67
|
+
const allowlist = ['openrouter.ai']
|
|
68
|
+
expect(isBaseurlAllowlisted('not-a-url', allowlist)).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('is case-insensitive for hostname comparison', () => {
|
|
72
|
+
const allowlist = ['openrouter.ai']
|
|
73
|
+
expect(isBaseurlAllowlisted('https://OPENROUTER.AI/api/v1', allowlist)).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -69,4 +69,22 @@ describe('AnthropicAdapter', () => {
|
|
|
69
69
|
expect(model).toBeDefined()
|
|
70
70
|
expect(model).not.toBeNull()
|
|
71
71
|
})
|
|
72
|
+
|
|
73
|
+
it('createModel forwards baseURL to createAnthropic without throwing', () => {
|
|
74
|
+
const model = adapter.createModel({
|
|
75
|
+
apiKey: 'sk-ant-test',
|
|
76
|
+
modelId: 'claude-haiku-4-5-20251001',
|
|
77
|
+
baseURL: 'https://gateway.ai.cloudflare.com/v1/account/gateway-name/anthropic',
|
|
78
|
+
})
|
|
79
|
+
expect(model).toBeDefined()
|
|
80
|
+
expect(model).not.toBeNull()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('createModel without baseURL still works (Messages API default)', () => {
|
|
84
|
+
const model = adapter.createModel({
|
|
85
|
+
apiKey: 'sk-ant-test',
|
|
86
|
+
modelId: 'claude-haiku-4-5-20251001',
|
|
87
|
+
})
|
|
88
|
+
expect(model).toBeDefined()
|
|
89
|
+
})
|
|
72
90
|
})
|
|
@@ -68,4 +68,22 @@ describe('GoogleAdapter', () => {
|
|
|
68
68
|
expect(model).toBeDefined()
|
|
69
69
|
expect(model).not.toBeNull()
|
|
70
70
|
})
|
|
71
|
+
|
|
72
|
+
it('createModel forwards baseURL to createGoogleGenerativeAI without throwing', () => {
|
|
73
|
+
const model = adapter.createModel({
|
|
74
|
+
apiKey: 'AIza-test',
|
|
75
|
+
modelId: 'gemini-3-flash',
|
|
76
|
+
baseURL: 'https://generativelanguage-proxy.example.com/v1beta',
|
|
77
|
+
})
|
|
78
|
+
expect(model).toBeDefined()
|
|
79
|
+
expect(model).not.toBeNull()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('createModel without baseURL still works (Google API default)', () => {
|
|
83
|
+
const model = adapter.createModel({
|
|
84
|
+
apiKey: 'AIza-test',
|
|
85
|
+
modelId: 'gemini-3-flash',
|
|
86
|
+
})
|
|
87
|
+
expect(model).toBeDefined()
|
|
88
|
+
})
|
|
71
89
|
})
|