@open-mercato/ai-assistant 0.6.1-develop.3246.1.dbef9d7392 → 0.6.1-develop.3256.1.fe3dec2464

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +82 -18
  3. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +370 -0
  4. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +7 -0
  5. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +194 -0
  6. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +7 -0
  7. package/dist/modules/ai_assistant/api/ai/agents/route.js +4 -0
  8. package/dist/modules/ai_assistant/api/ai/agents/route.js.map +2 -2
  9. package/dist/modules/ai_assistant/api/ai/chat/route.js +169 -5
  10. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  11. package/dist/modules/ai_assistant/api/route/route.js +38 -19
  12. package/dist/modules/ai_assistant/api/route/route.js.map +3 -3
  13. package/dist/modules/ai_assistant/api/settings/allowlist/route.js +195 -0
  14. package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +7 -0
  15. package/dist/modules/ai_assistant/api/settings/route.js +537 -22
  16. package/dist/modules/ai_assistant/api/settings/route.js.map +3 -3
  17. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +701 -147
  18. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
  19. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +338 -0
  20. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +7 -0
  21. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js +10 -0
  22. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js.map +7 -0
  23. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js +25 -0
  24. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js.map +7 -0
  25. package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js +1 -1
  26. package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js.map +2 -2
  27. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +75 -26
  28. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
  29. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js +10 -0
  30. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js.map +7 -0
  31. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js +25 -0
  32. package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js.map +7 -0
  33. package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js +503 -168
  34. package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +2 -2
  35. package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js +5 -0
  36. package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js.map +7 -0
  37. package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js +5 -0
  38. package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js.map +7 -0
  39. package/dist/modules/ai_assistant/data/entities.js +123 -1
  40. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  41. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +157 -0
  42. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +7 -0
  43. package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js +77 -0
  44. package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js.map +7 -0
  45. package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js +1 -1
  46. package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js.map +2 -2
  47. package/dist/modules/ai_assistant/i18n/de.json +90 -1
  48. package/dist/modules/ai_assistant/i18n/en.json +90 -1
  49. package/dist/modules/ai_assistant/i18n/es.json +90 -1
  50. package/dist/modules/ai_assistant/i18n/pl.json +90 -1
  51. package/dist/modules/ai_assistant/lib/agent-registry.js +17 -1
  52. package/dist/modules/ai_assistant/lib/agent-registry.js.map +2 -2
  53. package/dist/modules/ai_assistant/lib/agent-runtime.js +133 -36
  54. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
  55. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  56. package/dist/modules/ai_assistant/lib/baseurl-allowlist.js +29 -0
  57. package/dist/modules/ai_assistant/lib/baseurl-allowlist.js.map +7 -0
  58. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +4 -1
  59. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
  60. package/dist/modules/ai_assistant/lib/llm-adapters/google.js +4 -1
  61. package/dist/modules/ai_assistant/lib/llm-adapters/google.js.map +2 -2
  62. package/dist/modules/ai_assistant/lib/model-allowlist.js +211 -0
  63. package/dist/modules/ai_assistant/lib/model-allowlist.js.map +7 -0
  64. package/dist/modules/ai_assistant/lib/model-factory.js +203 -31
  65. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  66. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +32 -1
  67. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
  68. package/dist/modules/ai_assistant/migrations/Migration20260508140000.js +18 -0
  69. package/dist/modules/ai_assistant/migrations/Migration20260508140000.js.map +7 -0
  70. package/dist/modules/ai_assistant/migrations/Migration20260512090000.js +16 -0
  71. package/dist/modules/ai_assistant/migrations/Migration20260512090000.js.map +7 -0
  72. package/dist/modules/ai_assistant/migrations/Migration20260512130000.js +15 -0
  73. package/dist/modules/ai_assistant/migrations/Migration20260512130000.js.map +7 -0
  74. package/generated/entities/ai_agent_runtime_override/index.ts +13 -0
  75. package/generated/entities/ai_tenant_model_allowlist/index.ts +9 -0
  76. package/generated/entities.ids.generated.ts +2 -0
  77. package/generated/entity-fields-registry.ts +26 -0
  78. package/jest.config.cjs +2 -0
  79. package/package.json +4 -4
  80. package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +477 -0
  81. package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +116 -0
  82. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +240 -0
  83. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +251 -0
  84. package/src/modules/ai_assistant/api/ai/agents/route.ts +4 -0
  85. package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +273 -0
  86. package/src/modules/ai_assistant/api/ai/chat/route.ts +211 -2
  87. package/src/modules/ai_assistant/api/route/route.ts +49 -25
  88. package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +408 -0
  89. package/src/modules/ai_assistant/api/settings/allowlist/route.ts +221 -0
  90. package/src/modules/ai_assistant/api/settings/route.ts +721 -27
  91. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +858 -177
  92. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +458 -0
  93. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.ts +23 -0
  94. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.tsx +12 -0
  95. package/src/modules/ai_assistant/backend/config/ai-assistant/legacy/page.tsx +1 -1
  96. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +89 -12
  97. package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.ts +23 -0
  98. package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.tsx +18 -0
  99. package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +617 -209
  100. package/src/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.ts +7 -0
  101. package/src/modules/ai_assistant/data/entities/AiTenantModelAllowlist.ts +2 -0
  102. package/src/modules/ai_assistant/data/entities.ts +164 -0
  103. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +227 -0
  104. package/src/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.ts +132 -0
  105. package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +337 -0
  106. package/src/modules/ai_assistant/data/repositories/__tests__/AiTenantModelAllowlistRepository.test.ts +181 -0
  107. package/src/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.tsx +1 -1
  108. package/src/modules/ai_assistant/i18n/de.json +90 -1
  109. package/src/modules/ai_assistant/i18n/en.json +90 -1
  110. package/src/modules/ai_assistant/i18n/es.json +90 -1
  111. package/src/modules/ai_assistant/i18n/pl.json +90 -1
  112. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +396 -0
  113. package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +60 -6
  114. package/src/modules/ai_assistant/lib/__tests__/ai-api-operation-runner.test.ts +4 -2
  115. package/src/modules/ai_assistant/lib/__tests__/baseurl-allowlist.test.ts +75 -0
  116. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +18 -0
  117. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-google.test.ts +18 -0
  118. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +150 -4
  119. package/src/modules/ai_assistant/lib/__tests__/model-allowlist.test.ts +290 -0
  120. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +634 -0
  121. package/src/modules/ai_assistant/lib/agent-registry.ts +20 -1
  122. package/src/modules/ai_assistant/lib/agent-runtime.ts +220 -44
  123. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +48 -0
  124. package/src/modules/ai_assistant/lib/baseurl-allowlist.ts +64 -0
  125. package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +11 -1
  126. package/src/modules/ai_assistant/lib/llm-adapters/google.ts +4 -1
  127. package/src/modules/ai_assistant/lib/model-allowlist.ts +407 -0
  128. package/src/modules/ai_assistant/lib/model-factory.ts +486 -58
  129. package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +44 -0
  130. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +704 -235
  131. package/src/modules/ai_assistant/migrations/Migration20260508140000.ts +18 -0
  132. package/src/modules/ai_assistant/migrations/Migration20260512090000.ts +16 -0
  133. package/src/modules/ai_assistant/migrations/Migration20260512130000.ts +13 -0
@@ -1,31 +1,127 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server'
2
+ import { z } from 'zod'
2
3
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
4
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
+ import type { EntityManager } from '@mikro-orm/postgresql'
7
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
8
+ import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
4
9
  import {
5
10
  OPEN_CODE_PROVIDER_IDS,
6
11
  OPEN_CODE_PROVIDERS,
7
12
  getOpenCodeProviderConfiguredEnvKey,
8
13
  isOpenCodeProviderConfigured,
9
- resolveAiProviderIdFromEnv,
10
- resolveOpenCodeModel,
11
14
  } from '@open-mercato/shared/lib/ai/opencode-provider'
15
+ import { AiAgentRuntimeOverrideRepository, AiAgentRuntimeOverrideValidationError } from '../../data/repositories/AiAgentRuntimeOverrideRepository'
16
+ import { AiTenantModelAllowlistRepository } from '../../data/repositories/AiTenantModelAllowlistRepository'
17
+ import { isBaseurlAllowlisted, readBaseurlAllowlist } from '../../lib/baseurl-allowlist'
18
+ import { loadAgentRegistry, listAgents } from '../../lib/agent-registry'
19
+ import { createModelFactory } from '../../lib/model-factory'
20
+ import {
21
+ agentOverrideModelAllowlistEnvVarName,
22
+ agentOverrideProviderAllowlistEnvVarName,
23
+ canonicalProviderId,
24
+ hasAllowlistSnapshotRestrictions,
25
+ intersectEffectiveAllowlistWithSnapshot,
26
+ intersectAllowlists,
27
+ isProviderAllowed,
28
+ isProviderAllowedInEffective,
29
+ isProviderModelAllowed,
30
+ isProviderModelAllowedInEffective,
31
+ modelAllowlistEnvVarName,
32
+ readAgentRuntimeOverrideAllowlist,
33
+ readAllowedModels,
34
+ readAllowedProviders,
35
+ readAllowlistConfig,
36
+ type TenantAllowlistSnapshot,
37
+ } from '../../lib/model-allowlist'
38
+
39
+ function modelCatalogWithAllowlistFallback(
40
+ models: ReadonlyArray<{ id: string; name: string; contextWindow?: number | null; tags?: readonly string[] }>,
41
+ allowlistModelIds: string[] | undefined,
42
+ ): ReadonlyArray<{ id: string; name: string; contextWindow?: number | null; tags?: readonly string[] }> {
43
+ if (models.length > 0) return models
44
+ return (allowlistModelIds ?? []).map((id) => ({ id, name: id }))
45
+ }
46
+
47
+ const runtimeOverrideUpsertSchema = z.object({
48
+ providerId: z.string().min(1).max(64).nullable().optional(),
49
+ modelId: z.string().min(1).max(256).nullable().optional(),
50
+ baseURL: z.string().url().max(2048).nullable().optional(),
51
+ agentId: z.string().min(1).max(128).nullable().optional(),
52
+ allowedOverrideProviders: z.array(z.string().min(1).max(64)).nullable().optional(),
53
+ allowedOverrideModelsByProvider: z
54
+ .record(z.string().min(1).max(64), z.array(z.string().min(1).max(256)))
55
+ .optional(),
56
+ })
57
+
58
+ const runtimeOverrideClearSchema = z.object({
59
+ agentId: z.string().min(1).max(128).nullable().optional(),
60
+ })
61
+
62
+ export type RuntimeOverrideUpsertBody = z.infer<typeof runtimeOverrideUpsertSchema>
63
+ export type RuntimeOverrideClearBody = z.infer<typeof runtimeOverrideClearSchema>
12
64
 
13
65
  export const openApi: OpenApiRouteDoc = {
14
66
  tag: 'AI Assistant',
15
67
  summary: 'AI assistant settings',
16
68
  methods: {
17
69
  GET: { summary: 'Get AI provider configuration' },
70
+ PUT: {
71
+ summary: 'Upsert per-tenant AI runtime override',
72
+ description:
73
+ 'Creates or updates the per-tenant AI runtime override (provider, model, baseURL). ' +
74
+ 'Optionally scoped to a specific agent via `agentId`. ' +
75
+ 'Gated by `ai_assistant.settings.manage`. ' +
76
+ 'baseURL must match AI_RUNTIME_BASEURL_ALLOWLIST when set.',
77
+ requestBody: {
78
+ contentType: 'application/json',
79
+ description: 'Override payload. All fields nullable/optional; null explicitly clears the axis.',
80
+ schema: runtimeOverrideUpsertSchema,
81
+ },
82
+ responses: [
83
+ { status: 200, description: 'Override saved. Returns the saved row.' },
84
+ ],
85
+ errors: [
86
+ { status: 400, description: 'Validation error: unknown provider, invalid URL, or baseURL not allowlisted.' },
87
+ { status: 401, description: 'Unauthenticated.' },
88
+ { status: 403, description: 'Caller lacks ai_assistant.settings.manage.' },
89
+ ],
90
+ },
91
+ DELETE: {
92
+ summary: 'Clear per-tenant AI runtime override',
93
+ description:
94
+ 'Soft-deletes the active per-tenant runtime override. ' +
95
+ 'Pass `agentId` to clear only the agent-specific row; omit to clear the tenant-wide default. ' +
96
+ 'Gated by `ai_assistant.settings.manage`. Idempotent — returns 200 with `cleared: false` when no active row existed.',
97
+ requestBody: {
98
+ contentType: 'application/json',
99
+ description: 'Optional agentId to scope the delete.',
100
+ schema: runtimeOverrideClearSchema,
101
+ },
102
+ responses: [
103
+ { status: 200, description: 'Returns `{ cleared: boolean }` indicating whether a row was found and removed.' },
104
+ ],
105
+ errors: [
106
+ { status: 401, description: 'Unauthenticated.' },
107
+ { status: 403, description: 'Caller lacks ai_assistant.settings.manage.' },
108
+ ],
109
+ },
18
110
  },
19
111
  }
20
112
 
21
113
  export const metadata = {
22
114
  GET: { requireAuth: true, requireFeatures: ['ai_assistant.view'] },
115
+ PUT: { requireAuth: true, requireFeatures: ['ai_assistant.settings.manage'] },
116
+ DELETE: { requireAuth: true, requireFeatures: ['ai_assistant.settings.manage'] },
23
117
  }
24
118
 
25
119
  /**
26
120
  * GET /api/ai_assistant/settings
27
121
  *
28
- * Returns the current OpenCode provider configuration from environment variables.
122
+ * Returns the current OpenCode provider configuration from environment variables
123
+ * plus the Phase 4a additive fields: resolvedDefault, tenantOverride, agents[],
124
+ * and availableProviders[].defaultModels.
29
125
  */
30
126
  export async function GET(req: NextRequest) {
31
127
  const auth = await getAuthFromRequest(req)
@@ -34,48 +130,646 @@ export async function GET(req: NextRequest) {
34
130
  }
35
131
 
36
132
  try {
37
- // Read provider config from environment. `OM_AI_PROVIDER` is the new
38
- // canonical variable; `OPENCODE_PROVIDER` is kept as a BC fallback by
39
- // `resolveAiProviderIdFromEnv`. Falls back to the unified default
40
- // (`openai`) when neither is set.
41
- const providerId = resolveAiProviderIdFromEnv(process.env)
42
- const providerInfo = OPEN_CODE_PROVIDERS[providerId]
43
-
44
- // Check if the provider's API key is configured (supports multiple fallback keys)
45
- const apiKeyConfigured = isOpenCodeProviderConfigured(providerId)
46
-
47
- // Get model (custom or default)
48
- const resolvedModel = resolveOpenCodeModel(providerId)
133
+ const env = process.env as Record<string, string | undefined>
134
+ const configuredProviderHint = env.OM_AI_PROVIDER?.trim() || env.OPENCODE_PROVIDER?.trim() || null
135
+ const registryProviders = llmProviderRegistry.list()
136
+ const knownProviderIdsForAllowlist: string[] = [
137
+ ...OPEN_CODE_PROVIDER_IDS,
138
+ ...registryProviders
139
+ .map((p) => p.id)
140
+ .filter((id) => !(OPEN_CODE_PROVIDER_IDS as readonly string[]).includes(id)),
141
+ ]
142
+ const registryProviderId = configuredProviderHint
143
+ ? canonicalProviderId(configuredProviderHint, registryProviders.map((provider) => provider.id))
144
+ : null
145
+ const registryProvider = registryProviderId ? llmProviderRegistry.get(registryProviderId) : null
146
+ const fallbackOpenCodeProviderId = (
147
+ (configuredProviderHint
148
+ ? canonicalProviderId(configuredProviderHint, OPEN_CODE_PROVIDER_IDS as readonly string[])
149
+ : null) ?? 'openai'
150
+ ) as keyof typeof OPEN_CODE_PROVIDERS
151
+ const fallbackOpenCodeProvider = OPEN_CODE_PROVIDERS[fallbackOpenCodeProviderId]
49
152
 
50
- // Show the env key that's configured, or the first one as instruction
51
- const displayEnvKey = getOpenCodeProviderConfiguredEnvKey(providerId)
153
+ const providerId = registryProvider?.id ?? fallbackOpenCodeProviderId
154
+ const providerName = registryProvider?.name ?? fallbackOpenCodeProvider?.name ?? providerId
155
+ const defaultProviderModel = registryProvider?.defaultModel ?? fallbackOpenCodeProvider?.defaultModel ?? ''
156
+ const configuredModelHint = env.OM_AI_MODEL?.trim() || env.OPENCODE_MODEL?.trim() || defaultProviderModel
157
+ const fallbackModelWithProvider = `${providerId}/${configuredModelHint}`
158
+ const apiKeyConfigured = registryProvider
159
+ ? registryProvider.isConfigured(env)
160
+ : fallbackOpenCodeProvider
161
+ ? isOpenCodeProviderConfigured(fallbackOpenCodeProviderId)
162
+ : false
163
+ const displayEnvKey = registryProvider
164
+ ? registryProvider.getConfiguredEnvKey(env)
165
+ : fallbackOpenCodeProvider
166
+ ? getOpenCodeProviderConfiguredEnvKey(fallbackOpenCodeProviderId)
167
+ : null
52
168
 
53
169
  // Check if MCP_SERVER_API_KEY is configured (required for MCP authentication)
54
170
  const mcpKeyConfigured = !!process.env.MCP_SERVER_API_KEY?.trim()
55
171
 
56
- return NextResponse.json({
57
- provider: {
58
- id: providerId,
59
- name: providerInfo.name,
60
- model: resolvedModel.modelWithProvider,
61
- defaultModel: providerInfo.defaultModel,
62
- envKey: displayEnvKey,
63
- configured: apiKeyConfigured,
64
- },
65
- availableProviders: OPEN_CODE_PROVIDER_IDS.map((id) => {
172
+ // Phase 4a: resolve tenant override row and per-agent resolution matrix
173
+ let tenantOverride: {
174
+ providerId: string | null
175
+ modelId: string | null
176
+ baseURL: string | null
177
+ agentId: string | null
178
+ updatedAt: string
179
+ } | null = null
180
+
181
+ let agentResolutions: Array<{
182
+ agentId: string
183
+ moduleId: string
184
+ allowRuntimeModelOverride: boolean
185
+ codeDefaultProviderId: string | null
186
+ codeDefaultModelId: string | null
187
+ override: {
188
+ providerId: string | null
189
+ modelId: string | null
190
+ baseURL: string | null
191
+ updatedAt: string
192
+ } | null
193
+ runtimeOverrideAllowlist: {
194
+ env: TenantAllowlistSnapshot | null
195
+ tenant: TenantAllowlistSnapshot | null
196
+ effective: ReturnType<typeof intersectAllowlists>
197
+ envVarNames: {
198
+ providers: string
199
+ modelsByProvider: Record<string, string>
200
+ }
201
+ }
202
+ providerId: string
203
+ modelId: string
204
+ baseURL: string | null
205
+ source: string
206
+ }> = []
207
+
208
+ let resolvedDefault: {
209
+ providerId: string
210
+ modelId: string
211
+ baseURL: string | null
212
+ source: string
213
+ } | null = null
214
+
215
+ let tenantAllowlistSnapshot: TenantAllowlistSnapshot | null = null
216
+
217
+ try {
218
+ const container = await createRequestContainer()
219
+ const tenantId = auth.tenantId ?? null
220
+ const organizationId = auth.orgId ?? null
221
+
222
+ if (tenantId) {
223
+ const em = container.resolve<EntityManager>('em')
224
+ const repo = new AiAgentRuntimeOverrideRepository(em)
225
+ const overrideRow = await repo.getDefault({ tenantId, organizationId, agentId: null })
226
+ if (overrideRow) {
227
+ tenantOverride = {
228
+ providerId: overrideRow.providerId ?? null,
229
+ modelId: overrideRow.modelId ?? null,
230
+ baseURL: overrideRow.baseUrl ?? null,
231
+ agentId: overrideRow.agentId ?? null,
232
+ updatedAt: overrideRow.updatedAt.toISOString(),
233
+ }
234
+ }
235
+
236
+ const allowlistRepo = new AiTenantModelAllowlistRepository(em)
237
+ tenantAllowlistSnapshot = await allowlistRepo.getSnapshot({
238
+ tenantId,
239
+ organizationId,
240
+ })
241
+
242
+ const factory = createModelFactory(container)
243
+ const defaultResolution = factory.resolveModel({
244
+ tenantAllowlist: tenantAllowlistSnapshot,
245
+ tenantOverride: tenantOverride
246
+ ? { providerId: tenantOverride.providerId, modelId: tenantOverride.modelId, baseURL: tenantOverride.baseURL }
247
+ : undefined,
248
+ })
249
+ resolvedDefault = {
250
+ providerId: defaultResolution.providerId,
251
+ modelId: defaultResolution.modelId,
252
+ baseURL: defaultResolution.baseURL ?? null,
253
+ source: defaultResolution.source,
254
+ }
255
+
256
+ await loadAgentRegistry()
257
+ const agents = listAgents()
258
+ const agentResolutionPromises = agents.map(async (agent) => {
259
+ const agentOverrideRow = await repo.getExact({
260
+ tenantId,
261
+ organizationId,
262
+ agentId: agent.id,
263
+ })
264
+ const agentTenantOverride = agentOverrideRow
265
+ ? {
266
+ providerId: agentOverrideRow.providerId ?? null,
267
+ modelId: agentOverrideRow.modelId ?? null,
268
+ baseURL: agentOverrideRow.baseUrl ?? null,
269
+ }
270
+ : (tenantOverride ?? undefined)
271
+ const agentResolution = factory.resolveModel({
272
+ moduleId: agent.moduleId,
273
+ agentDefaultModel: agent.defaultModel,
274
+ agentDefaultProvider: agent.defaultProvider,
275
+ agentDefaultBaseUrl: agent.defaultBaseUrl,
276
+ allowRuntimeModelOverride: agent.allowRuntimeModelOverride,
277
+ tenantOverride: agentTenantOverride,
278
+ tenantAllowlist: tenantAllowlistSnapshot,
279
+ })
280
+ const agentEnvAllowlist = readAgentRuntimeOverrideAllowlist(
281
+ env,
282
+ agent.id,
283
+ knownProviderIdsForAllowlist,
284
+ )
285
+ const agentTenantAllowlist = agentOverrideRow
286
+ ? {
287
+ allowedProviders: agentOverrideRow.allowedOverrideProviders ?? null,
288
+ allowedModelsByProvider: agentOverrideRow.allowedOverrideModelsByProvider ?? {},
289
+ }
290
+ : null
291
+ const baseEffectiveAllowlist = intersectAllowlists(
292
+ env,
293
+ knownProviderIdsForAllowlist,
294
+ tenantAllowlistSnapshot,
295
+ )
296
+ const agentEffectiveAllowlist = intersectEffectiveAllowlistWithSnapshot(
297
+ intersectEffectiveAllowlistWithSnapshot(
298
+ baseEffectiveAllowlist,
299
+ knownProviderIdsForAllowlist,
300
+ agentEnvAllowlist,
301
+ ),
302
+ knownProviderIdsForAllowlist,
303
+ agentTenantAllowlist,
304
+ )
305
+ const agentModelEnvVars = Object.fromEntries(
306
+ knownProviderIdsForAllowlist.map((providerId) => [
307
+ providerId,
308
+ agentOverrideModelAllowlistEnvVarName(agent.id, providerId),
309
+ ]),
310
+ )
311
+ return {
312
+ agentId: agent.id,
313
+ moduleId: agent.moduleId,
314
+ allowRuntimeModelOverride: agent.allowRuntimeModelOverride !== false,
315
+ codeDefaultProviderId: agent.defaultProvider ?? null,
316
+ codeDefaultModelId: agent.defaultModel ?? null,
317
+ override: agentOverrideRow
318
+ ? {
319
+ providerId: agentOverrideRow.providerId ?? null,
320
+ modelId: agentOverrideRow.modelId ?? null,
321
+ baseURL: agentOverrideRow.baseUrl ?? null,
322
+ updatedAt: agentOverrideRow.updatedAt.toISOString(),
323
+ }
324
+ : null,
325
+ runtimeOverrideAllowlist: {
326
+ env: agentEnvAllowlist,
327
+ tenant: hasAllowlistSnapshotRestrictions(agentTenantAllowlist)
328
+ ? agentTenantAllowlist
329
+ : null,
330
+ effective: agentEffectiveAllowlist,
331
+ envVarNames: {
332
+ providers: agentOverrideProviderAllowlistEnvVarName(agent.id),
333
+ modelsByProvider: agentModelEnvVars,
334
+ },
335
+ },
336
+ providerId: agentResolution.providerId,
337
+ modelId: agentResolution.modelId,
338
+ baseURL: agentResolution.baseURL ?? null,
339
+ source: agentResolution.source,
340
+ }
341
+ })
342
+ agentResolutions = await Promise.all(agentResolutionPromises)
343
+ }
344
+ } catch (overrideError) {
345
+ // Phase 4a fields are best-effort — log and continue returning the base response
346
+ console.warn('[AI Settings] Failed to compute Phase 4a override fields:', overrideError)
347
+ }
348
+
349
+ // Build availableProviders with Phase 4a defaultModels, then clip to the
350
+ // EFFECTIVE allowlist — env intersected with the per-tenant snapshot.
351
+ // The env allowlist is the OUTER constraint; the tenant allowlist (Phase
352
+ // 1780-6) narrows it further. The settings UI must never offer a value
353
+ // the runtime would refuse to honor.
354
+ const allowlistConfig = readAllowlistConfig(env, knownProviderIdsForAllowlist)
355
+ const effectiveAllowlist = intersectAllowlists(
356
+ env,
357
+ knownProviderIdsForAllowlist,
358
+ tenantAllowlistSnapshot,
359
+ )
360
+
361
+ const allRawProviders = [
362
+ ...OPEN_CODE_PROVIDER_IDS.map((id) => {
66
363
  const info = OPEN_CODE_PROVIDERS[id]
364
+ const registryProvider = llmProviderRegistry.get(id)
67
365
  return {
68
366
  id,
69
367
  name: info.name,
70
368
  defaultModel: info.defaultModel,
71
369
  envKey: getOpenCodeProviderConfiguredEnvKey(id),
72
370
  configured: isOpenCodeProviderConfigured(id),
371
+ defaultModels: registryProvider?.defaultModels ?? [],
73
372
  }
74
373
  }),
374
+ // Also surface any llmProviderRegistry providers not in OPEN_CODE_PROVIDER_IDS
375
+ ...llmProviderRegistry.list()
376
+ .filter((p) => !(OPEN_CODE_PROVIDER_IDS as readonly string[]).includes(p.id))
377
+ .map((p) => ({
378
+ id: p.id,
379
+ name: p.name,
380
+ defaultModel: p.defaultModels[0]?.id ?? '',
381
+ envKey: null,
382
+ configured: p.isConfigured(),
383
+ defaultModels: p.defaultModels,
384
+ })),
385
+ ]
386
+
387
+ const availableProviders = allRawProviders
388
+ .filter((p) => isProviderAllowedInEffective(effectiveAllowlist, p.id))
389
+ .map((p) => {
390
+ const effectiveModelsList = effectiveAllowlist.modelsByProvider[p.id]
391
+ const catalogModels = modelCatalogWithAllowlistFallback(
392
+ p.defaultModels,
393
+ effectiveModelsList,
394
+ )
395
+ const clippedDefaults = effectiveModelsList !== undefined
396
+ ? catalogModels.filter((m) => effectiveModelsList.includes(m.id))
397
+ : catalogModels
398
+ return {
399
+ ...p,
400
+ defaultModel: effectiveModelsList && !effectiveModelsList.includes(p.defaultModel)
401
+ ? (effectiveModelsList[0] ?? p.defaultModel)
402
+ : p.defaultModel || clippedDefaults[0]?.id || '',
403
+ defaultModels: clippedDefaults,
404
+ }
405
+ })
406
+
407
+ const allowlistProviders = allRawProviders
408
+ .filter((p) => isProviderAllowed(env, p.id))
409
+ .map((p) => {
410
+ const envModelsList = allowlistConfig.modelsByProvider[p.id]
411
+ const catalogModels = modelCatalogWithAllowlistFallback(
412
+ p.defaultModels,
413
+ envModelsList,
414
+ )
415
+ const envClippedDefaults = envModelsList !== undefined
416
+ ? catalogModels.filter((m) => envModelsList.includes(m.id))
417
+ : catalogModels
418
+ return {
419
+ ...p,
420
+ defaultModel: envModelsList && !envModelsList.includes(p.defaultModel)
421
+ ? (envModelsList[0] ?? p.defaultModel)
422
+ : p.defaultModel || envClippedDefaults[0]?.id || '',
423
+ defaultModels: envClippedDefaults,
424
+ }
425
+ })
426
+
427
+ return NextResponse.json({
428
+ provider: {
429
+ id: providerId,
430
+ name: providerName,
431
+ model: resolvedDefault
432
+ ? `${resolvedDefault.providerId}/${resolvedDefault.modelId}`
433
+ : fallbackModelWithProvider,
434
+ defaultModel: defaultProviderModel,
435
+ envKey: displayEnvKey,
436
+ configured: apiKeyConfigured,
437
+ },
438
+ availableProviders,
439
+ // Editable universe for the tenant allowlist page. This is clipped only
440
+ // by env so tenant-hidden models remain visible and can be re-enabled.
441
+ allowlistProviders,
442
+ // Snapshot of the env-driven allowlist so the UI can render hints like
443
+ // "limited to: openai, anthropic" without re-implementing the parser.
444
+ allowlist: allowlistConfig,
445
+ // Per-tenant allowlist snapshot (Phase 1780-6). `null` when no row has
446
+ // been persisted yet — the runtime then falls back to env-only
447
+ // enforcement. The UI uses this to drive the editable MultiSelect.
448
+ tenantAllowlist: tenantAllowlistSnapshot,
449
+ // Effective allowlist after intersecting env with tenant. The UI uses
450
+ // this to render the "what the runtime will actually accept" summary
451
+ // and to clip pickers without re-implementing the intersection.
452
+ effectiveAllowlist,
75
453
  mcpKeyConfigured,
454
+ resolvedDefault,
455
+ tenantOverride,
456
+ agents: agentResolutions,
76
457
  })
77
458
  } catch (error) {
78
459
  console.error('[AI Settings] GET error:', error)
79
460
  return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 })
80
461
  }
81
462
  }
463
+
464
+ /**
465
+ * PUT /api/ai_assistant/settings
466
+ *
467
+ * Upserts the per-tenant AI runtime override (Phase 4a). Requires
468
+ * `ai_assistant.settings.manage`. The body is Zod-validated; a `baseURL`
469
+ * must match `AI_RUNTIME_BASEURL_ALLOWLIST` when that env var is set.
470
+ */
471
+ export async function PUT(req: NextRequest) {
472
+ const auth = await getAuthFromRequest(req)
473
+ if (!auth?.sub) {
474
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
475
+ }
476
+
477
+ let parsedBody: unknown
478
+ try {
479
+ parsedBody = await req.json()
480
+ } catch {
481
+ return NextResponse.json({ error: 'Request body must be valid JSON.', code: 'validation_error' }, { status: 400 })
482
+ }
483
+
484
+ const bodyResult = runtimeOverrideUpsertSchema.safeParse(parsedBody)
485
+ if (!bodyResult.success) {
486
+ return NextResponse.json(
487
+ { error: 'Invalid request body.', code: 'validation_error', issues: bodyResult.error.issues },
488
+ { status: 400 },
489
+ )
490
+ }
491
+
492
+ const { providerId: requestedProviderId, modelId, baseURL, agentId } = bodyResult.data
493
+ const knownProviderIdsForRequest = llmProviderRegistry.list().map((p) => p.id)
494
+ const providerId = requestedProviderId
495
+ ? canonicalProviderId(requestedProviderId, knownProviderIdsForRequest) ?? requestedProviderId
496
+ : requestedProviderId
497
+
498
+ if (baseURL && baseURL.trim().length > 0) {
499
+ const allowlist = readBaseurlAllowlist()
500
+ if (!isBaseurlAllowlisted(baseURL.trim(), allowlist)) {
501
+ return NextResponse.json(
502
+ {
503
+ error: `baseURL "${baseURL}" is not in AI_RUNTIME_BASEURL_ALLOWLIST.`,
504
+ code: 'baseurl_not_allowlisted',
505
+ },
506
+ { status: 400 },
507
+ )
508
+ }
509
+ }
510
+
511
+ const allowedOverrideProviders = bodyResult.data.allowedOverrideProviders === undefined
512
+ ? undefined
513
+ : bodyResult.data.allowedOverrideProviders?.map((id) =>
514
+ canonicalProviderId(id, knownProviderIdsForRequest) ?? id,
515
+ ) ?? null
516
+ const allowedOverrideModelsByProvider = bodyResult.data.allowedOverrideModelsByProvider === undefined
517
+ ? undefined
518
+ : Object.fromEntries(
519
+ Object.entries(bodyResult.data.allowedOverrideModelsByProvider ?? {}).map(([id, models]) => [
520
+ canonicalProviderId(id, knownProviderIdsForRequest) ?? id,
521
+ models,
522
+ ]),
523
+ )
524
+ const hasRuntimeOverrideAllowlistWrite =
525
+ allowedOverrideProviders !== undefined || allowedOverrideModelsByProvider !== undefined
526
+
527
+ // Env-driven provider/model allowlist (Phase 1780-5) intersected with the
528
+ // per-tenant allowlist (Phase 1780-6): the EFFECTIVE allowlist clips which
529
+ // (provider, model) pairs the runtime accepts. Reject settings PUT requests
530
+ // for pairs outside that effective set so the settings UI never persists a
531
+ // value the runtime would later refuse. Tenant-allowlist enforcement is
532
+ // best-effort here: if the snapshot lookup fails we fall back to env-only
533
+ // checks (the runtime still re-clips at resolution time).
534
+ let putEffectiveAllowlist: ReturnType<typeof intersectAllowlists> | null = null
535
+ if (providerId || hasRuntimeOverrideAllowlistWrite) {
536
+ try {
537
+ const previewContainer = await createRequestContainer()
538
+ const knownIdsForCheck = [
539
+ ...OPEN_CODE_PROVIDER_IDS,
540
+ ...llmProviderRegistry
541
+ .list()
542
+ .map((p) => p.id)
543
+ .filter((id) => !(OPEN_CODE_PROVIDER_IDS as readonly string[]).includes(id)),
544
+ ]
545
+ let snapshot: TenantAllowlistSnapshot | null = null
546
+ if (auth.tenantId) {
547
+ try {
548
+ const em = previewContainer.resolve<EntityManager>('em')
549
+ const allowlistRepo = new AiTenantModelAllowlistRepository(em)
550
+ snapshot = await allowlistRepo.getSnapshot({
551
+ tenantId: auth.tenantId,
552
+ organizationId: auth.orgId ?? null,
553
+ })
554
+ } catch {
555
+ snapshot = null
556
+ }
557
+ }
558
+ putEffectiveAllowlist = intersectAllowlists(
559
+ process.env as Record<string, string | undefined>,
560
+ knownIdsForCheck,
561
+ snapshot,
562
+ )
563
+ } catch {
564
+ putEffectiveAllowlist = null
565
+ }
566
+
567
+ if (providerId && putEffectiveAllowlist) {
568
+ if (!isProviderAllowedInEffective(putEffectiveAllowlist, providerId)) {
569
+ const source = putEffectiveAllowlist.tenantOverridesActive
570
+ ? 'the effective allowlist (env ∩ tenant)'
571
+ : 'OM_AI_AVAILABLE_PROVIDERS'
572
+ return NextResponse.json(
573
+ {
574
+ error: `Provider "${providerId}" is not in ${source}.`,
575
+ code: 'provider_not_allowlisted',
576
+ },
577
+ { status: 400 },
578
+ )
579
+ }
580
+ if (modelId && !isProviderModelAllowedInEffective(putEffectiveAllowlist, providerId, modelId)) {
581
+ const source = putEffectiveAllowlist.tenantOverridesActive
582
+ ? `the effective allowlist (env ∩ tenant) for "${providerId}"`
583
+ : modelAllowlistEnvVarName(providerId)
584
+ return NextResponse.json(
585
+ {
586
+ error: `Model "${modelId}" is not in ${source}.`,
587
+ code: 'model_not_allowlisted',
588
+ },
589
+ { status: 400 },
590
+ )
591
+ }
592
+ } else if (providerId) {
593
+ if (!isProviderAllowed(process.env, providerId)) {
594
+ return NextResponse.json(
595
+ {
596
+ error: `Provider "${requestedProviderId}" is not in OM_AI_AVAILABLE_PROVIDERS.`,
597
+ code: 'provider_not_allowlisted',
598
+ },
599
+ { status: 400 },
600
+ )
601
+ }
602
+ if (modelId && !isProviderModelAllowed(process.env, providerId, modelId)) {
603
+ return NextResponse.json(
604
+ {
605
+ error: `Model "${modelId}" is not in ${modelAllowlistEnvVarName(providerId)}.`,
606
+ code: 'model_not_allowlisted',
607
+ },
608
+ { status: 400 },
609
+ )
610
+ }
611
+ }
612
+ }
613
+
614
+ if (hasRuntimeOverrideAllowlistWrite && !agentId) {
615
+ return NextResponse.json(
616
+ {
617
+ error: 'agentId is required when saving chat override allowlist settings.',
618
+ code: 'agent_required',
619
+ },
620
+ { status: 400 },
621
+ )
622
+ }
623
+
624
+ if (Array.isArray(allowedOverrideProviders)) {
625
+ for (const id of allowedOverrideProviders) {
626
+ if (putEffectiveAllowlist && !isProviderAllowedInEffective(putEffectiveAllowlist, id)) {
627
+ return NextResponse.json(
628
+ {
629
+ error: `Provider "${id}" is not in the effective tenant allowlist; per-agent chat override choices may not widen it.`,
630
+ code: 'provider_not_allowlisted',
631
+ },
632
+ { status: 400 },
633
+ )
634
+ }
635
+ }
636
+ }
637
+ if (allowedOverrideModelsByProvider) {
638
+ for (const [id, models] of Object.entries(allowedOverrideModelsByProvider)) {
639
+ if (putEffectiveAllowlist && !isProviderAllowedInEffective(putEffectiveAllowlist, id)) {
640
+ return NextResponse.json(
641
+ {
642
+ error: `Provider "${id}" is not in the effective tenant allowlist; cannot save per-agent model choices for it.`,
643
+ code: 'provider_not_allowlisted',
644
+ },
645
+ { status: 400 },
646
+ )
647
+ }
648
+ for (const allowedModelId of models) {
649
+ if (
650
+ putEffectiveAllowlist &&
651
+ !isProviderModelAllowedInEffective(putEffectiveAllowlist, id, allowedModelId)
652
+ ) {
653
+ return NextResponse.json(
654
+ {
655
+ error: `Model "${allowedModelId}" is not in the effective tenant allowlist for "${id}".`,
656
+ code: 'model_not_allowlisted',
657
+ },
658
+ { status: 400 },
659
+ )
660
+ }
661
+ }
662
+ }
663
+ }
664
+
665
+ try {
666
+ const container = await createRequestContainer()
667
+ const rbacService = container.resolve<RbacService>('rbacService')
668
+ const acl = await rbacService.loadAcl(auth.sub, {
669
+ tenantId: auth.tenantId,
670
+ organizationId: auth.orgId,
671
+ })
672
+ const canManage = acl.isSuperAdmin || acl.features.includes('ai_assistant.settings.manage')
673
+ if (!canManage) {
674
+ return NextResponse.json({ error: 'Forbidden', code: 'forbidden' }, { status: 403 })
675
+ }
676
+
677
+ const em = container.resolve<EntityManager>('em')
678
+ const repo = new AiAgentRuntimeOverrideRepository(em)
679
+ const upsertInput = {
680
+ agentId: agentId ?? null,
681
+ ...(Object.prototype.hasOwnProperty.call(bodyResult.data, 'providerId')
682
+ ? { providerId: providerId ?? null }
683
+ : {}),
684
+ ...(Object.prototype.hasOwnProperty.call(bodyResult.data, 'modelId')
685
+ ? { modelId: modelId ?? null }
686
+ : {}),
687
+ ...(Object.prototype.hasOwnProperty.call(bodyResult.data, 'baseURL')
688
+ ? { baseURL: baseURL ?? null }
689
+ : {}),
690
+ ...(allowedOverrideProviders !== undefined
691
+ ? { allowedOverrideProviders }
692
+ : {}),
693
+ ...(allowedOverrideModelsByProvider !== undefined
694
+ ? { allowedOverrideModelsByProvider }
695
+ : {}),
696
+ }
697
+ const row = await repo.upsertDefault(
698
+ upsertInput,
699
+ { tenantId: auth.tenantId ?? '', organizationId: auth.orgId ?? null, userId: auth.sub },
700
+ )
701
+ return NextResponse.json({
702
+ id: row.id,
703
+ tenantId: row.tenantId,
704
+ organizationId: row.organizationId,
705
+ agentId: row.agentId,
706
+ providerId: row.providerId,
707
+ modelId: row.modelId,
708
+ baseURL: row.baseUrl,
709
+ allowedOverrideProviders: row.allowedOverrideProviders ?? null,
710
+ allowedOverrideModelsByProvider: row.allowedOverrideModelsByProvider ?? {},
711
+ updatedAt: row.updatedAt,
712
+ })
713
+ } catch (error) {
714
+ if (error instanceof AiAgentRuntimeOverrideValidationError) {
715
+ return NextResponse.json({ error: error.message, code: 'provider_unknown' }, { status: 400 })
716
+ }
717
+ console.error('[AI Settings] PUT error:', error)
718
+ return NextResponse.json({ error: 'Failed to save runtime override.' }, { status: 500 })
719
+ }
720
+ }
721
+
722
+ /**
723
+ * DELETE /api/ai_assistant/settings
724
+ *
725
+ * Soft-deletes the active per-tenant AI runtime override (Phase 4a). Requires
726
+ * `ai_assistant.settings.manage`. Pass `agentId` in the body to clear only
727
+ * the agent-specific row; omit (or null) to clear the tenant-wide default.
728
+ * Idempotent — returns `{ cleared: false }` when no active row was found.
729
+ */
730
+ export async function DELETE(req: NextRequest) {
731
+ const auth = await getAuthFromRequest(req)
732
+ if (!auth?.sub) {
733
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
734
+ }
735
+
736
+ let parsedBody: unknown = {}
737
+ try {
738
+ parsedBody = await req.json()
739
+ } catch {
740
+ // Body is optional for DELETE — empty body is fine
741
+ }
742
+
743
+ const bodyResult = runtimeOverrideClearSchema.safeParse(parsedBody)
744
+ if (!bodyResult.success) {
745
+ return NextResponse.json(
746
+ { error: 'Invalid request body.', code: 'validation_error', issues: bodyResult.error.issues },
747
+ { status: 400 },
748
+ )
749
+ }
750
+
751
+ try {
752
+ const container = await createRequestContainer()
753
+ const rbacService = container.resolve<RbacService>('rbacService')
754
+ const acl = await rbacService.loadAcl(auth.sub, {
755
+ tenantId: auth.tenantId,
756
+ organizationId: auth.orgId,
757
+ })
758
+ const canManage = acl.isSuperAdmin || acl.features.includes('ai_assistant.settings.manage')
759
+ if (!canManage) {
760
+ return NextResponse.json({ error: 'Forbidden', code: 'forbidden' }, { status: 403 })
761
+ }
762
+
763
+ const em = container.resolve<EntityManager>('em')
764
+ const repo = new AiAgentRuntimeOverrideRepository(em)
765
+ const cleared = await repo.clearDefault({
766
+ tenantId: auth.tenantId ?? '',
767
+ organizationId: auth.orgId ?? null,
768
+ agentId: bodyResult.data.agentId ?? null,
769
+ })
770
+ return NextResponse.json({ cleared })
771
+ } catch (error) {
772
+ console.error('[AI Settings] DELETE error:', error)
773
+ return NextResponse.json({ error: 'Failed to clear runtime override.' }, { status: 500 })
774
+ }
775
+ }