@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,3 +1,4 @@
1
+ import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
1
2
  import type { AiAgentDefinition, AiAgentExtension, AiAgentSuggestion } from './ai-agent-definition'
2
3
  import {
3
4
  applyAgentOverrideMap,
@@ -104,6 +105,24 @@ function applySuggestionPatch(
104
105
  return out
105
106
  }
106
107
 
108
+ function validateAndNormalizeAgent(candidate: AiAgentDefinition): AiAgentDefinition {
109
+ const rawProvider = candidate.defaultProvider
110
+ if (typeof rawProvider !== 'string' || rawProvider.trim().length === 0) {
111
+ return candidate
112
+ }
113
+ const providerHint = rawProvider.trim()
114
+ const registered = llmProviderRegistry.get(providerHint)
115
+ if (!registered) {
116
+ console.warn(
117
+ `[AI Agents] Agent "${candidate.id}" declares defaultProvider "${providerHint}" which is not registered in llmProviderRegistry. ` +
118
+ `The agent will be registered with defaultProvider: undefined so the resolution chain still works. ` +
119
+ `Built-in provider ids: anthropic, google, openai, deepinfra, groq, together, fireworks, azure, litellm, ollama.`,
120
+ )
121
+ return { ...candidate, defaultProvider: undefined }
122
+ }
123
+ return candidate
124
+ }
125
+
107
126
  function populateFromAgents(agents: unknown[]): void {
108
127
  for (const candidate of agents) {
109
128
  if (!isAiAgentDefinition(candidate)) {
@@ -116,7 +135,7 @@ function populateFromAgents(agents: unknown[]): void {
116
135
  `[AI Agents] Duplicate agent id "${candidate.id}" — already registered by module "${existing.moduleId}", conflicts with module "${candidate.moduleId}". Export \`aiAgentOverrides\` from your module's \`ai-agents.ts\` (or set it on the modules.ts entry) to replace an agent across modules.`
117
136
  )
118
137
  }
119
- agentsById.set(candidate.id, candidate)
138
+ agentsById.set(candidate.id, validateAndNormalizeAgent(candidate))
120
139
  }
121
140
  }
122
141
 
@@ -1,3 +1,4 @@
1
+ import { createContainer } from 'awilix'
1
2
  import type { AwilixContainer } from 'awilix'
2
3
  import type { EntityManager } from '@mikro-orm/postgresql'
3
4
  import type { LanguageModel, UIMessage } from 'ai'
@@ -9,8 +10,7 @@ import {
9
10
  streamText,
10
11
  } from 'ai'
11
12
  import type { ZodTypeAny } from 'zod'
12
- import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
13
- import { resolveAiProviderIdFromEnv } from '@open-mercato/shared/lib/ai/opencode-provider'
13
+ import { createModelFactory } from './model-factory'
14
14
  import type {
15
15
  AiAgentDefinition,
16
16
  AiAgentPageContextInput,
@@ -30,6 +30,9 @@ import {
30
30
  } from './attachment-parts'
31
31
  import { AiAgentPromptOverrideRepository } from '../data/repositories/AiAgentPromptOverrideRepository'
32
32
  import { AiAgentMutationPolicyOverrideRepository } from '../data/repositories/AiAgentMutationPolicyOverrideRepository'
33
+ import { AiAgentRuntimeOverrideRepository } from '../data/repositories/AiAgentRuntimeOverrideRepository'
34
+ import { AiTenantModelAllowlistRepository } from '../data/repositories/AiTenantModelAllowlistRepository'
35
+ import type { TenantAllowlistSnapshot } from './model-allowlist'
33
36
  import { composeSystemPromptWithOverride } from './prompt-override-merge'
34
37
  import { isKnownMutationPolicy } from './agent-policy'
35
38
  import type { AiAgentMutationPolicy } from './ai-agent-definition'
@@ -65,6 +68,36 @@ export interface RunAiAgentTextInput {
65
68
  * `agent.defaultModel` without inventing a new indirection layer.
66
69
  */
67
70
  modelOverride?: string
71
+ /**
72
+ * Optional request-time provider override. When non-empty, wins for the
73
+ * provider axis at the same priority as `modelOverride` for the model axis.
74
+ * A value that does not match any registered provider id is silently ignored.
75
+ *
76
+ * Phase 1 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
77
+ */
78
+ providerOverride?: string
79
+ /**
80
+ * Optional per-call base URL override. Wins over every other source in the
81
+ * baseURL resolution chain. Intended for programmatic callers only — the
82
+ * HTTP query-param baseUrl and the AI_RUNTIME_BASEURL_ALLOWLIST arrive in
83
+ * Phase 4a and MUST NOT be exposed here.
84
+ *
85
+ * Phase 2 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
86
+ */
87
+ baseUrlOverride?: string
88
+ /**
89
+ * Per-request HTTP dispatcher override (query params `?provider=`, `?model=`,
90
+ * `?baseUrl=`). Validated by the dispatcher route before being forwarded
91
+ * here. Wins over tenantOverride and all lower-priority sources when
92
+ * `agent.allowRuntimeModelOverride !== false`.
93
+ *
94
+ * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
95
+ */
96
+ requestOverride?: {
97
+ providerId?: string | null
98
+ modelId?: string | null
99
+ baseURL?: string | null
100
+ }
68
101
  /**
69
102
  * Optional DI container used by `resolvePageContext` callbacks. When omitted
70
103
  * and the agent declares a `resolvePageContext`, hydration is skipped with a
@@ -90,38 +123,61 @@ interface ResolvedAgentModel {
90
123
  function resolveAgentModel(
91
124
  agent: AiAgentDefinition,
92
125
  modelOverride: string | undefined,
126
+ providerOverride: string | undefined,
127
+ container: AwilixContainer | undefined,
128
+ baseUrlOverride?: string,
129
+ tenantOverride?: { providerId?: string | null; modelId?: string | null; baseURL?: string | null } | null,
130
+ requestOverride?: { providerId?: string | null; modelId?: string | null; baseURL?: string | null } | null,
131
+ tenantAllowlist?: TenantAllowlistSnapshot | null,
93
132
  ): ResolvedAgentModel {
94
- const env = process.env
95
- // Honor the operator-selected provider (new OM_AI_PROVIDER, with legacy
96
- // OPENCODE_PROVIDER as the BC fallback) so deployments that have stale
97
- // keys for other providers still hit the intended target.
98
- const omProvider = (env.OM_AI_PROVIDER ?? '').trim()
99
- const opencodeProvider = (env.OPENCODE_PROVIDER ?? '').trim()
100
- const providerHint = omProvider.length > 0 || opencodeProvider.length > 0
101
- ? [resolveAiProviderIdFromEnv(env)]
102
- : undefined
103
- const provider = llmProviderRegistry.resolveFirstConfigured(
104
- providerHint ? { order: providerHint } : undefined,
105
- )
106
- if (!provider) {
107
- throw new Error(
108
- 'No LLM provider is configured. Set OM_AI_PROVIDER (or the legacy OPENCODE_PROVIDER) plus a matching API key such as OPENAI_API_KEY, ANTHROPIC_API_KEY, or GOOGLE_GENERATIVE_AI_API_KEY, then restart the app. See https://docs.openmercato.com/framework/ai-assistant/overview.',
109
- )
133
+ const effectiveContainer = container ?? createContainer()
134
+ const allowRuntimeModelOverride = agent.allowRuntimeModelOverride !== false
135
+ const resolution = createModelFactory(effectiveContainer).resolveModel({
136
+ moduleId: agent.moduleId,
137
+ agentDefaultModel: agent.defaultModel,
138
+ agentDefaultProvider: agent.defaultProvider,
139
+ agentDefaultBaseUrl: agent.defaultBaseUrl,
140
+ callerOverride: modelOverride,
141
+ providerOverride,
142
+ baseUrlOverride,
143
+ allowRuntimeModelOverride,
144
+ tenantOverride: tenantOverride ?? undefined,
145
+ requestOverride: requestOverride ?? undefined,
146
+ tenantAllowlist: tenantAllowlist ?? null,
147
+ })
148
+ return {
149
+ model: resolution.model as LanguageModel,
150
+ modelId: resolution.modelId,
151
+ providerId: resolution.providerId,
110
152
  }
111
- const apiKey = provider.resolveApiKey()
112
- if (!apiKey) {
113
- throw new Error(
114
- `LLM provider "${provider.id}" is advertised as configured but resolveApiKey() returned empty.`,
153
+ }
154
+
155
+ async function resolveTenantAllowlistSnapshot(
156
+ container: AwilixContainer | undefined,
157
+ tenantId: string | null,
158
+ organizationId: string | null,
159
+ ): Promise<TenantAllowlistSnapshot | null> {
160
+ if (!tenantId || !container) return null
161
+ let em: EntityManager | null = null
162
+ try {
163
+ em = container.resolve<EntityManager>('em')
164
+ } catch {
165
+ em = null
166
+ }
167
+ if (!em) return null
168
+ try {
169
+ const repo = new AiTenantModelAllowlistRepository(em)
170
+ return await repo.getSnapshot({
171
+ tenantId,
172
+ organizationId: organizationId ?? null,
173
+ })
174
+ } catch (error) {
175
+ console.warn(
176
+ '[AI Agents] Tenant allowlist lookup failed; falling back to env-only enforcement.',
177
+ error,
115
178
  )
179
+ return null
116
180
  }
117
- const globalEnvModel = ((env.OM_AI_MODEL ?? env.OPENCODE_MODEL) ?? '').trim()
118
- const modelId =
119
- (modelOverride && modelOverride.trim().length > 0 ? modelOverride : undefined) ??
120
- (globalEnvModel.length > 0 ? globalEnvModel : undefined) ??
121
- agent.defaultModel ??
122
- provider.defaultModel
123
- const model = provider.createModel({ modelId, apiKey }) as LanguageModel
124
- return { model, modelId, providerId: provider.id }
125
181
  }
126
182
 
127
183
  /**
@@ -264,6 +320,53 @@ async function resolveMutationPolicyOverride(
264
320
  }
265
321
  }
266
322
 
323
+ /**
324
+ * Looks up the per-tenant AI runtime override (provider / model / baseURL) for
325
+ * the given agent (Phase 4a of spec
326
+ * `2026-04-27-ai-agents-provider-model-baseurl-overrides`).
327
+ *
328
+ * Mirrors the fail-open contract of {@link resolveMutationPolicyOverride}: any
329
+ * error — missing container, missing `em`, repository throw, missing migration
330
+ * — is logged at `warn` level and returns null so the model factory falls
331
+ * through to lower-priority sources. A chat turn MUST never fail on override
332
+ * lookup.
333
+ */
334
+ async function resolveRuntimeModelOverride(
335
+ agentId: string,
336
+ container: AwilixContainer | undefined,
337
+ tenantId: string | null,
338
+ organizationId: string | null,
339
+ ): Promise<{ providerId?: string | null; modelId?: string | null; baseURL?: string | null } | null> {
340
+ if (!tenantId || !container) return null
341
+ let em: EntityManager | null = null
342
+ try {
343
+ em = container.resolve<EntityManager>('em')
344
+ } catch {
345
+ em = null
346
+ }
347
+ if (!em) return null
348
+ try {
349
+ const repo = new AiAgentRuntimeOverrideRepository(em)
350
+ const row = await repo.getDefault({
351
+ tenantId,
352
+ organizationId: organizationId ?? null,
353
+ agentId,
354
+ })
355
+ if (!row) return null
356
+ return {
357
+ providerId: row.providerId ?? null,
358
+ modelId: row.modelId ?? null,
359
+ baseURL: row.baseUrl ?? null,
360
+ }
361
+ } catch (error) {
362
+ console.warn(
363
+ `[AI Agents] Runtime model override lookup failed for agent "${agentId}"; falling back to lower-priority sources.`,
364
+ error,
365
+ )
366
+ return null
367
+ }
368
+ }
369
+
267
370
  /**
268
371
  * Normalizes simple `{ role, content }` chat messages into the AI SDK
269
372
  * `UIMessage` shape that `convertToModelMessages` requires. When the
@@ -481,12 +584,25 @@ function appendRuntimeMutationPolicy(
481
584
  * invariant #7 still holds.
482
585
  */
483
586
  export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Response> {
484
- const mutationPolicyOverride = await resolveMutationPolicyOverride(
485
- input.agentId,
486
- input.container,
487
- input.authContext.tenantId,
488
- input.authContext.organizationId,
489
- )
587
+ const [mutationPolicyOverride, tenantRuntimeOverride, tenantAllowlistSnapshot] = await Promise.all([
588
+ resolveMutationPolicyOverride(
589
+ input.agentId,
590
+ input.container,
591
+ input.authContext.tenantId,
592
+ input.authContext.organizationId,
593
+ ),
594
+ resolveRuntimeModelOverride(
595
+ input.agentId,
596
+ input.container,
597
+ input.authContext.tenantId,
598
+ input.authContext.organizationId,
599
+ ),
600
+ resolveTenantAllowlistSnapshot(
601
+ input.container,
602
+ input.authContext.tenantId,
603
+ input.authContext.organizationId,
604
+ ),
605
+ ])
490
606
  const { agent, tools } = await resolveAiAgentTools({
491
607
  agentId: input.agentId,
492
608
  authContext: input.authContext,
@@ -517,7 +633,16 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
517
633
  mutationPolicyOverride,
518
634
  )
519
635
 
520
- const { model } = resolveAgentModel(agent, input.modelOverride)
636
+ const { model } = resolveAgentModel(
637
+ agent,
638
+ input.modelOverride,
639
+ input.providerOverride,
640
+ input.container,
641
+ input.baseUrlOverride,
642
+ tenantRuntimeOverride,
643
+ input.requestOverride,
644
+ tenantAllowlistSnapshot,
645
+ )
521
646
  const normalizedMessages = ensureUiMessageShape(input.messages)
522
647
  const hydratedMessages = attachAttachmentsToMessages(normalizedMessages, resolvedAttachments)
523
648
  const modelMessages = await convertToModelMessages(hydratedMessages)
@@ -580,6 +705,35 @@ export interface RunAiAgentObjectInput<TSchema = ZodTypeAny> {
580
705
  */
581
706
  authContext: AiChatRequestContext
582
707
  modelOverride?: string
708
+ /**
709
+ * Optional request-time provider override. When non-empty, wins for the
710
+ * provider axis at the same priority as `modelOverride` for the model axis.
711
+ *
712
+ * Phase 1 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
713
+ */
714
+ providerOverride?: string
715
+ /**
716
+ * Optional per-call base URL override. Wins over every other source in the
717
+ * baseURL resolution chain. Intended for programmatic callers only — the
718
+ * HTTP query-param baseUrl and the AI_RUNTIME_BASEURL_ALLOWLIST arrive in
719
+ * Phase 4a and MUST NOT be exposed here.
720
+ *
721
+ * Phase 2 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
722
+ */
723
+ baseUrlOverride?: string
724
+ /**
725
+ * Per-request HTTP dispatcher override (query params `?provider=`, `?model=`,
726
+ * `?baseUrl=`). Validated by the dispatcher route before being forwarded
727
+ * here. Wins over tenantOverride and all lower-priority sources when
728
+ * `agent.allowRuntimeModelOverride !== false`.
729
+ *
730
+ * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
731
+ */
732
+ requestOverride?: {
733
+ providerId?: string | null
734
+ modelId?: string | null
735
+ baseURL?: string | null
736
+ }
583
737
  output?: RunAiAgentObjectOutputOverride<TSchema>
584
738
  debug?: boolean
585
739
  container?: AwilixContainer
@@ -662,12 +816,25 @@ function resolveStructuredOutput<TSchema>(
662
816
  export async function runAiAgentObject<TSchema = unknown>(
663
817
  input: RunAiAgentObjectInput<TSchema>,
664
818
  ): Promise<RunAiAgentObjectResult<TSchema>> {
665
- const mutationPolicyOverride = await resolveMutationPolicyOverride(
666
- input.agentId,
667
- input.container,
668
- input.authContext.tenantId,
669
- input.authContext.organizationId,
670
- )
819
+ const [mutationPolicyOverride, tenantRuntimeOverride, tenantAllowlistSnapshot] = await Promise.all([
820
+ resolveMutationPolicyOverride(
821
+ input.agentId,
822
+ input.container,
823
+ input.authContext.tenantId,
824
+ input.authContext.organizationId,
825
+ ),
826
+ resolveRuntimeModelOverride(
827
+ input.agentId,
828
+ input.container,
829
+ input.authContext.tenantId,
830
+ input.authContext.organizationId,
831
+ ),
832
+ resolveTenantAllowlistSnapshot(
833
+ input.container,
834
+ input.authContext.tenantId,
835
+ input.authContext.organizationId,
836
+ ),
837
+ ])
671
838
  const { agent, tools } = await resolveAiAgentTools({
672
839
  agentId: input.agentId,
673
840
  authContext: input.authContext,
@@ -700,7 +867,16 @@ export async function runAiAgentObject<TSchema = unknown>(
700
867
  mutationPolicyOverride,
701
868
  )
702
869
 
703
- const { model } = resolveAgentModel(agent, input.modelOverride)
870
+ const { model } = resolveAgentModel(
871
+ agent,
872
+ input.modelOverride,
873
+ input.providerOverride,
874
+ input.container,
875
+ input.baseUrlOverride,
876
+ tenantRuntimeOverride,
877
+ input.requestOverride,
878
+ tenantAllowlistSnapshot,
879
+ )
704
880
  const normalizedMessages = ensureUiMessageShape(normalizeObjectMessages(input.input))
705
881
  const hydratedMessages = attachAttachmentsToMessages(
706
882
  normalizedMessages,
@@ -46,7 +46,55 @@ export interface AiAgentDefinition {
46
46
  allowedTools: string[]
47
47
  suggestions?: AiAgentSuggestion[]
48
48
  executionMode?: AiAgentExecutionMode
49
+ /**
50
+ * Optional provider id this agent prefers (e.g. `'openai'`, `'anthropic'`).
51
+ * Must match a registered `LlmProvider.id`. When the named provider is
52
+ * registered but unconfigured at runtime the factory falls through
53
+ * transparently to the next configured provider.
54
+ *
55
+ * Phase 1 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
56
+ */
57
+ defaultProvider?: string
58
+ /**
59
+ * Optional model id fed through `createModelFactory` for this agent.
60
+ * Accepts either a plain model id (`claude-haiku-4-5-20251001`) or a
61
+ * slash-qualified `<provider>/<model>` shorthand (e.g. `openai/gpt-5-mini`).
62
+ * When the slash form is used the prefix must match a registered provider id;
63
+ * the registry-membership guard prevents mis-splitting model ids that already
64
+ * contain slashes (DeepInfra: `meta-llama/Llama-3.3-70B-Instruct-Turbo`).
65
+ *
66
+ * A higher-priority provider source still wins over the slash hint, but a
67
+ * lower-priority one cannot overwrite a slash-qualified model (cross-axis
68
+ * tie-break rule from spec §Phase-1).
69
+ *
70
+ * Phase 0 and Phase 1 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
71
+ */
49
72
  defaultModel?: string
73
+ /**
74
+ * Optional base URL this agent prefers for its chosen provider.
75
+ * Sits between the `<MODULE>_AI_BASE_URL` env (step 2 of the public 5-step
76
+ * baseURL hierarchy) and the preset env override (`baseURLEnvKeys`, step 4).
77
+ * Only honoured by adapters that support baseURL (Anthropic Messages-
78
+ * protocol relays, all OpenAI-compatible adapters, Google via
79
+ * @ai-sdk/google ≥3.0). See `packages/ai-assistant/AGENTS.md` →
80
+ * "baseURL override hierarchy" for the full numbered chain.
81
+ *
82
+ * Phase 2 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
83
+ */
84
+ defaultBaseUrl?: string
85
+ /**
86
+ * When false, per-request HTTP overrides (query params `provider`, `model`,
87
+ * `baseUrl`) and the per-tenant settings override stored in
88
+ * `ai_agent_runtime_overrides` are both suppressed. Steps 1 and 3 of the
89
+ * model-factory resolution chain are skipped for this agent.
90
+ *
91
+ * Default is `true` (permissive). Agents that pin a specific model for
92
+ * correctness reasons (e.g. a structured-output agent whose JSON-mode schema
93
+ * only works with one provider) should set this to `false`.
94
+ *
95
+ * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
96
+ */
97
+ allowRuntimeModelOverride?: boolean
50
98
  acceptedMediaTypes?: AiAgentAcceptedMediaType[]
51
99
  requiredFeatures?: string[]
52
100
  uiParts?: string[]
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `AI_RUNTIME_BASEURL_ALLOWLIST` guard helper.
3
+ *
4
+ * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`
5
+ * (R6 mitigation). An operator who wants to expose the `?baseUrl=` query
6
+ * parameter over HTTP MUST set this env var to a comma-separated list of
7
+ * allowed host patterns. When the env var is absent or empty, ANY non-empty
8
+ * `baseUrl` value received by the dispatcher is rejected with a typed 400
9
+ * error — ensuring that a misconfigured deployment cannot be tricked into
10
+ * forwarding API calls to an attacker-controlled endpoint.
11
+ *
12
+ * Host patterns use a simple glob: a leading `*` matches any subdomain.
13
+ * Examples:
14
+ * `openrouter.ai` — exact host
15
+ * `*.openrouter.ai` — any subdomain of openrouter.ai
16
+ * `openrouter.ai,api.myproxy.io` — two hosts (comma-separated, no spaces)
17
+ *
18
+ * The comparison is case-insensitive and ignores the URL path/query —
19
+ * only the `hostname` portion of the submitted URL is checked.
20
+ */
21
+
22
+ /**
23
+ * Returns the parsed allowlist from `AI_RUNTIME_BASEURL_ALLOWLIST`.
24
+ * An empty array means the allowlist is empty (reject all).
25
+ */
26
+ export function readBaseurlAllowlist(env: Record<string, string | undefined> = process.env): string[] {
27
+ const raw = env.AI_RUNTIME_BASEURL_ALLOWLIST ?? ''
28
+ return raw
29
+ .split(',')
30
+ .map((entry) => entry.trim().toLowerCase())
31
+ .filter((entry) => entry.length > 0)
32
+ }
33
+
34
+ /**
35
+ * Returns true when `baseUrl` is permitted under the current allowlist.
36
+ *
37
+ * Rules:
38
+ * - An empty `baseUrl` is always allowed (no override requested).
39
+ * - When the allowlist is empty, any non-empty `baseUrl` is rejected.
40
+ * - A pattern `*.example.com` matches `foo.example.com` but NOT `example.com`.
41
+ * - A pattern `example.com` matches only `example.com` (exact, case-insensitive).
42
+ */
43
+ export function isBaseurlAllowlisted(baseUrl: string, allowlist: string[]): boolean {
44
+ const trimmed = baseUrl.trim()
45
+ if (!trimmed) return true
46
+ if (allowlist.length === 0) return false
47
+
48
+ let parsedHostname: string
49
+ try {
50
+ parsedHostname = new URL(trimmed).hostname.toLowerCase()
51
+ } catch {
52
+ return false
53
+ }
54
+
55
+ for (const pattern of allowlist) {
56
+ if (pattern.startsWith('*.')) {
57
+ const suffix = pattern.slice(2)
58
+ if (parsedHostname.endsWith(`.${suffix}`)) return true
59
+ } else if (pattern === parsedHostname) {
60
+ return true
61
+ }
62
+ }
63
+ return false
64
+ }
@@ -43,6 +43,13 @@ const DEFAULT_MODELS: readonly LlmModelInfo[] = [
43
43
  /**
44
44
  * Factory returning a fresh `AnthropicAdapter` instance. The adapter is
45
45
  * stateless — caller is free to reuse the returned object.
46
+ *
47
+ * `createModel` accepts an optional `baseURL` from {@link LlmCreateModelOptions}
48
+ * and forwards it to `createAnthropic({ apiKey, baseURL })`.
49
+ *
50
+ * baseURL only works for Anthropic Messages-protocol relays (Cloudflare AI
51
+ * Gateway in Anthropic mode, Helicone proxy). For OpenAI-format gateways,
52
+ * use the OpenAI / OpenRouter presets.
46
53
  */
47
54
  export function createAnthropicAdapter(): LlmProvider {
48
55
  const envKeys = ['ANTHROPIC_API_KEY', 'OPENCODE_ANTHROPIC_API_KEY'] as const
@@ -84,7 +91,10 @@ export function createAnthropicAdapter(): LlmProvider {
84
91
  },
85
92
 
86
93
  createModel(options: LlmCreateModelOptions): unknown {
87
- const anthropic = createAnthropic({ apiKey: options.apiKey })
94
+ const anthropic = createAnthropic({
95
+ apiKey: options.apiKey,
96
+ ...(options.baseURL ? { baseURL: options.baseURL } : {}),
97
+ })
88
98
  return anthropic(options.modelId)
89
99
  },
90
100
  }
@@ -73,7 +73,10 @@ export function createGoogleAdapter(): LlmProvider {
73
73
  },
74
74
 
75
75
  createModel(options: LlmCreateModelOptions): unknown {
76
- const google = createGoogleGenerativeAI({ apiKey: options.apiKey })
76
+ const google = createGoogleGenerativeAI({
77
+ apiKey: options.apiKey,
78
+ ...(options.baseURL ? { baseURL: options.baseURL } : {}),
79
+ })
77
80
  return google(options.modelId)
78
81
  },
79
82
  }