@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
@@ -24,6 +24,39 @@ jest.mock('../../../../lib/agent-runtime', () => ({
24
24
  runAiAgentText: (...args: unknown[]) => runAiAgentTextMock(...args),
25
25
  }))
26
26
 
27
+ const getMock = jest.fn()
28
+ const listMock = jest.fn()
29
+
30
+ jest.mock('@open-mercato/shared/lib/ai/llm-provider-registry', () => ({
31
+ llmProviderRegistry: {
32
+ get: (...args: unknown[]) => getMock(...args),
33
+ list: (...args: unknown[]) => listMock(...args),
34
+ },
35
+ }))
36
+
37
+ const readBaseurlAllowlistMock = jest.fn()
38
+ const isBaseurlAllowlistedMock = jest.fn()
39
+
40
+ jest.mock('../../../../lib/baseurl-allowlist', () => ({
41
+ readBaseurlAllowlist: (...args: unknown[]) => readBaseurlAllowlistMock(...args),
42
+ isBaseurlAllowlisted: (...args: unknown[]) => isBaseurlAllowlistedMock(...args),
43
+ }))
44
+
45
+ const tenantAllowlistGetSnapshotMock = jest.fn()
46
+ const agentRuntimeOverrideGetExactMock = jest.fn()
47
+
48
+ jest.mock('../../../../data/repositories/AiTenantModelAllowlistRepository', () => ({
49
+ AiTenantModelAllowlistRepository: jest.fn().mockImplementation(() => ({
50
+ getSnapshot: (...args: unknown[]) => tenantAllowlistGetSnapshotMock(...args),
51
+ })),
52
+ }))
53
+
54
+ jest.mock('../../../../data/repositories/AiAgentRuntimeOverrideRepository', () => ({
55
+ AiAgentRuntimeOverrideRepository: jest.fn().mockImplementation(() => ({
56
+ getExact: (...args: unknown[]) => agentRuntimeOverrideGetExactMock(...args),
57
+ })),
58
+ }))
59
+
27
60
  import { POST } from '../route'
28
61
 
29
62
  function makeAgent(
@@ -85,15 +118,23 @@ describe('POST /api/ai/chat', () => {
85
118
  createRequestContainerMock.mockResolvedValue({
86
119
  resolve: (name: string) => {
87
120
  if (name === 'rbacService') return { loadAcl: loadAclMock }
121
+ if (name === 'em') return {}
88
122
  return null
89
123
  },
90
124
  })
125
+ tenantAllowlistGetSnapshotMock.mockResolvedValue(null)
126
+ agentRuntimeOverrideGetExactMock.mockResolvedValue(null)
91
127
  runAiAgentTextMock.mockResolvedValue(
92
128
  new Response('data: {"type":"text","content":"ok"}\n\ndata: [DONE]\n\n', {
93
129
  status: 200,
94
130
  headers: { 'Content-Type': 'text/event-stream' },
95
131
  }),
96
132
  )
133
+ // Phase 4a defaults: provider registry returns a configured provider by default
134
+ getMock.mockReturnValue({ id: 'openai', isConfigured: () => true })
135
+ listMock.mockReturnValue([{ id: 'openai', isConfigured: () => true }])
136
+ readBaseurlAllowlistMock.mockReturnValue(['openrouter.ai'])
137
+ isBaseurlAllowlistedMock.mockReturnValue(true)
97
138
  })
98
139
 
99
140
  afterEach(() => {
@@ -279,4 +320,236 @@ describe('POST /api/ai/chat', () => {
279
320
  const json = await response.json()
280
321
  expect(json.code).toBe('tool_not_whitelisted')
281
322
  })
323
+
324
+ describe('Phase 4a — query-param override validation', () => {
325
+ function buildRequestWithOverrides(overrides: {
326
+ provider?: string
327
+ model?: string
328
+ baseUrl?: string
329
+ }): Request {
330
+ const url = new URL('http://localhost/api/ai/chat')
331
+ url.searchParams.set('agent', 'customers.assistant')
332
+ if (overrides.provider) url.searchParams.set('provider', overrides.provider)
333
+ if (overrides.model) url.searchParams.set('model', overrides.model)
334
+ if (overrides.baseUrl) url.searchParams.set('baseUrl', overrides.baseUrl)
335
+ return new Request(url, {
336
+ method: 'POST',
337
+ body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }),
338
+ headers: { 'content-type': 'application/json' },
339
+ })
340
+ }
341
+
342
+ it('returns 400 with code runtime_override_disabled when agent has allowRuntimeModelOverride: false', async () => {
343
+ seedAgentRegistryForTests([
344
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers', allowRuntimeModelOverride: false }),
345
+ ])
346
+
347
+ const response = await POST(buildRequestWithOverrides({ provider: 'openai' }) as any)
348
+
349
+ expect(response.status).toBe(400)
350
+ const json = await response.json()
351
+ expect(json.code).toBe('runtime_override_disabled')
352
+ })
353
+
354
+ it('returns 400 with code provider_unknown when provider is not registered', async () => {
355
+ getMock.mockReturnValue(null)
356
+ seedAgentRegistryForTests([
357
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
358
+ ])
359
+
360
+ const response = await POST(buildRequestWithOverrides({ provider: 'unknown-provider' }) as any)
361
+
362
+ expect(response.status).toBe(400)
363
+ const json = await response.json()
364
+ expect(json.code).toBe('provider_unknown')
365
+ })
366
+
367
+ it('returns 400 with code provider_not_configured when provider is registered but not configured', async () => {
368
+ getMock.mockReturnValue({ id: 'openai', isConfigured: () => false })
369
+ seedAgentRegistryForTests([
370
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
371
+ ])
372
+
373
+ const response = await POST(buildRequestWithOverrides({ provider: 'openai' }) as any)
374
+
375
+ expect(response.status).toBe(400)
376
+ const json = await response.json()
377
+ expect(json.code).toBe('provider_not_configured')
378
+ })
379
+
380
+ it('returns 400 with code baseurl_not_allowlisted when baseUrl is not in the allowlist', async () => {
381
+ isBaseurlAllowlistedMock.mockReturnValue(false)
382
+ seedAgentRegistryForTests([
383
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
384
+ ])
385
+
386
+ const response = await POST(buildRequestWithOverrides({ baseUrl: 'https://evil.example.com/v1' }) as any)
387
+
388
+ expect(response.status).toBe(400)
389
+ const json = await response.json()
390
+ expect(json.code).toBe('baseurl_not_allowlisted')
391
+ })
392
+
393
+ it('accepts valid provider and model overrides and forwards requestOverride to runAiAgentText', async () => {
394
+ seedAgentRegistryForTests([
395
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
396
+ ])
397
+
398
+ await POST(buildRequestWithOverrides({ provider: 'openai', model: 'gpt-5-mini' }) as any)
399
+
400
+ expect(runAiAgentTextMock).toHaveBeenCalledTimes(1)
401
+ const callArg = runAiAgentTextMock.mock.calls[0][0] as {
402
+ requestOverride?: { providerId?: string | null; modelId?: string | null; baseURL?: string | null }
403
+ }
404
+ expect(callArg.requestOverride).toEqual({
405
+ providerId: 'openai',
406
+ modelId: 'gpt-5-mini',
407
+ baseURL: null,
408
+ })
409
+ })
410
+
411
+ it('does NOT set requestOverride when no override query params are present', async () => {
412
+ seedAgentRegistryForTests([
413
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
414
+ ])
415
+
416
+ await POST(
417
+ buildRequest({ agent: 'customers.assistant', body: { messages: [{ role: 'user', content: 'hi' }] } }) as any,
418
+ )
419
+
420
+ const callArg = runAiAgentTextMock.mock.calls[0][0] as { requestOverride?: unknown }
421
+ expect(callArg.requestOverride).toBeUndefined()
422
+ })
423
+
424
+ it('accepts valid baseUrl that passes the allowlist check', async () => {
425
+ isBaseurlAllowlistedMock.mockReturnValue(true)
426
+ seedAgentRegistryForTests([
427
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
428
+ ])
429
+
430
+ const response = await POST(
431
+ buildRequestWithOverrides({ baseUrl: 'https://openrouter.ai/api/v1' }) as any,
432
+ )
433
+
434
+ expect(response.status).toBe(200)
435
+ const callArg = runAiAgentTextMock.mock.calls[0][0] as {
436
+ requestOverride?: { providerId?: string | null; modelId?: string | null; baseURL?: string | null }
437
+ }
438
+ expect(callArg.requestOverride?.baseURL).toBe('https://openrouter.ai/api/v1')
439
+ })
440
+ })
441
+
442
+ describe('Phase 1780-5 / 1780-6 — env + tenant allowlist rejections', () => {
443
+ function buildRequestWithOverrides(overrides: {
444
+ provider?: string
445
+ model?: string
446
+ }): Request {
447
+ const url = new URL('http://localhost/api/ai/chat')
448
+ url.searchParams.set('agent', 'customers.assistant')
449
+ if (overrides.provider) url.searchParams.set('provider', overrides.provider)
450
+ if (overrides.model) url.searchParams.set('model', overrides.model)
451
+ return new Request(url, {
452
+ method: 'POST',
453
+ body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }),
454
+ headers: { 'content-type': 'application/json' },
455
+ })
456
+ }
457
+
458
+ const savedEnv: Record<string, string | undefined> = {}
459
+ const ENV_KEYS = [
460
+ 'OM_AI_AVAILABLE_PROVIDERS',
461
+ 'OM_AI_AVAILABLE_MODELS_OPENAI',
462
+ 'OM_AI_AVAILABLE_MODELS_ANTHROPIC',
463
+ ]
464
+
465
+ beforeEach(() => {
466
+ for (const key of ENV_KEYS) {
467
+ savedEnv[key] = process.env[key]
468
+ delete process.env[key]
469
+ }
470
+ getMock.mockImplementation((id: string) => {
471
+ if (id === 'openai') return { id: 'openai', isConfigured: () => true }
472
+ if (id === 'anthropic') return { id: 'anthropic', isConfigured: () => true }
473
+ return null
474
+ })
475
+ listMock.mockReturnValue([
476
+ { id: 'openai', isConfigured: () => true },
477
+ { id: 'anthropic', isConfigured: () => true },
478
+ ])
479
+ })
480
+
481
+ afterEach(() => {
482
+ for (const key of ENV_KEYS) {
483
+ if (savedEnv[key] === undefined) {
484
+ delete process.env[key]
485
+ } else {
486
+ process.env[key] = savedEnv[key]
487
+ }
488
+ }
489
+ })
490
+
491
+ it('returns 400 provider_not_allowlisted when OM_AI_AVAILABLE_PROVIDERS excludes the requested provider', async () => {
492
+ process.env.OM_AI_AVAILABLE_PROVIDERS = 'anthropic'
493
+ seedAgentRegistryForTests([
494
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
495
+ ])
496
+
497
+ const response = await POST(buildRequestWithOverrides({ provider: 'openai' }) as any)
498
+
499
+ expect(response.status).toBe(400)
500
+ const json = await response.json()
501
+ expect(json.code).toBe('provider_not_allowlisted')
502
+ expect(json.error).toContain('OM_AI_AVAILABLE_PROVIDERS')
503
+ })
504
+
505
+ it('returns 400 model_not_allowlisted when OM_AI_AVAILABLE_MODELS_OPENAI excludes the requested model', async () => {
506
+ process.env.OM_AI_AVAILABLE_MODELS_OPENAI = 'gpt-4o'
507
+ seedAgentRegistryForTests([
508
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
509
+ ])
510
+
511
+ const response = await POST(
512
+ buildRequestWithOverrides({ provider: 'openai', model: 'gpt-5-mini' }) as any,
513
+ )
514
+
515
+ expect(response.status).toBe(400)
516
+ const json = await response.json()
517
+ expect(json.code).toBe('model_not_allowlisted')
518
+ expect(json.error).toContain('OM_AI_AVAILABLE_MODELS_OPENAI')
519
+ })
520
+
521
+ it('returns 503 tenant_allowlist_unavailable when the tenant allowlist lookup throws (fail closed)', async () => {
522
+ tenantAllowlistGetSnapshotMock.mockRejectedValueOnce(new Error('db connection refused'))
523
+ seedAgentRegistryForTests([
524
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
525
+ ])
526
+
527
+ const response = await POST(buildRequestWithOverrides({ provider: 'openai' }) as any)
528
+
529
+ expect(response.status).toBe(503)
530
+ const json = await response.json()
531
+ expect(json.code).toBe('tenant_allowlist_unavailable')
532
+ })
533
+
534
+ it('returns 400 model_not_allowlisted with "env ∩ tenant" wording when the tenant snapshot narrows the env allowlist', async () => {
535
+ process.env.OM_AI_AVAILABLE_PROVIDERS = 'openai'
536
+ process.env.OM_AI_AVAILABLE_MODELS_OPENAI = 'gpt-4o,gpt-5-mini'
537
+ tenantAllowlistGetSnapshotMock.mockResolvedValue({
538
+ allowedProviders: ['openai'],
539
+ allowedModelsByProvider: { openai: ['gpt-4o'] },
540
+ })
541
+ seedAgentRegistryForTests([
542
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
543
+ ])
544
+
545
+ const response = await POST(
546
+ buildRequestWithOverrides({ provider: 'openai', model: 'gpt-5-mini' }) as any,
547
+ )
548
+
549
+ expect(response.status).toBe(400)
550
+ const json = await response.json()
551
+ expect(json.code).toBe('model_not_allowlisted')
552
+ expect(json.error).toContain('env ∩ tenant')
553
+ })
554
+ })
282
555
  })
@@ -5,10 +5,26 @@ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
5
5
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
6
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
7
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
8
+ import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
8
9
  import { loadAgentRegistry } from '../../../lib/agent-registry'
9
10
  import { checkAgentPolicy, type AgentPolicyDenyCode } from '../../../lib/agent-policy'
10
11
  import { runAiAgentText } from '../../../lib/agent-runtime'
11
12
  import { AgentPolicyError } from '../../../lib/agent-tools'
13
+ import { readBaseurlAllowlist, isBaseurlAllowlisted } from '../../../lib/baseurl-allowlist'
14
+ import {
15
+ canonicalProviderId,
16
+ hasAllowlistSnapshotRestrictions,
17
+ intersectEffectiveAllowlistWithSnapshot,
18
+ intersectAllowlists,
19
+ isModelAllowedForProviderInEffective,
20
+ isProviderAllowedInEffective,
21
+ modelAllowlistEnvVarName,
22
+ readAgentRuntimeOverrideAllowlist,
23
+ type TenantAllowlistSnapshot,
24
+ } from '../../../lib/model-allowlist'
25
+ import { AiTenantModelAllowlistRepository } from '../../../data/repositories/AiTenantModelAllowlistRepository'
26
+ import { AiAgentRuntimeOverrideRepository } from '../../../data/repositories/AiAgentRuntimeOverrideRepository'
27
+ import type { EntityManager } from '@mikro-orm/postgresql'
12
28
 
13
29
  const MAX_MESSAGES = 100
14
30
 
@@ -50,6 +66,29 @@ const agentQuerySchema = z.object({
50
66
  agent: z
51
67
  .string()
52
68
  .regex(agentIdPattern, 'agent must match "<module>.<agent>" (lowercase, digits, underscores only)'),
69
+ /**
70
+ * Per-request provider override. Must match a registered + configured
71
+ * provider id. Validated against `llmProviderRegistry` at dispatch time.
72
+ * Rejected when the agent has `allowRuntimeModelOverride: false`.
73
+ *
74
+ * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
75
+ */
76
+ provider: z.string().optional(),
77
+ /**
78
+ * Per-request model id override. Free-form string. Logged (not rejected)
79
+ * when not in the provider's curated `defaultModels` catalog.
80
+ *
81
+ * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
82
+ */
83
+ model: z.string().optional(),
84
+ /**
85
+ * Per-request base URL override. Must parse as a URL and match
86
+ * `AI_RUNTIME_BASEURL_ALLOWLIST` (comma-separated host patterns). When the
87
+ * env var is unset or empty, any non-empty value is rejected.
88
+ *
89
+ * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
90
+ */
91
+ baseUrl: z.string().optional(),
53
92
  })
54
93
 
55
94
  export const openApi: OpenApiRouteDoc = {
@@ -63,7 +102,12 @@ export const openApi: OpenApiRouteDoc = {
63
102
  'Dispatches a chat turn to the focused AI agent identified by `?agent=<module>.<agent>`. ' +
64
103
  'Enforces agent-level `requiredFeatures`, tool whitelisting, read-only / mutationPolicy, ' +
65
104
  'execution-mode compatibility, and attachment media-type policy. The streaming response ' +
66
- 'body uses an AI SDK-compatible `text/event-stream` transport.',
105
+ 'body uses an AI SDK-compatible `text/event-stream` transport. ' +
106
+ 'Optional `?provider=`, `?model=`, and `?baseUrl=` query params let callers ' +
107
+ 'override the resolved provider/model/base-URL for this turn (Phase 4a). ' +
108
+ 'Provider must be registered and configured; baseUrl must match ' +
109
+ '`AI_RUNTIME_BASEURL_ALLOWLIST` when set. Both are suppressed when the ' +
110
+ 'agent declares `allowRuntimeModelOverride: false`.',
67
111
  query: agentQuerySchema,
68
112
  requestBody: {
69
113
  contentType: 'application/json',
@@ -74,7 +118,15 @@ export const openApi: OpenApiRouteDoc = {
74
118
  { status: 200, description: 'Streaming text/event-stream response compatible with AI SDK chat transports.', mediaType: 'text/event-stream' },
75
119
  ],
76
120
  errors: [
77
- { status: 400, description: 'Invalid query param, malformed payload, or message count above the cap.' },
121
+ {
122
+ status: 400,
123
+ description:
124
+ 'Invalid query param, malformed payload, or message count above the cap. ' +
125
+ 'Typed codes: `runtime_override_disabled` (agent has allowRuntimeModelOverride:false), ' +
126
+ '`provider_unknown` (provider id not registered), ' +
127
+ '`provider_not_configured` (provider registered but no API key in env), ' +
128
+ '`baseurl_not_allowlisted` (baseUrl not in AI_RUNTIME_BASEURL_ALLOWLIST).',
129
+ },
78
130
  { status: 401, description: 'Unauthenticated caller.' },
79
131
  { status: 403, description: 'Caller lacks agent-level or tool-level required features.' },
80
132
  { status: 404, description: 'Unknown agent id.' },
@@ -127,6 +179,9 @@ export async function POST(req: NextRequest): Promise<Response> {
127
179
  const requestUrl = new URL(req.url)
128
180
  const queryResult = agentQuerySchema.safeParse({
129
181
  agent: requestUrl.searchParams.get('agent') ?? undefined,
182
+ provider: requestUrl.searchParams.get('provider') ?? undefined,
183
+ model: requestUrl.searchParams.get('model') ?? undefined,
184
+ baseUrl: requestUrl.searchParams.get('baseUrl') ?? undefined,
130
185
  })
131
186
  if (!queryResult.success) {
132
187
  return jsonError(400, 'Invalid or missing "agent" query parameter.', 'validation_error', {
@@ -134,6 +189,9 @@ export async function POST(req: NextRequest): Promise<Response> {
134
189
  })
135
190
  }
136
191
  const agentId = queryResult.data.agent
192
+ const rawProvider = queryResult.data.provider
193
+ const rawModel = queryResult.data.model
194
+ const rawBaseUrl = queryResult.data.baseUrl
137
195
 
138
196
  let parsedBody: unknown
139
197
  try {
@@ -177,6 +235,156 @@ export async function POST(req: NextRequest): Promise<Response> {
177
235
  return jsonError(statusForDenyCode(decision.code), decision.message, decision.code)
178
236
  }
179
237
 
238
+ const agentDef = decision.agent
239
+
240
+ // --- Phase 4a: validate runtime override query params ---
241
+ const hasRuntimeOverride =
242
+ (rawProvider && rawProvider.trim().length > 0) ||
243
+ (rawModel && rawModel.trim().length > 0) ||
244
+ (rawBaseUrl && rawBaseUrl.trim().length > 0)
245
+
246
+ if (hasRuntimeOverride) {
247
+ if (agentDef.allowRuntimeModelOverride === false) {
248
+ return jsonError(
249
+ 400,
250
+ `Agent "${agentId}" has runtime model override disabled (allowRuntimeModelOverride: false).`,
251
+ 'runtime_override_disabled',
252
+ )
253
+ }
254
+ }
255
+
256
+ let tenantAllowlistSnapshot: TenantAllowlistSnapshot | null = null
257
+ let agentRuntimeOverrideAllowlist: TenantAllowlistSnapshot | null = null
258
+ if (auth.tenantId) {
259
+ try {
260
+ const em = container.resolve<EntityManager>('em')
261
+ const allowlistRepo = new AiTenantModelAllowlistRepository(em)
262
+ tenantAllowlistSnapshot = await allowlistRepo.getSnapshot({
263
+ tenantId: auth.tenantId,
264
+ organizationId: auth.orgId ?? null,
265
+ })
266
+ const runtimeOverrideRepo = new AiAgentRuntimeOverrideRepository(em)
267
+ const agentRuntimeOverrideRow = await runtimeOverrideRepo.getExact({
268
+ tenantId: auth.tenantId,
269
+ organizationId: auth.orgId ?? null,
270
+ agentId,
271
+ })
272
+ const tenantAgentAllowlist = agentRuntimeOverrideRow
273
+ ? {
274
+ allowedProviders: agentRuntimeOverrideRow.allowedOverrideProviders ?? null,
275
+ allowedModelsByProvider: agentRuntimeOverrideRow.allowedOverrideModelsByProvider ?? {},
276
+ }
277
+ : null
278
+ agentRuntimeOverrideAllowlist = hasAllowlistSnapshotRestrictions(tenantAgentAllowlist)
279
+ ? tenantAgentAllowlist
280
+ : null
281
+ } catch (snapshotError) {
282
+ // Fail closed: refuse to dispatch if we cannot confirm the tenant allowlist.
283
+ // Silently falling back to env-only would widen the effective allowlist when
284
+ // the DB is unavailable, which is the opposite of what an admin intends.
285
+ console.error(
286
+ '[AI Chat Agent] Tenant allowlist lookup failed; refusing to dispatch:',
287
+ snapshotError,
288
+ )
289
+ return jsonError(
290
+ 503,
291
+ 'Tenant allowlist is temporarily unavailable. Try again shortly.',
292
+ 'tenant_allowlist_unavailable',
293
+ )
294
+ }
295
+ }
296
+ const knownProviderIds = llmProviderRegistry.list().map((p) => p.id)
297
+ const baseEffectiveAllowlist = intersectAllowlists(
298
+ process.env as Record<string, string | undefined>,
299
+ knownProviderIds,
300
+ tenantAllowlistSnapshot,
301
+ )
302
+ const envAgentAllowlist = readAgentRuntimeOverrideAllowlist(
303
+ process.env as Record<string, string | undefined>,
304
+ agentId,
305
+ knownProviderIds,
306
+ )
307
+ const effectiveAllowlist = intersectEffectiveAllowlistWithSnapshot(
308
+ intersectEffectiveAllowlistWithSnapshot(
309
+ baseEffectiveAllowlist,
310
+ knownProviderIds,
311
+ envAgentAllowlist,
312
+ ),
313
+ knownProviderIds,
314
+ agentRuntimeOverrideAllowlist,
315
+ )
316
+
317
+ const normalizedProvider = rawProvider && rawProvider.trim().length > 0
318
+ ? canonicalProviderId(rawProvider.trim(), llmProviderRegistry.list().map((p) => p.id))
319
+ : null
320
+
321
+ if (rawProvider && rawProvider.trim().length > 0) {
322
+ const providerEntry = normalizedProvider ? llmProviderRegistry.get(normalizedProvider) : null
323
+ if (!providerEntry) {
324
+ return jsonError(
325
+ 400,
326
+ `Provider "${rawProvider}" is not registered. Registered provider ids: ${llmProviderRegistry.list().map((p) => p.id).join(', ')}.`,
327
+ 'provider_unknown',
328
+ )
329
+ }
330
+ if (!providerEntry.isConfigured()) {
331
+ return jsonError(
332
+ 400,
333
+ `Provider "${rawProvider}" is registered but not configured in this environment (missing API key).`,
334
+ 'provider_not_configured',
335
+ )
336
+ }
337
+ if (!isProviderAllowedInEffective(effectiveAllowlist, normalizedProvider!)) {
338
+ const source = effectiveAllowlist.tenantOverridesActive
339
+ ? 'the effective allowlist (env ∩ tenant)'
340
+ : 'OM_AI_AVAILABLE_PROVIDERS'
341
+ return jsonError(
342
+ 400,
343
+ `Provider "${rawProvider}" is not in ${source}.`,
344
+ 'provider_not_allowlisted',
345
+ )
346
+ }
347
+ if (
348
+ rawModel
349
+ && rawModel.trim().length > 0
350
+ && !isModelAllowedForProviderInEffective(
351
+ effectiveAllowlist,
352
+ normalizedProvider!,
353
+ rawModel.trim(),
354
+ )
355
+ ) {
356
+ const source = effectiveAllowlist.tenantOverridesActive
357
+ ? `the effective allowlist (env ∩ tenant) for "${normalizedProvider}"`
358
+ : modelAllowlistEnvVarName(normalizedProvider!)
359
+ return jsonError(
360
+ 400,
361
+ `Model "${rawModel}" is not in ${source}.`,
362
+ 'model_not_allowlisted',
363
+ )
364
+ }
365
+ }
366
+
367
+ if (rawBaseUrl && rawBaseUrl.trim().length > 0) {
368
+ const allowlist = readBaseurlAllowlist()
369
+ if (!isBaseurlAllowlisted(rawBaseUrl.trim(), allowlist)) {
370
+ return jsonError(
371
+ 400,
372
+ `baseUrl "${rawBaseUrl}" is not in the AI_RUNTIME_BASEURL_ALLOWLIST. Set that env var to a comma-separated list of allowed host patterns to enable per-request baseUrl overrides.`,
373
+ 'baseurl_not_allowlisted',
374
+ )
375
+ }
376
+ }
377
+ // --- end Phase 4a validation ---
378
+
379
+ const requestOverride =
380
+ hasRuntimeOverride
381
+ ? {
382
+ providerId: normalizedProvider,
383
+ modelId: rawModel && rawModel.trim().length > 0 ? rawModel.trim() : null,
384
+ baseURL: rawBaseUrl && rawBaseUrl.trim().length > 0 ? rawBaseUrl.trim() : null,
385
+ }
386
+ : undefined
387
+
180
388
  return await runAiAgentText({
181
389
  agentId,
182
390
  messages: bodyResult.data.messages as unknown as UIMessage[],
@@ -192,6 +400,7 @@ export async function POST(req: NextRequest): Promise<Response> {
192
400
  isSuperAdmin: acl.isSuperAdmin,
193
401
  },
194
402
  container,
403
+ requestOverride,
195
404
  })
196
405
  } catch (error) {
197
406
  if (error instanceof AgentPolicyError) {
@@ -5,15 +5,13 @@ import { z } from 'zod'
5
5
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
6
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
7
  import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
8
- import {
9
- resolveAiProviderIdFromEnv,
10
- resolveOpenCodeModel,
11
- } from '@open-mercato/shared/lib/ai/opencode-provider'
8
+ import { resolveOpenCodeModel } from '@open-mercato/shared/lib/ai/opencode-provider'
12
9
  import {
13
10
  resolveChatConfig,
14
11
  isProviderConfigured,
15
12
  type ChatProviderId,
16
13
  } from '../../lib/chat-config'
14
+ import { createModelFactory, AiModelFactoryError } from '../../lib/model-factory'
17
15
 
18
16
  export const openApi: OpenApiRouteDoc = {
19
17
  tag: 'AI Assistant',
@@ -103,29 +101,55 @@ export async function POST(req: NextRequest) {
103
101
  const container = await createRequestContainer()
104
102
  let config = await resolveChatConfig(container)
105
103
 
106
- // Fallback to first configured provider from the LLM provider registry.
107
- // Honors the operator-selected provider via OM_AI_PROVIDER (with the
108
- // legacy OPENCODE_PROVIDER as the BC fallback) and only then walks the
109
- // native adapters before any OpenAI-compatible presets so a deployment
110
- // that just sets OPENAI_API_KEY still resolves to OpenAI by default.
104
+ // When no DB-stored config is present, delegate provider + model resolution
105
+ // to createModelFactory so OM_AI_PROVIDER / OM_AI_MODEL (Phase 0 of spec
106
+ // 2026-04-27-ai-agents-provider-model-baseurl-overrides) and all registered
107
+ // OpenAI-compatible presets are respected without duplicating the
108
+ // resolution chain here. Legacy OPENCODE_PROVIDER / OPENCODE_MODEL envs are
109
+ // still honored as BC fallbacks inside the factory.
111
110
  if (!config) {
112
- const operatorPreferred = resolveAiProviderIdFromEnv(process.env)
113
- const baseOrder: ChatProviderId[] = ['openai', 'anthropic', 'google']
114
- const order = [
115
- operatorPreferred,
116
- ...baseOrder.filter((id) => id !== operatorPreferred),
117
- ]
118
- const picked = llmProviderRegistry.resolveFirstConfigured({ order })
119
- if (!picked) {
120
- return NextResponse.json(
121
- {
122
- error:
123
- 'No AI provider configured. Please set an API key for one of the registered providers (Anthropic, OpenAI, Google, DeepInfra, Groq, …).',
124
- },
125
- { status: 503 },
126
- )
111
+ let factoryResolution
112
+ try {
113
+ factoryResolution = createModelFactory(container).resolveModel({
114
+ callerOverride: undefined,
115
+ })
116
+ } catch (error) {
117
+ if (error instanceof AiModelFactoryError && error.code === 'no_provider_configured') {
118
+ return NextResponse.json(
119
+ {
120
+ error:
121
+ 'No AI provider configured. Please set an API key for one of the registered providers (Anthropic, OpenAI, Google, DeepInfra, Groq, …).',
122
+ },
123
+ { status: 503 },
124
+ )
125
+ }
126
+ throw error
127
127
  }
128
- config = { providerId: picked.id, model: '', updatedAt: '' }
128
+
129
+ console.log('[AI Route] Using provider:', factoryResolution.providerId)
130
+
131
+ const modelWithProvider = `${factoryResolution.providerId}/${factoryResolution.modelId}`
132
+ console.log('[AI Route] Calling generateObject with', modelWithProvider)
133
+
134
+ const result = await generateObject({
135
+ model: factoryResolution.model as Parameters<typeof generateObject>[0]['model'],
136
+ schema: RouteResultSchema,
137
+ prompt: `You are a routing assistant. Given a user query, determine if they want to use a specific tool or have a general conversation.
138
+
139
+ Available tools:
140
+ ${availableTools.map((t) => `- ${t.name}: ${t.description}`).join('\n')}
141
+
142
+ User query: "${query}"
143
+
144
+ Respond with:
145
+ - intent: "tool" if user wants to perform an action with a specific tool, "general_chat" otherwise
146
+ - toolName: the exact tool name if intent is "tool"
147
+ - confidence: 0-1 how confident you are
148
+ - reasoning: brief explanation`,
149
+ })
150
+
151
+ console.log('[AI Route] Result:', result.object)
152
+ return NextResponse.json(result.object)
129
153
  }
130
154
 
131
155
  console.log('[AI Route] Using provider:', config.providerId)