@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,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
- id: 'test-provider',
27
- defaultModel: 'provider-default-model',
28
- resolveApiKey: resolveApiKeyMock,
29
- createModel: createModelMock,
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
- // 200ms is generous; the linear scan typically finishes in <20ms.
425
- expect(elapsed).toBeLessThan(200)
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
  })