@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
@@ -10,13 +10,16 @@
10
10
  *
11
11
  * 1. `callerOverride` (non-empty string) — highest precedence, e.g. the
12
12
  * `modelOverride` field on `runAiAgentText`/`runAiAgentObject`.
13
+ * Accepts a slash-qualified `<provider>/<model>` shorthand (Phase 1).
13
14
  * 2. Env variable `OM_AI_<MODULE>_MODEL` (uppercased `moduleId`) when
14
15
  * `moduleId` is provided. Example:
15
16
  * `OM_AI_INBOX_OPS_MODEL=claude-haiku-4-5`,
16
17
  * `OM_AI_CATALOG_MODEL=gpt-4o-mini`. The legacy
17
18
  * `<MODULE>_AI_MODEL` form (e.g. `INBOX_OPS_AI_MODEL`) is read as a
18
19
  * backward-compatibility fallback when the canonical name is unset.
20
+ * Accepts a slash-qualified shorthand (Phase 1).
19
21
  * 3. `agentDefaultModel` — typically `AiAgentDefinition.defaultModel`.
22
+ * Accepts a slash-qualified `<provider>/<model>` shorthand (Phase 1).
20
23
  * 4. Global env `OM_AI_MODEL` (canonical) with `OPENCODE_MODEL` kept as
21
24
  * a backward-compatibility fallback. Accepts either a plain model id
22
25
  * (`gpt-5-mini`) or a slash-qualified id (`openai/gpt-5-mini`).
@@ -26,15 +29,23 @@
26
29
  * 5. The configured provider's own default model id
27
30
  * (`provider.defaultModel`).
28
31
  *
29
- * Resolution walks the `llmProviderRegistry`'s `resolveFirstConfigured()`
30
- * output. The walk's `order` argument is seeded from (in priority order):
32
+ * Every model-axis source is parsed through {@link parseSlashShorthand}.
33
+ * Resolution walks the chain top-down and takes the first non-null hint as
34
+ * the registry-walk seed:
31
35
  *
32
- * 1. The slash-qualified provider hint extracted from `OM_AI_MODEL` —
33
- * consumes the provider axis for this resolution.
34
- * 2. `OM_AI_PROVIDER` (canonical) with `OPENCODE_PROVIDER` as a
35
- * backward-compatibility fallback names a registered provider id;
36
- * falls through transparently when the named provider is
37
- * registered-but-unconfigured.
36
+ * Provider-axis seed order (highest priority first):
37
+ * 1. Slash-prefix from `callerOverride` (Phase 1).
38
+ * 2. `providerOverride` request-time provider override (Phase 1).
39
+ * 3. Slash-prefix from `OM_AI_<MODULE>_MODEL` (legacy `<MODULE>_AI_MODEL`) (Phase 1).
40
+ * 4. `OM_AI_<MODULE>_PROVIDER` env (legacy `<MODULE>_AI_PROVIDER`) (Phase 1).
41
+ * 5. Slash-prefix from `agentDefaultModel` (Phase 1).
42
+ * 6. `agentDefaultProvider` — `AiAgentDefinition.defaultProvider` (Phase 1).
43
+ * 7. Slash-prefix from `OM_AI_MODEL` (legacy `OPENCODE_MODEL`) (Phase 0).
44
+ * 8. `OM_AI_PROVIDER` (legacy `OPENCODE_PROVIDER`) (Phase 0).
45
+ *
46
+ * The `OM_AI_*` env knobs are canonical; the legacy `OPENCODE_PROVIDER` /
47
+ * `OPENCODE_MODEL` envs stay bound to the OpenCode Code Mode stack and are
48
+ * also honored as backward-compatibility fallbacks here.
38
49
  *
39
50
  * The factory throws {@link AiModelFactoryError} when no provider is
40
51
  * configured — every current call site already expects the throw (see the
@@ -49,7 +60,15 @@
49
60
  import type { AwilixContainer } from 'awilix'
50
61
  import type { EnvLookup, LlmProvider } from '@open-mercato/shared/lib/ai/llm-provider'
51
62
  import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
52
- import { resolveAiProviderIdFromEnv } from '@open-mercato/shared/lib/ai/opencode-provider'
63
+ import {
64
+ intersectAllowlists,
65
+ canonicalProviderId,
66
+ isModelAllowedForProviderInEffective,
67
+ isProviderAllowedInEffective,
68
+ providerIdAliases,
69
+ type EffectiveAllowlist,
70
+ type TenantAllowlistSnapshot,
71
+ } from './model-allowlist'
53
72
 
54
73
  /**
55
74
  * Minimal AI SDK LanguageModel shape — the factory exposes the protocol-
@@ -72,13 +91,26 @@ export interface AiModelFactoryInput {
72
91
  * the legacy `<MODULE>_AI_MODEL` form honored as a backward-compatibility
73
92
  * fallback. Example: `moduleId: 'inbox_ops'` → canonical env var
74
93
  * `OM_AI_INBOX_OPS_MODEL` (legacy `INBOX_OPS_AI_MODEL`).
94
+ *
95
+ * Also enables the `OM_AI_<MODULE>_PROVIDER` env axis (legacy
96
+ * `<MODULE>_AI_PROVIDER` honored as a backward-compatibility fallback).
75
97
  */
76
98
  moduleId?: string
77
99
  /**
78
100
  * Agent-level default, typically `AiAgentDefinition.defaultModel`. Used
79
101
  * when neither `callerOverride` nor the module env override is present.
102
+ * Accepts a slash-qualified `<provider>/<model>` shorthand (Phase 1).
80
103
  */
81
104
  agentDefaultModel?: string
105
+ /**
106
+ * Agent-level default provider, typically `AiAgentDefinition.defaultProvider`.
107
+ * Named provider id; falls through transparently when the named provider is
108
+ * registered-but-unconfigured. Sits between `OM_AI_<MODULE>_PROVIDER`
109
+ * and the global `OM_AI_PROVIDER` in the provider-axis seed list above.
110
+ *
111
+ * Phase 1 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
112
+ */
113
+ agentDefaultProvider?: string
82
114
  /**
83
115
  * Per-call override (e.g. `runAiAgentText({ modelOverride })`). Wins over
84
116
  * every other source when it is a non-empty trimmed string. Empty strings
@@ -86,6 +118,84 @@ export interface AiModelFactoryInput {
86
118
  * callers MUST NOT need a separate "clear override" API.
87
119
  */
88
120
  callerOverride?: string
121
+ /**
122
+ * Request-time provider override — wins for the provider axis at the same
123
+ * priority as `callerOverride` for the model axis. A non-empty string
124
+ * that does not match any registered provider id is silently ignored and
125
+ * the factory falls through to the next provider source.
126
+ *
127
+ * Phase 1 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
128
+ */
129
+ providerOverride?: string
130
+ /**
131
+ * Agent-level default base URL, typically `AiAgentDefinition.defaultBaseUrl`.
132
+ * Sits between the `<MODULE>_AI_BASE_URL` env var and the preset's own
133
+ * `baseURLEnvKeys` in the resolution chain.
134
+ *
135
+ * Phase 2 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
136
+ */
137
+ agentDefaultBaseUrl?: string
138
+ /**
139
+ * Per-call base URL override that wins over every other source. Intended
140
+ * for programmatic callers only — the HTTP query-param baseUrl and the
141
+ * AI_RUNTIME_BASEURL_ALLOWLIST arrive in Phase 4a.
142
+ *
143
+ * Phase 2 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
144
+ */
145
+ baseUrlOverride?: string
146
+ /**
147
+ * Per-tenant default loaded from `ai_agent_runtime_overrides` by the agent
148
+ * runtime (best-effort, fail-open). Sits at step 3 of the resolution chain
149
+ * between the caller/request override (step 1–2) and the module-env axis
150
+ * (step 4).
151
+ *
152
+ * Honored ONLY when `allowRuntimeModelOverride !== false` on the agent
153
+ * definition. The agent runtime is responsible for hydration — the factory
154
+ * does NOT load the row itself.
155
+ *
156
+ * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
157
+ */
158
+ tenantOverride?: {
159
+ providerId?: string | null
160
+ modelId?: string | null
161
+ baseURL?: string | null
162
+ }
163
+ /**
164
+ * Per-request override forwarded from the HTTP dispatcher query params
165
+ * (`?provider=`, `?model=`, `?baseUrl=`). Sits at step 1 of the resolution
166
+ * chain — wins over everything else for that turn.
167
+ *
168
+ * Honored ONLY when `allowRuntimeModelOverride !== false` on the agent.
169
+ * The dispatcher validates all three values before setting this input.
170
+ *
171
+ * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
172
+ */
173
+ requestOverride?: {
174
+ providerId?: string | null
175
+ modelId?: string | null
176
+ baseURL?: string | null
177
+ }
178
+ /**
179
+ * When false, steps 1 (requestOverride) and 3 (tenantOverride) of the
180
+ * resolution chain are skipped. Agents that pin a specific model for
181
+ * correctness reasons set `AiAgentDefinition.allowRuntimeModelOverride =
182
+ * false`. Default behavior (omitted) is permissive (= true).
183
+ *
184
+ * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
185
+ */
186
+ allowRuntimeModelOverride?: boolean
187
+ /**
188
+ * Optional tenant allowlist snapshot (Phase 1780-6). When supplied, the
189
+ * factory clips the resolved (provider, model) to the intersection of the
190
+ * env allowlist (`OM_AI_AVAILABLE_*`) and this tenant allowlist. Pass `null`
191
+ * or omit to fall back to env-only enforcement.
192
+ *
193
+ * The settings PUT route validates writes against the env allowlist before
194
+ * persisting, so the snapshot here is trusted to be a subset of env. The
195
+ * factory still defends against drift (env tightened after write) by
196
+ * intersecting at resolution time.
197
+ */
198
+ tenantAllowlist?: TenantAllowlistSnapshot | null
89
199
  }
90
200
 
91
201
  /**
@@ -111,11 +221,32 @@ export interface AiModelResolution {
111
221
  * `OPENCODE_MODEL` fallback supplied the model id.
112
222
  */
113
223
  source:
224
+ | 'request_override'
114
225
  | 'caller_override'
226
+ | 'tenant_override'
115
227
  | 'module_env'
116
228
  | 'agent_default'
117
229
  | 'env_default'
118
230
  | 'provider_default'
231
+ | 'allowlist_fallback'
232
+ /**
233
+ * Resolved base URL passed to the adapter (if any). Undefined when the
234
+ * adapter will use its built-in default. Included for observability and
235
+ * test assertions; never exposed over HTTP (Phase 4a adds the allowlist).
236
+ */
237
+ baseURL?: string
238
+ /**
239
+ * Populated when the env-driven OM_AI_AVAILABLE_PROVIDERS /
240
+ * OM_AI_AVAILABLE_MODELS_<PROVIDER> allowlist rejected the originally
241
+ * resolved (provider, model) and the factory fell back to a safe pair.
242
+ * Includes the rejected ids and a human-readable reason so the UI / logs
243
+ * can surface why the runtime did not honor the requested combination.
244
+ */
245
+ allowlistFallback?: {
246
+ originalProviderId: string
247
+ originalModelId: string
248
+ reason: string
249
+ }
119
250
  }
120
251
 
121
252
  /**
@@ -167,6 +298,14 @@ export interface AiModelFactoryRegistry {
167
298
  * behavior).
168
299
  */
169
300
  get?(id: string): LlmProvider | null
301
+ /**
302
+ * Optional registry enumeration used by the Phase 1780-6 allowlist
303
+ * intersection so the env model lists are pre-loaded for every provider
304
+ * (and not just the resolved one). Test doubles MAY omit this — the
305
+ * factory still defends correctly by also seeding the resolved provider's
306
+ * id directly into `intersectAllowlists(...)`.
307
+ */
308
+ list?(): readonly LlmProvider[]
170
309
  }
171
310
 
172
311
  /**
@@ -180,7 +319,7 @@ export interface CreateModelFactoryDependencies {
180
319
  * `order` argument to prefer the operator-selected provider.
181
320
  */
182
321
  registry?: AiModelFactoryRegistry
183
- /** Env lookup for `<MODULE>_AI_MODEL` + provider credentials. */
322
+ /** Env lookup for `OM_AI_<MODULE>_MODEL` + provider credentials. */
184
323
  env?: EnvLookup
185
324
  }
186
325
 
@@ -196,15 +335,19 @@ function normalizeOverride(value: string | undefined): string | null {
196
335
  * `OPENCODE_PROVIDER` resolves to a known provider — in that case the
197
336
  * registry falls back to its default registration walk.
198
337
  */
199
- function readProviderOrderFromEnv(env: EnvLookup): readonly string[] | undefined {
200
- const raw = normalizeOverride(env.OM_AI_PROVIDER) ?? normalizeOverride(env.OPENCODE_PROVIDER)
201
- if (!raw) return undefined
202
- // Reuse the shared resolver so unknown ids fall back through both keys.
203
- // When the raw value is unknown the resolver returns the default; passing
204
- // that through as an explicit hint is still safe because the registry
205
- // only honors registered + configured providers.
206
- const resolved = resolveAiProviderIdFromEnv(env)
207
- return [resolved]
338
+ function readGlobalProviderFromEnv(
339
+ env: EnvLookup,
340
+ registry: Pick<AiModelFactoryRegistry, 'get'>,
341
+ ): string | null {
342
+ const candidates = [normalizeOverride(env.OM_AI_PROVIDER), normalizeOverride(env.OPENCODE_PROVIDER)]
343
+ for (const candidate of candidates) {
344
+ if (!candidate) continue
345
+ if (!registry.get) return providerIdAliases(candidate)[0] ?? candidate
346
+ for (const alias of providerIdAliases(candidate)) {
347
+ if (registry.get(alias)) return alias
348
+ }
349
+ }
350
+ return null
208
351
  }
209
352
 
210
353
  /**
@@ -216,7 +359,7 @@ function readGlobalModelFromEnv(env: EnvLookup): string | null {
216
359
  }
217
360
 
218
361
  /** Canonical per-module model env. Example: `OM_AI_INBOX_OPS_MODEL`. */
219
- function moduleEnvVarName(moduleId: string): string {
362
+ function moduleModelEnvVarName(moduleId: string): string {
220
363
  return `OM_AI_${moduleId.toUpperCase()}_MODEL`
221
364
  }
222
365
 
@@ -224,17 +367,53 @@ function moduleEnvVarName(moduleId: string): string {
224
367
  * Legacy per-module model env (pre-OM_AI_* rename). Example:
225
368
  * `INBOX_OPS_AI_MODEL`. Read as a backward-compatibility fallback only.
226
369
  */
227
- function legacyModuleEnvVarName(moduleId: string): string {
370
+ function legacyModuleModelEnvVarName(moduleId: string): string {
228
371
  return `${moduleId.toUpperCase()}_AI_MODEL`
229
372
  }
230
373
 
231
- function readModuleEnvOverride(env: EnvLookup, moduleId: string): string | null {
374
+ function readModuleModelEnvOverride(env: EnvLookup, moduleId: string): string | null {
375
+ return (
376
+ normalizeOverride(env[moduleModelEnvVarName(moduleId)]) ??
377
+ normalizeOverride(env[legacyModuleModelEnvVarName(moduleId)])
378
+ )
379
+ }
380
+
381
+ /** Canonical per-module provider env. Example: `OM_AI_INBOX_OPS_PROVIDER`. */
382
+ function moduleProviderEnvVarName(moduleId: string): string {
383
+ return `OM_AI_${moduleId.toUpperCase()}_PROVIDER`
384
+ }
385
+
386
+ /**
387
+ * Legacy per-module provider env (pre-OM_AI_* rename). Example:
388
+ * `INBOX_OPS_AI_PROVIDER`. Read as a backward-compatibility fallback only.
389
+ */
390
+ function legacyModuleProviderEnvVarName(moduleId: string): string {
391
+ return `${moduleId.toUpperCase()}_AI_PROVIDER`
392
+ }
393
+
394
+ function readModuleProviderEnvOverride(env: EnvLookup, moduleId: string): string | null {
232
395
  return (
233
- normalizeOverride(env[moduleEnvVarName(moduleId)]) ??
234
- normalizeOverride(env[legacyModuleEnvVarName(moduleId)])
396
+ normalizeOverride(env[moduleProviderEnvVarName(moduleId)]) ??
397
+ normalizeOverride(env[legacyModuleProviderEnvVarName(moduleId)])
235
398
  )
236
399
  }
237
400
 
401
+ function normalizeProviderHint(
402
+ providerId: string | null,
403
+ registry: AiModelFactoryRegistry,
404
+ ): string | null {
405
+ if (!providerId) return null
406
+ const knownProviderIds = registry.list?.().map((provider) => provider.id) ?? []
407
+ if (knownProviderIds.length > 0) {
408
+ return canonicalProviderId(providerId, knownProviderIds)
409
+ }
410
+ return providerIdAliases(providerId)[0] ?? providerId
411
+ }
412
+
413
+ function moduleBaseUrlEnvVarName(moduleId: string): string {
414
+ return `${moduleId.toUpperCase()}_AI_BASE_URL`
415
+ }
416
+
238
417
  /**
239
418
  * Splits a slash-qualified model token (e.g. `openai/gpt-5-mini`) into
240
419
  * `{ providerHint, modelId }` when the prefix matches a registered provider
@@ -280,20 +459,84 @@ export function createModelFactory(
280
459
 
281
460
  return {
282
461
  resolveModel(input: AiModelFactoryInput): AiModelResolution {
462
+ const hasModule = typeof input.moduleId === 'string' && input.moduleId.length > 0
463
+ // When allowRuntimeModelOverride is explicitly false, skip steps 1
464
+ // (requestOverride) and 3 (tenantOverride) — the agent pins a model.
465
+ const runtimeOverridesAllowed = input.allowRuntimeModelOverride !== false
466
+
467
+ // --- Step 1: requestOverride (HTTP query params) — gated by flag ---
468
+ const requestModelRaw = runtimeOverridesAllowed
469
+ ? normalizeOverride(input.requestOverride?.modelId ?? undefined)
470
+ : null
471
+ const requestProviderRaw = runtimeOverridesAllowed
472
+ ? normalizeOverride(input.requestOverride?.providerId ?? undefined)
473
+ : null
474
+ const requestBaseUrlRaw = runtimeOverridesAllowed
475
+ ? normalizeOverride(input.requestOverride?.baseURL ?? undefined)
476
+ : null
477
+
478
+ // --- Step 2: callerOverride (programmatic) ---
479
+ const callerRaw = normalizeOverride(input.callerOverride)
480
+
481
+ // --- Step 3: tenantOverride (DB row) — gated by flag ---
482
+ const tenantModelRaw = runtimeOverridesAllowed
483
+ ? normalizeOverride(input.tenantOverride?.modelId ?? undefined)
484
+ : null
485
+ const tenantProviderRaw = runtimeOverridesAllowed
486
+ ? normalizeOverride(input.tenantOverride?.providerId ?? undefined)
487
+ : null
488
+ const tenantBaseUrlRaw = runtimeOverridesAllowed
489
+ ? normalizeOverride(input.tenantOverride?.baseURL ?? undefined)
490
+ : null
491
+
492
+ // --- Steps 4+: env / agent / global ---
493
+ const moduleModelRaw = hasModule
494
+ ? readModuleModelEnvOverride(env, input.moduleId!)
495
+ : null
496
+ const agentModelRaw = normalizeOverride(input.agentDefaultModel)
283
497
  // OM_AI_MODEL is canonical; the legacy OPENCODE_MODEL is read as a
284
498
  // backward-compatibility fallback through readGlobalModelFromEnv.
285
- const globalModelEnv = readGlobalModelFromEnv(env)
286
- // Slash-qualified env-model values consume the provider axis at the
287
- // env-default step. Phase 1 of the per-axis-overrides spec generalizes
288
- // the parser to every model-axis source.
289
- const globalModelParsed = globalModelEnv
290
- ? parseSlashShorthand(globalModelEnv, registry)
499
+ const globalModelRaw = readGlobalModelFromEnv(env)
500
+
501
+ // Parse slash shorthand on every model-axis source.
502
+ const requestModelParsed = requestModelRaw ? parseSlashShorthand(requestModelRaw, registry) : null
503
+ const callerParsed = callerRaw ? parseSlashShorthand(callerRaw, registry) : null
504
+ const tenantModelParsed = tenantModelRaw ? parseSlashShorthand(tenantModelRaw, registry) : null
505
+ const moduleModelParsed = moduleModelRaw ? parseSlashShorthand(moduleModelRaw, registry) : null
506
+ const agentModelParsed = agentModelRaw ? parseSlashShorthand(agentModelRaw, registry) : null
507
+ const globalModelParsed = globalModelRaw ? parseSlashShorthand(globalModelRaw, registry) : null
508
+
509
+ // --- Provider-axis: walk from highest to lowest priority for the seed.
510
+ // A slash-qualified hint from a model source wins over a plain provider
511
+ // source at the same priority step. We walk top-down and take the first
512
+ // non-null hint.
513
+ const providerOverrideRaw = normalizeOverride(input.providerOverride)
514
+ const moduleProviderRaw = hasModule
515
+ ? readModuleProviderEnvOverride(env, input.moduleId!)
291
516
  : null
292
- const slashProviderHint = globalModelParsed?.providerHint ?? null
293
- const providerOrderFromEnv = readProviderOrderFromEnv(env)
294
- const order = slashProviderHint
295
- ? [slashProviderHint, ...(providerOrderFromEnv ?? [])]
296
- : providerOrderFromEnv
517
+ const agentDefaultProviderRaw = normalizeOverride(input.agentDefaultProvider)
518
+ // OM_AI_PROVIDER is canonical; the legacy OPENCODE_PROVIDER is read as
519
+ // a backward-compatibility fallback through readGlobalProviderFromEnv.
520
+ const globalProviderRaw = readGlobalProviderFromEnv(env, registry)
521
+
522
+ // Walk the provider-axis seed list: slash hint beats plain provider at
523
+ // the same step. We keep only the first (highest-priority) non-null hint.
524
+ const providerHintCandidates: Array<string | null> = [
525
+ requestModelParsed?.providerHint ?? null,
526
+ normalizeProviderHint(requestProviderRaw, registry),
527
+ callerParsed?.providerHint ?? null,
528
+ normalizeProviderHint(providerOverrideRaw, registry),
529
+ tenantModelParsed?.providerHint ?? null,
530
+ normalizeProviderHint(tenantProviderRaw, registry),
531
+ moduleModelParsed?.providerHint ?? null,
532
+ normalizeProviderHint(moduleProviderRaw, registry),
533
+ agentModelParsed?.providerHint ?? null,
534
+ normalizeProviderHint(agentDefaultProviderRaw, registry),
535
+ globalModelParsed?.providerHint ?? null,
536
+ globalProviderRaw,
537
+ ]
538
+ const orderHint = providerHintCandidates.find((hint) => hint !== null) ?? null
539
+ const order = orderHint ? [orderHint] : undefined
297
540
 
298
541
  const provider = registry.resolveFirstConfigured({ env, order })
299
542
  if (!provider) {
@@ -310,43 +553,228 @@ export function createModelFactory(
310
553
  )
311
554
  }
312
555
 
313
- const callerOverride = normalizeOverride(input.callerOverride)
314
- const moduleEnvOverride =
315
- input.moduleId && input.moduleId.length > 0
316
- ? readModuleEnvOverride(env, input.moduleId)
317
- : null
318
- const agentDefault = normalizeOverride(input.agentDefaultModel)
319
- // The slash parser already split the global model token; use the
320
- // post-parse model id so `OM_AI_MODEL=openai/gpt-5-mini` resolves
321
- // model `gpt-5-mini` against provider `openai`.
322
- const envDefaultModel = globalModelParsed?.modelId ?? globalModelEnv
323
-
556
+ // --- Model-axis: use the post-parse model id from the winning source.
324
557
  let modelId: string
325
558
  let source: AiModelResolution['source']
326
- if (callerOverride) {
327
- modelId = callerOverride
559
+ if (requestModelParsed) {
560
+ modelId = requestModelParsed.modelId
561
+ source = 'request_override'
562
+ } else if (callerParsed) {
563
+ modelId = callerParsed.modelId
328
564
  source = 'caller_override'
329
- } else if (moduleEnvOverride) {
330
- modelId = moduleEnvOverride
565
+ } else if (tenantModelParsed) {
566
+ modelId = tenantModelParsed.modelId
567
+ source = 'tenant_override'
568
+ } else if (moduleModelParsed) {
569
+ modelId = moduleModelParsed.modelId
331
570
  source = 'module_env'
332
- } else if (agentDefault) {
333
- modelId = agentDefault
571
+ } else if (agentModelParsed) {
572
+ modelId = agentModelParsed.modelId
334
573
  source = 'agent_default'
335
- } else if (envDefaultModel) {
336
- modelId = envDefaultModel
574
+ } else if (globalModelParsed) {
575
+ modelId = globalModelParsed.modelId
337
576
  source = 'env_default'
338
577
  } else {
339
578
  modelId = provider.defaultModel
340
579
  source = 'provider_default'
341
580
  }
342
581
 
343
- const model = provider.createModel({ modelId, apiKey })
582
+ // --- BaseURL-axis resolution (highest to lowest priority) ---
583
+ // 1. requestOverride.baseURL (HTTP dispatcher) — gated by allowRuntimeModelOverride
584
+ // 2. baseUrlOverride (programmatic caller)
585
+ // 3. tenantOverride.baseURL (DB row) — gated by allowRuntimeModelOverride
586
+ // 4. <MODULE>_AI_BASE_URL env
587
+ // 5. agentDefaultBaseUrl
588
+ // Steps 6-7 (preset env + preset default) are handled inside the adapter's
589
+ // createModel when no explicit baseURL is passed.
590
+ const resolvedBaseURL = requestBaseUrlRaw
591
+ ?? normalizeOverride(input.baseUrlOverride)
592
+ ?? tenantBaseUrlRaw
593
+ ?? (hasModule ? normalizeOverride(env[moduleBaseUrlEnvVarName(input.moduleId!)]) : null)
594
+ ?? normalizeOverride(input.agentDefaultBaseUrl)
595
+ ?? undefined
596
+
597
+ // --- Allowlist enforcement (Phase 1780-5 + 1780-6) -------------------
598
+ // OM_AI_AVAILABLE_PROVIDERS / OM_AI_AVAILABLE_MODELS_<PROVIDER> clip
599
+ // the resolution to an operator-approved set. The optional tenant
600
+ // allowlist snapshot narrows the env outer constraint further. If the
601
+ // resolved pair isn't allowed, fall back to a safe (provider, model)
602
+ // — never throw, so a stale tenant override or chat picker can't take
603
+ // the runtime down. The fallback is logged so the operator can see
604
+ // what happened.
605
+ const registryProviderIds = registry.list?.()?.map((p) => p.id) ?? []
606
+ const tenantProviderIds = input.tenantAllowlist
607
+ ? Object.keys(input.tenantAllowlist.allowedModelsByProvider ?? {})
608
+ : []
609
+ const knownProviderIds = Array.from(
610
+ new Set([provider.id, ...registryProviderIds, ...tenantProviderIds]),
611
+ )
612
+ const effectiveAllowlist = intersectAllowlists(
613
+ env,
614
+ knownProviderIds,
615
+ input.tenantAllowlist ?? null,
616
+ )
617
+ const allowlistResult = enforceAllowlist({
618
+ env,
619
+ registry,
620
+ resolved: { provider, modelId },
621
+ agentDefaultProvider: agentDefaultProviderRaw,
622
+ agentDefaultModel: agentModelParsed?.modelId ?? agentModelRaw,
623
+ effective: effectiveAllowlist,
624
+ })
625
+
626
+ const finalProvider = allowlistResult.provider
627
+ const finalModelId = allowlistResult.modelId
628
+ const finalSource = allowlistResult.fallback ? 'allowlist_fallback' : source
629
+ const finalApiKey = allowlistResult.fallback
630
+ ? finalProvider.resolveApiKey(env)
631
+ : apiKey
632
+ if (!finalApiKey) {
633
+ throw new AiModelFactoryError(
634
+ 'api_key_missing',
635
+ `LLM provider "${finalProvider.id}" is advertised as configured but resolveApiKey() returned empty.`,
636
+ )
637
+ }
638
+
639
+ const model = finalProvider.createModel({
640
+ modelId: finalModelId,
641
+ apiKey: finalApiKey,
642
+ baseURL: resolvedBaseURL,
643
+ })
344
644
  return {
345
645
  model,
346
- modelId,
347
- providerId: provider.id,
348
- source,
646
+ modelId: finalModelId,
647
+ providerId: finalProvider.id,
648
+ source: finalSource,
649
+ ...(resolvedBaseURL !== undefined ? { baseURL: resolvedBaseURL } : {}),
650
+ ...(allowlistResult.fallback
651
+ ? {
652
+ allowlistFallback: {
653
+ originalProviderId: provider.id,
654
+ originalModelId: modelId,
655
+ reason: allowlistResult.fallback,
656
+ },
657
+ }
658
+ : {}),
349
659
  }
350
660
  },
351
661
  }
352
662
  }
663
+
664
+ interface EnforceAllowlistInput {
665
+ env: EnvLookup
666
+ registry: AiModelFactoryRegistry
667
+ resolved: { provider: LlmProvider; modelId: string }
668
+ agentDefaultProvider: string | null
669
+ agentDefaultModel: string | null
670
+ effective: EffectiveAllowlist
671
+ }
672
+
673
+ interface EnforceAllowlistResult {
674
+ provider: LlmProvider
675
+ modelId: string
676
+ /** Populated only when the resolved pair was rejected. */
677
+ fallback: string | null
678
+ }
679
+
680
+ /**
681
+ * Clips a resolved `(provider, model)` to what the effective allowlist
682
+ * permits (env intersected with optional tenant allowlist).
683
+ *
684
+ * Order of fallback when the resolved provider is not allowed:
685
+ * 1. The agent's `defaultProvider` (if allowed and configured).
686
+ * 2. The first allowed provider that is also configured in the registry.
687
+ *
688
+ * Order of fallback when the model is not allowed for the resolved provider:
689
+ * 1. The agent's `defaultModel` (if allowed for that provider).
690
+ * 2. The provider's `defaultModel` (if allowed).
691
+ * 3. The first model from the effective allowlist for that provider.
692
+ *
693
+ * Both fall-back paths emit a `console.warn` so the operator can see why the
694
+ * runtime did not honor the requested combination. The function never throws.
695
+ */
696
+ function enforceAllowlist(input: EnforceAllowlistInput): EnforceAllowlistResult {
697
+ const { registry, resolved, agentDefaultProvider, agentDefaultModel, effective } = input
698
+ let provider = resolved.provider
699
+ let modelId = resolved.modelId
700
+ let fallback: string | null = null
701
+
702
+ if (effective.providers !== null && !isProviderAllowedInEffective(effective, provider.id)) {
703
+ const replacement = pickAllowedProvider({
704
+ registry,
705
+ agentDefaultProvider,
706
+ effective,
707
+ })
708
+ if (replacement) {
709
+ const source = effective.tenantOverridesActive
710
+ ? 'the effective allowlist (env ∩ tenant)'
711
+ : 'OM_AI_AVAILABLE_PROVIDERS'
712
+ fallback = `Provider "${provider.id}" is not in ${source}; using "${replacement.id}" instead.`
713
+ console.warn(`[AI Model Factory] ${fallback}`)
714
+ provider = replacement
715
+ modelId = pickAllowedModel({
716
+ provider,
717
+ preferred: agentDefaultModel,
718
+ effective,
719
+ })
720
+ }
721
+ // If no replacement is configured we keep the resolved provider — the
722
+ // throw at the api-key gate will surface the misconfiguration to the
723
+ // operator instead of silently masking it.
724
+ }
725
+
726
+ if (!isModelAllowedForProviderInEffective(effective, provider.id, modelId)) {
727
+ const replacementModel = pickAllowedModel({
728
+ provider,
729
+ preferred: agentDefaultModel,
730
+ effective,
731
+ })
732
+ if (replacementModel !== modelId) {
733
+ const source = effective.tenantOverridesActive
734
+ ? `the effective allowlist (env ∩ tenant) for "${provider.id}"`
735
+ : `OM_AI_AVAILABLE_MODELS_${provider.id.toUpperCase()}`
736
+ const reason = `Model "${modelId}" is not in ${source}; using "${replacementModel}" instead.`
737
+ console.warn(`[AI Model Factory] ${reason}`)
738
+ fallback = fallback ? `${fallback} ${reason}` : reason
739
+ modelId = replacementModel
740
+ }
741
+ }
742
+
743
+ return { provider, modelId, fallback }
744
+ }
745
+
746
+ function pickAllowedProvider(input: {
747
+ registry: AiModelFactoryRegistry
748
+ agentDefaultProvider: string | null
749
+ effective: EffectiveAllowlist
750
+ }): LlmProvider | null {
751
+ const { registry, agentDefaultProvider, effective } = input
752
+ if (agentDefaultProvider) {
753
+ if (isProviderAllowedInEffective(effective, agentDefaultProvider)) {
754
+ const provider = registry.get?.(agentDefaultProvider)
755
+ if (provider && provider.isConfigured(process.env as EnvLookup)) return provider
756
+ }
757
+ }
758
+ const allowed = effective.providers
759
+ if (!allowed) return null
760
+ for (const id of allowed) {
761
+ const provider = registry.get?.(id)
762
+ if (provider && provider.isConfigured(process.env as EnvLookup)) return provider
763
+ }
764
+ return null
765
+ }
766
+
767
+ function pickAllowedModel(input: {
768
+ provider: LlmProvider
769
+ preferred: string | null
770
+ effective: EffectiveAllowlist
771
+ }): string {
772
+ const { provider, preferred, effective } = input
773
+ const allowed = effective.modelsByProvider[provider.id]
774
+ if (allowed === undefined) {
775
+ return preferred && preferred.length > 0 ? preferred : provider.defaultModel
776
+ }
777
+ if (preferred && allowed.includes(preferred)) return preferred
778
+ if (allowed.includes(provider.defaultModel)) return provider.defaultModel
779
+ return allowed[0] ?? provider.defaultModel
780
+ }