@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
@@ -432,6 +432,48 @@ describe('createModelFactory', () => {
432
432
  expect(resolution.source).toBe('env_default')
433
433
  })
434
434
 
435
+ it('preserves slashy LM Studio model ids while honoring underscored provider env aliases', () => {
436
+ const lmStudio = makeProvider({ id: 'lm-studio' })
437
+ const openai = makeProvider({ id: 'openai' })
438
+ const { registry, spy } = makeMultiProviderRegistry([openai, lmStudio])
439
+ const factory = createModelFactory(fakeContainer, {
440
+ registry,
441
+ env: {
442
+ OM_AI_PROVIDER: 'lm_studio',
443
+ OM_AI_MODEL: 'qwen/qwen3.5-9b',
444
+ },
445
+ })
446
+ const resolution = factory.resolveModel({})
447
+ expect(spy).toHaveBeenCalledWith(
448
+ expect.objectContaining({ order: ['lm-studio'] }),
449
+ )
450
+ expect(resolution.providerId).toBe('lm-studio')
451
+ expect(resolution.modelId).toBe('qwen/qwen3.5-9b')
452
+ expect(resolution.source).toBe('env_default')
453
+ })
454
+
455
+ it('honors underscore aliases for module and agent provider defaults', () => {
456
+ const lmStudio = makeProvider({ id: 'lm-studio' })
457
+ const openai = makeProvider({ id: 'openai' })
458
+ const { registry, spy } = makeMultiProviderRegistry([openai, lmStudio])
459
+ const factory = createModelFactory(fakeContainer, {
460
+ registry,
461
+ env: {
462
+ OM_AI_CATALOG_PROVIDER: 'lm_studio',
463
+ },
464
+ })
465
+ const resolution = factory.resolveModel({
466
+ moduleId: 'catalog',
467
+ agentDefaultProvider: 'openai',
468
+ agentDefaultModel: 'agent-model',
469
+ })
470
+ expect(spy).toHaveBeenCalledWith(
471
+ expect.objectContaining({ order: ['lm-studio'] }),
472
+ )
473
+ expect(resolution.providerId).toBe('lm-studio')
474
+ expect(resolution.modelId).toBe('agent-model')
475
+ })
476
+
435
477
  it('agent_default still beats env_default at lower priority', () => {
436
478
  const anthropic = makeProvider({ id: 'anthropic' })
437
479
  const { registry } = makeMultiProviderRegistry([anthropic])
@@ -444,6 +486,299 @@ describe('createModelFactory', () => {
444
486
  expect(resolution.source).toBe('agent_default')
445
487
  })
446
488
  })
489
+
490
+ describe('Phase 1 — agentDefaultProvider, OM_AI_<MODULE>_PROVIDER, providerOverride, slash-shorthand on every source', () => {
491
+ it('agentDefaultProvider seeds the provider-axis order hint', () => {
492
+ const anthropic = makeProvider({ id: 'anthropic', defaultModel: 'claude-sonnet' })
493
+ const openai = makeProvider({ id: 'openai', defaultModel: 'gpt-4o-mini' })
494
+ const { registry, spy } = makeMultiProviderRegistry([anthropic, openai])
495
+ const factory = createModelFactory(fakeContainer, { registry, env: {} })
496
+ const resolution = factory.resolveModel({ agentDefaultProvider: 'openai' })
497
+ expect(spy).toHaveBeenCalledWith(
498
+ expect.objectContaining({ order: ['openai'] }),
499
+ )
500
+ expect(resolution.providerId).toBe('openai')
501
+ expect(resolution.modelId).toBe('gpt-4o-mini')
502
+ expect(resolution.source).toBe('provider_default')
503
+ })
504
+
505
+ it('OM_AI_<MODULE>_PROVIDER env beats agentDefaultProvider for the provider axis', () => {
506
+ const anthropic = makeProvider({ id: 'anthropic', defaultModel: 'claude-sonnet' })
507
+ const openai = makeProvider({ id: 'openai', defaultModel: 'gpt-4o-mini' })
508
+ const google = makeProvider({ id: 'google', defaultModel: 'gemini-1.5-pro' })
509
+ const { registry, spy } = makeMultiProviderRegistry([anthropic, openai, google])
510
+ const factory = createModelFactory(fakeContainer, {
511
+ registry,
512
+ env: { OM_AI_CATALOG_PROVIDER: 'google' },
513
+ })
514
+ const resolution = factory.resolveModel({
515
+ moduleId: 'catalog',
516
+ agentDefaultProvider: 'openai',
517
+ })
518
+ expect(spy).toHaveBeenCalledWith(
519
+ expect.objectContaining({ order: ['google'] }),
520
+ )
521
+ expect(resolution.providerId).toBe('google')
522
+ })
523
+
524
+ it('falls back to legacy <MODULE>_AI_PROVIDER when OM_AI_<MODULE>_PROVIDER is unset', () => {
525
+ const anthropic = makeProvider({ id: 'anthropic', defaultModel: 'claude-sonnet' })
526
+ const openai = makeProvider({ id: 'openai', defaultModel: 'gpt-4o-mini' })
527
+ const google = makeProvider({ id: 'google', defaultModel: 'gemini-1.5-pro' })
528
+ const { registry, spy } = makeMultiProviderRegistry([anthropic, openai, google])
529
+ const factory = createModelFactory(fakeContainer, {
530
+ registry,
531
+ env: { CATALOG_AI_PROVIDER: 'google' },
532
+ })
533
+ const resolution = factory.resolveModel({
534
+ moduleId: 'catalog',
535
+ agentDefaultProvider: 'openai',
536
+ })
537
+ expect(spy).toHaveBeenCalledWith(
538
+ expect.objectContaining({ order: ['google'] }),
539
+ )
540
+ expect(resolution.providerId).toBe('google')
541
+ })
542
+
543
+ it('prefers OM_AI_<MODULE>_PROVIDER over legacy <MODULE>_AI_PROVIDER', () => {
544
+ const anthropic = makeProvider({ id: 'anthropic' })
545
+ const openai = makeProvider({ id: 'openai', defaultModel: 'gpt-4o-mini' })
546
+ const google = makeProvider({ id: 'google', defaultModel: 'gemini-1.5-pro' })
547
+ const { registry, spy } = makeMultiProviderRegistry([anthropic, openai, google])
548
+ const factory = createModelFactory(fakeContainer, {
549
+ registry,
550
+ env: {
551
+ OM_AI_CATALOG_PROVIDER: 'openai',
552
+ CATALOG_AI_PROVIDER: 'google',
553
+ },
554
+ })
555
+ const resolution = factory.resolveModel({ moduleId: 'catalog' })
556
+ expect(spy).toHaveBeenCalledWith(
557
+ expect.objectContaining({ order: ['openai'] }),
558
+ )
559
+ expect(resolution.providerId).toBe('openai')
560
+ })
561
+
562
+ it('providerOverride beats OM_AI_<MODULE>_PROVIDER for the provider axis', () => {
563
+ const anthropic = makeProvider({ id: 'anthropic' })
564
+ const openai = makeProvider({ id: 'openai', defaultModel: 'gpt-4o-mini' })
565
+ const google = makeProvider({ id: 'google', defaultModel: 'gemini-1.5-pro' })
566
+ const { registry, spy } = makeMultiProviderRegistry([anthropic, openai, google])
567
+ const factory = createModelFactory(fakeContainer, {
568
+ registry,
569
+ env: { OM_AI_CATALOG_PROVIDER: 'google' },
570
+ })
571
+ const resolution = factory.resolveModel({
572
+ moduleId: 'catalog',
573
+ providerOverride: 'openai',
574
+ })
575
+ expect(spy).toHaveBeenCalledWith(
576
+ expect.objectContaining({ order: ['openai'] }),
577
+ )
578
+ expect(resolution.providerId).toBe('openai')
579
+ })
580
+
581
+ it('slash-qualified agentDefaultModel provides both provider hint and model id', () => {
582
+ const anthropic = makeProvider({ id: 'anthropic' })
583
+ const openai = makeProvider({ id: 'openai', defaultModel: 'gpt-4o-mini' })
584
+ const { registry, spy } = makeMultiProviderRegistry([anthropic, openai])
585
+ const factory = createModelFactory(fakeContainer, { registry, env: {} })
586
+ const resolution = factory.resolveModel({
587
+ agentDefaultModel: 'openai/gpt-5-mini',
588
+ })
589
+ expect(spy).toHaveBeenCalledWith(
590
+ expect.objectContaining({ order: ['openai'] }),
591
+ )
592
+ expect(resolution.providerId).toBe('openai')
593
+ expect(resolution.modelId).toBe('gpt-5-mini')
594
+ expect(resolution.source).toBe('agent_default')
595
+ })
596
+
597
+ it('slash-qualified OM_AI_<MODULE>_MODEL provides both provider hint and model id', () => {
598
+ const anthropic = makeProvider({ id: 'anthropic' })
599
+ const openai = makeProvider({ id: 'openai', defaultModel: 'gpt-4o-mini' })
600
+ const { registry, spy } = makeMultiProviderRegistry([anthropic, openai])
601
+ const factory = createModelFactory(fakeContainer, {
602
+ registry,
603
+ env: { OM_AI_CATALOG_MODEL: 'openai/gpt-5' },
604
+ })
605
+ const resolution = factory.resolveModel({ moduleId: 'catalog' })
606
+ expect(spy).toHaveBeenCalledWith(
607
+ expect.objectContaining({ order: ['openai'] }),
608
+ )
609
+ expect(resolution.providerId).toBe('openai')
610
+ expect(resolution.modelId).toBe('gpt-5')
611
+ expect(resolution.source).toBe('module_env')
612
+ })
613
+
614
+ it('slash-qualified callerOverride provides both provider hint and model id', () => {
615
+ const anthropic = makeProvider({ id: 'anthropic' })
616
+ const openai = makeProvider({ id: 'openai', defaultModel: 'gpt-4o-mini' })
617
+ const { registry, spy } = makeMultiProviderRegistry([anthropic, openai])
618
+ const factory = createModelFactory(fakeContainer, { registry, env: {} })
619
+ const resolution = factory.resolveModel({ callerOverride: 'openai/gpt-5-mini' })
620
+ expect(spy).toHaveBeenCalledWith(
621
+ expect.objectContaining({ order: ['openai'] }),
622
+ )
623
+ expect(resolution.providerId).toBe('openai')
624
+ expect(resolution.modelId).toBe('gpt-5-mini')
625
+ expect(resolution.source).toBe('caller_override')
626
+ })
627
+
628
+ it('cross-axis tie-break: slash-qualified higher-priority model wins over lower-priority plain provider', () => {
629
+ const anthropic = makeProvider({ id: 'anthropic', defaultModel: 'claude-sonnet' })
630
+ const openai = makeProvider({ id: 'openai', defaultModel: 'gpt-4o-mini' })
631
+ const { registry, spy } = makeMultiProviderRegistry([anthropic, openai])
632
+ const factory = createModelFactory(fakeContainer, { registry, env: {} })
633
+ const resolution = factory.resolveModel({
634
+ callerOverride: 'openai/gpt-5-mini',
635
+ agentDefaultProvider: 'anthropic',
636
+ })
637
+ expect(spy).toHaveBeenCalledWith(
638
+ expect.objectContaining({ order: ['openai'] }),
639
+ )
640
+ expect(resolution.providerId).toBe('openai')
641
+ expect(resolution.modelId).toBe('gpt-5-mini')
642
+ })
643
+
644
+ it('DeepInfra-style model id in agentDefaultModel is not split (registry guard)', () => {
645
+ const deepinfra = makeProvider({ id: 'deepinfra' })
646
+ const { registry, spy } = makeMultiProviderRegistry([deepinfra])
647
+ const factory = createModelFactory(fakeContainer, { registry, env: {} })
648
+ const resolution = factory.resolveModel({
649
+ agentDefaultModel: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
650
+ })
651
+ expect(spy).toHaveBeenCalledWith(
652
+ expect.objectContaining({ order: undefined }),
653
+ )
654
+ expect(resolution.modelId).toBe('meta-llama/Llama-3.3-70B-Instruct-Turbo')
655
+ expect(resolution.source).toBe('agent_default')
656
+ })
657
+ })
658
+
659
+ describe('Phase 2 — agentDefaultBaseUrl, <MODULE>_AI_BASE_URL, baseUrlOverride', () => {
660
+ function makeBaseUrlProviderWithSpy(): {
661
+ provider: FakeProvider
662
+ createModel: jest.Mock<unknown, [{ modelId: string; apiKey: string; baseURL?: string }]>
663
+ } {
664
+ const createModel = jest.fn(
665
+ (options: { modelId: string; apiKey: string; baseURL?: string }) => ({
666
+ kind: 'fake-model',
667
+ ...options,
668
+ }),
669
+ ) as jest.Mock<unknown, [{ modelId: string; apiKey: string; baseURL?: string }]>
670
+ const provider = makeProvider({
671
+ createModel: createModel as unknown as FakeProvider['createModel'],
672
+ })
673
+ return { provider, createModel }
674
+ }
675
+
676
+ it('omits baseURL from AiModelResolution when no caller-side source applies', () => {
677
+ const { provider, createModel } = makeBaseUrlProviderWithSpy()
678
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
679
+ const resolution = factory.resolveModel({})
680
+ expect(resolution.baseURL).toBeUndefined()
681
+ expect(createModel).toHaveBeenCalledWith(
682
+ expect.objectContaining({ baseURL: undefined }),
683
+ )
684
+ })
685
+
686
+ it('forwards agentDefaultBaseUrl to provider.createModel and surfaces it on AiModelResolution', () => {
687
+ const { provider, createModel } = makeBaseUrlProviderWithSpy()
688
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
689
+ const resolution = factory.resolveModel({
690
+ agentDefaultBaseUrl: 'https://agent.example.com/v1',
691
+ })
692
+ expect(resolution.baseURL).toBe('https://agent.example.com/v1')
693
+ expect(createModel).toHaveBeenCalledWith(
694
+ expect.objectContaining({ baseURL: 'https://agent.example.com/v1' }),
695
+ )
696
+ })
697
+
698
+ it('<MODULE>_AI_BASE_URL env beats agentDefaultBaseUrl for the baseURL axis', () => {
699
+ const { provider, createModel } = makeBaseUrlProviderWithSpy()
700
+ const env = { CATALOG_AI_BASE_URL: 'https://catalog-env.example.com/v1' }
701
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider, env))
702
+ const resolution = factory.resolveModel({
703
+ moduleId: 'catalog',
704
+ agentDefaultBaseUrl: 'https://agent.example.com/v1',
705
+ })
706
+ expect(resolution.baseURL).toBe('https://catalog-env.example.com/v1')
707
+ expect(createModel).toHaveBeenCalledWith(
708
+ expect.objectContaining({ baseURL: 'https://catalog-env.example.com/v1' }),
709
+ )
710
+ })
711
+
712
+ it('baseUrlOverride beats <MODULE>_AI_BASE_URL env and agentDefaultBaseUrl', () => {
713
+ const { provider, createModel } = makeBaseUrlProviderWithSpy()
714
+ const env = { CATALOG_AI_BASE_URL: 'https://catalog-env.example.com/v1' }
715
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider, env))
716
+ const resolution = factory.resolveModel({
717
+ moduleId: 'catalog',
718
+ agentDefaultBaseUrl: 'https://agent.example.com/v1',
719
+ baseUrlOverride: 'https://caller.example.com/v1',
720
+ })
721
+ expect(resolution.baseURL).toBe('https://caller.example.com/v1')
722
+ expect(createModel).toHaveBeenCalledWith(
723
+ expect.objectContaining({ baseURL: 'https://caller.example.com/v1' }),
724
+ )
725
+ })
726
+
727
+ it('uppercases moduleId when deriving the <MODULE>_AI_BASE_URL env var name', () => {
728
+ const { provider, createModel } = makeBaseUrlProviderWithSpy()
729
+ const env = { INBOX_OPS_AI_BASE_URL: 'https://inbox-env.example.com/v1' }
730
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider, env))
731
+ const resolution = factory.resolveModel({ moduleId: 'inbox_ops' })
732
+ expect(resolution.baseURL).toBe('https://inbox-env.example.com/v1')
733
+ expect(createModel).toHaveBeenCalledWith(
734
+ expect.objectContaining({ baseURL: 'https://inbox-env.example.com/v1' }),
735
+ )
736
+ })
737
+
738
+ it('treats empty baseUrlOverride as "no override" and falls through to env', () => {
739
+ const { provider, createModel } = makeBaseUrlProviderWithSpy()
740
+ const env = { CATALOG_AI_BASE_URL: 'https://catalog-env.example.com/v1' }
741
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider, env))
742
+ const resolution = factory.resolveModel({
743
+ moduleId: 'catalog',
744
+ baseUrlOverride: ' ',
745
+ })
746
+ expect(resolution.baseURL).toBe('https://catalog-env.example.com/v1')
747
+ expect(createModel).toHaveBeenCalledWith(
748
+ expect.objectContaining({ baseURL: 'https://catalog-env.example.com/v1' }),
749
+ )
750
+ })
751
+
752
+ it('skips <MODULE>_AI_BASE_URL lookup when moduleId is undefined', () => {
753
+ const { provider, createModel } = makeBaseUrlProviderWithSpy()
754
+ const env = { CATALOG_AI_BASE_URL: 'https://catalog-env.example.com/v1' }
755
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider, env))
756
+ const resolution = factory.resolveModel({
757
+ agentDefaultBaseUrl: 'https://agent.example.com/v1',
758
+ } satisfies AiModelFactoryInput)
759
+ expect(resolution.baseURL).toBe('https://agent.example.com/v1')
760
+ expect(createModel).toHaveBeenCalledWith(
761
+ expect.objectContaining({ baseURL: 'https://agent.example.com/v1' }),
762
+ )
763
+ })
764
+
765
+ it('baseURL axis is independent of model + provider axes (composes with caller_override)', () => {
766
+ const { provider, createModel } = makeBaseUrlProviderWithSpy()
767
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
768
+ const resolution = factory.resolveModel({
769
+ callerOverride: 'caller-model',
770
+ baseUrlOverride: 'https://caller.example.com/v1',
771
+ })
772
+ expect(resolution.source).toBe('caller_override')
773
+ expect(resolution.modelId).toBe('caller-model')
774
+ expect(resolution.baseURL).toBe('https://caller.example.com/v1')
775
+ expect(createModel).toHaveBeenCalledWith({
776
+ modelId: 'caller-model',
777
+ apiKey: 'test-api-key',
778
+ baseURL: 'https://caller.example.com/v1',
779
+ })
780
+ })
781
+ })
447
782
  })
448
783
 
449
784
  describe('parseSlashShorthand', () => {
@@ -473,6 +808,10 @@ describe('parseSlashShorthand', () => {
473
808
  providerHint: null,
474
809
  modelId: 'meta-llama/Llama-3.3-70B',
475
810
  })
811
+ expect(parseSlashShorthand('qwen/qwen3.5-9b', registry)).toEqual({
812
+ providerHint: null,
813
+ modelId: 'qwen/qwen3.5-9b',
814
+ })
476
815
  })
477
816
 
478
817
  it('treats empty prefixes or suffixes as plain model ids', () => {
@@ -493,3 +832,298 @@ describe('parseSlashShorthand', () => {
493
832
  })
494
833
  })
495
834
  })
835
+
836
+ describe('Phase 4a — tenantOverride, requestOverride, allowRuntimeModelOverride', () => {
837
+ function makeMultiRegistry(providers: FakeProvider[]): AiModelFactoryRegistry {
838
+ return {
839
+ resolveFirstConfigured: (options) => {
840
+ const order = options?.order
841
+ if (order && order.length > 0) {
842
+ for (const id of order) {
843
+ const found = providers.find((p) => p.id === id)
844
+ if (found && found.isConfigured()) return found as unknown as ReturnType<AiModelFactoryRegistry['resolveFirstConfigured']>
845
+ }
846
+ const listed = new Set(order)
847
+ for (const p of providers) {
848
+ if (!listed.has(p.id) && p.isConfigured()) return p as unknown as ReturnType<AiModelFactoryRegistry['resolveFirstConfigured']>
849
+ }
850
+ return null
851
+ }
852
+ return providers.find((p) => p.isConfigured()) as unknown as ReturnType<AiModelFactoryRegistry['resolveFirstConfigured']> ?? null
853
+ },
854
+ get: (id: string) => providers.find((p) => p.id === id) as unknown as ReturnType<NonNullable<AiModelFactoryRegistry['get']>> ?? null,
855
+ }
856
+ }
857
+
858
+ it('requestOverride wins over callerOverride for both model and provider axes', () => {
859
+ const anthropic = makeProvider({ id: 'anthropic' })
860
+ const openai = makeProvider({ id: 'openai', defaultModel: 'gpt-4o-mini' })
861
+ const factory = createModelFactory({} as AwilixContainer, {
862
+ registry: makeMultiRegistry([anthropic, openai]),
863
+ env: {},
864
+ })
865
+ const resolution = factory.resolveModel({
866
+ callerOverride: 'some-caller-model',
867
+ requestOverride: { providerId: 'openai', modelId: 'gpt-5-mini' },
868
+ })
869
+ expect(resolution.source).toBe('request_override')
870
+ expect(resolution.modelId).toBe('gpt-5-mini')
871
+ expect(resolution.providerId).toBe('openai')
872
+ })
873
+
874
+ it('tenantOverride sits below callerOverride but above module_env', () => {
875
+ const anthropic = makeProvider({ id: 'anthropic' })
876
+ const openai = makeProvider({ id: 'openai', defaultModel: 'gpt-4o-mini' })
877
+ const factory = createModelFactory({} as AwilixContainer, {
878
+ registry: makeMultiRegistry([anthropic, openai]),
879
+ env: {},
880
+ })
881
+ const resolution = factory.resolveModel({
882
+ tenantOverride: { providerId: 'openai', modelId: 'tenant-model' },
883
+ agentDefaultModel: 'agent-model',
884
+ })
885
+ expect(resolution.source).toBe('tenant_override')
886
+ expect(resolution.modelId).toBe('tenant-model')
887
+ expect(resolution.providerId).toBe('openai')
888
+ })
889
+
890
+ it('allowRuntimeModelOverride: false skips requestOverride (step 1)', () => {
891
+ const provider = makeProvider()
892
+ const factory = createModelFactory({} as AwilixContainer, makeFactoryDeps(provider))
893
+ const resolution = factory.resolveModel({
894
+ allowRuntimeModelOverride: false,
895
+ requestOverride: { modelId: 'blocked-model' },
896
+ agentDefaultModel: 'agent-wins',
897
+ })
898
+ expect(resolution.source).toBe('agent_default')
899
+ expect(resolution.modelId).toBe('agent-wins')
900
+ })
901
+
902
+ it('allowRuntimeModelOverride: false skips tenantOverride (step 3)', () => {
903
+ const provider = makeProvider()
904
+ const factory = createModelFactory({} as AwilixContainer, makeFactoryDeps(provider))
905
+ const resolution = factory.resolveModel({
906
+ allowRuntimeModelOverride: false,
907
+ tenantOverride: { modelId: 'blocked-tenant-model' },
908
+ agentDefaultModel: 'agent-wins',
909
+ })
910
+ expect(resolution.source).toBe('agent_default')
911
+ expect(resolution.modelId).toBe('agent-wins')
912
+ })
913
+
914
+ it('allowRuntimeModelOverride: false still honors callerOverride (step 2)', () => {
915
+ const provider = makeProvider()
916
+ const factory = createModelFactory({} as AwilixContainer, makeFactoryDeps(provider))
917
+ const resolution = factory.resolveModel({
918
+ allowRuntimeModelOverride: false,
919
+ callerOverride: 'caller-still-wins',
920
+ tenantOverride: { modelId: 'blocked' },
921
+ })
922
+ expect(resolution.source).toBe('caller_override')
923
+ expect(resolution.modelId).toBe('caller-still-wins')
924
+ })
925
+
926
+ it('allowRuntimeModelOverride: true (default) honors tenantOverride', () => {
927
+ const provider = makeProvider()
928
+ const factory = createModelFactory({} as AwilixContainer, makeFactoryDeps(provider))
929
+ const resolution = factory.resolveModel({
930
+ tenantOverride: { modelId: 'tenant-model' },
931
+ })
932
+ expect(resolution.source).toBe('tenant_override')
933
+ expect(resolution.modelId).toBe('tenant-model')
934
+ })
935
+
936
+ it('requestOverride baseURL is resolved when runtimeOverrides are allowed', () => {
937
+ const provider = makeProvider()
938
+ const factory = createModelFactory({} as AwilixContainer, makeFactoryDeps(provider))
939
+ const resolution = factory.resolveModel({
940
+ requestOverride: { baseURL: 'https://custom.example.com/v1' },
941
+ })
942
+ expect(resolution.baseURL).toBe('https://custom.example.com/v1')
943
+ })
944
+
945
+ it('tenantOverride baseURL sits below requestOverride but above agentDefaultBaseUrl', () => {
946
+ const provider = makeProvider()
947
+ const factory = createModelFactory({} as AwilixContainer, makeFactoryDeps(provider))
948
+ const resolution = factory.resolveModel({
949
+ tenantOverride: { baseURL: 'https://tenant.example.com/v1' },
950
+ agentDefaultBaseUrl: 'https://agent.example.com/v1',
951
+ })
952
+ expect(resolution.baseURL).toBe('https://tenant.example.com/v1')
953
+ })
954
+
955
+ it('allowRuntimeModelOverride: false suppresses requestOverride baseURL', () => {
956
+ const provider = makeProvider()
957
+ const factory = createModelFactory({} as AwilixContainer, makeFactoryDeps(provider))
958
+ const resolution = factory.resolveModel({
959
+ allowRuntimeModelOverride: false,
960
+ requestOverride: { baseURL: 'https://blocked.example.com/v1' },
961
+ })
962
+ expect(resolution.baseURL).toBeUndefined()
963
+ })
964
+
965
+ describe('Phase 1780-5 — OM_AI_AVAILABLE_PROVIDERS / OM_AI_AVAILABLE_MODELS_<PROVIDER>', () => {
966
+ let warnSpy: jest.SpyInstance
967
+ beforeEach(() => {
968
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
969
+ })
970
+ afterEach(() => {
971
+ warnSpy.mockRestore()
972
+ })
973
+
974
+ it('passes through unchanged when no allowlist is configured', () => {
975
+ const provider = makeProvider({ id: 'openai', defaultModel: 'gpt-5-mini' })
976
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
977
+ const resolution = factory.resolveModel({ callerOverride: 'gpt-4o' })
978
+ expect(resolution.providerId).toBe('openai')
979
+ expect(resolution.modelId).toBe('gpt-4o')
980
+ expect(resolution.allowlistFallback).toBeUndefined()
981
+ expect(warnSpy).not.toHaveBeenCalled()
982
+ })
983
+
984
+ it('falls back to the agent default model when the resolved model is not allowlisted for the provider', () => {
985
+ const provider = makeProvider({
986
+ id: 'openai',
987
+ defaultModel: 'gpt-5-mini',
988
+ createModel: ({ modelId }) => ({ modelId }),
989
+ })
990
+ const env = { OM_AI_AVAILABLE_MODELS_OPENAI: 'gpt-5-mini' }
991
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider, env))
992
+ const resolution = factory.resolveModel({
993
+ callerOverride: 'gpt-4o',
994
+ agentDefaultModel: 'gpt-5-mini',
995
+ })
996
+ expect(resolution.providerId).toBe('openai')
997
+ expect(resolution.modelId).toBe('gpt-5-mini')
998
+ expect(resolution.source).toBe('allowlist_fallback')
999
+ expect(resolution.allowlistFallback).toEqual({
1000
+ originalProviderId: 'openai',
1001
+ originalModelId: 'gpt-4o',
1002
+ reason: expect.stringContaining('OM_AI_AVAILABLE_MODELS_OPENAI'),
1003
+ })
1004
+ expect(warnSpy).toHaveBeenCalledWith(
1005
+ expect.stringContaining('[AI Model Factory]'),
1006
+ )
1007
+ })
1008
+
1009
+ it('falls back to the first allowlisted model when neither the resolved nor the provider default is allowed', () => {
1010
+ const provider = makeProvider({
1011
+ id: 'openai',
1012
+ defaultModel: 'gpt-5-mini',
1013
+ })
1014
+ const env = { OM_AI_AVAILABLE_MODELS_OPENAI: 'gpt-4o,gpt-4-turbo' }
1015
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider, env))
1016
+ const resolution = factory.resolveModel({ callerOverride: 'gpt-5-pro' })
1017
+ expect(resolution.modelId).toBe('gpt-4o')
1018
+ expect(resolution.allowlistFallback).toBeDefined()
1019
+ })
1020
+
1021
+ it('swaps to the agent default provider when the resolved provider is not allowlisted', () => {
1022
+ const blocked = makeProvider({
1023
+ id: 'anthropic',
1024
+ defaultModel: 'claude-haiku-4-5',
1025
+ })
1026
+ const replacement = makeProvider({
1027
+ id: 'openai',
1028
+ defaultModel: 'gpt-5-mini',
1029
+ })
1030
+ const env = { OM_AI_AVAILABLE_PROVIDERS: 'openai' }
1031
+ const { registry } = makeMultiProviderRegistry([blocked, replacement])
1032
+ const factory = createModelFactory(fakeContainer, { registry, env })
1033
+ const resolution = factory.resolveModel({
1034
+ agentDefaultProvider: 'openai',
1035
+ agentDefaultModel: 'gpt-5-mini',
1036
+ providerOverride: 'anthropic',
1037
+ })
1038
+ expect(resolution.providerId).toBe('openai')
1039
+ expect(resolution.modelId).toBe('gpt-5-mini')
1040
+ expect(resolution.source).toBe('allowlist_fallback')
1041
+ expect(resolution.allowlistFallback?.originalProviderId).toBe('anthropic')
1042
+ })
1043
+
1044
+ it('accepts allowlisted (provider, model) pairs without intervention', () => {
1045
+ const provider = makeProvider({ id: 'openai', defaultModel: 'gpt-5-mini' })
1046
+ const env = {
1047
+ OM_AI_AVAILABLE_PROVIDERS: 'openai',
1048
+ OM_AI_AVAILABLE_MODELS_OPENAI: 'gpt-5-mini,gpt-4o',
1049
+ }
1050
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider, env))
1051
+ const resolution = factory.resolveModel({ callerOverride: 'gpt-4o' })
1052
+ expect(resolution.providerId).toBe('openai')
1053
+ expect(resolution.modelId).toBe('gpt-4o')
1054
+ expect(resolution.source).toBe('caller_override')
1055
+ expect(resolution.allowlistFallback).toBeUndefined()
1056
+ expect(warnSpy).not.toHaveBeenCalled()
1057
+ })
1058
+ })
1059
+
1060
+ describe('Phase 1780-6 — tenantAllowlist clipping', () => {
1061
+ let warnSpy: jest.SpyInstance
1062
+ beforeEach(() => {
1063
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
1064
+ })
1065
+ afterEach(() => {
1066
+ warnSpy.mockRestore()
1067
+ })
1068
+
1069
+ it('clips a tenant-blocked model down to the agent default', () => {
1070
+ const provider = makeProvider({ id: 'openai', defaultModel: 'gpt-5-mini' })
1071
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
1072
+ const resolution = factory.resolveModel({
1073
+ callerOverride: 'gpt-4o',
1074
+ agentDefaultModel: 'gpt-5-mini',
1075
+ tenantAllowlist: {
1076
+ allowedProviders: null,
1077
+ allowedModelsByProvider: { openai: ['gpt-5-mini'] },
1078
+ },
1079
+ })
1080
+ expect(resolution.modelId).toBe('gpt-5-mini')
1081
+ expect(resolution.source).toBe('allowlist_fallback')
1082
+ expect(resolution.allowlistFallback?.originalModelId).toBe('gpt-4o')
1083
+ expect(resolution.allowlistFallback?.reason).toContain('effective allowlist (env ∩ tenant)')
1084
+ })
1085
+
1086
+ it('passes through when the resolved pair satisfies env and tenant', () => {
1087
+ const provider = makeProvider({ id: 'openai', defaultModel: 'gpt-5-mini' })
1088
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
1089
+ const resolution = factory.resolveModel({
1090
+ callerOverride: 'gpt-5-mini',
1091
+ tenantAllowlist: {
1092
+ allowedProviders: ['openai'],
1093
+ allowedModelsByProvider: { openai: ['gpt-5-mini'] },
1094
+ },
1095
+ })
1096
+ expect(resolution.modelId).toBe('gpt-5-mini')
1097
+ expect(resolution.allowlistFallback).toBeUndefined()
1098
+ })
1099
+
1100
+ it('treats an empty tenant model list as "no models permitted" and falls back to provider default', () => {
1101
+ const provider = makeProvider({ id: 'openai', defaultModel: 'gpt-5-mini' })
1102
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
1103
+ const resolution = factory.resolveModel({
1104
+ callerOverride: 'gpt-4o',
1105
+ tenantAllowlist: {
1106
+ allowedProviders: null,
1107
+ allowedModelsByProvider: { openai: [] },
1108
+ },
1109
+ })
1110
+ expect(resolution.modelId).toBe('gpt-5-mini')
1111
+ expect(resolution.allowlistFallback).toBeDefined()
1112
+ })
1113
+
1114
+ it('clipping is the intersection of env and tenant — env stays the outer constraint', () => {
1115
+ const provider = makeProvider({ id: 'openai', defaultModel: 'gpt-5-mini' })
1116
+ const env = { OM_AI_AVAILABLE_MODELS_OPENAI: 'gpt-5-mini' }
1117
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider, env))
1118
+ const resolution = factory.resolveModel({
1119
+ callerOverride: 'gpt-4o',
1120
+ tenantAllowlist: {
1121
+ allowedProviders: null,
1122
+ allowedModelsByProvider: { openai: ['gpt-4o', 'gpt-5-mini'] },
1123
+ },
1124
+ })
1125
+ expect(resolution.modelId).toBe('gpt-5-mini')
1126
+ expect(resolution.allowlistFallback).toBeDefined()
1127
+ })
1128
+ })
1129
+ })