@open-mercato/ai-assistant 0.6.1-develop.3246.1.dbef9d7392 → 0.6.1-develop.3256.1.fe3dec2464
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +82 -18
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +370 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +194 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/route.js +4 -0
- package/dist/modules/ai_assistant/api/ai/agents/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js +169 -5
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/route/route.js +38 -19
- package/dist/modules/ai_assistant/api/route/route.js.map +3 -3
- package/dist/modules/ai_assistant/api/settings/allowlist/route.js +195 -0
- package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/settings/route.js +537 -22
- package/dist/modules/ai_assistant/api/settings/route.js.map +3 -3
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +701 -147
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +338 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js +25 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js +1 -1
- package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +75 -26
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js +25 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js +503 -168
- package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiTenantModelAllowlist.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities.js +123 -1
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +157 -0
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +7 -0
- package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js +77 -0
- package/dist/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.js.map +7 -0
- package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js +1 -1
- package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +90 -1
- package/dist/modules/ai_assistant/i18n/en.json +90 -1
- package/dist/modules/ai_assistant/i18n/es.json +90 -1
- package/dist/modules/ai_assistant/i18n/pl.json +90 -1
- package/dist/modules/ai_assistant/lib/agent-registry.js +17 -1
- package/dist/modules/ai_assistant/lib/agent-registry.js.map +2 -2
- package/dist/modules/ai_assistant/lib/agent-runtime.js +133 -36
- package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
- package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
- package/dist/modules/ai_assistant/lib/baseurl-allowlist.js +29 -0
- package/dist/modules/ai_assistant/lib/baseurl-allowlist.js.map +7 -0
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +4 -1
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
- package/dist/modules/ai_assistant/lib/llm-adapters/google.js +4 -1
- package/dist/modules/ai_assistant/lib/llm-adapters/google.js.map +2 -2
- package/dist/modules/ai_assistant/lib/model-allowlist.js +211 -0
- package/dist/modules/ai_assistant/lib/model-allowlist.js.map +7 -0
- package/dist/modules/ai_assistant/lib/model-factory.js +203 -31
- package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +32 -1
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
- package/dist/modules/ai_assistant/migrations/Migration20260508140000.js +18 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508140000.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260512090000.js +16 -0
- package/dist/modules/ai_assistant/migrations/Migration20260512090000.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260512130000.js +15 -0
- package/dist/modules/ai_assistant/migrations/Migration20260512130000.js.map +7 -0
- package/generated/entities/ai_agent_runtime_override/index.ts +13 -0
- package/generated/entities/ai_tenant_model_allowlist/index.ts +9 -0
- package/generated/entities.ids.generated.ts +2 -0
- package/generated/entity-fields-registry.ts +26 -0
- package/jest.config.cjs +2 -0
- package/package.json +4 -4
- package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +477 -0
- package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +116 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +240 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +251 -0
- package/src/modules/ai_assistant/api/ai/agents/route.ts +4 -0
- package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +273 -0
- package/src/modules/ai_assistant/api/ai/chat/route.ts +211 -2
- package/src/modules/ai_assistant/api/route/route.ts +49 -25
- package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +408 -0
- package/src/modules/ai_assistant/api/settings/allowlist/route.ts +221 -0
- package/src/modules/ai_assistant/api/settings/route.ts +721 -27
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +858 -177
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +458 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.meta.ts +23 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/page.tsx +12 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/legacy/page.tsx +1 -1
- package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +89 -12
- package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.meta.ts +23 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/settings/page.tsx +18 -0
- package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +617 -209
- package/src/modules/ai_assistant/data/entities/AiAgentRuntimeOverride.ts +7 -0
- package/src/modules/ai_assistant/data/entities/AiTenantModelAllowlist.ts +2 -0
- package/src/modules/ai_assistant/data/entities.ts +164 -0
- package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +227 -0
- package/src/modules/ai_assistant/data/repositories/AiTenantModelAllowlistRepository.ts +132 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +337 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiTenantModelAllowlistRepository.test.ts +181 -0
- package/src/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.tsx +1 -1
- package/src/modules/ai_assistant/i18n/de.json +90 -1
- package/src/modules/ai_assistant/i18n/en.json +90 -1
- package/src/modules/ai_assistant/i18n/es.json +90 -1
- package/src/modules/ai_assistant/i18n/pl.json +90 -1
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +396 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +60 -6
- package/src/modules/ai_assistant/lib/__tests__/ai-api-operation-runner.test.ts +4 -2
- package/src/modules/ai_assistant/lib/__tests__/baseurl-allowlist.test.ts +75 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +18 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-google.test.ts +18 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +150 -4
- package/src/modules/ai_assistant/lib/__tests__/model-allowlist.test.ts +290 -0
- package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +634 -0
- package/src/modules/ai_assistant/lib/agent-registry.ts +20 -1
- package/src/modules/ai_assistant/lib/agent-runtime.ts +220 -44
- package/src/modules/ai_assistant/lib/ai-agent-definition.ts +48 -0
- package/src/modules/ai_assistant/lib/baseurl-allowlist.ts +64 -0
- package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +11 -1
- package/src/modules/ai_assistant/lib/llm-adapters/google.ts +4 -1
- package/src/modules/ai_assistant/lib/model-allowlist.ts +407 -0
- package/src/modules/ai_assistant/lib/model-factory.ts +486 -58
- package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +44 -0
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +704 -235
- package/src/modules/ai_assistant/migrations/Migration20260508140000.ts +18 -0
- package/src/modules/ai_assistant/migrations/Migration20260512090000.ts +16 -0
- package/src/modules/ai_assistant/migrations/Migration20260512130000.ts +13 -0
|
@@ -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
|
-
*
|
|
30
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* 2. `
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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
|
|
370
|
+
function legacyModuleModelEnvVarName(moduleId: string): string {
|
|
228
371
|
return `${moduleId.toUpperCase()}_AI_MODEL`
|
|
229
372
|
}
|
|
230
373
|
|
|
231
|
-
function
|
|
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[
|
|
234
|
-
normalizeOverride(env[
|
|
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
|
|
286
|
-
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
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
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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 (
|
|
327
|
-
modelId =
|
|
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 (
|
|
330
|
-
modelId =
|
|
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 (
|
|
333
|
-
modelId =
|
|
571
|
+
} else if (agentModelParsed) {
|
|
572
|
+
modelId = agentModelParsed.modelId
|
|
334
573
|
source = 'agent_default'
|
|
335
|
-
} else if (
|
|
336
|
-
modelId =
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|