@open-mercato/ai-assistant 0.6.8-develop.6897.1.586171ad21 → 0.6.8-develop.6899.1.433952fd93
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/AGENTS.md +1 -1
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +2 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +7 -3
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +4 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +83 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +21 -4
package/AGENTS.md
CHANGED
|
@@ -413,7 +413,7 @@ Per-agent provider/model overrides are edited from `/backend/config/ai-assistant
|
|
|
413
413
|
| `PUT /api/ai_assistant/settings/allowlist` | Persists the tenant snapshot. Body validates against env first — out-of-env entries are rejected with `provider_not_in_env_allowlist` / `model_not_in_env_allowlist` 400 codes. Tenant allowlist may NEVER widen the env allowlist. |
|
|
414
414
|
| `DELETE /api/ai_assistant/settings/allowlist` | Soft-deletes the row; runtime falls back to env-only enforcement. Idempotent — `{ cleared: false }` when no active row exists. |
|
|
415
415
|
| `PUT /api/ai_assistant/settings` (runtime override) | Re-validates against the **effective** allowlist when an `org_id`/tenant snapshot is available, so admins can't store an override that the tenant allowlist would later reject. |
|
|
416
|
-
| `GET /api/ai_assistant/ai/agents/:id/models` | Picker response is clipped to the effective allowlist. The `<ModelPicker>` therefore only offers tenant-permitted values out of the box. |
|
|
416
|
+
| `GET /api/ai_assistant/ai/agents/:id/models` | Picker response is clipped to the effective allowlist. The `<ModelPicker>` therefore only offers tenant-permitted values out of the box. A failed tenant-scoped lookup returns 200 with a non-authoritative `degraded` list. |
|
|
417
417
|
| `POST /api/ai_assistant/ai/chat?provider=&model=` | Chat dispatcher rejects out-of-effective-allowlist query params with the same `provider_not_allowlisted` / `model_not_allowlisted` codes. The error message names "the effective allowlist (env ∩ tenant)" when the tenant snapshot contributes a narrowing. |
|
|
418
418
|
| `createModelFactory(...).resolveModel({ tenantAllowlist })` | The factory accepts an optional snapshot and intersects it with env at resolution time, so a stale tenant override or higher-priority source can never escape the effective set. Falls back via `allowlist_fallback` (same telemetry shape as Phase 1780-5). |
|
|
419
419
|
|
package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js
CHANGED
|
@@ -370,6 +370,8 @@ test.describe("TC-AI-RUNTIME-OVERRIDES-006: runtime model overrides", () => {
|
|
|
370
370
|
expect(body).toHaveProperty("agentId");
|
|
371
371
|
expect(body).toHaveProperty("allowRuntimeOverride");
|
|
372
372
|
expect(body).toHaveProperty("providers");
|
|
373
|
+
expect(body).toHaveProperty("degraded", false);
|
|
374
|
+
expect(body.degradedReason).toBeNull();
|
|
373
375
|
}
|
|
374
376
|
});
|
|
375
377
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts"],
|
|
4
|
-
"sourcesContent": ["import { test, expect } from '@playwright/test';\nimport { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth';\n\n/**\n * TC-AI-RUNTIME-OVERRIDES-006: Phase 4b \u2014 runtime model overrides (ModelPicker,\n * editable settings form, playground resolution panel).\n *\n * Coverage:\n * - /backend/config/ai-assistant/settings page loads with override form\n * - GlobalOverrideForm: provider + model selects, save, clear\n * - PerAgentOverrideList: table rows, source column, Clear override button\n * - /backend/config/ai-assistant/playground: ModelResolutionPanel renders\n * - ModelPicker renders in the playground's <AiChat> composer when the\n * agent allows runtime model override\n * - ModelPicker is absent when allowRuntimeOverride === false\n *\n * All API calls that would hit a real LLM or require a configured provider\n * are intercepted via page.route() stubs.\n */\ntest.describe('TC-AI-RUNTIME-OVERRIDES-006: runtime model overrides', () => {\n const settingsPath = '/backend/config/ai-assistant/settings';\n const playgroundPath = '/backend/config/ai-assistant/playground';\n\n // ---------------------------------------------------------------------------\n // Shared stubs\n // ---------------------------------------------------------------------------\n const settingsPayload = {\n provider: {\n id: 'anthropic',\n name: 'Anthropic',\n model: 'claude-haiku-4-5',\n defaultModel: 'claude-haiku-4-5',\n envKey: 'ANTHROPIC_API_KEY',\n configured: true,\n defaultModels: [\n { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' },\n { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' },\n ],\n },\n availableProviders: [\n {\n id: 'anthropic',\n name: 'Anthropic',\n model: 'claude-haiku-4-5',\n defaultModel: 'claude-haiku-4-5',\n envKey: 'ANTHROPIC_API_KEY',\n configured: true,\n defaultModels: [\n { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' },\n { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' },\n ],\n },\n {\n id: 'openai',\n name: 'OpenAI',\n model: 'gpt-5-mini',\n defaultModel: 'gpt-5-mini',\n envKey: 'OPENAI_API_KEY',\n configured: false,\n defaultModels: [{ id: 'gpt-5-mini', name: 'GPT-5 Mini' }],\n },\n ],\n mcpKeyConfigured: true,\n resolvedDefault: {\n providerId: 'anthropic',\n modelId: 'claude-haiku-4-5',\n baseURL: null,\n source: 'provider_default',\n },\n tenantOverride: null,\n agents: [\n {\n agentId: 'catalog.merchandising_assistant',\n moduleId: 'catalog',\n allowRuntimeOverride: true,\n providerId: 'anthropic',\n modelId: 'claude-haiku-4-5',\n baseURL: null,\n source: 'provider_default',\n },\n {\n agentId: 'customers.account_assistant',\n moduleId: 'customers',\n allowRuntimeOverride: false,\n providerId: 'anthropic',\n modelId: 'claude-sonnet-4-5',\n baseURL: null,\n source: 'tenant_override',\n },\n ],\n };\n\n const agentsPayload = {\n agents: [\n {\n id: 'catalog.merchandising_assistant',\n moduleId: 'catalog',\n label: 'Merchandising Assistant',\n description: 'Catalog merchandising tool.',\n systemPrompt: 'You are a merchandising assistant.',\n executionMode: 'chat',\n mutationPolicy: 'confirm-required',\n readOnly: false,\n maxSteps: 10,\n allowedTools: ['catalog.list_products'],\n tools: [{ name: 'catalog.list_products', displayName: 'List products', isMutation: false, registered: true }],\n requiredFeatures: ['catalog.view'],\n acceptedMediaTypes: [],\n hasOutputSchema: false,\n },\n ],\n total: 1,\n };\n\n // ---------------------------------------------------------------------------\n // Settings page\n // ---------------------------------------------------------------------------\n test.describe('Settings page (/backend/config/ai-assistant/settings)', () => {\n test('renders override form and per-agent resolution table', async ({ page }) => {\n test.setTimeout(120_000);\n await login(page, 'superadmin');\n\n await page.route('**/api/ai_assistant/settings', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(settingsPayload),\n });\n });\n\n await page.route('**/api/ai_assistant/health', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({ status: 'ok', url: 'http://localhost', mcpUrl: 'http://localhost:3001' }),\n });\n });\n\n await page.route('**/api/ai_assistant/tools', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({ tools: [] }),\n });\n });\n\n await page.goto(settingsPath, { waitUntil: 'domcontentloaded' });\n\n // The main settings container should be visible\n const settingsContainer = page.locator('[data-ai-assistant-settings]');\n await expect(settingsContainer).toBeVisible({ timeout: 30_000 });\n\n // Global override form\n const overrideForm = page.locator('[data-ai-settings-override-form]');\n await expect(overrideForm).toBeVisible({ timeout: 15_000 });\n\n // Per-agent override table\n const agentOverridesTable = page.locator('[data-ai-settings-agent-overrides]');\n await expect(agentOverridesTable).toBeVisible({ timeout: 15_000 });\n\n // Both registered agents appear as rows\n const catalogRow = page.locator('[data-ai-settings-agent-row=\"catalog.merchandising_assistant\"]');\n await expect(catalogRow).toBeVisible();\n\n const customersRow = page.locator('[data-ai-settings-agent-row=\"customers.account_assistant\"]');\n await expect(customersRow).toBeVisible();\n });\n\n test('shows Clear override button only for agents with non-default source', async ({ page }) => {\n test.setTimeout(120_000);\n await login(page, 'superadmin');\n\n await page.route('**/api/ai_assistant/settings', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(settingsPayload),\n });\n });\n\n await page.route('**/api/ai_assistant/ai/agents', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(agentsPayload),\n });\n });\n\n await page.route('**/api/ai_assistant/health', async (route) => {\n await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', url: 'http://localhost', mcpUrl: 'http://localhost:3001' }) });\n });\n\n await page.route('**/api/ai_assistant/tools', async (route) => {\n await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tools: [] }) });\n });\n\n await page.goto(settingsPath, { waitUntil: 'domcontentloaded' });\n\n const agentOverridesTable = page.locator('[data-ai-settings-agent-overrides]');\n await expect(agentOverridesTable).toBeVisible({ timeout: 30_000 });\n\n // customers.account_assistant has source='tenant_override' \u2192 should have Clear button\n const customersClear = page.locator('[data-ai-settings-clear-agent-override=\"customers.account_assistant\"]');\n await expect(customersClear).toBeVisible();\n\n // catalog.merchandising_assistant has source='provider_default' \u2192 no Clear button\n const catalogClear = page.locator('[data-ai-settings-clear-agent-override=\"catalog.merchandising_assistant\"]');\n await expect(catalogClear).not.toBeVisible();\n });\n\n test('save override calls PUT /api/ai_assistant/settings', async ({ page }) => {\n test.setTimeout(120_000);\n await login(page, 'superadmin');\n\n let putCalls = 0;\n await page.route('**/api/ai_assistant/settings', async (route) => {\n if (route.request().method() === 'PUT') {\n putCalls += 1;\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({\n id: 'row-1',\n tenantId: 'tenant-1',\n organizationId: 'org-1',\n agentId: null,\n providerId: 'anthropic',\n modelId: 'claude-sonnet-4-5',\n baseUrl: null,\n updatedAt: new Date().toISOString(),\n }),\n });\n return;\n }\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(settingsPayload),\n });\n });\n\n await page.route('**/api/ai_assistant/health', async (route) => {\n await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', url: 'http://localhost', mcpUrl: 'http://localhost:3001' }) });\n });\n\n await page.route('**/api/ai_assistant/tools', async (route) => {\n await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tools: [] }) });\n });\n\n await page.goto(settingsPath, { waitUntil: 'domcontentloaded' });\n\n const overrideForm = page.locator('[data-ai-settings-override-form]');\n await expect(overrideForm).toBeVisible({ timeout: 30_000 });\n\n // Select provider\n const providerSelect = page.locator('[data-ai-settings-provider-select]');\n await providerSelect.click();\n const anthropicOption = page.getByRole('option', { name: 'Anthropic' });\n await anthropicOption.click();\n\n // Select model\n const modelSelect = page.locator('[data-ai-settings-model-select]');\n await modelSelect.click();\n const sonnetOption = page.getByRole('option', { name: 'Claude Sonnet 4.5' });\n await sonnetOption.click();\n\n // Save\n const saveButton = page.locator('[data-ai-settings-save-override]');\n await saveButton.click();\n\n await page.waitForTimeout(500);\n expect(putCalls).toBeGreaterThanOrEqual(1);\n });\n\n test('clear override calls DELETE /api/ai_assistant/settings', async ({ page }) => {\n test.setTimeout(120_000);\n await login(page, 'superadmin');\n\n const settingsWithOverride = {\n ...settingsPayload,\n tenantOverride: {\n providerId: 'anthropic',\n modelId: 'claude-sonnet-4-5',\n baseURL: null,\n agentId: null,\n updatedAt: new Date().toISOString(),\n },\n };\n\n let deleteCalls = 0;\n await page.route('**/api/ai_assistant/settings', async (route) => {\n if (route.request().method() === 'DELETE') {\n deleteCalls += 1;\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({ cleared: true }),\n });\n return;\n }\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(settingsWithOverride),\n });\n });\n\n await page.route('**/api/ai_assistant/health', async (route) => {\n await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', url: 'http://localhost', mcpUrl: 'http://localhost:3001' }) });\n });\n\n await page.route('**/api/ai_assistant/tools', async (route) => {\n await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tools: [] }) });\n });\n\n await page.goto(settingsPath, { waitUntil: 'domcontentloaded' });\n\n // The \"Clear override\" button for the active override\n const clearButton = page.locator('[data-ai-settings-clear-override]');\n await expect(clearButton).toBeVisible({ timeout: 30_000 });\n await clearButton.click();\n\n await page.waitForTimeout(500);\n expect(deleteCalls).toBeGreaterThanOrEqual(1);\n });\n });\n\n // ---------------------------------------------------------------------------\n // Playground page \u2014 ModelResolutionPanel\n // ---------------------------------------------------------------------------\n test.describe('Playground page (/backend/config/ai-assistant/playground)', () => {\n test('renders ModelResolutionPanel with provider/model/source for the selected agent', async ({\n page,\n }) => {\n test.setTimeout(120_000);\n await login(page, 'superadmin');\n\n await page.route('**/api/ai_assistant/ai/agents', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(agentsPayload),\n });\n });\n\n await page.route('**/api/ai_assistant/settings', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(settingsPayload),\n });\n });\n\n await page.goto(playgroundPath, { waitUntil: 'domcontentloaded' });\n\n // Wait for agent list to load\n const agentSection = page.locator('[data-ai-playground-chat=\"catalog.merchandising_assistant\"]');\n await expect(agentSection).toBeVisible({ timeout: 30_000 });\n\n // The resolution panel should show provider info\n const resolutionPanel = page.locator('[data-ai-playground-model-resolution=\"catalog.merchandising_assistant\"]');\n await expect(resolutionPanel).toBeVisible({ timeout: 15_000 });\n\n // Provider field should be present\n const providerField = page.locator('[data-ai-playground-resolution-provider]');\n await expect(providerField).toBeVisible();\n\n // Model field should be present\n const modelField = page.locator('[data-ai-playground-resolution-model]');\n await expect(modelField).toBeVisible();\n\n // Source field should be present\n const sourceField = page.locator('[data-ai-playground-resolution-source]');\n await expect(sourceField).toBeVisible();\n });\n\n test('ModelPicker is present in AiChat composer when allowRuntimeOverride is true', async ({\n page,\n }) => {\n test.setTimeout(120_000);\n await login(page, 'superadmin');\n\n await page.route('**/api/ai_assistant/ai/agents', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(agentsPayload),\n });\n });\n\n await page.route('**/api/ai_assistant/settings', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(settingsPayload),\n });\n });\n\n // Stub the models endpoint\n await page.route('**/api/ai_assistant/ai/agents/*/models', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({\n agentId: 'catalog.merchandising_assistant',\n allowRuntimeOverride: true,\n defaultProviderId: 'anthropic',\n defaultModelId: 'claude-haiku-4-5',\n providers: [\n {\n id: 'anthropic',\n name: 'Anthropic',\n isDefault: true,\n models: [\n { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', isDefault: true },\n ],\n },\n ],\n }),\n });\n });\n\n await page.goto(playgroundPath, { waitUntil: 'domcontentloaded' });\n\n // Wait for the chat area to be visible under the selected agent\n const chatContainer = page.locator('[data-ai-playground-chat=\"catalog.merchandising_assistant\"]');\n await expect(chatContainer).toBeVisible({ timeout: 30_000 });\n\n // The ModelPicker trigger should be visible inside the chat container\n const modelPickerTrigger = chatContainer.locator('[data-ai-model-picker-trigger]');\n // Use a soft assertion \u2014 the picker requires the models endpoint to resolve;\n // if the CI environment skips the endpoint, we verify the playground itself loaded.\n const pickerVisible = await modelPickerTrigger.isVisible().catch(() => false);\n if (pickerVisible) {\n await expect(modelPickerTrigger).toBeVisible();\n } else {\n // At minimum the chat area must be visible\n await expect(chatContainer).toBeVisible();\n }\n });\n });\n\n // ---------------------------------------------------------------------------\n // API contract tests (no browser needed)\n // ---------------------------------------------------------------------------\n test.describe('API contract \u2014 GET /api/ai_assistant/settings', () => {\n test('unauthenticated request returns 401 or redirect', async ({ request }) => {\n const response = await request.get('/api/ai_assistant/settings');\n expect([200, 401, 302, 403]).toContain(response.status());\n });\n });\n\n test.describe('API contract \u2014 PUT /api/ai_assistant/settings', () => {\n test('unauthenticated PUT returns 401', async ({ request }) => {\n const response = await request.put('/api/ai_assistant/settings', {\n data: { providerId: 'anthropic', modelId: 'claude-haiku-4-5' },\n headers: { 'content-type': 'application/json' },\n });\n expect([400, 401, 403]).toContain(response.status());\n });\n });\n\n test.describe('API contract \u2014 DELETE /api/ai_assistant/settings', () => {\n test('unauthenticated DELETE returns 401', async ({ request }) => {\n const response = await request.delete('/api/ai_assistant/settings', {\n data: {},\n headers: { 'content-type': 'application/json' },\n });\n expect([400, 401, 403]).toContain(response.status());\n });\n });\n\n test.describe('API contract \u2014 GET /api/ai_assistant/ai/agents/:agentId/models', () => {\n test('route is mounted and returns 401 or JSON payload', async ({ request }) => {\n const response = await request.get('/api/ai_assistant/ai/agents/catalog.merchandising_assistant/models');\n expect([200, 401, 403]).toContain(response.status());\n if (response.status() === 200) {\n const body = await response.json();\n expect(body).toHaveProperty('agentId');\n expect(body).toHaveProperty('allowRuntimeOverride');\n expect(body).toHaveProperty('providers');\n }\n });\n });\n});\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import { test, expect } from '@playwright/test';\nimport { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth';\n\n/**\n * TC-AI-RUNTIME-OVERRIDES-006: Phase 4b \u2014 runtime model overrides (ModelPicker,\n * editable settings form, playground resolution panel).\n *\n * Coverage:\n * - /backend/config/ai-assistant/settings page loads with override form\n * - GlobalOverrideForm: provider + model selects, save, clear\n * - PerAgentOverrideList: table rows, source column, Clear override button\n * - /backend/config/ai-assistant/playground: ModelResolutionPanel renders\n * - ModelPicker renders in the playground's <AiChat> composer when the\n * agent allows runtime model override\n * - ModelPicker is absent when allowRuntimeOverride === false\n *\n * All API calls that would hit a real LLM or require a configured provider\n * are intercepted via page.route() stubs.\n */\ntest.describe('TC-AI-RUNTIME-OVERRIDES-006: runtime model overrides', () => {\n const settingsPath = '/backend/config/ai-assistant/settings';\n const playgroundPath = '/backend/config/ai-assistant/playground';\n\n // ---------------------------------------------------------------------------\n // Shared stubs\n // ---------------------------------------------------------------------------\n const settingsPayload = {\n provider: {\n id: 'anthropic',\n name: 'Anthropic',\n model: 'claude-haiku-4-5',\n defaultModel: 'claude-haiku-4-5',\n envKey: 'ANTHROPIC_API_KEY',\n configured: true,\n defaultModels: [\n { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' },\n { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' },\n ],\n },\n availableProviders: [\n {\n id: 'anthropic',\n name: 'Anthropic',\n model: 'claude-haiku-4-5',\n defaultModel: 'claude-haiku-4-5',\n envKey: 'ANTHROPIC_API_KEY',\n configured: true,\n defaultModels: [\n { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' },\n { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' },\n ],\n },\n {\n id: 'openai',\n name: 'OpenAI',\n model: 'gpt-5-mini',\n defaultModel: 'gpt-5-mini',\n envKey: 'OPENAI_API_KEY',\n configured: false,\n defaultModels: [{ id: 'gpt-5-mini', name: 'GPT-5 Mini' }],\n },\n ],\n mcpKeyConfigured: true,\n resolvedDefault: {\n providerId: 'anthropic',\n modelId: 'claude-haiku-4-5',\n baseURL: null,\n source: 'provider_default',\n },\n tenantOverride: null,\n agents: [\n {\n agentId: 'catalog.merchandising_assistant',\n moduleId: 'catalog',\n allowRuntimeOverride: true,\n providerId: 'anthropic',\n modelId: 'claude-haiku-4-5',\n baseURL: null,\n source: 'provider_default',\n },\n {\n agentId: 'customers.account_assistant',\n moduleId: 'customers',\n allowRuntimeOverride: false,\n providerId: 'anthropic',\n modelId: 'claude-sonnet-4-5',\n baseURL: null,\n source: 'tenant_override',\n },\n ],\n };\n\n const agentsPayload = {\n agents: [\n {\n id: 'catalog.merchandising_assistant',\n moduleId: 'catalog',\n label: 'Merchandising Assistant',\n description: 'Catalog merchandising tool.',\n systemPrompt: 'You are a merchandising assistant.',\n executionMode: 'chat',\n mutationPolicy: 'confirm-required',\n readOnly: false,\n maxSteps: 10,\n allowedTools: ['catalog.list_products'],\n tools: [{ name: 'catalog.list_products', displayName: 'List products', isMutation: false, registered: true }],\n requiredFeatures: ['catalog.view'],\n acceptedMediaTypes: [],\n hasOutputSchema: false,\n },\n ],\n total: 1,\n };\n\n // ---------------------------------------------------------------------------\n // Settings page\n // ---------------------------------------------------------------------------\n test.describe('Settings page (/backend/config/ai-assistant/settings)', () => {\n test('renders override form and per-agent resolution table', async ({ page }) => {\n test.setTimeout(120_000);\n await login(page, 'superadmin');\n\n await page.route('**/api/ai_assistant/settings', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(settingsPayload),\n });\n });\n\n await page.route('**/api/ai_assistant/health', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({ status: 'ok', url: 'http://localhost', mcpUrl: 'http://localhost:3001' }),\n });\n });\n\n await page.route('**/api/ai_assistant/tools', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({ tools: [] }),\n });\n });\n\n await page.goto(settingsPath, { waitUntil: 'domcontentloaded' });\n\n // The main settings container should be visible\n const settingsContainer = page.locator('[data-ai-assistant-settings]');\n await expect(settingsContainer).toBeVisible({ timeout: 30_000 });\n\n // Global override form\n const overrideForm = page.locator('[data-ai-settings-override-form]');\n await expect(overrideForm).toBeVisible({ timeout: 15_000 });\n\n // Per-agent override table\n const agentOverridesTable = page.locator('[data-ai-settings-agent-overrides]');\n await expect(agentOverridesTable).toBeVisible({ timeout: 15_000 });\n\n // Both registered agents appear as rows\n const catalogRow = page.locator('[data-ai-settings-agent-row=\"catalog.merchandising_assistant\"]');\n await expect(catalogRow).toBeVisible();\n\n const customersRow = page.locator('[data-ai-settings-agent-row=\"customers.account_assistant\"]');\n await expect(customersRow).toBeVisible();\n });\n\n test('shows Clear override button only for agents with non-default source', async ({ page }) => {\n test.setTimeout(120_000);\n await login(page, 'superadmin');\n\n await page.route('**/api/ai_assistant/settings', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(settingsPayload),\n });\n });\n\n await page.route('**/api/ai_assistant/ai/agents', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(agentsPayload),\n });\n });\n\n await page.route('**/api/ai_assistant/health', async (route) => {\n await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', url: 'http://localhost', mcpUrl: 'http://localhost:3001' }) });\n });\n\n await page.route('**/api/ai_assistant/tools', async (route) => {\n await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tools: [] }) });\n });\n\n await page.goto(settingsPath, { waitUntil: 'domcontentloaded' });\n\n const agentOverridesTable = page.locator('[data-ai-settings-agent-overrides]');\n await expect(agentOverridesTable).toBeVisible({ timeout: 30_000 });\n\n // customers.account_assistant has source='tenant_override' \u2192 should have Clear button\n const customersClear = page.locator('[data-ai-settings-clear-agent-override=\"customers.account_assistant\"]');\n await expect(customersClear).toBeVisible();\n\n // catalog.merchandising_assistant has source='provider_default' \u2192 no Clear button\n const catalogClear = page.locator('[data-ai-settings-clear-agent-override=\"catalog.merchandising_assistant\"]');\n await expect(catalogClear).not.toBeVisible();\n });\n\n test('save override calls PUT /api/ai_assistant/settings', async ({ page }) => {\n test.setTimeout(120_000);\n await login(page, 'superadmin');\n\n let putCalls = 0;\n await page.route('**/api/ai_assistant/settings', async (route) => {\n if (route.request().method() === 'PUT') {\n putCalls += 1;\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({\n id: 'row-1',\n tenantId: 'tenant-1',\n organizationId: 'org-1',\n agentId: null,\n providerId: 'anthropic',\n modelId: 'claude-sonnet-4-5',\n baseUrl: null,\n updatedAt: new Date().toISOString(),\n }),\n });\n return;\n }\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(settingsPayload),\n });\n });\n\n await page.route('**/api/ai_assistant/health', async (route) => {\n await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', url: 'http://localhost', mcpUrl: 'http://localhost:3001' }) });\n });\n\n await page.route('**/api/ai_assistant/tools', async (route) => {\n await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tools: [] }) });\n });\n\n await page.goto(settingsPath, { waitUntil: 'domcontentloaded' });\n\n const overrideForm = page.locator('[data-ai-settings-override-form]');\n await expect(overrideForm).toBeVisible({ timeout: 30_000 });\n\n // Select provider\n const providerSelect = page.locator('[data-ai-settings-provider-select]');\n await providerSelect.click();\n const anthropicOption = page.getByRole('option', { name: 'Anthropic' });\n await anthropicOption.click();\n\n // Select model\n const modelSelect = page.locator('[data-ai-settings-model-select]');\n await modelSelect.click();\n const sonnetOption = page.getByRole('option', { name: 'Claude Sonnet 4.5' });\n await sonnetOption.click();\n\n // Save\n const saveButton = page.locator('[data-ai-settings-save-override]');\n await saveButton.click();\n\n await page.waitForTimeout(500);\n expect(putCalls).toBeGreaterThanOrEqual(1);\n });\n\n test('clear override calls DELETE /api/ai_assistant/settings', async ({ page }) => {\n test.setTimeout(120_000);\n await login(page, 'superadmin');\n\n const settingsWithOverride = {\n ...settingsPayload,\n tenantOverride: {\n providerId: 'anthropic',\n modelId: 'claude-sonnet-4-5',\n baseURL: null,\n agentId: null,\n updatedAt: new Date().toISOString(),\n },\n };\n\n let deleteCalls = 0;\n await page.route('**/api/ai_assistant/settings', async (route) => {\n if (route.request().method() === 'DELETE') {\n deleteCalls += 1;\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({ cleared: true }),\n });\n return;\n }\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(settingsWithOverride),\n });\n });\n\n await page.route('**/api/ai_assistant/health', async (route) => {\n await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', url: 'http://localhost', mcpUrl: 'http://localhost:3001' }) });\n });\n\n await page.route('**/api/ai_assistant/tools', async (route) => {\n await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tools: [] }) });\n });\n\n await page.goto(settingsPath, { waitUntil: 'domcontentloaded' });\n\n // The \"Clear override\" button for the active override\n const clearButton = page.locator('[data-ai-settings-clear-override]');\n await expect(clearButton).toBeVisible({ timeout: 30_000 });\n await clearButton.click();\n\n await page.waitForTimeout(500);\n expect(deleteCalls).toBeGreaterThanOrEqual(1);\n });\n });\n\n // ---------------------------------------------------------------------------\n // Playground page \u2014 ModelResolutionPanel\n // ---------------------------------------------------------------------------\n test.describe('Playground page (/backend/config/ai-assistant/playground)', () => {\n test('renders ModelResolutionPanel with provider/model/source for the selected agent', async ({\n page,\n }) => {\n test.setTimeout(120_000);\n await login(page, 'superadmin');\n\n await page.route('**/api/ai_assistant/ai/agents', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(agentsPayload),\n });\n });\n\n await page.route('**/api/ai_assistant/settings', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(settingsPayload),\n });\n });\n\n await page.goto(playgroundPath, { waitUntil: 'domcontentloaded' });\n\n // Wait for agent list to load\n const agentSection = page.locator('[data-ai-playground-chat=\"catalog.merchandising_assistant\"]');\n await expect(agentSection).toBeVisible({ timeout: 30_000 });\n\n // The resolution panel should show provider info\n const resolutionPanel = page.locator('[data-ai-playground-model-resolution=\"catalog.merchandising_assistant\"]');\n await expect(resolutionPanel).toBeVisible({ timeout: 15_000 });\n\n // Provider field should be present\n const providerField = page.locator('[data-ai-playground-resolution-provider]');\n await expect(providerField).toBeVisible();\n\n // Model field should be present\n const modelField = page.locator('[data-ai-playground-resolution-model]');\n await expect(modelField).toBeVisible();\n\n // Source field should be present\n const sourceField = page.locator('[data-ai-playground-resolution-source]');\n await expect(sourceField).toBeVisible();\n });\n\n test('ModelPicker is present in AiChat composer when allowRuntimeOverride is true', async ({\n page,\n }) => {\n test.setTimeout(120_000);\n await login(page, 'superadmin');\n\n await page.route('**/api/ai_assistant/ai/agents', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(agentsPayload),\n });\n });\n\n await page.route('**/api/ai_assistant/settings', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(settingsPayload),\n });\n });\n\n // Stub the models endpoint\n await page.route('**/api/ai_assistant/ai/agents/*/models', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({\n agentId: 'catalog.merchandising_assistant',\n allowRuntimeOverride: true,\n defaultProviderId: 'anthropic',\n defaultModelId: 'claude-haiku-4-5',\n providers: [\n {\n id: 'anthropic',\n name: 'Anthropic',\n isDefault: true,\n models: [\n { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', isDefault: true },\n ],\n },\n ],\n }),\n });\n });\n\n await page.goto(playgroundPath, { waitUntil: 'domcontentloaded' });\n\n // Wait for the chat area to be visible under the selected agent\n const chatContainer = page.locator('[data-ai-playground-chat=\"catalog.merchandising_assistant\"]');\n await expect(chatContainer).toBeVisible({ timeout: 30_000 });\n\n // The ModelPicker trigger should be visible inside the chat container\n const modelPickerTrigger = chatContainer.locator('[data-ai-model-picker-trigger]');\n // Use a soft assertion \u2014 the picker requires the models endpoint to resolve;\n // if the CI environment skips the endpoint, we verify the playground itself loaded.\n const pickerVisible = await modelPickerTrigger.isVisible().catch(() => false);\n if (pickerVisible) {\n await expect(modelPickerTrigger).toBeVisible();\n } else {\n // At minimum the chat area must be visible\n await expect(chatContainer).toBeVisible();\n }\n });\n });\n\n // ---------------------------------------------------------------------------\n // API contract tests (no browser needed)\n // ---------------------------------------------------------------------------\n test.describe('API contract \u2014 GET /api/ai_assistant/settings', () => {\n test('unauthenticated request returns 401 or redirect', async ({ request }) => {\n const response = await request.get('/api/ai_assistant/settings');\n expect([200, 401, 302, 403]).toContain(response.status());\n });\n });\n\n test.describe('API contract \u2014 PUT /api/ai_assistant/settings', () => {\n test('unauthenticated PUT returns 401', async ({ request }) => {\n const response = await request.put('/api/ai_assistant/settings', {\n data: { providerId: 'anthropic', modelId: 'claude-haiku-4-5' },\n headers: { 'content-type': 'application/json' },\n });\n expect([400, 401, 403]).toContain(response.status());\n });\n });\n\n test.describe('API contract \u2014 DELETE /api/ai_assistant/settings', () => {\n test('unauthenticated DELETE returns 401', async ({ request }) => {\n const response = await request.delete('/api/ai_assistant/settings', {\n data: {},\n headers: { 'content-type': 'application/json' },\n });\n expect([400, 401, 403]).toContain(response.status());\n });\n });\n\n test.describe('API contract \u2014 GET /api/ai_assistant/ai/agents/:agentId/models', () => {\n test('route is mounted and returns 401 or JSON payload', async ({ request }) => {\n const response = await request.get('/api/ai_assistant/ai/agents/catalog.merchandising_assistant/models');\n expect([200, 401, 403]).toContain(response.status());\n if (response.status() === 200) {\n const body = await response.json();\n expect(body).toHaveProperty('agentId');\n expect(body).toHaveProperty('allowRuntimeOverride');\n expect(body).toHaveProperty('providers');\n // Both degradation fields are emitted unconditionally, so a healthy response\n // pins them to the fully-resolved values rather than leaving them absent.\n expect(body).toHaveProperty('degraded', false);\n expect(body.degradedReason).toBeNull();\n }\n });\n });\n});\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,MAAM,cAAc;AAC7B,SAAS,aAAa;AAkBtB,KAAK,SAAS,wDAAwD,MAAM;AAC1E,QAAM,eAAe;AACrB,QAAM,iBAAiB;AAKvB,QAAM,kBAAkB;AAAA,IACtB,UAAU;AAAA,MACR,IAAI;AAAA,MACJ,MAAM;AAAA,MACN,OAAO;AAAA,MACP,cAAc;AAAA,MACd,QAAQ;AAAA,MACR,YAAY;AAAA,MACZ,eAAe;AAAA,QACb,EAAE,IAAI,oBAAoB,MAAM,mBAAmB;AAAA,QACnD,EAAE,IAAI,qBAAqB,MAAM,oBAAoB;AAAA,MACvD;AAAA,IACF;AAAA,IACA,oBAAoB;AAAA,MAClB;AAAA,QACE,IAAI;AAAA,QACJ,MAAM;AAAA,QACN,OAAO;AAAA,QACP,cAAc;AAAA,QACd,QAAQ;AAAA,QACR,YAAY;AAAA,QACZ,eAAe;AAAA,UACb,EAAE,IAAI,oBAAoB,MAAM,mBAAmB;AAAA,UACnD,EAAE,IAAI,qBAAqB,MAAM,oBAAoB;AAAA,QACvD;AAAA,MACF;AAAA,MACA;AAAA,QACE,IAAI;AAAA,QACJ,MAAM;AAAA,QACN,OAAO;AAAA,QACP,cAAc;AAAA,QACd,QAAQ;AAAA,QACR,YAAY;AAAA,QACZ,eAAe,CAAC,EAAE,IAAI,cAAc,MAAM,aAAa,CAAC;AAAA,MAC1D;AAAA,IACF;AAAA,IACA,kBAAkB;AAAA,IAClB,iBAAiB;AAAA,MACf,YAAY;AAAA,MACZ,SAAS;AAAA,MACT,SAAS;AAAA,MACT,QAAQ;AAAA,IACV;AAAA,IACA,gBAAgB;AAAA,IAChB,QAAQ;AAAA,MACN;AAAA,QACE,SAAS;AAAA,QACT,UAAU;AAAA,QACV,sBAAsB;AAAA,QACtB,YAAY;AAAA,QACZ,SAAS;AAAA,QACT,SAAS;AAAA,QACT,QAAQ;AAAA,MACV;AAAA,MACA;AAAA,QACE,SAAS;AAAA,QACT,UAAU;AAAA,QACV,sBAAsB;AAAA,QACtB,YAAY;AAAA,QACZ,SAAS;AAAA,QACT,SAAS;AAAA,QACT,QAAQ;AAAA,MACV;AAAA,IACF;AAAA,EACF;AAEA,QAAM,gBAAgB;AAAA,IACpB,QAAQ;AAAA,MACN;AAAA,QACE,IAAI;AAAA,QACJ,UAAU;AAAA,QACV,OAAO;AAAA,QACP,aAAa;AAAA,QACb,cAAc;AAAA,QACd,eAAe;AAAA,QACf,gBAAgB;AAAA,QAChB,UAAU;AAAA,QACV,UAAU;AAAA,QACV,cAAc,CAAC,uBAAuB;AAAA,QACtC,OAAO,CAAC,EAAE,MAAM,yBAAyB,aAAa,iBAAiB,YAAY,OAAO,YAAY,KAAK,CAAC;AAAA,QAC5G,kBAAkB,CAAC,cAAc;AAAA,QACjC,oBAAoB,CAAC;AAAA,QACrB,iBAAiB;AAAA,MACnB;AAAA,IACF;AAAA,IACA,OAAO;AAAA,EACT;AAKA,OAAK,SAAS,yDAAyD,MAAM;AAC3E,SAAK,wDAAwD,OAAO,EAAE,KAAK,MAAM;AAC/E,WAAK,WAAW,IAAO;AACvB,YAAM,MAAM,MAAM,YAAY;AAE9B,YAAM,KAAK,MAAM,gCAAgC,OAAO,UAAU;AAChE,cAAM,MAAM,QAAQ;AAAA,UAClB,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,MAAM,KAAK,UAAU,eAAe;AAAA,QACtC,CAAC;AAAA,MACH,CAAC;AAED,YAAM,KAAK,MAAM,8BAA8B,OAAO,UAAU;AAC9D,cAAM,MAAM,QAAQ;AAAA,UAClB,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,MAAM,KAAK,UAAU,EAAE,QAAQ,MAAM,KAAK,oBAAoB,QAAQ,wBAAwB,CAAC;AAAA,QACjG,CAAC;AAAA,MACH,CAAC;AAED,YAAM,KAAK,MAAM,6BAA6B,OAAO,UAAU;AAC7D,cAAM,MAAM,QAAQ;AAAA,UAClB,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,MAAM,KAAK,UAAU,EAAE,OAAO,CAAC,EAAE,CAAC;AAAA,QACpC,CAAC;AAAA,MACH,CAAC;AAED,YAAM,KAAK,KAAK,cAAc,EAAE,WAAW,mBAAmB,CAAC;AAG/D,YAAM,oBAAoB,KAAK,QAAQ,8BAA8B;AACrE,YAAM,OAAO,iBAAiB,EAAE,YAAY,EAAE,SAAS,IAAO,CAAC;AAG/D,YAAM,eAAe,KAAK,QAAQ,kCAAkC;AACpE,YAAM,OAAO,YAAY,EAAE,YAAY,EAAE,SAAS,KAAO,CAAC;AAG1D,YAAM,sBAAsB,KAAK,QAAQ,oCAAoC;AAC7E,YAAM,OAAO,mBAAmB,EAAE,YAAY,EAAE,SAAS,KAAO,CAAC;AAGjE,YAAM,aAAa,KAAK,QAAQ,gEAAgE;AAChG,YAAM,OAAO,UAAU,EAAE,YAAY;AAErC,YAAM,eAAe,KAAK,QAAQ,4DAA4D;AAC9F,YAAM,OAAO,YAAY,EAAE,YAAY;AAAA,IACzC,CAAC;AAED,SAAK,uEAAuE,OAAO,EAAE,KAAK,MAAM;AAC9F,WAAK,WAAW,IAAO;AACvB,YAAM,MAAM,MAAM,YAAY;AAE9B,YAAM,KAAK,MAAM,gCAAgC,OAAO,UAAU;AAChE,cAAM,MAAM,QAAQ;AAAA,UAClB,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,MAAM,KAAK,UAAU,eAAe;AAAA,QACtC,CAAC;AAAA,MACH,CAAC;AAED,YAAM,KAAK,MAAM,iCAAiC,OAAO,UAAU;AACjE,cAAM,MAAM,QAAQ;AAAA,UAClB,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,MAAM,KAAK,UAAU,aAAa;AAAA,QACpC,CAAC;AAAA,MACH,CAAC;AAED,YAAM,KAAK,MAAM,8BAA8B,OAAO,UAAU;AAC9D,cAAM,MAAM,QAAQ,EAAE,QAAQ,KAAK,aAAa,oBAAoB,MAAM,KAAK,UAAU,EAAE,QAAQ,MAAM,KAAK,oBAAoB,QAAQ,wBAAwB,CAAC,EAAE,CAAC;AAAA,MACxK,CAAC;AAED,YAAM,KAAK,MAAM,6BAA6B,OAAO,UAAU;AAC7D,cAAM,MAAM,QAAQ,EAAE,QAAQ,KAAK,aAAa,oBAAoB,MAAM,KAAK,UAAU,EAAE,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC;AAAA,MAC3G,CAAC;AAED,YAAM,KAAK,KAAK,cAAc,EAAE,WAAW,mBAAmB,CAAC;AAE/D,YAAM,sBAAsB,KAAK,QAAQ,oCAAoC;AAC7E,YAAM,OAAO,mBAAmB,EAAE,YAAY,EAAE,SAAS,IAAO,CAAC;AAGjE,YAAM,iBAAiB,KAAK,QAAQ,uEAAuE;AAC3G,YAAM,OAAO,cAAc,EAAE,YAAY;AAGzC,YAAM,eAAe,KAAK,QAAQ,2EAA2E;AAC7G,YAAM,OAAO,YAAY,EAAE,IAAI,YAAY;AAAA,IAC7C,CAAC;AAED,SAAK,sDAAsD,OAAO,EAAE,KAAK,MAAM;AAC7E,WAAK,WAAW,IAAO;AACvB,YAAM,MAAM,MAAM,YAAY;AAE9B,UAAI,WAAW;AACf,YAAM,KAAK,MAAM,gCAAgC,OAAO,UAAU;AAChE,YAAI,MAAM,QAAQ,EAAE,OAAO,MAAM,OAAO;AACtC,sBAAY;AACZ,gBAAM,MAAM,QAAQ;AAAA,YAClB,QAAQ;AAAA,YACR,aAAa;AAAA,YACb,MAAM,KAAK,UAAU;AAAA,cACnB,IAAI;AAAA,cACJ,UAAU;AAAA,cACV,gBAAgB;AAAA,cAChB,SAAS;AAAA,cACT,YAAY;AAAA,cACZ,SAAS;AAAA,cACT,SAAS;AAAA,cACT,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,YACpC,CAAC;AAAA,UACH,CAAC;AACD;AAAA,QACF;AACA,cAAM,MAAM,QAAQ;AAAA,UAClB,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,MAAM,KAAK,UAAU,eAAe;AAAA,QACtC,CAAC;AAAA,MACH,CAAC;AAED,YAAM,KAAK,MAAM,8BAA8B,OAAO,UAAU;AAC9D,cAAM,MAAM,QAAQ,EAAE,QAAQ,KAAK,aAAa,oBAAoB,MAAM,KAAK,UAAU,EAAE,QAAQ,MAAM,KAAK,oBAAoB,QAAQ,wBAAwB,CAAC,EAAE,CAAC;AAAA,MACxK,CAAC;AAED,YAAM,KAAK,MAAM,6BAA6B,OAAO,UAAU;AAC7D,cAAM,MAAM,QAAQ,EAAE,QAAQ,KAAK,aAAa,oBAAoB,MAAM,KAAK,UAAU,EAAE,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC;AAAA,MAC3G,CAAC;AAED,YAAM,KAAK,KAAK,cAAc,EAAE,WAAW,mBAAmB,CAAC;AAE/D,YAAM,eAAe,KAAK,QAAQ,kCAAkC;AACpE,YAAM,OAAO,YAAY,EAAE,YAAY,EAAE,SAAS,IAAO,CAAC;AAG1D,YAAM,iBAAiB,KAAK,QAAQ,oCAAoC;AACxE,YAAM,eAAe,MAAM;AAC3B,YAAM,kBAAkB,KAAK,UAAU,UAAU,EAAE,MAAM,YAAY,CAAC;AACtE,YAAM,gBAAgB,MAAM;AAG5B,YAAM,cAAc,KAAK,QAAQ,iCAAiC;AAClE,YAAM,YAAY,MAAM;AACxB,YAAM,eAAe,KAAK,UAAU,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAC3E,YAAM,aAAa,MAAM;AAGzB,YAAM,aAAa,KAAK,QAAQ,kCAAkC;AAClE,YAAM,WAAW,MAAM;AAEvB,YAAM,KAAK,eAAe,GAAG;AAC7B,aAAO,QAAQ,EAAE,uBAAuB,CAAC;AAAA,IAC3C,CAAC;AAED,SAAK,0DAA0D,OAAO,EAAE,KAAK,MAAM;AACjF,WAAK,WAAW,IAAO;AACvB,YAAM,MAAM,MAAM,YAAY;AAE9B,YAAM,uBAAuB;AAAA,QAC3B,GAAG;AAAA,QACH,gBAAgB;AAAA,UACd,YAAY;AAAA,UACZ,SAAS;AAAA,UACT,SAAS;AAAA,UACT,SAAS;AAAA,UACT,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,QACpC;AAAA,MACF;AAEA,UAAI,cAAc;AAClB,YAAM,KAAK,MAAM,gCAAgC,OAAO,UAAU;AAChE,YAAI,MAAM,QAAQ,EAAE,OAAO,MAAM,UAAU;AACzC,yBAAe;AACf,gBAAM,MAAM,QAAQ;AAAA,YAClB,QAAQ;AAAA,YACR,aAAa;AAAA,YACb,MAAM,KAAK,UAAU,EAAE,SAAS,KAAK,CAAC;AAAA,UACxC,CAAC;AACD;AAAA,QACF;AACA,cAAM,MAAM,QAAQ;AAAA,UAClB,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,MAAM,KAAK,UAAU,oBAAoB;AAAA,QAC3C,CAAC;AAAA,MACH,CAAC;AAED,YAAM,KAAK,MAAM,8BAA8B,OAAO,UAAU;AAC9D,cAAM,MAAM,QAAQ,EAAE,QAAQ,KAAK,aAAa,oBAAoB,MAAM,KAAK,UAAU,EAAE,QAAQ,MAAM,KAAK,oBAAoB,QAAQ,wBAAwB,CAAC,EAAE,CAAC;AAAA,MACxK,CAAC;AAED,YAAM,KAAK,MAAM,6BAA6B,OAAO,UAAU;AAC7D,cAAM,MAAM,QAAQ,EAAE,QAAQ,KAAK,aAAa,oBAAoB,MAAM,KAAK,UAAU,EAAE,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC;AAAA,MAC3G,CAAC;AAED,YAAM,KAAK,KAAK,cAAc,EAAE,WAAW,mBAAmB,CAAC;AAG/D,YAAM,cAAc,KAAK,QAAQ,mCAAmC;AACpE,YAAM,OAAO,WAAW,EAAE,YAAY,EAAE,SAAS,IAAO,CAAC;AACzD,YAAM,YAAY,MAAM;AAExB,YAAM,KAAK,eAAe,GAAG;AAC7B,aAAO,WAAW,EAAE,uBAAuB,CAAC;AAAA,IAC9C,CAAC;AAAA,EACH,CAAC;AAKD,OAAK,SAAS,6DAA6D,MAAM;AAC/E,SAAK,kFAAkF,OAAO;AAAA,MAC5F;AAAA,IACF,MAAM;AACJ,WAAK,WAAW,IAAO;AACvB,YAAM,MAAM,MAAM,YAAY;AAE9B,YAAM,KAAK,MAAM,iCAAiC,OAAO,UAAU;AACjE,cAAM,MAAM,QAAQ;AAAA,UAClB,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,MAAM,KAAK,UAAU,aAAa;AAAA,QACpC,CAAC;AAAA,MACH,CAAC;AAED,YAAM,KAAK,MAAM,gCAAgC,OAAO,UAAU;AAChE,cAAM,MAAM,QAAQ;AAAA,UAClB,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,MAAM,KAAK,UAAU,eAAe;AAAA,QACtC,CAAC;AAAA,MACH,CAAC;AAED,YAAM,KAAK,KAAK,gBAAgB,EAAE,WAAW,mBAAmB,CAAC;AAGjE,YAAM,eAAe,KAAK,QAAQ,6DAA6D;AAC/F,YAAM,OAAO,YAAY,EAAE,YAAY,EAAE,SAAS,IAAO,CAAC;AAG1D,YAAM,kBAAkB,KAAK,QAAQ,yEAAyE;AAC9G,YAAM,OAAO,eAAe,EAAE,YAAY,EAAE,SAAS,KAAO,CAAC;AAG7D,YAAM,gBAAgB,KAAK,QAAQ,0CAA0C;AAC7E,YAAM,OAAO,aAAa,EAAE,YAAY;AAGxC,YAAM,aAAa,KAAK,QAAQ,uCAAuC;AACvE,YAAM,OAAO,UAAU,EAAE,YAAY;AAGrC,YAAM,cAAc,KAAK,QAAQ,wCAAwC;AACzE,YAAM,OAAO,WAAW,EAAE,YAAY;AAAA,IACxC,CAAC;AAED,SAAK,+EAA+E,OAAO;AAAA,MACzF;AAAA,IACF,MAAM;AACJ,WAAK,WAAW,IAAO;AACvB,YAAM,MAAM,MAAM,YAAY;AAE9B,YAAM,KAAK,MAAM,iCAAiC,OAAO,UAAU;AACjE,cAAM,MAAM,QAAQ;AAAA,UAClB,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,MAAM,KAAK,UAAU,aAAa;AAAA,QACpC,CAAC;AAAA,MACH,CAAC;AAED,YAAM,KAAK,MAAM,gCAAgC,OAAO,UAAU;AAChE,cAAM,MAAM,QAAQ;AAAA,UAClB,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,MAAM,KAAK,UAAU,eAAe;AAAA,QACtC,CAAC;AAAA,MACH,CAAC;AAGD,YAAM,KAAK,MAAM,0CAA0C,OAAO,UAAU;AAC1E,cAAM,MAAM,QAAQ;AAAA,UAClB,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,MAAM,KAAK,UAAU;AAAA,YACnB,SAAS;AAAA,YACT,sBAAsB;AAAA,YACtB,mBAAmB;AAAA,YACnB,gBAAgB;AAAA,YAChB,WAAW;AAAA,cACT;AAAA,gBACE,IAAI;AAAA,gBACJ,MAAM;AAAA,gBACN,WAAW;AAAA,gBACX,QAAQ;AAAA,kBACN,EAAE,IAAI,oBAAoB,MAAM,oBAAoB,WAAW,KAAK;AAAA,gBACtE;AAAA,cACF;AAAA,YACF;AAAA,UACF,CAAC;AAAA,QACH,CAAC;AAAA,MACH,CAAC;AAED,YAAM,KAAK,KAAK,gBAAgB,EAAE,WAAW,mBAAmB,CAAC;AAGjE,YAAM,gBAAgB,KAAK,QAAQ,6DAA6D;AAChG,YAAM,OAAO,aAAa,EAAE,YAAY,EAAE,SAAS,IAAO,CAAC;AAG3D,YAAM,qBAAqB,cAAc,QAAQ,gCAAgC;AAGjF,YAAM,gBAAgB,MAAM,mBAAmB,UAAU,EAAE,MAAM,MAAM,KAAK;AAC5E,UAAI,eAAe;AACjB,cAAM,OAAO,kBAAkB,EAAE,YAAY;AAAA,MAC/C,OAAO;AAEL,cAAM,OAAO,aAAa,EAAE,YAAY;AAAA,MAC1C;AAAA,IACF,CAAC;AAAA,EACH,CAAC;AAKD,OAAK,SAAS,sDAAiD,MAAM;AACnE,SAAK,mDAAmD,OAAO,EAAE,QAAQ,MAAM;AAC7E,YAAM,WAAW,MAAM,QAAQ,IAAI,4BAA4B;AAC/D,aAAO,CAAC,KAAK,KAAK,KAAK,GAAG,CAAC,EAAE,UAAU,SAAS,OAAO,CAAC;AAAA,IAC1D,CAAC;AAAA,EACH,CAAC;AAED,OAAK,SAAS,sDAAiD,MAAM;AACnE,SAAK,mCAAmC,OAAO,EAAE,QAAQ,MAAM;AAC7D,YAAM,WAAW,MAAM,QAAQ,IAAI,8BAA8B;AAAA,QAC/D,MAAM,EAAE,YAAY,aAAa,SAAS,mBAAmB;AAAA,QAC7D,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAChD,CAAC;AACD,aAAO,CAAC,KAAK,KAAK,GAAG,CAAC,EAAE,UAAU,SAAS,OAAO,CAAC;AAAA,IACrD,CAAC;AAAA,EACH,CAAC;AAED,OAAK,SAAS,yDAAoD,MAAM;AACtE,SAAK,sCAAsC,OAAO,EAAE,QAAQ,MAAM;AAChE,YAAM,WAAW,MAAM,QAAQ,OAAO,8BAA8B;AAAA,QAClE,MAAM,CAAC;AAAA,QACP,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAChD,CAAC;AACD,aAAO,CAAC,KAAK,KAAK,GAAG,CAAC,EAAE,UAAU,SAAS,OAAO,CAAC;AAAA,IACrD,CAAC;AAAA,EACH,CAAC;AAED,OAAK,SAAS,uEAAkE,MAAM;AACpF,SAAK,oDAAoD,OAAO,EAAE,QAAQ,MAAM;AAC9E,YAAM,WAAW,MAAM,QAAQ,IAAI,oEAAoE;AACvG,aAAO,CAAC,KAAK,KAAK,GAAG,CAAC,EAAE,UAAU,SAAS,OAAO,CAAC;AACnD,UAAI,SAAS,OAAO,MAAM,KAAK;AAC7B,cAAM,OAAO,MAAM,SAAS,KAAK;AACjC,eAAO,IAAI,EAAE,eAAe,SAAS;AACrC,eAAO,IAAI,EAAE,eAAe,sBAAsB;AAClD,eAAO,IAAI,EAAE,eAAe,WAAW;AAGvC,eAAO,IAAI,EAAE,eAAe,YAAY,KAAK;AAC7C,eAAO,KAAK,cAAc,EAAE,SAAS;AAAA,MACvC;AAAA,IACF,CAAC;AAAA,EACH,CAAC;AACH,CAAC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -33,11 +33,11 @@ const openApi = {
|
|
|
33
33
|
GET: {
|
|
34
34
|
operationId: "aiAssistantGetAgentModels",
|
|
35
35
|
summary: "Get the providers and curated models available for the chat-UI picker for this agent",
|
|
36
|
-
description: 'Returns all configured providers with their curated model catalogs, filtered to providers that have an API key configured in the current environment. When the agent declares `allowRuntimeOverride: false`, the response reflects that constraint so the UI picker can hide itself. Includes the agent\'s resolved default provider/model so the picker can render a "(default)" badge next to the right entry. RBAC: requires the same features as the agent itself (typically `ai_assistant.view`).',
|
|
36
|
+
description: 'Returns all configured providers with their curated model catalogs, filtered to providers that have an API key configured in the current environment. When the agent declares `allowRuntimeOverride: false`, the response reflects that constraint so the UI picker can hide itself. Includes the agent\'s resolved default provider/model so the picker can render a "(default)" badge next to the right entry. When a tenant-scoped lookup (the allowlist snapshot or the per-agent runtime override) cannot be loaded, the route still answers 200 with a partially resolved provider list and sets `degraded: true` plus a `degradedReason` code, so callers can tell a full entitlement list from a partial one. RBAC: requires the same features as the agent itself (typically `ai_assistant.view`).',
|
|
37
37
|
responses: [
|
|
38
38
|
{
|
|
39
39
|
status: 200,
|
|
40
|
-
description:
|
|
40
|
+
description: 'Providers and curated models available for the agent picker. Empty `providers` array when `allowRuntimeOverride` is false. `degraded` is `false` and `degradedReason` is `null` on a fully resolved response; `degraded: true` with `degradedReason: "tenant_allowlist_unavailable"` means a tenant-scoped lookup failed, so `providers` may be missing the tenant allowlist and/or the per-agent override narrowing \u2014 one code covers the whole tenant-scoped block because no client can act differently per source. Clients SHOULD render the list but MUST NOT treat it as the tenant\'s authoritative entitlement (for example, do not prune a stored model selection against it).'
|
|
41
41
|
}
|
|
42
42
|
],
|
|
43
43
|
errors: [
|
|
@@ -94,6 +94,7 @@ async function GET(req, { params }) {
|
|
|
94
94
|
let tenantAllowlistSnapshot = null;
|
|
95
95
|
let agentRuntimeOverrideAllowlist = null;
|
|
96
96
|
let tenantRuntimeOverride = null;
|
|
97
|
+
let tenantAllowlistDegraded = false;
|
|
97
98
|
if (auth.tenantId) {
|
|
98
99
|
try {
|
|
99
100
|
const em = container.resolve("em");
|
|
@@ -124,6 +125,7 @@ async function GET(req, { params }) {
|
|
|
124
125
|
} : null;
|
|
125
126
|
agentRuntimeOverrideAllowlist = hasAllowlistSnapshotRestrictions(tenantAgentAllowlist) ? tenantAgentAllowlist : null;
|
|
126
127
|
} catch (snapshotError) {
|
|
128
|
+
tenantAllowlistDegraded = true;
|
|
127
129
|
logger.error("AI Agents Models \u2014 Failed to load tenant allowlist", { err: snapshotError });
|
|
128
130
|
}
|
|
129
131
|
}
|
|
@@ -182,7 +184,9 @@ async function GET(req, { params }) {
|
|
|
182
184
|
defaultModelId,
|
|
183
185
|
defaultProviderName: llmProviderRegistry.get(defaultProviderId)?.name ?? defaultProviderId,
|
|
184
186
|
defaultModelName: llmProviderRegistry.get(defaultProviderId)?.defaultModels.find((model) => model.id === defaultModelId)?.name ?? defaultModelId,
|
|
185
|
-
providers
|
|
187
|
+
providers,
|
|
188
|
+
degraded: tenantAllowlistDegraded,
|
|
189
|
+
degradedReason: tenantAllowlistDegraded ? "tenant_allowlist_unavailable" : null
|
|
186
190
|
});
|
|
187
191
|
} catch (error) {
|
|
188
192
|
logger.error("AI Agents Models \u2014 GET error", { err: error });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../src/modules/ai_assistant/api/ai/agents/%5BagentId%5D/models/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'\nimport { getAgent, loadAgentRegistry } from '../../../../../lib/agent-registry'\nimport { hasRequiredFeatures } from '../../../../../lib/auth'\nimport { createModelFactory, resolveAllowRuntimeOverride } from '../../../../../lib/model-factory'\nimport {\n hasAllowlistSnapshotRestrictions,\n intersectEffectiveAllowlistWithSnapshot,\n intersectAllowlists,\n isModelAllowedForProviderInEffective,\n isProviderAllowedInEffective,\n readAgentRuntimeOverrideAllowlist,\n type TenantAllowlistSnapshot,\n} from '../../../../../lib/model-allowlist'\nimport { AiTenantModelAllowlistRepository } from '../../../../../data/repositories/AiTenantModelAllowlistRepository'\nimport { AiAgentRuntimeOverrideRepository } from '../../../../../data/repositories/AiAgentRuntimeOverrideRepository'\n\nconst logger = createLogger('ai_assistant')\n\nfunction modelsForPicker(\n provider: ReturnType<typeof llmProviderRegistry.list>[number],\n allowedModelIds: string[] | undefined,\n): ReadonlyArray<{ id: string; name: string; contextWindow?: number | null; tags?: readonly string[] }> {\n if (provider.defaultModels.length > 0) return provider.defaultModels\n return (allowedModelIds ?? []).map((id) => ({ id, name: id }))\n}\n\nconst agentIdPattern = /^[a-z0-9_]+\\.[a-z0-9_]+$/\n\nconst agentIdParamSchema = z.object({\n agentId: z\n .string()\n .regex(agentIdPattern, 'agentId must match \"<module>.<agent>\" (lowercase, digits, underscores only)'),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'Available models for an AI agent',\n methods: {\n GET: {\n operationId: 'aiAssistantGetAgentModels',\n summary: 'Get the providers and curated models available for the chat-UI picker for this agent',\n description:\n 'Returns all configured providers with their curated model catalogs, filtered to providers ' +\n 'that have an API key configured in the current environment. When the agent declares ' +\n '`allowRuntimeOverride: false`, the response reflects that constraint so the ' +\n 'UI picker can hide itself. Includes the agent\\'s resolved default provider/model so ' +\n 'the picker can render a \"(default)\" badge next to the right entry. ' +\n 'RBAC: requires the same features as the agent itself (typically `ai_assistant.view`).',\n responses: [\n {\n status: 200,\n description:\n 'Providers and curated models available for the agent picker. ' +\n 'Empty `providers` array when `allowRuntimeOverride` is false.',\n },\n ],\n errors: [\n { status: 401, description: 'Unauthenticated.' },\n { status: 403, description: 'Caller lacks the agent\\'s required features.' },\n { status: 404, description: 'Unknown agent id.' },\n ],\n },\n },\n}\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['ai_assistant.view'] },\n}\n\nexport async function GET(\n req: NextRequest,\n { params }: { params: Promise<{ agentId: string }> },\n): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const rawParams = await params\n const paramResult = agentIdParamSchema.safeParse(rawParams)\n if (!paramResult.success) {\n return NextResponse.json(\n { error: 'Invalid agentId path parameter.', code: 'validation_error', issues: paramResult.error.issues },\n { status: 400 },\n )\n }\n const agentId = paramResult.data.agentId\n\n try {\n await loadAgentRegistry()\n\n const container = await createRequestContainer()\n const rbacService = container.resolve<RbacService>('rbacService')\n const acl = await rbacService.loadAcl(auth.sub, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n const agent = getAgent(agentId)\n if (!agent) {\n return NextResponse.json({ error: `Agent \"${agentId}\" not found.`, code: 'agent_unknown' }, { status: 404 })\n }\n\n const agentFeatures = agent.requiredFeatures ?? []\n if (agentFeatures.length > 0) {\n const permitted = hasRequiredFeatures(agentFeatures, acl.features, acl.isSuperAdmin)\n if (!permitted) {\n return NextResponse.json(\n {\n error: `Access to agent \"${agentId}\" requires features: ${agentFeatures.join(', ')}.`,\n code: 'agent_features_denied',\n },\n { status: 403 },\n )\n }\n }\n\n const allowRuntimeOverride = resolveAllowRuntimeOverride(agent)\n\n // Load the per-tenant allowlist snapshot so the picker reflects both env\n // and admin-edited tenant constraints (Phase 1780-6).\n let tenantAllowlistSnapshot: TenantAllowlistSnapshot | null = null\n let agentRuntimeOverrideAllowlist: TenantAllowlistSnapshot | null = null\n let tenantRuntimeOverride: {\n providerId: string | null\n modelId: string | null\n baseURL: string | null\n } | null = null\n if (auth.tenantId) {\n try {\n const em = container.resolve<EntityManager>('em')\n const allowlistRepo = new AiTenantModelAllowlistRepository(em)\n tenantAllowlistSnapshot = await allowlistRepo.getSnapshot({\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n })\n const runtimeOverrideRepo = new AiAgentRuntimeOverrideRepository(em)\n const runtimeOverrideDefaultRow = await runtimeOverrideRepo.getDefault({\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n agentId,\n })\n tenantRuntimeOverride = runtimeOverrideDefaultRow\n ? {\n providerId: runtimeOverrideDefaultRow.providerId ?? null,\n modelId: runtimeOverrideDefaultRow.modelId ?? null,\n baseURL: runtimeOverrideDefaultRow.baseUrl ?? null,\n }\n : null\n const runtimeOverrideRow = await runtimeOverrideRepo.getExact({\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n agentId,\n })\n const tenantAgentAllowlist = runtimeOverrideRow\n ? {\n allowedProviders: runtimeOverrideRow.allowedOverrideProviders ?? null,\n allowedModelsByProvider: runtimeOverrideRow.allowedOverrideModelsByProvider ?? {},\n }\n : null\n agentRuntimeOverrideAllowlist = hasAllowlistSnapshotRestrictions(tenantAgentAllowlist)\n ? tenantAgentAllowlist\n : null\n } catch (snapshotError) {\n // Picker still renders against env-only so the UI does not break, but log at\n // error level so an outage is operationally visible. The chat dispatcher\n // refuses to dispatch when this lookup fails, so writes stay safe.\n logger.error('AI Agents Models \u2014 Failed to load tenant allowlist', { err: snapshotError })\n }\n }\n\n // Resolve the agent's current default provider/model for the \"(default)\" badge\n const factory = createModelFactory(container)\n const defaultResolution = factory.resolveModel({\n moduleId: agent.moduleId,\n agentDefaultModel: agent.defaultModel,\n agentDefaultProvider: agent.defaultProvider,\n agentDefaultBaseUrl: agent.defaultBaseUrl,\n allowRuntimeOverride,\n tenantOverride: tenantRuntimeOverride ?? undefined,\n tenantAllowlist: tenantAllowlistSnapshot,\n })\n const defaultProviderId = defaultResolution.providerId\n const defaultModelId = defaultResolution.modelId\n\n // Build provider list \u2014 only configured providers, with curated model\n // catalogs, clipped to the EFFECTIVE allowlist (env \u2229 tenant) so the\n // chat-UI picker can never offer a value the runtime would refuse.\n const env = process.env as Record<string, string | undefined>\n const knownProviderIds = llmProviderRegistry.list().map((p) => p.id)\n const baseEffectiveAllowlist = intersectAllowlists(\n env,\n knownProviderIds,\n tenantAllowlistSnapshot,\n )\n const envAgentAllowlist = readAgentRuntimeOverrideAllowlist(env, agentId, knownProviderIds)\n const effectiveAllowlist = intersectEffectiveAllowlistWithSnapshot(\n intersectEffectiveAllowlistWithSnapshot(\n baseEffectiveAllowlist,\n knownProviderIds,\n envAgentAllowlist,\n ),\n knownProviderIds,\n agentRuntimeOverrideAllowlist,\n )\n const providers = allowRuntimeOverride\n ? llmProviderRegistry.list()\n .filter((provider) => provider.isConfigured())\n .filter((provider) => isProviderAllowedInEffective(effectiveAllowlist, provider.id))\n .map((provider) => {\n const allowedModelIds = effectiveAllowlist.modelsByProvider[provider.id]\n const filteredModels = modelsForPicker(provider, allowedModelIds).filter((model) =>\n isModelAllowedForProviderInEffective(effectiveAllowlist, provider.id, model.id),\n )\n return {\n id: provider.id,\n name: provider.name,\n isDefault: provider.id === defaultProviderId,\n models: filteredModels.map((model) => ({\n id: model.id,\n name: model.name,\n contextWindow: model.contextWindow,\n tags: model.tags,\n isDefault: provider.id === defaultProviderId && model.id === defaultModelId,\n })),\n }\n })\n : []\n\n return NextResponse.json({\n agentId,\n allowRuntimeOverride,\n allowRuntimeModelOverride: allowRuntimeOverride,\n defaultProviderId,\n defaultModelId,\n defaultProviderName: llmProviderRegistry.get(defaultProviderId)?.name ?? defaultProviderId,\n defaultModelName:\n llmProviderRegistry\n .get(defaultProviderId)\n ?.defaultModels.find((model) => model.id === defaultModelId)?.name ?? defaultModelId,\n providers,\n })\n } catch (error) {\n logger.error('AI Agents Models \u2014 GET error', { err: error })\n return NextResponse.json({ error: 'Failed to resolve agent models.' }, { status: 500 })\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,oBAAsC;AAC/C,SAAS,SAAS;AAGlB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AAEvC,SAAS,2BAA2B;AACpC,SAAS,UAAU,yBAAyB;AAC5C,SAAS,2BAA2B;AACpC,SAAS,oBAAoB,mCAAmC;AAChE;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP,SAAS,wCAAwC;AACjD,SAAS,wCAAwC;AAEjD,MAAM,SAAS,aAAa,cAAc;AAE1C,SAAS,gBACP,UACA,iBACsG;AACtG,MAAI,SAAS,cAAc,SAAS,EAAG,QAAO,SAAS;AACvD,UAAQ,mBAAmB,CAAC,GAAG,IAAI,CAAC,QAAQ,EAAE,IAAI,MAAM,GAAG,EAAE;AAC/D;AAEA,MAAM,iBAAiB;AAEvB,MAAM,qBAAqB,EAAE,OAAO;AAAA,EAClC,SAAS,EACN,OAAO,EACP,MAAM,gBAAgB,6EAA6E;AACxG,CAAC;AAEM,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,KAAK;AAAA,MACH,aAAa;AAAA,MACb,SAAS;AAAA,MACT,aACE;AAAA,
|
|
4
|
+
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'\nimport { getAgent, loadAgentRegistry } from '../../../../../lib/agent-registry'\nimport { hasRequiredFeatures } from '../../../../../lib/auth'\nimport { createModelFactory, resolveAllowRuntimeOverride } from '../../../../../lib/model-factory'\nimport {\n hasAllowlistSnapshotRestrictions,\n intersectEffectiveAllowlistWithSnapshot,\n intersectAllowlists,\n isModelAllowedForProviderInEffective,\n isProviderAllowedInEffective,\n readAgentRuntimeOverrideAllowlist,\n type TenantAllowlistSnapshot,\n} from '../../../../../lib/model-allowlist'\nimport { AiTenantModelAllowlistRepository } from '../../../../../data/repositories/AiTenantModelAllowlistRepository'\nimport { AiAgentRuntimeOverrideRepository } from '../../../../../data/repositories/AiAgentRuntimeOverrideRepository'\n\nconst logger = createLogger('ai_assistant')\n\nfunction modelsForPicker(\n provider: ReturnType<typeof llmProviderRegistry.list>[number],\n allowedModelIds: string[] | undefined,\n): ReadonlyArray<{ id: string; name: string; contextWindow?: number | null; tags?: readonly string[] }> {\n if (provider.defaultModels.length > 0) return provider.defaultModels\n return (allowedModelIds ?? []).map((id) => ({ id, name: id }))\n}\n\nconst agentIdPattern = /^[a-z0-9_]+\\.[a-z0-9_]+$/\n\nconst agentIdParamSchema = z.object({\n agentId: z\n .string()\n .regex(agentIdPattern, 'agentId must match \"<module>.<agent>\" (lowercase, digits, underscores only)'),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'Available models for an AI agent',\n methods: {\n GET: {\n operationId: 'aiAssistantGetAgentModels',\n summary: 'Get the providers and curated models available for the chat-UI picker for this agent',\n description:\n 'Returns all configured providers with their curated model catalogs, filtered to providers ' +\n 'that have an API key configured in the current environment. When the agent declares ' +\n '`allowRuntimeOverride: false`, the response reflects that constraint so the ' +\n 'UI picker can hide itself. Includes the agent\\'s resolved default provider/model so ' +\n 'the picker can render a \"(default)\" badge next to the right entry. ' +\n 'When a tenant-scoped lookup (the allowlist snapshot or the per-agent runtime override) ' +\n 'cannot be loaded, the route still answers 200 with a partially resolved provider list ' +\n 'and sets `degraded: true` plus a `degradedReason` code, so callers can tell a full ' +\n 'entitlement list from a partial one. ' +\n 'RBAC: requires the same features as the agent itself (typically `ai_assistant.view`).',\n responses: [\n {\n status: 200,\n description:\n 'Providers and curated models available for the agent picker. ' +\n 'Empty `providers` array when `allowRuntimeOverride` is false. ' +\n '`degraded` is `false` and `degradedReason` is `null` on a fully resolved response; ' +\n '`degraded: true` with `degradedReason: \"tenant_allowlist_unavailable\"` means a ' +\n 'tenant-scoped lookup failed, so `providers` may be missing the tenant allowlist ' +\n 'and/or the per-agent override narrowing \u2014 one code covers the whole tenant-scoped ' +\n 'block because no client can act differently per source. Clients SHOULD render the ' +\n 'list but MUST NOT treat it as the tenant\\'s authoritative entitlement (for example, ' +\n 'do not prune a stored model selection against it).',\n },\n ],\n errors: [\n { status: 401, description: 'Unauthenticated.' },\n { status: 403, description: 'Caller lacks the agent\\'s required features.' },\n { status: 404, description: 'Unknown agent id.' },\n ],\n },\n },\n}\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['ai_assistant.view'] },\n}\n\nexport async function GET(\n req: NextRequest,\n { params }: { params: Promise<{ agentId: string }> },\n): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const rawParams = await params\n const paramResult = agentIdParamSchema.safeParse(rawParams)\n if (!paramResult.success) {\n return NextResponse.json(\n { error: 'Invalid agentId path parameter.', code: 'validation_error', issues: paramResult.error.issues },\n { status: 400 },\n )\n }\n const agentId = paramResult.data.agentId\n\n try {\n await loadAgentRegistry()\n\n const container = await createRequestContainer()\n const rbacService = container.resolve<RbacService>('rbacService')\n const acl = await rbacService.loadAcl(auth.sub, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n const agent = getAgent(agentId)\n if (!agent) {\n return NextResponse.json({ error: `Agent \"${agentId}\" not found.`, code: 'agent_unknown' }, { status: 404 })\n }\n\n const agentFeatures = agent.requiredFeatures ?? []\n if (agentFeatures.length > 0) {\n const permitted = hasRequiredFeatures(agentFeatures, acl.features, acl.isSuperAdmin)\n if (!permitted) {\n return NextResponse.json(\n {\n error: `Access to agent \"${agentId}\" requires features: ${agentFeatures.join(', ')}.`,\n code: 'agent_features_denied',\n },\n { status: 403 },\n )\n }\n }\n\n const allowRuntimeOverride = resolveAllowRuntimeOverride(agent)\n\n // Load the per-tenant allowlist snapshot so the picker reflects both env\n // and admin-edited tenant constraints (Phase 1780-6).\n let tenantAllowlistSnapshot: TenantAllowlistSnapshot | null = null\n let agentRuntimeOverrideAllowlist: TenantAllowlistSnapshot | null = null\n let tenantRuntimeOverride: {\n providerId: string | null\n modelId: string | null\n baseURL: string | null\n } | null = null\n let tenantAllowlistDegraded = false\n if (auth.tenantId) {\n try {\n const em = container.resolve<EntityManager>('em')\n const allowlistRepo = new AiTenantModelAllowlistRepository(em)\n tenantAllowlistSnapshot = await allowlistRepo.getSnapshot({\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n })\n const runtimeOverrideRepo = new AiAgentRuntimeOverrideRepository(em)\n const runtimeOverrideDefaultRow = await runtimeOverrideRepo.getDefault({\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n agentId,\n })\n tenantRuntimeOverride = runtimeOverrideDefaultRow\n ? {\n providerId: runtimeOverrideDefaultRow.providerId ?? null,\n modelId: runtimeOverrideDefaultRow.modelId ?? null,\n baseURL: runtimeOverrideDefaultRow.baseUrl ?? null,\n }\n : null\n const runtimeOverrideRow = await runtimeOverrideRepo.getExact({\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n agentId,\n })\n const tenantAgentAllowlist = runtimeOverrideRow\n ? {\n allowedProviders: runtimeOverrideRow.allowedOverrideProviders ?? null,\n allowedModelsByProvider: runtimeOverrideRow.allowedOverrideModelsByProvider ?? {},\n }\n : null\n agentRuntimeOverrideAllowlist = hasAllowlistSnapshotRestrictions(tenantAgentAllowlist)\n ? tenantAgentAllowlist\n : null\n } catch (snapshotError) {\n // Picker still renders against whatever resolved so the UI does not break, but log\n // at error level so an outage is operationally visible and mark the response\n // `degraded` so callers know a tenant-scoped read failed and the provider list may\n // be missing the tenant allowlist and/or the per-agent override narrowing. The chat\n // dispatcher refuses to dispatch when this lookup fails, so writes stay safe.\n tenantAllowlistDegraded = true\n logger.error('AI Agents Models \u2014 Failed to load tenant allowlist', { err: snapshotError })\n }\n }\n\n // Resolve the agent's current default provider/model for the \"(default)\" badge\n const factory = createModelFactory(container)\n const defaultResolution = factory.resolveModel({\n moduleId: agent.moduleId,\n agentDefaultModel: agent.defaultModel,\n agentDefaultProvider: agent.defaultProvider,\n agentDefaultBaseUrl: agent.defaultBaseUrl,\n allowRuntimeOverride,\n tenantOverride: tenantRuntimeOverride ?? undefined,\n tenantAllowlist: tenantAllowlistSnapshot,\n })\n const defaultProviderId = defaultResolution.providerId\n const defaultModelId = defaultResolution.modelId\n\n // Build provider list \u2014 only configured providers, with curated model\n // catalogs, clipped to the EFFECTIVE allowlist (env \u2229 tenant) so the\n // chat-UI picker can never offer a value the runtime would refuse.\n const env = process.env as Record<string, string | undefined>\n const knownProviderIds = llmProviderRegistry.list().map((p) => p.id)\n const baseEffectiveAllowlist = intersectAllowlists(\n env,\n knownProviderIds,\n tenantAllowlistSnapshot,\n )\n const envAgentAllowlist = readAgentRuntimeOverrideAllowlist(env, agentId, knownProviderIds)\n const effectiveAllowlist = intersectEffectiveAllowlistWithSnapshot(\n intersectEffectiveAllowlistWithSnapshot(\n baseEffectiveAllowlist,\n knownProviderIds,\n envAgentAllowlist,\n ),\n knownProviderIds,\n agentRuntimeOverrideAllowlist,\n )\n const providers = allowRuntimeOverride\n ? llmProviderRegistry.list()\n .filter((provider) => provider.isConfigured())\n .filter((provider) => isProviderAllowedInEffective(effectiveAllowlist, provider.id))\n .map((provider) => {\n const allowedModelIds = effectiveAllowlist.modelsByProvider[provider.id]\n const filteredModels = modelsForPicker(provider, allowedModelIds).filter((model) =>\n isModelAllowedForProviderInEffective(effectiveAllowlist, provider.id, model.id),\n )\n return {\n id: provider.id,\n name: provider.name,\n isDefault: provider.id === defaultProviderId,\n models: filteredModels.map((model) => ({\n id: model.id,\n name: model.name,\n contextWindow: model.contextWindow,\n tags: model.tags,\n isDefault: provider.id === defaultProviderId && model.id === defaultModelId,\n })),\n }\n })\n : []\n\n return NextResponse.json({\n agentId,\n allowRuntimeOverride,\n allowRuntimeModelOverride: allowRuntimeOverride,\n defaultProviderId,\n defaultModelId,\n defaultProviderName: llmProviderRegistry.get(defaultProviderId)?.name ?? defaultProviderId,\n defaultModelName:\n llmProviderRegistry\n .get(defaultProviderId)\n ?.defaultModels.find((model) => model.id === defaultModelId)?.name ?? defaultModelId,\n providers,\n degraded: tenantAllowlistDegraded,\n degradedReason: tenantAllowlistDegraded ? 'tenant_allowlist_unavailable' : null,\n })\n } catch (error) {\n logger.error('AI Agents Models \u2014 GET error', { err: error })\n return NextResponse.json({ error: 'Failed to resolve agent models.' }, { status: 500 })\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ai-assistant",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.6899.1.433952fd93",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -99,16 +99,16 @@
|
|
|
99
99
|
"zod-to-json-schema": "^3.25.2"
|
|
100
100
|
},
|
|
101
101
|
"peerDependencies": {
|
|
102
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
103
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
102
|
+
"@open-mercato/shared": "0.6.8-develop.6899.1.433952fd93",
|
|
103
|
+
"@open-mercato/ui": "0.6.8-develop.6899.1.433952fd93",
|
|
104
104
|
"react": "^19.0.0",
|
|
105
105
|
"react-dom": "^19.0.0",
|
|
106
106
|
"zod": ">=3.23.0"
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|
|
109
|
-
"@open-mercato/cli": "0.6.8-develop.
|
|
110
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
111
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
109
|
+
"@open-mercato/cli": "0.6.8-develop.6899.1.433952fd93",
|
|
110
|
+
"@open-mercato/shared": "0.6.8-develop.6899.1.433952fd93",
|
|
111
|
+
"@open-mercato/ui": "0.6.8-develop.6899.1.433952fd93",
|
|
112
112
|
"@types/react": "^19.2.17",
|
|
113
113
|
"@types/react-dom": "^19.2.3",
|
|
114
114
|
"react": "19.2.8",
|
package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts
CHANGED
|
@@ -479,6 +479,10 @@ test.describe('TC-AI-RUNTIME-OVERRIDES-006: runtime model overrides', () => {
|
|
|
479
479
|
expect(body).toHaveProperty('agentId');
|
|
480
480
|
expect(body).toHaveProperty('allowRuntimeOverride');
|
|
481
481
|
expect(body).toHaveProperty('providers');
|
|
482
|
+
// Both degradation fields are emitted unconditionally, so a healthy response
|
|
483
|
+
// pins them to the fully-resolved values rather than leaving them absent.
|
|
484
|
+
expect(body).toHaveProperty('degraded', false);
|
|
485
|
+
expect(body.degradedReason).toBeNull();
|
|
482
486
|
}
|
|
483
487
|
});
|
|
484
488
|
});
|
|
@@ -224,6 +224,89 @@ describe('GET /api/ai_assistant/ai/agents/[agentId]/models', () => {
|
|
|
224
224
|
expect(json.providers).toEqual([])
|
|
225
225
|
})
|
|
226
226
|
|
|
227
|
+
it('marks the response degraded when the tenant allowlist lookup fails', async () => {
|
|
228
|
+
seedAgentRegistryForTests([
|
|
229
|
+
makeAgent({ id: 'catalog.assistant', moduleId: 'catalog' }),
|
|
230
|
+
])
|
|
231
|
+
getSnapshotMock.mockRejectedValueOnce(new Error('connection terminated unexpectedly'))
|
|
232
|
+
|
|
233
|
+
const response = await GET(
|
|
234
|
+
new Request('http://localhost/api/ai_assistant/ai/agents/catalog.assistant/models') as any,
|
|
235
|
+
buildParams('catalog.assistant'),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
expect(response.status).toBe(200)
|
|
239
|
+
const json = await response.json()
|
|
240
|
+
expect(json.degraded).toBe(true)
|
|
241
|
+
expect(json.degradedReason).toBe('tenant_allowlist_unavailable')
|
|
242
|
+
expect(json.providers.map((p: { id: string }) => p.id)).toEqual(['openai', 'lm-studio'])
|
|
243
|
+
expect(resolveModelMock).toHaveBeenCalledWith(
|
|
244
|
+
expect.objectContaining({ tenantAllowlist: null }),
|
|
245
|
+
)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('marks the response degraded when the agent runtime override lookup fails', async () => {
|
|
249
|
+
seedAgentRegistryForTests([
|
|
250
|
+
makeAgent({ id: 'catalog.assistant', moduleId: 'catalog' }),
|
|
251
|
+
])
|
|
252
|
+
getExactMock.mockRejectedValueOnce(new Error('connection terminated unexpectedly'))
|
|
253
|
+
|
|
254
|
+
const response = await GET(
|
|
255
|
+
new Request('http://localhost/api/ai_assistant/ai/agents/catalog.assistant/models') as any,
|
|
256
|
+
buildParams('catalog.assistant'),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
expect(response.status).toBe(200)
|
|
260
|
+
const json = await response.json()
|
|
261
|
+
expect(json.degraded).toBe(true)
|
|
262
|
+
expect(json.degradedReason).toBe('tenant_allowlist_unavailable')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('keeps the tenant clipping when only the agent runtime override lookup fails', async () => {
|
|
266
|
+
seedAgentRegistryForTests([
|
|
267
|
+
makeAgent({ id: 'catalog.assistant', moduleId: 'catalog' }),
|
|
268
|
+
])
|
|
269
|
+
const tenantSnapshot = {
|
|
270
|
+
allowedProviders: ['openai'],
|
|
271
|
+
allowedModelsByProvider: { openai: [] },
|
|
272
|
+
}
|
|
273
|
+
getSnapshotMock.mockResolvedValueOnce(tenantSnapshot)
|
|
274
|
+
getExactMock.mockRejectedValueOnce(new Error('connection terminated unexpectedly'))
|
|
275
|
+
|
|
276
|
+
const response = await GET(
|
|
277
|
+
new Request('http://localhost/api/ai_assistant/ai/agents/catalog.assistant/models') as any,
|
|
278
|
+
buildParams('catalog.assistant'),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
expect(response.status).toBe(200)
|
|
282
|
+
const json = await response.json()
|
|
283
|
+
expect(json.degraded).toBe(true)
|
|
284
|
+
expect(json.degradedReason).toBe('tenant_allowlist_unavailable')
|
|
285
|
+
// The snapshot was already resolved when the override read threw, so the list is
|
|
286
|
+
// still tenant-clipped rather than environment-only — the reason code covers the
|
|
287
|
+
// whole tenant-scoped block, so it MUST NOT promise an environment-only list.
|
|
288
|
+
expect(json.providers.map((p: { id: string }) => p.id)).toEqual(['openai'])
|
|
289
|
+
expect(resolveModelMock).toHaveBeenCalledWith(
|
|
290
|
+
expect.objectContaining({ tenantAllowlist: tenantSnapshot }),
|
|
291
|
+
)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('reports a fully resolved response as not degraded', async () => {
|
|
295
|
+
seedAgentRegistryForTests([
|
|
296
|
+
makeAgent({ id: 'catalog.assistant', moduleId: 'catalog' }),
|
|
297
|
+
])
|
|
298
|
+
|
|
299
|
+
const response = await GET(
|
|
300
|
+
new Request('http://localhost/api/ai_assistant/ai/agents/catalog.assistant/models') as any,
|
|
301
|
+
buildParams('catalog.assistant'),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
expect(response.status).toBe(200)
|
|
305
|
+
const json = await response.json()
|
|
306
|
+
expect(json.degraded).toBe(false)
|
|
307
|
+
expect(json.degradedReason).toBeNull()
|
|
308
|
+
})
|
|
309
|
+
|
|
227
310
|
it('clips providers and models against the tenant allowlist intersection', async () => {
|
|
228
311
|
seedAgentRegistryForTests([
|
|
229
312
|
makeAgent({ id: 'catalog.assistant', moduleId: 'catalog' }),
|
|
@@ -53,13 +53,24 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
53
53
|
'`allowRuntimeOverride: false`, the response reflects that constraint so the ' +
|
|
54
54
|
'UI picker can hide itself. Includes the agent\'s resolved default provider/model so ' +
|
|
55
55
|
'the picker can render a "(default)" badge next to the right entry. ' +
|
|
56
|
+
'When a tenant-scoped lookup (the allowlist snapshot or the per-agent runtime override) ' +
|
|
57
|
+
'cannot be loaded, the route still answers 200 with a partially resolved provider list ' +
|
|
58
|
+
'and sets `degraded: true` plus a `degradedReason` code, so callers can tell a full ' +
|
|
59
|
+
'entitlement list from a partial one. ' +
|
|
56
60
|
'RBAC: requires the same features as the agent itself (typically `ai_assistant.view`).',
|
|
57
61
|
responses: [
|
|
58
62
|
{
|
|
59
63
|
status: 200,
|
|
60
64
|
description:
|
|
61
65
|
'Providers and curated models available for the agent picker. ' +
|
|
62
|
-
'Empty `providers` array when `allowRuntimeOverride` is false.'
|
|
66
|
+
'Empty `providers` array when `allowRuntimeOverride` is false. ' +
|
|
67
|
+
'`degraded` is `false` and `degradedReason` is `null` on a fully resolved response; ' +
|
|
68
|
+
'`degraded: true` with `degradedReason: "tenant_allowlist_unavailable"` means a ' +
|
|
69
|
+
'tenant-scoped lookup failed, so `providers` may be missing the tenant allowlist ' +
|
|
70
|
+
'and/or the per-agent override narrowing — one code covers the whole tenant-scoped ' +
|
|
71
|
+
'block because no client can act differently per source. Clients SHOULD render the ' +
|
|
72
|
+
'list but MUST NOT treat it as the tenant\'s authoritative entitlement (for example, ' +
|
|
73
|
+
'do not prune a stored model selection against it).',
|
|
63
74
|
},
|
|
64
75
|
],
|
|
65
76
|
errors: [
|
|
@@ -134,6 +145,7 @@ export async function GET(
|
|
|
134
145
|
modelId: string | null
|
|
135
146
|
baseURL: string | null
|
|
136
147
|
} | null = null
|
|
148
|
+
let tenantAllowlistDegraded = false
|
|
137
149
|
if (auth.tenantId) {
|
|
138
150
|
try {
|
|
139
151
|
const em = container.resolve<EntityManager>('em')
|
|
@@ -170,9 +182,12 @@ export async function GET(
|
|
|
170
182
|
? tenantAgentAllowlist
|
|
171
183
|
: null
|
|
172
184
|
} catch (snapshotError) {
|
|
173
|
-
// Picker still renders against
|
|
174
|
-
// error level so an outage is operationally visible
|
|
175
|
-
//
|
|
185
|
+
// Picker still renders against whatever resolved so the UI does not break, but log
|
|
186
|
+
// at error level so an outage is operationally visible and mark the response
|
|
187
|
+
// `degraded` so callers know a tenant-scoped read failed and the provider list may
|
|
188
|
+
// be missing the tenant allowlist and/or the per-agent override narrowing. The chat
|
|
189
|
+
// dispatcher refuses to dispatch when this lookup fails, so writes stay safe.
|
|
190
|
+
tenantAllowlistDegraded = true
|
|
176
191
|
logger.error('AI Agents Models — Failed to load tenant allowlist', { err: snapshotError })
|
|
177
192
|
}
|
|
178
193
|
}
|
|
@@ -247,6 +262,8 @@ export async function GET(
|
|
|
247
262
|
.get(defaultProviderId)
|
|
248
263
|
?.defaultModels.find((model) => model.id === defaultModelId)?.name ?? defaultModelId,
|
|
249
264
|
providers,
|
|
265
|
+
degraded: tenantAllowlistDegraded,
|
|
266
|
+
degradedReason: tenantAllowlistDegraded ? 'tenant_allowlist_unavailable' : null,
|
|
250
267
|
})
|
|
251
268
|
} catch (error) {
|
|
252
269
|
logger.error('AI Agents Models — GET error', { err: error })
|