@open-mercato/ai-assistant 0.6.1-develop.3246.1.dbef9d7392 → 0.6.1-develop.3256.1.fe3dec2464
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +82 -18
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +370 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +194 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/route.js +4 -0
- package/dist/modules/ai_assistant/api/ai/agents/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js +169 -5
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/route/route.js +38 -19
- package/dist/modules/ai_assistant/api/route/route.js.map +3 -3
- package/dist/modules/ai_assistant/api/settings/allowlist/route.js +195 -0
- package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/settings/route.js +537 -22
- package/dist/modules/ai_assistant/api/settings/route.js.map +3 -3
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +701 -147
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +338 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js +25 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js +1 -1
- package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +75 -26
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js +25 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js +503 -168
- package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities.js +123 -1
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +157 -0
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +7 -0
- package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js +77 -0
- package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js.map +7 -0
- package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js +1 -1
- package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +90 -1
- package/dist/modules/ai_assistant/i18n/en.json +90 -1
- package/dist/modules/ai_assistant/i18n/es.json +90 -1
- package/dist/modules/ai_assistant/i18n/pl.json +90 -1
- package/dist/modules/ai_assistant/lib/agent-registry.js +17 -1
- package/dist/modules/ai_assistant/lib/agent-registry.js.map +2 -2
- package/dist/modules/ai_assistant/lib/agent-runtime.js +133 -36
- package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
- package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
- package/dist/modules/ai_assistant/lib/baseurl-allowlist.js +29 -0
- package/dist/modules/ai_assistant/lib/baseurl-allowlist.js.map +7 -0
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +4 -1
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
- package/dist/modules/ai_assistant/lib/llm-adapters/google.js +4 -1
- package/dist/modules/ai_assistant/lib/llm-adapters/google.js.map +2 -2
- package/dist/modules/ai_assistant/lib/model-allowlist.js +211 -0
- package/dist/modules/ai_assistant/lib/model-allowlist.js.map +7 -0
- package/dist/modules/ai_assistant/lib/model-factory.js +203 -31
- package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +32 -1
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
- package/dist/modules/ai_assistant/migrations/Migration20260508140000.js +18 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508140000.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260512090000.js +16 -0
- package/dist/modules/ai_assistant/migrations/Migration20260512090000.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260512130000.js +15 -0
- package/dist/modules/ai_assistant/migrations/Migration20260512130000.js.map +7 -0
- package/generated/entities/ai_agent_runtime_override/index.ts +13 -0
- package/generated/entities/ai_tenant_model_allowlist/index.ts +9 -0
- package/generated/entities.ids.generated.ts +2 -0
- package/generated/entity-fields-registry.ts +26 -0
- package/jest.config.cjs +2 -0
- package/package.json +4 -4
- package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +477 -0
- package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +116 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +240 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +251 -0
- package/src/modules/ai_assistant/api/ai/agents/route.ts +4 -0
- package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +273 -0
- package/src/modules/ai_assistant/api/ai/chat/route.ts +211 -2
- package/src/modules/ai_assistant/api/route/route.ts +49 -25
- package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +408 -0
- package/src/modules/ai_assistant/api/settings/allowlist/route.ts +221 -0
- package/src/modules/ai_assistant/api/settings/route.ts +721 -27
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +858 -177
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +458 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.ts +23 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.tsx +12 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/legacy/page.tsx +1 -1
- package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +89 -12
- package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.ts +23 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.tsx +18 -0
- package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +617 -209
- package/src/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.ts +7 -0
- package/src/modules/ai_assistant/data/entities/AiTenantModelAllowlist.ts +2 -0
- package/src/modules/ai_assistant/data/entities.ts +164 -0
- package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +227 -0
- package/src/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.ts +132 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +337 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiTenantModelAllowlistRepository.test.ts +181 -0
- package/src/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.tsx +1 -1
- package/src/modules/ai_assistant/i18n/de.json +90 -1
- package/src/modules/ai_assistant/i18n/en.json +90 -1
- package/src/modules/ai_assistant/i18n/es.json +90 -1
- package/src/modules/ai_assistant/i18n/pl.json +90 -1
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +396 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +60 -6
- package/src/modules/ai_assistant/lib/__tests__/ai-api-operation-runner.test.ts +4 -2
- package/src/modules/ai_assistant/lib/__tests__/baseurl-allowlist.test.ts +75 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +18 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-google.test.ts +18 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +150 -4
- package/src/modules/ai_assistant/lib/__tests__/model-allowlist.test.ts +290 -0
- package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +634 -0
- package/src/modules/ai_assistant/lib/agent-registry.ts +20 -1
- package/src/modules/ai_assistant/lib/agent-runtime.ts +220 -44
- package/src/modules/ai_assistant/lib/ai-agent-definition.ts +48 -0
- package/src/modules/ai_assistant/lib/baseurl-allowlist.ts +64 -0
- package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +11 -1
- package/src/modules/ai_assistant/lib/llm-adapters/google.ts +4 -1
- package/src/modules/ai_assistant/lib/model-allowlist.ts +407 -0
- package/src/modules/ai_assistant/lib/model-factory.ts +486 -58
- package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +44 -0
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +704 -235
- package/src/modules/ai_assistant/migrations/Migration20260508140000.ts +18 -0
- package/src/modules/ai_assistant/migrations/Migration20260512090000.ts +16 -0
- package/src/modules/ai_assistant/migrations/Migration20260512130000.ts +13 -0
|
@@ -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
|
+
})
|