@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
|
@@ -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
|
-
{
|
|
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
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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)
|