@open-mercato/ai-assistant 0.6.7-develop.6775.1.c2313bb8a3 → 0.6.7-develop.6785.1.1dd7cfac55
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 +12 -29
- package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js +80 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js +124 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js +30 -0
- package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js +7 -0
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/moderation-flags/route.js +121 -0
- package/dist/modules/ai_assistant/api/moderation-flags/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/settings/allowlist/route.js +10 -10
- package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/settings/route.js +34 -13
- package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +106 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js +139 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js +26 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities.js +42 -0
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +3 -1
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js +44 -0
- package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js.map +7 -0
- package/dist/modules/ai_assistant/di.js +6 -2
- package/dist/modules/ai_assistant/di.js.map +2 -2
- package/dist/modules/ai_assistant/events.js +6 -0
- package/dist/modules/ai_assistant/events.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +26 -0
- package/dist/modules/ai_assistant/i18n/en.json +26 -0
- package/dist/modules/ai_assistant/i18n/es.json +26 -0
- package/dist/modules/ai_assistant/i18n/pl.json +26 -0
- package/dist/modules/ai_assistant/lib/agent-runtime.js +158 -5
- 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/auth.js +6 -7
- package/dist/modules/ai_assistant/lib/auth.js.map +2 -2
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +3 -0
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
- package/dist/modules/ai_assistant/lib/llm-adapters/openai.js +8 -1
- package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +2 -2
- package/dist/modules/ai_assistant/lib/model-factory.js +6 -1
- package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
- package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js +43 -0
- package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js.map +7 -0
- package/dist/modules/ai_assistant/lib/moderation-policy.js +24 -0
- package/dist/modules/ai_assistant/lib/moderation-policy.js.map +7 -0
- package/dist/modules/ai_assistant/lib/moderation.js +109 -0
- package/dist/modules/ai_assistant/lib/moderation.js.map +7 -0
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +2 -0
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
- package/dist/modules/ai_assistant/lib/tool-search.js +4 -12
- package/dist/modules/ai_assistant/lib/tool-search.js.map +2 -2
- package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js +19 -0
- package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js.map +7 -0
- package/generated/entities/ai_agent_runtime_override/index.ts +1 -0
- package/generated/entities/ai_moderation_flag/index.ts +9 -0
- package/generated/entities.ids.generated.ts +2 -1
- package/generated/entity-fields-registry.ts +13 -1
- package/package.json +6 -6
- package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.ts +126 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.ts +161 -0
- package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +48 -0
- package/src/modules/ai_assistant/api/ai/chat/route.ts +10 -0
- package/src/modules/ai_assistant/api/moderation-flags/__tests__/route.test.ts +122 -0
- package/src/modules/ai_assistant/api/moderation-flags/route.ts +137 -0
- package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +58 -8
- package/src/modules/ai_assistant/api/settings/allowlist/route.ts +10 -12
- package/src/modules/ai_assistant/api/settings/route.ts +33 -10
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +146 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.tsx +186 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.ts +24 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.tsx +12 -0
- package/src/modules/ai_assistant/data/entities.ts +56 -0
- package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +4 -0
- package/src/modules/ai_assistant/data/repositories/AiModerationFlagRepository.ts +79 -0
- package/src/modules/ai_assistant/di.ts +5 -1
- package/src/modules/ai_assistant/events.ts +21 -0
- package/src/modules/ai_assistant/i18n/de.json +26 -0
- package/src/modules/ai_assistant/i18n/en.json +26 -0
- package/src/modules/ai_assistant/i18n/es.json +26 -0
- package/src/modules/ai_assistant/i18n/pl.json +26 -0
- package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +2 -5
- package/src/modules/ai_assistant/lib/__tests__/input-moderation-gate.test.ts +121 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +13 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +41 -0
- package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +60 -0
- package/src/modules/ai_assistant/lib/__tests__/moderation-flag-recorder.test.ts +76 -0
- package/src/modules/ai_assistant/lib/__tests__/moderation-policy.test.ts +86 -0
- package/src/modules/ai_assistant/lib/__tests__/moderation.test.ts +114 -0
- package/src/modules/ai_assistant/lib/agent-runtime.ts +259 -0
- package/src/modules/ai_assistant/lib/ai-agent-definition.ts +10 -0
- package/src/modules/ai_assistant/lib/auth.ts +6 -15
- package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +10 -0
- package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +32 -1
- package/src/modules/ai_assistant/lib/model-factory.ts +33 -0
- package/src/modules/ai_assistant/lib/moderation-flag-recorder.ts +75 -0
- package/src/modules/ai_assistant/lib/moderation-policy.ts +77 -0
- package/src/modules/ai_assistant/lib/moderation.ts +179 -0
- package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +2 -0
- package/src/modules/ai_assistant/lib/tool-search.ts +4 -17
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +388 -16
- package/src/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.ts +21 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/ai_assistant/lib/ai-agent-definition.ts"],
|
|
4
|
-
"sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport type { ZodTypeAny } from 'zod'\nimport type {\n PrepareStepFunction,\n GenerateTextOnStepFinishCallback,\n GenerateTextOnStepStartCallback,\n OnToolExecutionStartCallback,\n OnToolExecutionEndCallback,\n ToolCallRepairFunction,\n StopCondition,\n ToolChoice,\n ToolSet,\n} from 'ai'\n\nexport type AiAgentExecutionMode = 'chat' | 'object'\n\n/**\n * Selects the underlying Vercel AI SDK dispatch strategy for this agent.\n *\n * - `'stream-text'` (default): the runtime calls `streamText(...)` directly on\n * every turn. All loop primitives are supported: `prepareStep`, `stopWhen`,\n * `repairToolCall`, `activeTools`, `toolChoice`.\n *\n * - `'tool-loop-agent'`: the runtime constructs a `ToolLoopAgent`\n * (`Experimental_Agent`) once and dispatches via `agent.generate(...)` /\n * `agent.stream(...)` per turn. The wrapper-owned `prepareStep` (security-\n * critical for mutation-approval) is supplied at construction via\n * `settings.prepareStep`. `stopWhen` is similarly wired at construction.\n * The `prepareCall` hook is used for per-turn narrowing of `model`, `tools`,\n * `stopWhen`, `activeTools`, and `providerOptions`; `prepareStep` is NOT in\n * its `Pick` list and MUST NOT be threaded through it.\n *\n * Note: the current SDK version ships `experimental_repairToolCall` on\n * `ToolLoopAgentSettings`, so `repairToolCall` is technically reachable via\n * this engine. The `loop.repairToolCall` JSDoc retains a caveat reflecting\n * the spec's documented limitation, which was written against an earlier SDK\n * snapshot where the setting was absent \u2014 use with awareness that SDK\n * behaviour may differ across versions.\n *\n * Phase 5 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\nexport type AiAgentExecutionEngine = 'stream-text' | 'tool-loop-agent'\n\n/**\n * A serializable stop condition for the agentic loop. The `kind` field\n * determines which Vercel AI SDK helper is used at runtime:\n * - `stepCount` \u2192 `stepCountIs(count)` \u2014 the loop stops after N steps.\n * - `hasToolCall` \u2192 `hasToolCall(toolName)` \u2014 the loop stops immediately\n * after the model emits a tool call for the named tool.\n * - `custom` \u2014 a raw `StopCondition<ToolSet>` predicate supplied in code.\n * NOT valid from JSON-only override sources (tenant DB overrides); only\n * accepted when declared directly in `agent.loop` or a `runAiAgentText`\n * caller override.\n *\n * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\nexport type AiAgentLoopStopCondition =\n | { kind: 'stepCount'; count: number }\n | { kind: 'hasToolCall'; toolName: string }\n | { kind: 'custom'; stop: StopCondition<ToolSet> }\n\n/**\n * Budget limits for the agentic loop turn. When any limit is exceeded the\n * wrapper's `prepareStep`/`onStepFinish` aborts the turn via the per-turn\n * `AbortController` and the loop terminates with a `loop_budget_exceeded`\n * finish condition.\n *\n * Budget enforcement is implemented in Phase 1782-3; for Phases 0\u20132 the\n * fields are accepted and forwarded to the prepared-options bag but are not\n * actively enforced.\n *\n * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\nexport interface AiAgentLoopBudget {\n /** Hard cap on tool calls across all steps in this turn. */\n maxToolCalls?: number\n /** Wall-clock cap (ms) per turn; runtime aborts via AbortController. */\n maxWallClockMs?: number\n /** Input+output token cap; aggregated from step `usage` fields. */\n maxTokens?: number\n}\n\n/**\n * First-class loop configuration for an AI agent. Supersedes the flat\n * `maxSteps` alias on `AiAgentDefinition`.\n *\n * All fields are optional; the runtime falls back to the wrapper default\n * (`{ maxSteps: 10 }` for chat, `{ maxSteps: undefined }` for object) when\n * neither the agent nor the caller supplies any loop config.\n *\n * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\nexport interface AiAgentLoopConfig {\n /** Maximum number of agentic steps before the loop is forced to stop. */\n maxSteps?: number\n /**\n * Additional stop conditions. The wrapper ALWAYS composes these with\n * `stepCountIs(maxSteps ?? 10)` so a misconfigured `hasToolCall` for a\n * non-existent tool can never cause an infinite loop (R3 mitigation).\n */\n stopWhen?: AiAgentLoopStopCondition | AiAgentLoopStopCondition[]\n /**\n * Per-step preparation hook. The wrapper composes this with its own\n * security-critical `prepareStep` that re-asserts the tool allowlist and\n * mutation-approval wrapping per step.\n *\n * Only valid for chat agents. Rejected with `loop_unsupported_in_object_mode`\n * for object-mode agents.\n */\n prepareStep?: PrepareStepFunction<ToolSet>\n /**\n * Callback fired when a step finishes. The wrapper chains its own\n * aggregation callback (LoopTrace builder) before invoking this one.\n * Exceptions thrown by this callback are caught and logged but do not\n * abort the turn (matching the SDK's own contract).\n */\n onStepFinish?: GenerateTextOnStepFinishCallback<ToolSet>\n /**\n * Callback fired when a step starts. Forwarded to the AI SDK as\n * `experimental_onStepStart`.\n */\n onStepStart?: GenerateTextOnStepStartCallback<ToolSet>\n /**\n * Callback fired when a tool call starts. Forwarded to the AI SDK as\n * `experimental_onToolCallStart`.\n */\n onToolCallStart?: OnToolExecutionStartCallback<ToolSet>\n /**\n * Callback fired when a tool call finishes. Forwarded to the AI SDK as\n * `experimental_onToolCallFinish`.\n */\n onToolCallFinish?: OnToolExecutionEndCallback<ToolSet>\n /**\n * Tool-call repair function. Forwarded to the AI SDK as\n * `experimental_repairToolCall`.\n *\n * Only valid for chat agents. Rejected with `loop_unsupported_in_object_mode`\n * for object-mode agents.\n *\n * **Engine note**: this primitive is honored under `executionEngine: 'stream-text'`\n * (default). Agents on `'tool-loop-agent'` may not reliably support\n * `repairToolCall` across all SDK versions \u2014 if you require it, use the\n * default `stream-text` engine until support is confirmed stable on the\n * `ToolLoopAgent` class.\n *\n * Phase 5 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\n repairToolCall?: ToolCallRepairFunction<ToolSet>\n /**\n * Narrow the active tool surface for each step. Names must be a subset of\n * `agent.allowedTools`; any names outside the allowlist are filtered out\n * with a `loop:active_tools_filtered` warning.\n *\n * Only valid for chat agents. Rejected with `loop_unsupported_in_object_mode`\n * for object-mode agents.\n */\n activeTools?: string[]\n /**\n * Tool choice strategy forwarded to the AI SDK on each step.\n *\n * Only valid for chat agents. Rejected with `loop_unsupported_in_object_mode`\n * for object-mode agents.\n */\n toolChoice?: ToolChoice<ToolSet>\n /** Budget caps for this loop turn. */\n budget?: AiAgentLoopBudget\n /**\n * When `false`, per-call `runAiAgentText({ loop })` / HTTP query-param\n * overrides are rejected with `AgentPolicyError` code\n * `loop_runtime_override_disabled`. Default is `true` (permissive).\n *\n * Agents that pin a loop policy for correctness reasons (e.g. a\n * `stopWhen: hasToolCall(...)` that must not be bypassed by callers)\n * should set this to `false`.\n */\n allowRuntimeOverride?: boolean\n /**\n * Kill switch \u2014 when `true`, the runtime forces `stopWhen: stepCountIs(1)` and\n * ignores all other loop config. Used by the per-tenant operator override to\n * collapse an agent to a single model call (no tool execution) without\n * disabling the agent entirely.\n *\n * Phase 3 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\n disabled?: boolean\n}\n\n/**\n * Per-step record aggregated by the wrapper-owned `onStepFinish` hook into\n * `LoopTrace`. Each completed agentic step produces one record.\n *\n * Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\nexport interface LoopStepRecord {\n stepIndex: number\n /** Model id resolved for this step (relevant when prepareStep swaps models). */\n modelId: string\n toolCalls: Array<{\n toolName: string\n args: unknown\n result?: unknown\n error?: { code: string; message: string }\n repairAttempted: boolean\n durationMs: number\n }>\n /** Raw assistant text emitted in this step. */\n textDelta: string\n usage: { inputTokens: number; outputTokens: number }\n finishReason: 'stop' | 'tool-calls' | 'length' | 'content-filter' | 'error'\n}\n\n/**\n * Per-turn trace aggregated by the wrapper-owned `buildLoopTraceCollector`.\n * Not persisted \u2014 in-memory only; surfaced via the dispatcher SSE stream and\n * the playground/`<AiChat>` debug panel.\n *\n * Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\nexport interface LoopTrace {\n agentId: string\n /**\n * Stable per-conversation id that ties every turn together. Echoed back on\n * the SSE `loop-finish` event so clients can persist it for subsequent turns.\n *\n * Phase 6.2 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\n sessionId: string\n turnId: string\n steps: LoopStepRecord[]\n stopReason:\n | 'step-count'\n | 'has-tool-call'\n | 'custom-stop'\n | 'budget-tokens'\n | 'budget-tool-calls'\n | 'budget-wall-clock'\n | 'tenant-disabled'\n | 'finish-reason'\n | 'abort'\n totalDurationMs: number\n totalUsage: { inputTokens: number; outputTokens: number }\n}\n\nexport type AiAgentMutationPolicy =\n | 'read-only'\n | 'confirm-required'\n | 'destructive-confirm-required'\n\nexport type AiAgentAcceptedMediaType = 'image' | 'pdf' | 'file'\n\nexport type AiAgentDataOperation = 'read' | 'search' | 'aggregate'\n\nexport interface AiAgentPageContextInput {\n entityType: string\n recordId: string\n container: AwilixContainer\n tenantId: string | null\n organizationId: string | null\n}\n\nexport interface AiAgentStructuredOutput<TSchema = ZodTypeAny> {\n schemaName: string\n schema: TSchema\n mode?: 'generate' | 'stream'\n}\n\nexport interface AiAgentDataCapabilities {\n entities?: string[]\n operations?: AiAgentDataOperation[]\n searchableFields?: string[]\n}\n\nexport interface AiAgentSuggestion {\n label: string\n prompt: string\n}\n\nexport interface AiAgentTaskPlanConfig {\n /**\n * Enables the optional visible planning helper for this agent. When true,\n * the runtime exposes `meta.update_task_plan` and injects prompt guidance\n * telling the model to set a user-visible plan before domain tools.\n *\n * Defaults to false. CRM/customer agents enable this by default in core;\n * other agents can opt in from their agent definition or extension config.\n */\n enabled?: boolean\n}\n\nexport interface AiAgentDefinition {\n id: string\n moduleId: string\n label: string\n description: string\n systemPrompt: string\n allowedTools: string[]\n suggestions?: AiAgentSuggestion[]\n executionMode?: AiAgentExecutionMode\n /**\n * Selects the underlying Vercel AI SDK dispatch strategy for this agent.\n * Defaults to `'stream-text'` \u2014 the existing behavior and the only engine\n * with unconditional full primitive coverage (`repairToolCall`, all loop\n * controls).\n *\n * Set to `'tool-loop-agent'` to use the `ToolLoopAgent` (`Experimental_Agent`)\n * class, which is closer to a semantic agent abstraction and receives upcoming\n * SDK features (multi-agent handoff, streaming approval responses) first.\n *\n * **Note on `repairToolCall`**: the current SDK version ships\n * `experimental_repairToolCall` on `ToolLoopAgentSettings`, so the primitive\n * is technically available. However, SDK behaviour is not guaranteed to be\n * identical across versions \u2014 prefer `'stream-text'` when `repairToolCall`\n * correctness is critical.\n *\n * This field is opt-in: omitting it leaves the existing `stream-text` path\n * completely unchanged.\n *\n * Phase 5 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\n executionEngine?: AiAgentExecutionEngine\n /**\n * Optional provider id this agent prefers (e.g. `'openai'`, `'anthropic'`).\n * Must match a registered `LlmProvider.id`. When the named provider is\n * registered but unconfigured at runtime the factory falls through\n * transparently to the next configured provider.\n *\n * Phase 1 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n defaultProvider?: string\n /**\n * Optional model id fed through `createModelFactory` for this agent.\n * Accepts either a plain model id (`claude-haiku-4-5-20251001`) or a\n * slash-qualified `<provider>/<model>` shorthand (e.g. `openai/gpt-5-mini`).\n * When the slash form is used the prefix must match a registered provider id;\n * the registry-membership guard prevents mis-splitting model ids that already\n * contain slashes (DeepInfra: `meta-llama/Llama-3.3-70B-Instruct-Turbo`).\n *\n * A higher-priority provider source still wins over the slash hint, but a\n * lower-priority one cannot overwrite a slash-qualified model (cross-axis\n * tie-break rule from spec \u00A7Phase-1).\n *\n * Phase 0 and Phase 1 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n defaultModel?: string\n /**\n * Optional base URL this agent prefers for its chosen provider.\n * Sits between the `<MODULE>_AI_BASE_URL` env (step 2 of the public 5-step\n * baseURL hierarchy) and the preset env override (`baseURLEnvKeys`, step 4).\n * Only honoured by adapters that support baseURL (Anthropic Messages-\n * protocol relays, all OpenAI-compatible adapters, Google via\n * @ai-sdk/google \u22653.0). See `packages/ai-assistant/AGENTS.md` \u2192\n * \"baseURL override hierarchy\" for the full numbered chain.\n *\n * Phase 2 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n defaultBaseUrl?: string\n /**\n * When false, per-request HTTP overrides (query params `provider`, `model`,\n * `baseUrl`, `loopBudget`) and the per-tenant settings override stored in\n * `ai_agent_runtime_overrides` are both suppressed. Steps 1 and 3 of the\n * model-factory resolution chain are skipped for this agent, and the\n * `loopBudget` query parameter is ignored by the chat dispatcher.\n *\n * Default is `true` (permissive). Agents that pin a specific model for\n * correctness reasons (e.g. a structured-output agent whose JSON-mode schema\n * only works with one provider) should set this to `false`.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n * Renamed from `allowRuntimeModelOverride` in Phase 4 of spec\n * `2026-04-28-ai-agents-agentic-loop-controls`.\n */\n allowRuntimeOverride?: boolean\n /**\n * @deprecated Use `allowRuntimeOverride` instead. This alias is kept for\n * one minor release and will be removed in a future version. The runtime\n * checks `allowRuntimeOverride` first; if absent it falls back to this field.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n allowRuntimeModelOverride?: boolean\n acceptedMediaTypes?: AiAgentAcceptedMediaType[]\n requiredFeatures?: string[]\n taskPlan?: AiAgentTaskPlanConfig\n uiParts?: string[]\n readOnly?: boolean\n mutationPolicy?: AiAgentMutationPolicy\n /**\n * @deprecated Use `loop.maxSteps` instead. Honored as alias when `loop` is\n * omitted. When both `maxSteps` and `loop.maxSteps` are specified, `loop.maxSteps`\n * wins. This field will be removed in a future minor release.\n *\n * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\n maxSteps?: number\n /**\n * First-class agentic loop configuration. Supersedes the flat `maxSteps`\n * alias. The runtime walks a precedence chain (per-call override \u2192 tenant\n * DB override \u2192 this block \u2192 legacy `maxSteps` alias \u2192 wrapper default)\n * to resolve the effective loop config for each turn.\n *\n * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\n loop?: AiAgentLoopConfig\n output?: AiAgentStructuredOutput\n resolvePageContext?: (ctx: AiAgentPageContextInput) => Promise<string | null>\n keywords?: string[]\n domain?: string\n dataCapabilities?: AiAgentDataCapabilities\n}\n\nexport interface AiAgentExtension {\n targetAgentId: string\n replaceAllowedTools?: string[]\n deleteAllowedTools?: string[]\n appendAllowedTools?: string[]\n taskPlan?: AiAgentTaskPlanConfig\n replaceSystemPrompt?: string\n appendSystemPrompt?: string\n replaceSuggestions?: AiAgentSuggestion[]\n deleteSuggestions?: string[]\n appendSuggestions?: AiAgentSuggestion[]\n /**\n * @deprecated Use `appendSuggestions` for new code. Preserved as the\n * original append-only field for backward compatibility.\n */\n suggestions?: AiAgentSuggestion[]\n}\n\nexport function defineAiAgent(definition: AiAgentDefinition): AiAgentDefinition {\n return definition\n}\n\nexport function defineAiAgentExtension(extension: AiAgentExtension): AiAgentExtension {\n return extension\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport type { ZodTypeAny } from 'zod'\nimport type {\n PrepareStepFunction,\n GenerateTextOnStepFinishCallback,\n GenerateTextOnStepStartCallback,\n OnToolExecutionStartCallback,\n OnToolExecutionEndCallback,\n ToolCallRepairFunction,\n StopCondition,\n ToolChoice,\n ToolSet,\n} from 'ai'\n\nexport type AiAgentExecutionMode = 'chat' | 'object'\n\n/**\n * Selects the underlying Vercel AI SDK dispatch strategy for this agent.\n *\n * - `'stream-text'` (default): the runtime calls `streamText(...)` directly on\n * every turn. All loop primitives are supported: `prepareStep`, `stopWhen`,\n * `repairToolCall`, `activeTools`, `toolChoice`.\n *\n * - `'tool-loop-agent'`: the runtime constructs a `ToolLoopAgent`\n * (`Experimental_Agent`) once and dispatches via `agent.generate(...)` /\n * `agent.stream(...)` per turn. The wrapper-owned `prepareStep` (security-\n * critical for mutation-approval) is supplied at construction via\n * `settings.prepareStep`. `stopWhen` is similarly wired at construction.\n * The `prepareCall` hook is used for per-turn narrowing of `model`, `tools`,\n * `stopWhen`, `activeTools`, and `providerOptions`; `prepareStep` is NOT in\n * its `Pick` list and MUST NOT be threaded through it.\n *\n * Note: the current SDK version ships `experimental_repairToolCall` on\n * `ToolLoopAgentSettings`, so `repairToolCall` is technically reachable via\n * this engine. The `loop.repairToolCall` JSDoc retains a caveat reflecting\n * the spec's documented limitation, which was written against an earlier SDK\n * snapshot where the setting was absent \u2014 use with awareness that SDK\n * behaviour may differ across versions.\n *\n * Phase 5 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\nexport type AiAgentExecutionEngine = 'stream-text' | 'tool-loop-agent'\n\n/**\n * A serializable stop condition for the agentic loop. The `kind` field\n * determines which Vercel AI SDK helper is used at runtime:\n * - `stepCount` \u2192 `stepCountIs(count)` \u2014 the loop stops after N steps.\n * - `hasToolCall` \u2192 `hasToolCall(toolName)` \u2014 the loop stops immediately\n * after the model emits a tool call for the named tool.\n * - `custom` \u2014 a raw `StopCondition<ToolSet>` predicate supplied in code.\n * NOT valid from JSON-only override sources (tenant DB overrides); only\n * accepted when declared directly in `agent.loop` or a `runAiAgentText`\n * caller override.\n *\n * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\nexport type AiAgentLoopStopCondition =\n | { kind: 'stepCount'; count: number }\n | { kind: 'hasToolCall'; toolName: string }\n | { kind: 'custom'; stop: StopCondition<ToolSet> }\n\n/**\n * Budget limits for the agentic loop turn. When any limit is exceeded the\n * wrapper's `prepareStep`/`onStepFinish` aborts the turn via the per-turn\n * `AbortController` and the loop terminates with a `loop_budget_exceeded`\n * finish condition.\n *\n * Budget enforcement is implemented in Phase 1782-3; for Phases 0\u20132 the\n * fields are accepted and forwarded to the prepared-options bag but are not\n * actively enforced.\n *\n * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\nexport interface AiAgentLoopBudget {\n /** Hard cap on tool calls across all steps in this turn. */\n maxToolCalls?: number\n /** Wall-clock cap (ms) per turn; runtime aborts via AbortController. */\n maxWallClockMs?: number\n /** Input+output token cap; aggregated from step `usage` fields. */\n maxTokens?: number\n}\n\n/**\n * First-class loop configuration for an AI agent. Supersedes the flat\n * `maxSteps` alias on `AiAgentDefinition`.\n *\n * All fields are optional; the runtime falls back to the wrapper default\n * (`{ maxSteps: 10 }` for chat, `{ maxSteps: undefined }` for object) when\n * neither the agent nor the caller supplies any loop config.\n *\n * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\nexport interface AiAgentLoopConfig {\n /** Maximum number of agentic steps before the loop is forced to stop. */\n maxSteps?: number\n /**\n * Additional stop conditions. The wrapper ALWAYS composes these with\n * `stepCountIs(maxSteps ?? 10)` so a misconfigured `hasToolCall` for a\n * non-existent tool can never cause an infinite loop (R3 mitigation).\n */\n stopWhen?: AiAgentLoopStopCondition | AiAgentLoopStopCondition[]\n /**\n * Per-step preparation hook. The wrapper composes this with its own\n * security-critical `prepareStep` that re-asserts the tool allowlist and\n * mutation-approval wrapping per step.\n *\n * Only valid for chat agents. Rejected with `loop_unsupported_in_object_mode`\n * for object-mode agents.\n */\n prepareStep?: PrepareStepFunction<ToolSet>\n /**\n * Callback fired when a step finishes. The wrapper chains its own\n * aggregation callback (LoopTrace builder) before invoking this one.\n * Exceptions thrown by this callback are caught and logged but do not\n * abort the turn (matching the SDK's own contract).\n */\n onStepFinish?: GenerateTextOnStepFinishCallback<ToolSet>\n /**\n * Callback fired when a step starts. Forwarded to the AI SDK as\n * `experimental_onStepStart`.\n */\n onStepStart?: GenerateTextOnStepStartCallback<ToolSet>\n /**\n * Callback fired when a tool call starts. Forwarded to the AI SDK as\n * `experimental_onToolCallStart`.\n */\n onToolCallStart?: OnToolExecutionStartCallback<ToolSet>\n /**\n * Callback fired when a tool call finishes. Forwarded to the AI SDK as\n * `experimental_onToolCallFinish`.\n */\n onToolCallFinish?: OnToolExecutionEndCallback<ToolSet>\n /**\n * Tool-call repair function. Forwarded to the AI SDK as\n * `experimental_repairToolCall`.\n *\n * Only valid for chat agents. Rejected with `loop_unsupported_in_object_mode`\n * for object-mode agents.\n *\n * **Engine note**: this primitive is honored under `executionEngine: 'stream-text'`\n * (default). Agents on `'tool-loop-agent'` may not reliably support\n * `repairToolCall` across all SDK versions \u2014 if you require it, use the\n * default `stream-text` engine until support is confirmed stable on the\n * `ToolLoopAgent` class.\n *\n * Phase 5 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\n repairToolCall?: ToolCallRepairFunction<ToolSet>\n /**\n * Narrow the active tool surface for each step. Names must be a subset of\n * `agent.allowedTools`; any names outside the allowlist are filtered out\n * with a `loop:active_tools_filtered` warning.\n *\n * Only valid for chat agents. Rejected with `loop_unsupported_in_object_mode`\n * for object-mode agents.\n */\n activeTools?: string[]\n /**\n * Tool choice strategy forwarded to the AI SDK on each step.\n *\n * Only valid for chat agents. Rejected with `loop_unsupported_in_object_mode`\n * for object-mode agents.\n */\n toolChoice?: ToolChoice<ToolSet>\n /** Budget caps for this loop turn. */\n budget?: AiAgentLoopBudget\n /**\n * When `false`, per-call `runAiAgentText({ loop })` / HTTP query-param\n * overrides are rejected with `AgentPolicyError` code\n * `loop_runtime_override_disabled`. Default is `true` (permissive).\n *\n * Agents that pin a loop policy for correctness reasons (e.g. a\n * `stopWhen: hasToolCall(...)` that must not be bypassed by callers)\n * should set this to `false`.\n */\n allowRuntimeOverride?: boolean\n /**\n * Kill switch \u2014 when `true`, the runtime forces `stopWhen: stepCountIs(1)` and\n * ignores all other loop config. Used by the per-tenant operator override to\n * collapse an agent to a single model call (no tool execution) without\n * disabling the agent entirely.\n *\n * Phase 3 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\n disabled?: boolean\n}\n\n/**\n * Per-step record aggregated by the wrapper-owned `onStepFinish` hook into\n * `LoopTrace`. Each completed agentic step produces one record.\n *\n * Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\nexport interface LoopStepRecord {\n stepIndex: number\n /** Model id resolved for this step (relevant when prepareStep swaps models). */\n modelId: string\n toolCalls: Array<{\n toolName: string\n args: unknown\n result?: unknown\n error?: { code: string; message: string }\n repairAttempted: boolean\n durationMs: number\n }>\n /** Raw assistant text emitted in this step. */\n textDelta: string\n usage: { inputTokens: number; outputTokens: number }\n finishReason: 'stop' | 'tool-calls' | 'length' | 'content-filter' | 'error'\n}\n\n/**\n * Per-turn trace aggregated by the wrapper-owned `buildLoopTraceCollector`.\n * Not persisted \u2014 in-memory only; surfaced via the dispatcher SSE stream and\n * the playground/`<AiChat>` debug panel.\n *\n * Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\nexport interface LoopTrace {\n agentId: string\n /**\n * Stable per-conversation id that ties every turn together. Echoed back on\n * the SSE `loop-finish` event so clients can persist it for subsequent turns.\n *\n * Phase 6.2 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\n sessionId: string\n turnId: string\n steps: LoopStepRecord[]\n stopReason:\n | 'step-count'\n | 'has-tool-call'\n | 'custom-stop'\n | 'budget-tokens'\n | 'budget-tool-calls'\n | 'budget-wall-clock'\n | 'tenant-disabled'\n | 'finish-reason'\n | 'abort'\n totalDurationMs: number\n totalUsage: { inputTokens: number; outputTokens: number }\n}\n\nexport type AiAgentMutationPolicy =\n | 'read-only'\n | 'confirm-required'\n | 'destructive-confirm-required'\n\nexport type AiAgentAcceptedMediaType = 'image' | 'pdf' | 'file'\n\nexport type AiAgentDataOperation = 'read' | 'search' | 'aggregate'\n\nexport interface AiAgentPageContextInput {\n entityType: string\n recordId: string\n container: AwilixContainer\n tenantId: string | null\n organizationId: string | null\n}\n\nexport interface AiAgentStructuredOutput<TSchema = ZodTypeAny> {\n schemaName: string\n schema: TSchema\n mode?: 'generate' | 'stream'\n}\n\nexport interface AiAgentDataCapabilities {\n entities?: string[]\n operations?: AiAgentDataOperation[]\n searchableFields?: string[]\n}\n\nexport interface AiAgentSuggestion {\n label: string\n prompt: string\n}\n\nexport interface AiAgentTaskPlanConfig {\n /**\n * Enables the optional visible planning helper for this agent. When true,\n * the runtime exposes `meta.update_task_plan` and injects prompt guidance\n * telling the model to set a user-visible plan before domain tools.\n *\n * Defaults to false. CRM/customer agents enable this by default in core;\n * other agents can opt in from their agent definition or extension config.\n */\n enabled?: boolean\n}\n\nexport interface AiAgentDefinition {\n id: string\n moduleId: string\n label: string\n description: string\n systemPrompt: string\n allowedTools: string[]\n suggestions?: AiAgentSuggestion[]\n executionMode?: AiAgentExecutionMode\n /**\n * Selects the underlying Vercel AI SDK dispatch strategy for this agent.\n * Defaults to `'stream-text'` \u2014 the existing behavior and the only engine\n * with unconditional full primitive coverage (`repairToolCall`, all loop\n * controls).\n *\n * Set to `'tool-loop-agent'` to use the `ToolLoopAgent` (`Experimental_Agent`)\n * class, which is closer to a semantic agent abstraction and receives upcoming\n * SDK features (multi-agent handoff, streaming approval responses) first.\n *\n * **Note on `repairToolCall`**: the current SDK version ships\n * `experimental_repairToolCall` on `ToolLoopAgentSettings`, so the primitive\n * is technically available. However, SDK behaviour is not guaranteed to be\n * identical across versions \u2014 prefer `'stream-text'` when `repairToolCall`\n * correctness is critical.\n *\n * This field is opt-in: omitting it leaves the existing `stream-text` path\n * completely unchanged.\n *\n * Phase 5 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\n executionEngine?: AiAgentExecutionEngine\n /**\n * Optional provider id this agent prefers (e.g. `'openai'`, `'anthropic'`).\n * Must match a registered `LlmProvider.id`. When the named provider is\n * registered but unconfigured at runtime the factory falls through\n * transparently to the next configured provider.\n *\n * Phase 1 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n defaultProvider?: string\n /**\n * Optional model id fed through `createModelFactory` for this agent.\n * Accepts either a plain model id (`claude-haiku-4-5-20251001`) or a\n * slash-qualified `<provider>/<model>` shorthand (e.g. `openai/gpt-5-mini`).\n * When the slash form is used the prefix must match a registered provider id;\n * the registry-membership guard prevents mis-splitting model ids that already\n * contain slashes (DeepInfra: `meta-llama/Llama-3.3-70B-Instruct-Turbo`).\n *\n * A higher-priority provider source still wins over the slash hint, but a\n * lower-priority one cannot overwrite a slash-qualified model (cross-axis\n * tie-break rule from spec \u00A7Phase-1).\n *\n * Phase 0 and Phase 1 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n defaultModel?: string\n /**\n * Optional base URL this agent prefers for its chosen provider.\n * Sits between the `<MODULE>_AI_BASE_URL` env (step 2 of the public 5-step\n * baseURL hierarchy) and the preset env override (`baseURLEnvKeys`, step 4).\n * Only honoured by adapters that support baseURL (Anthropic Messages-\n * protocol relays, all OpenAI-compatible adapters, Google via\n * @ai-sdk/google \u22653.0). See `packages/ai-assistant/AGENTS.md` \u2192\n * \"baseURL override hierarchy\" for the full numbered chain.\n *\n * Phase 2 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n defaultBaseUrl?: string\n /**\n * When false, per-request HTTP overrides (query params `provider`, `model`,\n * `baseUrl`, `loopBudget`) and the per-tenant settings override stored in\n * `ai_agent_runtime_overrides` are both suppressed. Steps 1 and 3 of the\n * model-factory resolution chain are skipped for this agent, and the\n * `loopBudget` query parameter is ignored by the chat dispatcher.\n *\n * Default is `true` (permissive). Agents that pin a specific model for\n * correctness reasons (e.g. a structured-output agent whose JSON-mode schema\n * only works with one provider) should set this to `false`.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n * Renamed from `allowRuntimeModelOverride` in Phase 4 of spec\n * `2026-04-28-ai-agents-agentic-loop-controls`.\n */\n allowRuntimeOverride?: boolean\n /**\n * @deprecated Use `allowRuntimeOverride` instead. This alias is kept for\n * one minor release and will be removed in a future version. The runtime\n * checks `allowRuntimeOverride` first; if absent it falls back to this field.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n allowRuntimeModelOverride?: boolean\n acceptedMediaTypes?: AiAgentAcceptedMediaType[]\n requiredFeatures?: string[]\n taskPlan?: AiAgentTaskPlanConfig\n uiParts?: string[]\n readOnly?: boolean\n mutationPolicy?: AiAgentMutationPolicy\n /**\n * Marks this agent as accepting untrusted end-user input (e.g. a customer\n * portal or public-widget surface). When `true`, input pre-moderation is\n * ENFORCED on every turn wherever the resolved chat provider supports it \u2014\n * tenants cannot disable it. Other agents default to off and are tenant\n * opt-in. Additive + optional; omitting it preserves today's behavior.\n *\n * Spec `2026-06-04-ai-input-moderation-and-safety-identifiers`.\n */\n untrustedInput?: boolean\n /**\n * @deprecated Use `loop.maxSteps` instead. Honored as alias when `loop` is\n * omitted. When both `maxSteps` and `loop.maxSteps` are specified, `loop.maxSteps`\n * wins. This field will be removed in a future minor release.\n *\n * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\n maxSteps?: number\n /**\n * First-class agentic loop configuration. Supersedes the flat `maxSteps`\n * alias. The runtime walks a precedence chain (per-call override \u2192 tenant\n * DB override \u2192 this block \u2192 legacy `maxSteps` alias \u2192 wrapper default)\n * to resolve the effective loop config for each turn.\n *\n * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\n loop?: AiAgentLoopConfig\n output?: AiAgentStructuredOutput\n resolvePageContext?: (ctx: AiAgentPageContextInput) => Promise<string | null>\n keywords?: string[]\n domain?: string\n dataCapabilities?: AiAgentDataCapabilities\n}\n\nexport interface AiAgentExtension {\n targetAgentId: string\n replaceAllowedTools?: string[]\n deleteAllowedTools?: string[]\n appendAllowedTools?: string[]\n taskPlan?: AiAgentTaskPlanConfig\n replaceSystemPrompt?: string\n appendSystemPrompt?: string\n replaceSuggestions?: AiAgentSuggestion[]\n deleteSuggestions?: string[]\n appendSuggestions?: AiAgentSuggestion[]\n /**\n * @deprecated Use `appendSuggestions` for new code. Preserved as the\n * original append-only field for backward compatibility.\n */\n suggestions?: AiAgentSuggestion[]\n}\n\nexport function defineAiAgent(definition: AiAgentDefinition): AiAgentDefinition {\n return definition\n}\n\nexport function defineAiAgentExtension(extension: AiAgentExtension): AiAgentExtension {\n return extension\n}\n"],
|
|
5
|
+
"mappings": "AAsbO,SAAS,cAAc,YAAkD;AAC9E,SAAO;AACT;AAEO,SAAS,uBAAuB,WAA+C;AACpF,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
2
|
-
import {
|
|
2
|
+
import { authorizeFeatures } from "@open-mercato/shared/security/featurePolicy";
|
|
3
3
|
const logger = createLogger("ai_assistant");
|
|
4
4
|
async function authenticateMcpRequest(apiKeySecret, container) {
|
|
5
5
|
if (!apiKeySecret || typeof apiKeySecret !== "string") {
|
|
@@ -47,12 +47,11 @@ async function authenticateMcpRequest(apiKeySecret, container) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
function hasRequiredFeatures(requiredFeatures, userFeatures, isSuperAdmin, rbacService) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
return hasAllFeatures(requiredFeatures, userFeatures);
|
|
50
|
+
void rbacService;
|
|
51
|
+
return authorizeFeatures(requiredFeatures ?? [], {
|
|
52
|
+
grantedFeatures: userFeatures,
|
|
53
|
+
unrestricted: isSuperAdmin
|
|
54
|
+
});
|
|
56
55
|
}
|
|
57
56
|
function extractApiKeyFromHeaders(headers) {
|
|
58
57
|
const getHeader = (name) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/ai_assistant/lib/auth.ts"],
|
|
4
|
-
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport {
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAI7B,SAAS,
|
|
4
|
+
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * Successful authentication result.\n */\nexport type McpAuthSuccess = {\n success: true\n keyId: string\n keyName: string\n tenantId: string | null\n organizationId: string | null\n userId: string\n features: string[]\n isSuperAdmin: boolean\n}\n\n/**\n * Failed authentication result.\n */\nexport type McpAuthFailure = {\n success: false\n error: string\n}\n\n/**\n * Result from MCP authentication.\n */\nexport type McpAuthResult = McpAuthSuccess | McpAuthFailure\n\n/**\n * Authenticate an MCP request using an API key.\n *\n * This function validates the API key secret and loads the associated\n * ACL (features, organizations, super admin status) from the key's roles.\n *\n * @param apiKeySecret - The full API key secret (e.g., 'omk_xxxx.yyyy...')\n * @param container - Awilix DI container with 'em' and 'rbacService'\n * @returns Authentication result with user context or error\n */\nexport async function authenticateMcpRequest(\n apiKeySecret: string,\n container: AwilixContainer\n): Promise<McpAuthResult> {\n if (!apiKeySecret || typeof apiKeySecret !== 'string') {\n return { success: false, error: 'API key is required' }\n }\n\n const trimmedSecret = apiKeySecret.trim()\n if (!trimmedSecret) {\n return { success: false, error: 'API key is required' }\n }\n\n if (!trimmedSecret.startsWith('omk_')) {\n return { success: false, error: 'Invalid API key format' }\n }\n\n try {\n const em = container.resolve('em') as EntityManager\n\n const { findApiKeyBySecret } = await import(\n '@open-mercato/core/modules/api_keys/services/apiKeyService'\n )\n\n const apiKey = await findApiKeyBySecret(em, trimmedSecret)\n\n if (!apiKey) {\n return { success: false, error: 'Invalid or expired API key' }\n }\n\n const userId = `api_key:${apiKey.id}`\n\n const rbacService = container.resolve('rbacService') as {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null }\n ) => Promise<{\n isSuperAdmin: boolean\n features: string[]\n organizations: string[] | null\n }>\n }\n\n const acl = await rbacService.loadAcl(userId, {\n tenantId: apiKey.tenantId ?? null,\n organizationId: apiKey.organizationId ?? null,\n })\n\n try {\n apiKey.lastUsedAt = new Date()\n await em.persist(apiKey).flush()\n } catch {\n // Best-effort update; ignore write failures\n }\n\n return {\n success: true,\n keyId: apiKey.id,\n keyName: apiKey.name,\n tenantId: apiKey.tenantId ?? null,\n organizationId: apiKey.organizationId ?? null,\n userId,\n features: acl.features,\n isSuperAdmin: acl.isSuperAdmin,\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n logger.error('MCP authentication failed', { err: error })\n return { success: false, error: 'Authentication failed' }\n }\n}\n\n/**\n * Check if user has the required features for a resource.\n *\n * Supports:\n * - Super admin bypass (always returns true)\n * - Direct feature match (e.g., 'customers.view')\n * - Global wildcard ('*' grants all features)\n * - Prefix wildcard (e.g., 'customers.*' grants 'customers.people.view' and the\n * bare 'customers' segment itself)\n *\n * @param requiredFeatures - List of features required for access\n * @param userFeatures - List of features the user has\n * @param isSuperAdmin - Whether the user is a super admin\n * @param rbacService - Optional RbacService to delegate feature matching\n * @returns True if user has access\n */\nexport function hasRequiredFeatures(\n requiredFeatures: string[] | undefined,\n userFeatures: string[],\n isSuperAdmin: boolean,\n rbacService?: RbacService\n): boolean {\n void rbacService\n return authorizeFeatures(requiredFeatures ?? [], {\n grantedFeatures: userFeatures,\n unrestricted: isSuperAdmin,\n })\n}\n\n/**\n * Extract API key from HTTP request headers.\n *\n * Supports two header formats:\n * - x-api-key: <secret>\n * - Authorization: ApiKey <secret>\n *\n * @param headers - Request headers (Map, Headers, or plain object)\n * @returns The API key secret or null if not found\n */\nexport function extractApiKeyFromHeaders(\n headers: Headers | Map<string, string> | Record<string, string | undefined>\n): string | null {\n const getHeader = (name: string): string | null => {\n if (headers instanceof Headers) {\n return headers.get(name)\n }\n if (headers instanceof Map) {\n return headers.get(name) ?? null\n }\n const value = headers[name] ?? headers[name.toLowerCase()]\n return typeof value === 'string' ? value : null\n }\n\n const xApiKey = getHeader('x-api-key')?.trim()\n if (xApiKey) {\n return xApiKey\n }\n\n const authHeader = getHeader('authorization')?.trim()\n if (authHeader && authHeader.toLowerCase().startsWith('apikey ')) {\n return authHeader.slice(7).trim()\n }\n\n return null\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB;AAI7B,SAAS,yBAAyB;AAElC,MAAM,SAAS,aAAa,cAAc;AAuC1C,eAAsB,uBACpB,cACA,WACwB;AACxB,MAAI,CAAC,gBAAgB,OAAO,iBAAiB,UAAU;AACrD,WAAO,EAAE,SAAS,OAAO,OAAO,sBAAsB;AAAA,EACxD;AAEA,QAAM,gBAAgB,aAAa,KAAK;AACxC,MAAI,CAAC,eAAe;AAClB,WAAO,EAAE,SAAS,OAAO,OAAO,sBAAsB;AAAA,EACxD;AAEA,MAAI,CAAC,cAAc,WAAW,MAAM,GAAG;AACrC,WAAO,EAAE,SAAS,OAAO,OAAO,yBAAyB;AAAA,EAC3D;AAEA,MAAI;AACF,UAAM,KAAK,UAAU,QAAQ,IAAI;AAEjC,UAAM,EAAE,mBAAmB,IAAI,MAAM,OACnC,4DACF;AAEA,UAAM,SAAS,MAAM,mBAAmB,IAAI,aAAa;AAEzD,QAAI,CAAC,QAAQ;AACX,aAAO,EAAE,SAAS,OAAO,OAAO,6BAA6B;AAAA,IAC/D;AAEA,UAAM,SAAS,WAAW,OAAO,EAAE;AAEnC,UAAM,cAAc,UAAU,QAAQ,aAAa;AAWnD,UAAM,MAAM,MAAM,YAAY,QAAQ,QAAQ;AAAA,MAC5C,UAAU,OAAO,YAAY;AAAA,MAC7B,gBAAgB,OAAO,kBAAkB;AAAA,IAC3C,CAAC;AAED,QAAI;AACF,aAAO,aAAa,oBAAI,KAAK;AAC7B,YAAM,GAAG,QAAQ,MAAM,EAAE,MAAM;AAAA,IACjC,QAAQ;AAAA,IAER;AAEA,WAAO;AAAA,MACL,SAAS;AAAA,MACT,OAAO,OAAO;AAAA,MACd,SAAS,OAAO;AAAA,MAChB,UAAU,OAAO,YAAY;AAAA,MAC7B,gBAAgB,OAAO,kBAAkB;AAAA,MACzC;AAAA,MACA,UAAU,IAAI;AAAA,MACd,cAAc,IAAI;AAAA,IACpB;AAAA,EACF,SAAS,OAAO;AACd,UAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AACrE,WAAO,MAAM,6BAA6B,EAAE,KAAK,MAAM,CAAC;AACxD,WAAO,EAAE,SAAS,OAAO,OAAO,wBAAwB;AAAA,EAC1D;AACF;AAkBO,SAAS,oBACd,kBACA,cACA,cACA,aACS;AACT,OAAK;AACL,SAAO,kBAAkB,oBAAoB,CAAC,GAAG;AAAA,IAC/C,iBAAiB;AAAA,IACjB,cAAc;AAAA,EAChB,CAAC;AACH;AAYO,SAAS,yBACd,SACe;AACf,QAAM,YAAY,CAAC,SAAgC;AACjD,QAAI,mBAAmB,SAAS;AAC9B,aAAO,QAAQ,IAAI,IAAI;AAAA,IACzB;AACA,QAAI,mBAAmB,KAAK;AAC1B,aAAO,QAAQ,IAAI,IAAI,KAAK;AAAA,IAC9B;AACA,UAAM,QAAQ,QAAQ,IAAI,KAAK,QAAQ,KAAK,YAAY,CAAC;AACzD,WAAO,OAAO,UAAU,WAAW,QAAQ;AAAA,EAC7C;AAEA,QAAM,UAAU,UAAU,WAAW,GAAG,KAAK;AAC7C,MAAI,SAAS;AACX,WAAO;AAAA,EACT;AAEA,QAAM,aAAa,UAAU,eAAe,GAAG,KAAK;AACpD,MAAI,cAAc,WAAW,YAAY,EAAE,WAAW,SAAS,GAAG;AAChE,WAAO,WAAW,MAAM,CAAC,EAAE,KAAK;AAAA,EAClC;AAEA,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -53,6 +53,9 @@ function createAnthropicAdapter() {
|
|
|
53
53
|
}
|
|
54
54
|
return envKeys[0];
|
|
55
55
|
},
|
|
56
|
+
mapEndUserIdentifier(identifier) {
|
|
57
|
+
return { anthropic: { metadata: { userId: identifier } } };
|
|
58
|
+
},
|
|
56
59
|
createModel(options) {
|
|
57
60
|
const anthropic = createAnthropic({
|
|
58
61
|
apiKey: options.apiKey,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/ai_assistant/lib/llm-adapters/anthropic.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * AnthropicAdapter \u2014 implements the LlmProvider port for Anthropic's\n * Messages API (Claude Haiku, Sonnet, Opus).\n *\n * Wraps `createAnthropic({ apiKey })` from `@ai-sdk/anthropic` and exposes\n * a curated model list for the AI Assistant UI dropdown.\n *\n * @see packages/shared/src/lib/ai/llm-provider.ts\n * @see .ai/specs/implemented/2026-04-14-llm-provider-ports-and-adapters.md\n */\n\nimport { createAnthropic } from '@ai-sdk/anthropic'\nimport type {\n EnvLookup,\n LlmCreateModelOptions,\n LlmModelInfo,\n LlmProvider,\n} from '@open-mercato/shared/lib/ai/llm-provider'\n\nconst DEFAULT_MODEL = 'claude-haiku-4-5-20251001'\n\nconst DEFAULT_MODELS: readonly LlmModelInfo[] = [\n {\n id: 'claude-haiku-4-5-20251001',\n name: 'Claude Haiku 4.5',\n contextWindow: 200000,\n tags: ['budget'],\n },\n {\n id: 'claude-sonnet-4-6-20260107',\n name: 'Claude Sonnet 4.6',\n contextWindow: 200000,\n tags: ['flagship'],\n },\n {\n id: 'claude-opus-4-6-20260107',\n name: 'Claude Opus 4.6',\n contextWindow: 1000000,\n tags: ['flagship', 'reasoning'],\n },\n] as const\n\n/**\n * Factory returning a fresh `AnthropicAdapter` instance. The adapter is\n * stateless \u2014 caller is free to reuse the returned object.\n *\n * `createModel` accepts an optional `baseURL` from {@link LlmCreateModelOptions}\n * and forwards it to `createAnthropic({ apiKey, baseURL })`.\n *\n * baseURL only works for Anthropic Messages-protocol relays (Cloudflare AI\n * Gateway in Anthropic mode, Helicone proxy). For OpenAI-format gateways,\n * use the OpenAI / OpenRouter presets.\n */\nexport function createAnthropicAdapter(): LlmProvider {\n const envKeys = ['ANTHROPIC_API_KEY', 'OPENCODE_ANTHROPIC_API_KEY'] as const\n\n function resolveApiKey(env?: EnvLookup): string | null {\n const lookup = env ?? process.env\n for (const key of envKeys) {\n const value = lookup[key]\n if (typeof value === 'string') {\n const trimmed = value.trim()\n if (trimmed.length > 0) return trimmed\n }\n }\n return null\n }\n\n return {\n id: 'anthropic',\n name: 'Anthropic',\n envKeys,\n defaultModel: DEFAULT_MODEL,\n defaultModels: DEFAULT_MODELS,\n\n isConfigured(env?: EnvLookup): boolean {\n return resolveApiKey(env) !== null\n },\n\n resolveApiKey,\n\n getConfiguredEnvKey(env?: EnvLookup): string {\n const lookup = env ?? process.env\n for (const key of envKeys) {\n const value = lookup[key]\n if (typeof value === 'string' && value.trim().length > 0) {\n return key\n }\n }\n return envKeys[0]\n },\n\n createModel(options: LlmCreateModelOptions): unknown {\n const anthropic = createAnthropic({\n apiKey: options.apiKey,\n ...(options.baseURL ? { baseURL: options.baseURL } : {}),\n })\n return anthropic(options.modelId)\n },\n }\n}\n"],
|
|
5
|
-
"mappings": "AAWA,SAAS,uBAAuB;AAQhC,MAAM,gBAAgB;AAEtB,MAAM,iBAA0C;AAAA,EAC9C;AAAA,IACE,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,eAAe;AAAA,IACf,MAAM,CAAC,QAAQ;AAAA,EACjB;AAAA,EACA;AAAA,IACE,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,eAAe;AAAA,IACf,MAAM,CAAC,UAAU;AAAA,EACnB;AAAA,EACA;AAAA,IACE,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,eAAe;AAAA,IACf,MAAM,CAAC,YAAY,WAAW;AAAA,EAChC;AACF;AAaO,SAAS,yBAAsC;AACpD,QAAM,UAAU,CAAC,qBAAqB,4BAA4B;AAElE,WAAS,cAAc,KAAgC;AACrD,UAAM,SAAS,OAAO,QAAQ;AAC9B,eAAW,OAAO,SAAS;AACzB,YAAM,QAAQ,OAAO,GAAG;AACxB,UAAI,OAAO,UAAU,UAAU;AAC7B,cAAM,UAAU,MAAM,KAAK;AAC3B,YAAI,QAAQ,SAAS,EAAG,QAAO;AAAA,MACjC;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL,IAAI;AAAA,IACJ,MAAM;AAAA,IACN;AAAA,IACA,cAAc;AAAA,IACd,eAAe;AAAA,IAEf,aAAa,KAA0B;AACrC,aAAO,cAAc,GAAG,MAAM;AAAA,IAChC;AAAA,IAEA;AAAA,IAEA,oBAAoB,KAAyB;AAC3C,YAAM,SAAS,OAAO,QAAQ;AAC9B,iBAAW,OAAO,SAAS;AACzB,cAAM,QAAQ,OAAO,GAAG;AACxB,YAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,GAAG;AACxD,iBAAO;AAAA,QACT;AAAA,MACF;AACA,aAAO,QAAQ,CAAC;AAAA,IAClB;AAAA,IAEA,YAAY,SAAyC;AACnD,YAAM,YAAY,gBAAgB;AAAA,QAChC,QAAQ,QAAQ;AAAA,QAChB,GAAI,QAAQ,UAAU,EAAE,SAAS,QAAQ,QAAQ,IAAI,CAAC;AAAA,MACxD,CAAC;AACD,aAAO,UAAU,QAAQ,OAAO;AAAA,IAClC;AAAA,EACF;AACF;",
|
|
4
|
+
"sourcesContent": ["/**\n * AnthropicAdapter \u2014 implements the LlmProvider port for Anthropic's\n * Messages API (Claude Haiku, Sonnet, Opus).\n *\n * Wraps `createAnthropic({ apiKey })` from `@ai-sdk/anthropic` and exposes\n * a curated model list for the AI Assistant UI dropdown.\n *\n * @see packages/shared/src/lib/ai/llm-provider.ts\n * @see .ai/specs/implemented/2026-04-14-llm-provider-ports-and-adapters.md\n */\n\nimport { createAnthropic } from '@ai-sdk/anthropic'\nimport type {\n EnvLookup,\n LlmCreateModelOptions,\n LlmModelInfo,\n LlmProvider,\n} from '@open-mercato/shared/lib/ai/llm-provider'\n\nconst DEFAULT_MODEL = 'claude-haiku-4-5-20251001'\n\nconst DEFAULT_MODELS: readonly LlmModelInfo[] = [\n {\n id: 'claude-haiku-4-5-20251001',\n name: 'Claude Haiku 4.5',\n contextWindow: 200000,\n tags: ['budget'],\n },\n {\n id: 'claude-sonnet-4-6-20260107',\n name: 'Claude Sonnet 4.6',\n contextWindow: 200000,\n tags: ['flagship'],\n },\n {\n id: 'claude-opus-4-6-20260107',\n name: 'Claude Opus 4.6',\n contextWindow: 1000000,\n tags: ['flagship', 'reasoning'],\n },\n] as const\n\n/**\n * Factory returning a fresh `AnthropicAdapter` instance. The adapter is\n * stateless \u2014 caller is free to reuse the returned object.\n *\n * `createModel` accepts an optional `baseURL` from {@link LlmCreateModelOptions}\n * and forwards it to `createAnthropic({ apiKey, baseURL })`.\n *\n * baseURL only works for Anthropic Messages-protocol relays (Cloudflare AI\n * Gateway in Anthropic mode, Helicone proxy). For OpenAI-format gateways,\n * use the OpenAI / OpenRouter presets.\n */\nexport function createAnthropicAdapter(): LlmProvider {\n const envKeys = ['ANTHROPIC_API_KEY', 'OPENCODE_ANTHROPIC_API_KEY'] as const\n\n function resolveApiKey(env?: EnvLookup): string | null {\n const lookup = env ?? process.env\n for (const key of envKeys) {\n const value = lookup[key]\n if (typeof value === 'string') {\n const trimmed = value.trim()\n if (trimmed.length > 0) return trimmed\n }\n }\n return null\n }\n\n return {\n id: 'anthropic',\n name: 'Anthropic',\n envKeys,\n defaultModel: DEFAULT_MODEL,\n defaultModels: DEFAULT_MODELS,\n\n isConfigured(env?: EnvLookup): boolean {\n return resolveApiKey(env) !== null\n },\n\n resolveApiKey,\n\n getConfiguredEnvKey(env?: EnvLookup): string {\n const lookup = env ?? process.env\n for (const key of envKeys) {\n const value = lookup[key]\n if (typeof value === 'string' && value.trim().length > 0) {\n return key\n }\n }\n return envKeys[0]\n },\n\n mapEndUserIdentifier(identifier: string): Record<string, unknown> {\n // Anthropic Messages API accepts `metadata.user_id` so abuse enforcement\n // can target a single hashed end user instead of the org key. The key MUST\n // be the AI SDK provider-option name `userId` (camelCase) \u2014 the SDK\n // translates it to the `metadata.user_id` request-body field and strips\n // unknown providerOptions keys, so a snake_case key would be dropped and\n // never reach Anthropic.\n return { anthropic: { metadata: { userId: identifier } } }\n },\n\n createModel(options: LlmCreateModelOptions): unknown {\n const anthropic = createAnthropic({\n apiKey: options.apiKey,\n ...(options.baseURL ? { baseURL: options.baseURL } : {}),\n })\n return anthropic(options.modelId)\n },\n }\n}\n"],
|
|
5
|
+
"mappings": "AAWA,SAAS,uBAAuB;AAQhC,MAAM,gBAAgB;AAEtB,MAAM,iBAA0C;AAAA,EAC9C;AAAA,IACE,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,eAAe;AAAA,IACf,MAAM,CAAC,QAAQ;AAAA,EACjB;AAAA,EACA;AAAA,IACE,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,eAAe;AAAA,IACf,MAAM,CAAC,UAAU;AAAA,EACnB;AAAA,EACA;AAAA,IACE,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,eAAe;AAAA,IACf,MAAM,CAAC,YAAY,WAAW;AAAA,EAChC;AACF;AAaO,SAAS,yBAAsC;AACpD,QAAM,UAAU,CAAC,qBAAqB,4BAA4B;AAElE,WAAS,cAAc,KAAgC;AACrD,UAAM,SAAS,OAAO,QAAQ;AAC9B,eAAW,OAAO,SAAS;AACzB,YAAM,QAAQ,OAAO,GAAG;AACxB,UAAI,OAAO,UAAU,UAAU;AAC7B,cAAM,UAAU,MAAM,KAAK;AAC3B,YAAI,QAAQ,SAAS,EAAG,QAAO;AAAA,MACjC;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL,IAAI;AAAA,IACJ,MAAM;AAAA,IACN;AAAA,IACA,cAAc;AAAA,IACd,eAAe;AAAA,IAEf,aAAa,KAA0B;AACrC,aAAO,cAAc,GAAG,MAAM;AAAA,IAChC;AAAA,IAEA;AAAA,IAEA,oBAAoB,KAAyB;AAC3C,YAAM,SAAS,OAAO,QAAQ;AAC9B,iBAAW,OAAO,SAAS;AACzB,cAAM,QAAQ,OAAO,GAAG;AACxB,YAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,GAAG;AACxD,iBAAO;AAAA,QACT;AAAA,MACF;AACA,aAAO,QAAQ,CAAC;AAAA,IAClB;AAAA,IAEA,qBAAqB,YAA6C;AAOhE,aAAO,EAAE,WAAW,EAAE,UAAU,EAAE,QAAQ,WAAW,EAAE,EAAE;AAAA,IAC3D;AAAA,IAEA,YAAY,SAAyC;AACnD,YAAM,YAAY,gBAAgB;AAAA,QAChC,QAAQ,QAAQ;AAAA,QAChB,GAAI,QAAQ,UAAU,EAAE,SAAS,QAAQ,QAAQ,IAAI,CAAC;AAAA,MACxD,CAAC;AACD,aAAO,UAAU,QAAQ,OAAO;AAAA,IAClC;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -29,12 +29,13 @@ function createOpenAICompatibleProvider(preset) {
|
|
|
29
29
|
}
|
|
30
30
|
return preset.baseURL;
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
const provider = {
|
|
33
33
|
id: preset.id,
|
|
34
34
|
name: preset.name,
|
|
35
35
|
envKeys: preset.envKeys,
|
|
36
36
|
defaultModel: preset.defaultModel,
|
|
37
37
|
defaultModels: preset.defaultModels,
|
|
38
|
+
supportsInputModeration: preset.supportsInputModeration ?? false,
|
|
38
39
|
usesVendorPrefixedModelIds: preset.usesVendorPrefixedModelIds ?? false,
|
|
39
40
|
isConfigured(env) {
|
|
40
41
|
return resolveApiKey(env) !== null;
|
|
@@ -59,6 +60,12 @@ function createOpenAICompatibleProvider(preset) {
|
|
|
59
60
|
return openai(options.modelId);
|
|
60
61
|
}
|
|
61
62
|
};
|
|
63
|
+
if (preset.supportsEndUserSafetyIdentifier) {
|
|
64
|
+
provider.mapEndUserIdentifier = (identifier) => ({
|
|
65
|
+
openai: { safetyIdentifier: identifier }
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return provider;
|
|
62
69
|
}
|
|
63
70
|
export {
|
|
64
71
|
createOpenAICompatibleProvider
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/ai_assistant/lib/llm-adapters/openai.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * OpenAIAdapter \u2014 implements the LlmProvider port for the OpenAI\n * chat-completions protocol.\n *\n * This single adapter serves OpenAI itself and every OpenAI-compatible\n * backend (DeepInfra, Groq, Together, Fireworks, Azure OpenAI, LiteLLM,\n * Ollama, LocalAI, vLLM, \u2026). Vendor-specific configuration \u2014 endpoint\n * URL, available models, env var conventions \u2014 lives in\n * `./openai-compatible-presets.ts` as plain data, not code.\n *\n * The factory {@link createOpenAICompatibleProvider} takes a preset and\n * returns a fully-configured `LlmProvider` that internally calls\n * `createOpenAI({ apiKey, baseURL })` from `@ai-sdk/openai`.\n *\n * @see packages/shared/src/lib/ai/llm-provider.ts\n * @see ./openai-compatible-presets.ts\n * @see .ai/specs/implemented/2026-04-14-llm-provider-ports-and-adapters.md\n */\n\nimport { createOpenAI } from '@ai-sdk/openai'\nimport type {\n EnvLookup,\n LlmCreateModelOptions,\n LlmModelInfo,\n LlmProvider,\n} from '@open-mercato/shared/lib/ai/llm-provider'\n\n/**\n * Configuration for a single OpenAI-compatible provider instance.\n *\n * Built-in presets live in {@link ./openai-compatible-presets.ts}.\n * Downstream applications may construct custom presets at bootstrap time\n * and register them with `llmProviderRegistry.register(\n * createOpenAICompatibleProvider(customPreset),\n * )`.\n */\nexport interface OpenAICompatiblePreset {\n /** Stable id (e.g. `openai`, `deepinfra`, `groq`, `together`). */\n id: string\n /** Human-readable display name. */\n name: string\n /**\n * Upstream base URL. Leave `undefined` to use the AI SDK default\n * (`https://api.openai.com/v1`). Required for DeepInfra, Groq, etc.\n */\n baseURL?: string\n /**\n * Env var names where the adapter looks for the API key, in priority\n * order. Each preset declares its own keys so unrelated presets never\n * accidentally share credentials (e.g. DeepInfra uses\n * `DEEPINFRA_API_KEY`, not `OPENAI_API_KEY`).\n */\n envKeys: readonly string[]\n /** Default model id used when the caller does not specify one. */\n defaultModel: string\n /** Curated model catalog shown in the UI dropdown. */\n defaultModels: readonly LlmModelInfo[]\n /**\n * Optional env var names for overriding the base URL at runtime.\n * Primarily used by presets that rely on a user-supplied URL\n * (Azure deployment, self-hosted LiteLLM, Ollama on a custom port).\n * The first non-empty value wins and overrides {@link baseURL}.\n */\n baseURLEnvKeys?: readonly string[]\n /**\n * True when this gateway's model ids are `vendor/model` strings (OpenRouter,\n * Requesty, LiteLLM). Surfaced on the resolved `LlmProvider` so the model\n * factory suppresses intra-tier slash provider-splitting for a configured\n * selection of this provider. Omit for non-gateway backends (openai,\n * deepinfra, groq, \u2026).\n */\n usesVendorPrefixedModelIds?: boolean\n}\n\nfunction readFirstNonEmpty(\n env: EnvLookup,\n keys: readonly string[],\n): string | null {\n for (const key of keys) {\n const value = env[key]\n if (typeof value === 'string') {\n const trimmed = value.trim()\n if (trimmed.length > 0) return trimmed\n }\n }\n return null\n}\n\n/**\n * Builds a `LlmProvider` instance bound to a specific OpenAI-compatible\n * preset. The returned object is stateless and can be registered directly\n * with `llmProviderRegistry.register(...)`.\n */\nexport function createOpenAICompatibleProvider(\n preset: OpenAICompatiblePreset,\n): LlmProvider {\n if (!preset.id || preset.id.length === 0) {\n throw new Error('[OpenAIAdapter] Preset must have a non-empty id')\n }\n if (!preset.envKeys || preset.envKeys.length === 0) {\n throw new Error(\n `[OpenAIAdapter] Preset \"${preset.id}\" must declare at least one env key`,\n )\n }\n\n function resolveApiKey(env?: EnvLookup): string | null {\n return readFirstNonEmpty(env ?? process.env, preset.envKeys)\n }\n\n function resolveBaseURL(env?: EnvLookup): string | undefined {\n const lookup = env ?? process.env\n if (preset.baseURLEnvKeys && preset.baseURLEnvKeys.length > 0) {\n const override = readFirstNonEmpty(lookup, preset.baseURLEnvKeys)\n if (override) return override\n }\n return preset.baseURL\n }\n\n
|
|
5
|
-
"mappings": "AAmBA,SAAS,oBAAoB;
|
|
4
|
+
"sourcesContent": ["/**\n * OpenAIAdapter \u2014 implements the LlmProvider port for the OpenAI\n * chat-completions protocol.\n *\n * This single adapter serves OpenAI itself and every OpenAI-compatible\n * backend (DeepInfra, Groq, Together, Fireworks, Azure OpenAI, LiteLLM,\n * Ollama, LocalAI, vLLM, \u2026). Vendor-specific configuration \u2014 endpoint\n * URL, available models, env var conventions \u2014 lives in\n * `./openai-compatible-presets.ts` as plain data, not code.\n *\n * The factory {@link createOpenAICompatibleProvider} takes a preset and\n * returns a fully-configured `LlmProvider` that internally calls\n * `createOpenAI({ apiKey, baseURL })` from `@ai-sdk/openai`.\n *\n * @see packages/shared/src/lib/ai/llm-provider.ts\n * @see ./openai-compatible-presets.ts\n * @see .ai/specs/implemented/2026-04-14-llm-provider-ports-and-adapters.md\n */\n\nimport { createOpenAI } from '@ai-sdk/openai'\nimport type {\n EnvLookup,\n LlmCreateModelOptions,\n LlmModelInfo,\n LlmProvider,\n} from '@open-mercato/shared/lib/ai/llm-provider'\n\n/**\n * Configuration for a single OpenAI-compatible provider instance.\n *\n * Built-in presets live in {@link ./openai-compatible-presets.ts}.\n * Downstream applications may construct custom presets at bootstrap time\n * and register them with `llmProviderRegistry.register(\n * createOpenAICompatibleProvider(customPreset),\n * )`.\n */\nexport interface OpenAICompatiblePreset {\n /** Stable id (e.g. `openai`, `deepinfra`, `groq`, `together`). */\n id: string\n /** Human-readable display name. */\n name: string\n /**\n * Upstream base URL. Leave `undefined` to use the AI SDK default\n * (`https://api.openai.com/v1`). Required for DeepInfra, Groq, etc.\n */\n baseURL?: string\n /**\n * Env var names where the adapter looks for the API key, in priority\n * order. Each preset declares its own keys so unrelated presets never\n * accidentally share credentials (e.g. DeepInfra uses\n * `DEEPINFRA_API_KEY`, not `OPENAI_API_KEY`).\n */\n envKeys: readonly string[]\n /** Default model id used when the caller does not specify one. */\n defaultModel: string\n /** Curated model catalog shown in the UI dropdown. */\n defaultModels: readonly LlmModelInfo[]\n /**\n * Optional env var names for overriding the base URL at runtime.\n * Primarily used by presets that rely on a user-supplied URL\n * (Azure deployment, self-hosted LiteLLM, Ollama on a custom port).\n * The first non-empty value wins and overrides {@link baseURL}.\n */\n baseURLEnvKeys?: readonly string[]\n /**\n * When `true`, the provider exposes OpenAI's `/v1/moderations` endpoint and\n * the runtime may run input pre-moderation through it. Only the native\n * `openai` preset sets this \u2014 OpenAI-compatible backends (Groq, DeepInfra,\n * Together, \u2026) do not implement the moderations endpoint, so they leave it\n * unset and rely on their own server-side filtering.\n */\n supportsInputModeration?: boolean\n /**\n * When `true`, the provider maps the runtime end-user safety identifier into\n * OpenAI's per-request `safetyIdentifier` provider option. Only the native\n * `openai` preset sets this \u2014 `safety_identifier` is an OpenAI-specific body\n * field, and OpenAI-compatible/self-hosted backends (Groq, DeepInfra, Azure,\n * Ollama, LM Studio, \u2026) may reject the unknown parameter, so they leave it\n * unset and send no identifier (matching pre-feature behavior).\n */\n supportsEndUserSafetyIdentifier?: boolean\n /**\n * True when this gateway's model ids are `vendor/model` strings (OpenRouter,\n * Requesty, LiteLLM). Surfaced on the resolved `LlmProvider` so the model\n * factory suppresses intra-tier slash provider-splitting for a configured\n * selection of this provider. Omit for non-gateway backends (openai,\n * deepinfra, groq, \u2026).\n */\n usesVendorPrefixedModelIds?: boolean\n}\n\nfunction readFirstNonEmpty(\n env: EnvLookup,\n keys: readonly string[],\n): string | null {\n for (const key of keys) {\n const value = env[key]\n if (typeof value === 'string') {\n const trimmed = value.trim()\n if (trimmed.length > 0) return trimmed\n }\n }\n return null\n}\n\n/**\n * Builds a `LlmProvider` instance bound to a specific OpenAI-compatible\n * preset. The returned object is stateless and can be registered directly\n * with `llmProviderRegistry.register(...)`.\n */\nexport function createOpenAICompatibleProvider(\n preset: OpenAICompatiblePreset,\n): LlmProvider {\n if (!preset.id || preset.id.length === 0) {\n throw new Error('[OpenAIAdapter] Preset must have a non-empty id')\n }\n if (!preset.envKeys || preset.envKeys.length === 0) {\n throw new Error(\n `[OpenAIAdapter] Preset \"${preset.id}\" must declare at least one env key`,\n )\n }\n\n function resolveApiKey(env?: EnvLookup): string | null {\n return readFirstNonEmpty(env ?? process.env, preset.envKeys)\n }\n\n function resolveBaseURL(env?: EnvLookup): string | undefined {\n const lookup = env ?? process.env\n if (preset.baseURLEnvKeys && preset.baseURLEnvKeys.length > 0) {\n const override = readFirstNonEmpty(lookup, preset.baseURLEnvKeys)\n if (override) return override\n }\n return preset.baseURL\n }\n\n const provider: LlmProvider = {\n id: preset.id,\n name: preset.name,\n envKeys: preset.envKeys,\n defaultModel: preset.defaultModel,\n defaultModels: preset.defaultModels,\n supportsInputModeration: preset.supportsInputModeration ?? false,\n usesVendorPrefixedModelIds: preset.usesVendorPrefixedModelIds ?? false,\n\n isConfigured(env?: EnvLookup): boolean {\n return resolveApiKey(env) !== null\n },\n\n resolveApiKey,\n\n getConfiguredEnvKey(env?: EnvLookup): string {\n const lookup = env ?? process.env\n for (const key of preset.envKeys) {\n const value = lookup[key]\n if (typeof value === 'string' && value.trim().length > 0) {\n return key\n }\n }\n return preset.envKeys[0]\n },\n\n createModel(options: LlmCreateModelOptions): unknown {\n // Per-request baseURL override wins over preset/env defaults.\n const baseURL = options.baseURL ?? resolveBaseURL()\n const openai = createOpenAI({\n apiKey: options.apiKey,\n ...(baseURL ? { baseURL } : {}),\n })\n return openai(options.modelId)\n },\n }\n\n // Only the native OpenAI preset maps the end-user safety identifier. The key\n // MUST be the AI SDK provider-option name `safetyIdentifier` (camelCase) \u2014 the\n // SDK translates it to the `safety_identifier` request-body field and strips\n // any unknown providerOptions keys, so a snake_case key would be silently\n // dropped and never reach OpenAI.\n if (preset.supportsEndUserSafetyIdentifier) {\n provider.mapEndUserIdentifier = (identifier: string): Record<string, unknown> => ({\n openai: { safetyIdentifier: identifier },\n })\n }\n\n return provider\n}\n"],
|
|
5
|
+
"mappings": "AAmBA,SAAS,oBAAoB;AAwE7B,SAAS,kBACP,KACA,MACe;AACf,aAAW,OAAO,MAAM;AACtB,UAAM,QAAQ,IAAI,GAAG;AACrB,QAAI,OAAO,UAAU,UAAU;AAC7B,YAAM,UAAU,MAAM,KAAK;AAC3B,UAAI,QAAQ,SAAS,EAAG,QAAO;AAAA,IACjC;AAAA,EACF;AACA,SAAO;AACT;AAOO,SAAS,+BACd,QACa;AACb,MAAI,CAAC,OAAO,MAAM,OAAO,GAAG,WAAW,GAAG;AACxC,UAAM,IAAI,MAAM,iDAAiD;AAAA,EACnE;AACA,MAAI,CAAC,OAAO,WAAW,OAAO,QAAQ,WAAW,GAAG;AAClD,UAAM,IAAI;AAAA,MACR,2BAA2B,OAAO,EAAE;AAAA,IACtC;AAAA,EACF;AAEA,WAAS,cAAc,KAAgC;AACrD,WAAO,kBAAkB,OAAO,QAAQ,KAAK,OAAO,OAAO;AAAA,EAC7D;AAEA,WAAS,eAAe,KAAqC;AAC3D,UAAM,SAAS,OAAO,QAAQ;AAC9B,QAAI,OAAO,kBAAkB,OAAO,eAAe,SAAS,GAAG;AAC7D,YAAM,WAAW,kBAAkB,QAAQ,OAAO,cAAc;AAChE,UAAI,SAAU,QAAO;AAAA,IACvB;AACA,WAAO,OAAO;AAAA,EAChB;AAEA,QAAM,WAAwB;AAAA,IAC5B,IAAI,OAAO;AAAA,IACX,MAAM,OAAO;AAAA,IACb,SAAS,OAAO;AAAA,IAChB,cAAc,OAAO;AAAA,IACrB,eAAe,OAAO;AAAA,IACtB,yBAAyB,OAAO,2BAA2B;AAAA,IAC3D,4BAA4B,OAAO,8BAA8B;AAAA,IAEjE,aAAa,KAA0B;AACrC,aAAO,cAAc,GAAG,MAAM;AAAA,IAChC;AAAA,IAEA;AAAA,IAEA,oBAAoB,KAAyB;AAC3C,YAAM,SAAS,OAAO,QAAQ;AAC9B,iBAAW,OAAO,OAAO,SAAS;AAChC,cAAM,QAAQ,OAAO,GAAG;AACxB,YAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,GAAG;AACxD,iBAAO;AAAA,QACT;AAAA,MACF;AACA,aAAO,OAAO,QAAQ,CAAC;AAAA,IACzB;AAAA,IAEA,YAAY,SAAyC;AAEnD,YAAM,UAAU,QAAQ,WAAW,eAAe;AAClD,YAAM,SAAS,aAAa;AAAA,QAC1B,QAAQ,QAAQ;AAAA,QAChB,GAAI,UAAU,EAAE,QAAQ,IAAI,CAAC;AAAA,MAC/B,CAAC;AACD,aAAO,OAAO,QAAQ,OAAO;AAAA,IAC/B;AAAA,EACF;AAOA,MAAI,OAAO,iCAAiC;AAC1C,aAAS,uBAAuB,CAAC,gBAAiD;AAAA,MAChF,QAAQ,EAAE,kBAAkB,WAAW;AAAA,IACzC;AAAA,EACF;AAEA,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -242,17 +242,22 @@ function createModelFactory(_container, deps = {}) {
|
|
|
242
242
|
`LLM provider "${finalProvider.id}" is advertised as configured but resolveApiKey() returned empty.`
|
|
243
243
|
);
|
|
244
244
|
}
|
|
245
|
+
const endUserIdentifier = normalizeOverride(input.endUserIdentifier);
|
|
246
|
+
const providerOptions = endUserIdentifier && finalProvider.mapEndUserIdentifier ? finalProvider.mapEndUserIdentifier(endUserIdentifier) : void 0;
|
|
245
247
|
const model = finalProvider.createModel({
|
|
246
248
|
modelId: finalModelId,
|
|
247
249
|
apiKey: finalApiKey,
|
|
248
|
-
baseURL: resolvedBaseURL
|
|
250
|
+
baseURL: resolvedBaseURL,
|
|
251
|
+
...endUserIdentifier ? { endUserIdentifier } : {}
|
|
249
252
|
});
|
|
250
253
|
return {
|
|
251
254
|
model,
|
|
252
255
|
modelId: finalModelId,
|
|
253
256
|
providerId: finalProvider.id,
|
|
254
257
|
source: finalSource,
|
|
258
|
+
supportsInputModeration: finalProvider.supportsInputModeration === true,
|
|
255
259
|
...resolvedBaseURL !== void 0 ? { baseURL: resolvedBaseURL } : {},
|
|
260
|
+
...providerOptions !== void 0 ? { providerOptions } : {},
|
|
256
261
|
...allowlistResult.fallback ? {
|
|
257
262
|
allowlistFallback: {
|
|
258
263
|
originalProviderId: provider.id,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/ai_assistant/lib/model-factory.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Shared AI model factory.\n *\n * Consolidates the previously-per-module model-creation plumbing (inbox_ops's\n * `llmProvider.ts`, the agent-runtime's inline `resolveAgentModel`) behind a\n * single DI-friendly port. Every AI-runtime caller (chat, object, inbox-ops\n * extraction, future agents) resolves the `LanguageModelV1` it hands to the\n * Vercel AI SDK through `createModelFactory(container).resolveModel(...)` so\n * all of them share one resolution order:\n *\n * 1. `callerOverride` (non-empty string) \u2014 highest precedence, e.g. the\n * `modelOverride` field on `runAiAgentText`/`runAiAgentObject`.\n * Accepts a slash-qualified `<provider>/<model>` shorthand (Phase 1).\n * 2. Env variable `OM_AI_<MODULE>_MODEL` (uppercased `moduleId`) when\n * `moduleId` is provided. Example:\n * `OM_AI_INBOX_OPS_MODEL=claude-haiku-4-5`,\n * `OM_AI_CATALOG_MODEL=gpt-4o-mini`. The legacy\n * `<MODULE>_AI_MODEL` form (e.g. `INBOX_OPS_AI_MODEL`) is read as a\n * backward-compatibility fallback when the canonical name is unset.\n * Accepts a slash-qualified shorthand (Phase 1).\n * 3. `agentDefaultModel` \u2014 typically `AiAgentDefinition.defaultModel`.\n * Accepts a slash-qualified `<provider>/<model>` shorthand (Phase 1).\n * 4. Global env `OM_AI_MODEL` (canonical) with `OPENCODE_MODEL` kept as\n * a backward-compatibility fallback. Accepts either a plain model id\n * (`gpt-5-mini`) or a slash-qualified id (`openai/gpt-5-mini`).\n * Slash qualifiers consume the provider axis at the same step. When the\n * selected model source carries a provider hint, the provider/model pair\n * is atomic: an unconfigured hinted provider fails instead of sending\n * the model id to a different configured provider.\n * 5. The configured provider's own default model id\n * (`provider.defaultModel`).\n *\n * Every model-axis source is parsed through {@link parseSlashShorthand}.\n * Resolution walks the chain top-down and takes the first non-null hint as\n * the registry-walk seed. If the winning model source also supplies the\n * winning provider hint \u2014 either through `<provider>/<model>` or through the\n * same-source provider field/env var \u2014 that pair is resolved exactly and\n * never mixed with a fallback provider.\n *\n * Provider-axis seed order (highest priority first):\n * 1. Slash-prefix from `callerOverride` (Phase 1).\n * 2. `providerOverride` \u2014 request-time provider override (Phase 1).\n * 3. Slash-prefix from `OM_AI_<MODULE>_MODEL` (legacy `<MODULE>_AI_MODEL`) (Phase 1).\n * 4. `OM_AI_<MODULE>_PROVIDER` env (legacy `<MODULE>_AI_PROVIDER`) (Phase 1).\n * 5. Slash-prefix from `agentDefaultModel` (Phase 1).\n * 6. `agentDefaultProvider` \u2014 `AiAgentDefinition.defaultProvider` (Phase 1).\n * 7. Slash-prefix from `OM_AI_MODEL` (legacy `OPENCODE_MODEL`) (Phase 0).\n * 8. `OM_AI_PROVIDER` (legacy `OPENCODE_PROVIDER`) (Phase 0).\n *\n * The `OM_AI_*` env knobs are canonical; the legacy `OPENCODE_PROVIDER` /\n * `OPENCODE_MODEL` envs stay bound to the OpenCode Code Mode stack and are\n * also honored as backward-compatibility fallbacks here.\n *\n * The factory throws {@link AiModelFactoryError} when no provider is\n * configured \u2014 every current call site already expects the throw (see the\n * bare `throw new Error('No LLM provider is configured...')` in\n * `agent-runtime.ts` prior to the consolidation).\n *\n * @see packages/shared/src/lib/ai/llm-provider-registry.ts\n * @see packages/ai-assistant/src/modules/ai_assistant/lib/agent-runtime.ts\n * @see packages/core/src/modules/inbox_ops/lib/llmProvider.ts\n */\n\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { AwilixContainer } from 'awilix'\nimport type { EnvLookup, LlmProvider } from '@open-mercato/shared/lib/ai/llm-provider'\nimport { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'\nimport {\n intersectAllowlists,\n canonicalProviderId,\n isModelAllowedForProviderInEffective,\n isProviderAllowedInEffective,\n providerIdAliases,\n type EffectiveAllowlist,\n type TenantAllowlistSnapshot,\n} from './model-allowlist'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * Minimal AI SDK LanguageModel shape \u2014 the factory exposes the protocol-\n * agnostic `unknown`-typed return from {@link LlmProvider.createModel} under a\n * dedicated alias so callers can document intent without importing the AI SDK\n * here. Call sites that hand the result to `generateText` / `streamText` /\n * `generateObject` / `streamObject` continue to cast to the SDK's\n * `LanguageModelV1` / `LanguageModel` union exactly as they already do.\n */\nexport type AiModelInstance = unknown\n\n/**\n * Input accepted by {@link AiModelFactory.resolveModel}. All fields are\n * optional \u2014 passing an empty input resolves the provider default.\n */\nexport interface AiModelFactoryInput {\n /**\n * Owning module id (matches `Module.id`). When set, the factory checks\n * `OM_AI_<MODULE>_MODEL` (uppercased) as the env-override source, with\n * the legacy `<MODULE>_AI_MODEL` form honored as a backward-compatibility\n * fallback. Example: `moduleId: 'inbox_ops'` \u2192 canonical env var\n * `OM_AI_INBOX_OPS_MODEL` (legacy `INBOX_OPS_AI_MODEL`).\n *\n * Also enables the `OM_AI_<MODULE>_PROVIDER` env axis (legacy\n * `<MODULE>_AI_PROVIDER` honored as a backward-compatibility fallback).\n */\n moduleId?: string\n /**\n * Agent-level default, typically `AiAgentDefinition.defaultModel`. Used\n * when neither `callerOverride` nor the module env override is present.\n * Accepts a slash-qualified `<provider>/<model>` shorthand (Phase 1).\n */\n agentDefaultModel?: string\n /**\n * Agent-level default provider, typically `AiAgentDefinition.defaultProvider`.\n * Named provider id. When paired with an agent default model, the pair is\n * resolved exactly and fails if the provider is unconfigured. When used as\n * a provider preference without an agent default model, it can fall through\n * to the next configured provider. Sits between `OM_AI_<MODULE>_PROVIDER`\n * and the global `OM_AI_PROVIDER` in the provider-axis seed list above.\n *\n * Phase 1 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n agentDefaultProvider?: string\n /**\n * Per-call override (e.g. `runAiAgentText({ modelOverride })`). Wins over\n * every other source when it is a non-empty trimmed string. Empty strings\n * are treated as \"no override\" so the next source in the chain wins \u2014\n * callers MUST NOT need a separate \"clear override\" API.\n */\n callerOverride?: string\n /**\n * Request-time provider override \u2014 wins for the provider axis at the same\n * priority as `callerOverride` for the model axis. A non-empty string\n * that does not match any registered provider id is silently ignored and\n * the factory falls through to the next provider source.\n *\n * Phase 1 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n providerOverride?: string\n /**\n * Agent-level default base URL, typically `AiAgentDefinition.defaultBaseUrl`.\n * Sits between the `<MODULE>_AI_BASE_URL` env var and the preset's own\n * `baseURLEnvKeys` in the resolution chain.\n *\n * Phase 2 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n agentDefaultBaseUrl?: string\n /**\n * Per-call base URL override that wins over every other source. Intended\n * for programmatic callers only \u2014 the HTTP query-param baseUrl and the\n * AI_RUNTIME_BASEURL_ALLOWLIST arrive in Phase 4a.\n *\n * Phase 2 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n baseUrlOverride?: string\n /**\n * Per-tenant default loaded from `ai_agent_runtime_overrides` by the agent\n * runtime (best-effort, fail-open). Sits at step 3 of the resolution chain\n * between the caller/request override (step 1\u20132) and the module-env axis\n * (step 4).\n *\n * Honored ONLY when `allowRuntimeOverride !== false` on the agent definition\n * (checked via `resolveAllowRuntimeOverride`). The agent runtime is\n * responsible for hydration \u2014 the factory does NOT load the row itself.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n tenantOverride?: {\n providerId?: string | null\n modelId?: string | null\n baseURL?: string | null\n }\n /**\n * Per-request override forwarded from the HTTP dispatcher query params\n * (`?provider=`, `?model=`, `?baseUrl=`). Sits at step 1 of the resolution\n * chain \u2014 wins over everything else for that turn.\n *\n * Honored ONLY when `allowRuntimeOverride !== false` on the agent (checked\n * via `resolveAllowRuntimeOverride`). The dispatcher validates all three\n * values before setting this input.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n requestOverride?: {\n providerId?: string | null\n modelId?: string | null\n baseURL?: string | null\n }\n /**\n * When false, steps 1 (requestOverride) and 3 (tenantOverride) of the\n * resolution chain are skipped. Agents that pin a specific model for\n * correctness reasons set `AiAgentDefinition.allowRuntimeOverride = false`.\n * Default behavior (omitted) is permissive (= true).\n *\n * Canonical field (renamed from `allowRuntimeModelOverride` in Phase 4 of\n * spec `2026-04-28-ai-agents-agentic-loop-controls`). The deprecated alias\n * `allowRuntimeModelOverride` is still accepted via the resolution helper\n * {@link resolveAllowRuntimeOverride}.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n allowRuntimeOverride?: boolean\n /**\n * @deprecated Use `allowRuntimeOverride` instead.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n allowRuntimeModelOverride?: boolean\n /**\n * Optional tenant allowlist snapshot (Phase 1780-6). When supplied, the\n * factory clips the resolved (provider, model) to the intersection of the\n * env allowlist (`OM_AI_AVAILABLE_*`) and this tenant allowlist. Pass `null`\n * or omit to fall back to env-only enforcement.\n *\n * The settings PUT route validates writes against the env allowlist before\n * persisting, so the snapshot here is trusted to be a subset of env. The\n * factory still defends against drift (env tightened after write) by\n * intersecting at resolution time.\n */\n tenantAllowlist?: TenantAllowlistSnapshot | null\n}\n\n/**\n * Materialized output returned by {@link AiModelFactory.resolveModel}.\n */\nexport interface AiModelResolution {\n /**\n * Concrete AI SDK model instance ready to pass to\n * `generateText`/`streamText`/`generateObject`/`streamObject`. Typed as\n * {@link AiModelInstance} to avoid coupling this port to a specific SDK\n * major version.\n */\n model: AiModelInstance\n /** Resolved upstream model id (e.g. `claude-haiku-4-5-20251001`). */\n modelId: string\n /** Stable provider id from {@link LlmProvider.id}. */\n providerId: string\n /**\n * Which source won resolution. Useful for logs and tests; never exposed\n * as a public contract beyond these enum values.\n *\n * - `env_default` indicates `OM_AI_MODEL` (preferred) or the legacy\n * `OPENCODE_MODEL` fallback supplied the model id.\n */\n source:\n | 'request_override'\n | 'caller_override'\n | 'tenant_override'\n | 'module_env'\n | 'agent_default'\n | 'env_default'\n | 'provider_default'\n | 'allowlist_fallback'\n /**\n * Resolved base URL passed to the adapter (if any). Undefined when the\n * adapter will use its built-in default. Included for observability and\n * test assertions; never exposed over HTTP (Phase 4a adds the allowlist).\n */\n baseURL?: string\n /**\n * Populated when the env-driven OM_AI_AVAILABLE_PROVIDERS /\n * OM_AI_AVAILABLE_MODELS_<PROVIDER> allowlist rejected the originally\n * resolved (provider, model) and the factory fell back to a safe pair.\n * Includes the rejected ids and a human-readable reason so the UI / logs\n * can surface why the runtime did not honor the requested combination.\n */\n allowlistFallback?: {\n originalProviderId: string\n originalModelId: string\n reason: string\n }\n}\n\n/**\n * Port exposed by {@link createModelFactory}. Stateless \u2014 the factory\n * re-reads the registry + env on every `resolveModel` call so hot-reload\n * and test overrides work without needing factory re-creation.\n */\nexport interface AiModelFactory {\n resolveModel(input: AiModelFactoryInput): AiModelResolution\n}\n\n/**\n * Typed error thrown by the factory when it cannot materialize a model.\n *\n * `code` is a stable string union so downstream callers can branch without\n * parsing error messages. `AiModelFactoryError`s bubble through\n * `runAiAgentText`/`runAiAgentObject` unchanged \u2014 the agent runtime does\n * NOT catch them, matching the pre-consolidation behavior of the inline\n * resolver.\n */\nexport type AiModelFactoryErrorCode =\n | 'no_provider_configured'\n | 'api_key_missing'\n\nexport class AiModelFactoryError extends Error {\n readonly code: AiModelFactoryErrorCode\n\n constructor(code: AiModelFactoryErrorCode, message: string) {\n super(message)\n this.name = 'AiModelFactoryError'\n this.code = code\n }\n}\n\n/**\n * Subset of {@link import('@open-mercato/shared/lib/ai/llm-provider-registry').LlmProviderRegistry}\n * the factory consumes. Defined locally so test doubles only need to mock\n * the methods the factory actually calls.\n */\nexport interface AiModelFactoryRegistry {\n resolveFirstConfigured(options?: {\n env?: EnvLookup\n order?: readonly string[]\n }): LlmProvider | null\n /**\n * Optional registry lookup used by the slash-shorthand parser to validate\n * a provider hint. When absent, slash parsing is disabled and the entire\n * model token is treated as a model id (mirrors the pre-Phase-0\n * behavior).\n */\n get?(id: string): LlmProvider | null\n /**\n * Optional registry enumeration used by the Phase 1780-6 allowlist\n * intersection so the env model lists are pre-loaded for every provider\n * (and not just the resolved one). Test doubles MAY omit this \u2014 the\n * factory still defends correctly by also seeding the resolved provider's\n * id directly into `intersectAllowlists(...)`.\n */\n list?(): readonly LlmProvider[]\n}\n\n/**\n * Internal dependencies of the factory. Exposed for tests only; production\n * callers rely on the defaults wired by {@link createModelFactory}.\n */\nexport interface CreateModelFactoryDependencies {\n /**\n * Registry used to resolve the first configured provider. Defaults to the\n * singleton `llmProviderRegistry`. Implementations MAY honor the optional\n * `order` argument to prefer the operator-selected provider.\n */\n registry?: AiModelFactoryRegistry\n /** Env lookup for `OM_AI_<MODULE>_MODEL` + provider credentials. */\n env?: EnvLookup\n}\n\nfunction normalizeOverride(value: string | undefined): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : null\n}\n\n/**\n * Reads the operator-selected provider id from the unified env vars.\n * Returns `null` when neither `OM_AI_PROVIDER` nor the legacy\n * `OPENCODE_PROVIDER` resolves to a known provider \u2014 in that case the\n * registry falls back to its default registration walk.\n */\nfunction readGlobalProviderFromEnv(\n env: EnvLookup,\n registry: Pick<AiModelFactoryRegistry, 'get'>,\n): string | null {\n const candidates = [normalizeOverride(env.OM_AI_PROVIDER), normalizeOverride(env.OPENCODE_PROVIDER)]\n for (const candidate of candidates) {\n if (!candidate) continue\n if (!registry.get) return providerIdAliases(candidate)[0] ?? candidate\n for (const alias of providerIdAliases(candidate)) {\n if (registry.get(alias)) return alias\n }\n }\n return null\n}\n\n/**\n * Reads the global model hint from the unified env vars. `OM_AI_MODEL`\n * wins over the legacy `OPENCODE_MODEL`.\n */\nfunction readGlobalModelFromEnv(env: EnvLookup): string | null {\n return normalizeOverride(env.OM_AI_MODEL) ?? normalizeOverride(env.OPENCODE_MODEL)\n}\n\n/** Canonical per-module model env. Example: `OM_AI_INBOX_OPS_MODEL`. */\nfunction moduleModelEnvVarName(moduleId: string): string {\n return `OM_AI_${moduleId.toUpperCase()}_MODEL`\n}\n\n/**\n * Legacy per-module model env (pre-OM_AI_* rename). Example:\n * `INBOX_OPS_AI_MODEL`. Read as a backward-compatibility fallback only.\n */\nfunction legacyModuleModelEnvVarName(moduleId: string): string {\n return `${moduleId.toUpperCase()}_AI_MODEL`\n}\n\nfunction readModuleModelEnvOverride(env: EnvLookup, moduleId: string): string | null {\n return (\n normalizeOverride(env[moduleModelEnvVarName(moduleId)]) ??\n normalizeOverride(env[legacyModuleModelEnvVarName(moduleId)])\n )\n}\n\n/** Canonical per-module provider env. Example: `OM_AI_INBOX_OPS_PROVIDER`. */\nfunction moduleProviderEnvVarName(moduleId: string): string {\n return `OM_AI_${moduleId.toUpperCase()}_PROVIDER`\n}\n\n/**\n * Legacy per-module provider env (pre-OM_AI_* rename). Example:\n * `INBOX_OPS_AI_PROVIDER`. Read as a backward-compatibility fallback only.\n */\nfunction legacyModuleProviderEnvVarName(moduleId: string): string {\n return `${moduleId.toUpperCase()}_AI_PROVIDER`\n}\n\nfunction readModuleProviderEnvOverride(env: EnvLookup, moduleId: string): string | null {\n return (\n normalizeOverride(env[moduleProviderEnvVarName(moduleId)]) ??\n normalizeOverride(env[legacyModuleProviderEnvVarName(moduleId)])\n )\n}\n\nfunction normalizeProviderHint(\n providerId: string | null,\n registry: AiModelFactoryRegistry,\n): string | null {\n if (!providerId) return null\n const knownProviderIds = registry.list?.().map((provider) => provider.id) ?? []\n if (knownProviderIds.length > 0) {\n return canonicalProviderId(providerId, knownProviderIds)\n }\n return providerIdAliases(providerId)[0] ?? providerId\n}\n\nfunction resolveRequiredProvider(\n providerId: string,\n registry: AiModelFactoryRegistry,\n env: EnvLookup,\n): LlmProvider | null {\n const resolved = registry.resolveFirstConfigured({ env, order: [providerId] })\n if (resolved?.id === providerId) return resolved\n\n const direct = registry.get?.(providerId) ?? null\n if (direct) return direct.isConfigured(env) ? direct : null\n return null\n}\n\nfunction requiredProviderMessage(providerId: string, registry: AiModelFactoryRegistry, env: EnvLookup): string {\n const provider = registry.get?.(providerId) ?? null\n const envKey = provider?.getConfiguredEnvKey?.(env)\n const credentialHint = envKey\n ? ` Set ${envKey} to use this provider.`\n : ' Configure the matching provider API key to use this provider.'\n return `The resolved model is pinned to provider \"${providerId}\", but that provider is not configured.${credentialHint} The runtime refuses to send provider-specific model ids to a different provider.`\n}\n\nfunction moduleBaseUrlEnvVarName(moduleId: string): string {\n return `${moduleId.toUpperCase()}_AI_BASE_URL`\n}\n\n/**\n * Splits a slash-qualified model token (e.g. `openai/gpt-5-mini`) into\n * `{ providerHint, modelId }` when the prefix matches a registered provider\n * id, otherwise returns the entire token as the model id and a null hint.\n *\n * The registry-membership guard avoids mis-splitting model ids that already\n * contain slashes (DeepInfra: `meta-llama/Llama-3.3-70B-Instruct-Turbo`,\n * `zai-org/GLM-5.1`). When the registry does not expose `get`, slash\n * parsing is disabled \u2014 callers without a configured registry behave as if\n * the entire token were a plain model id.\n *\n * Exported for test coverage; production callers go through\n * {@link createModelFactory}.\n */\nexport function parseSlashShorthand(\n token: string,\n registry: Pick<AiModelFactoryRegistry, 'get'>,\n): { providerHint: string | null; modelId: string } {\n const slashIndex = token.indexOf('/')\n if (slashIndex < 0) return { providerHint: null, modelId: token }\n const before = token.slice(0, slashIndex)\n const after = token.slice(slashIndex + 1)\n if (!before || !after) return { providerHint: null, modelId: token }\n if (!registry.get) return { providerHint: null, modelId: token }\n const provider = registry.get(before)\n if (!provider) return { providerHint: null, modelId: token }\n return { providerHint: before, modelId: after }\n}\n\n/** Drops one leading `${providerId}/` segment from `token` when present. */\nfunction stripLeadingProviderPrefix(token: string, providerId: string): string {\n const prefix = `${providerId}/`\n return token.startsWith(prefix) ? token.slice(prefix.length) : token\n}\n\n/**\n * True when `providerId` names a registered provider that (a) is an\n * OpenAI-compatible gateway using `vendor/model` ids and (b) is configured in\n * `env`. Only a *configured* gateway suppresses slash-splitting \u2014 an\n * unconfigured gateway hint must not steal a model token from a native adapter\n * that could still serve it.\n */\nfunction isConfiguredVendorPrefixGateway(\n providerId: string | null,\n registry: Pick<AiModelFactoryRegistry, 'get'>,\n env: EnvLookup,\n): boolean {\n if (!providerId || !registry.get) return false\n const provider = registry.get(providerId)\n return provider?.usesVendorPrefixedModelIds === true && provider.isConfigured(env)\n}\n\n/**\n * Parses one resolution tier's model token, honoring a same-tier gateway\n * provider hint. When the tier explicitly selects a *configured* vendor-prefix\n * gateway, the token's leading `vendor/` is part of the gateway model id (not a\n * native-provider pin): the gateway becomes the provider hint and at most one\n * leading `${gateway}/` prefix is stripped (so the documented\n * `openrouter/anthropic/\u2026` workaround still resolves without doubling).\n * Otherwise it falls back to {@link parseSlashShorthand}.\n *\n * Exported for test coverage; production callers go through\n * {@link createModelFactory}.\n */\nexport function parseTierModel(\n token: string,\n tierProviderHint: string | null,\n registry: Pick<AiModelFactoryRegistry, 'get'>,\n env: EnvLookup,\n): { providerHint: string | null; modelId: string } {\n if (tierProviderHint && isConfiguredVendorPrefixGateway(tierProviderHint, registry, env)) {\n return {\n providerHint: tierProviderHint,\n modelId: stripLeadingProviderPrefix(token, tierProviderHint),\n }\n }\n return parseSlashShorthand(token, registry)\n}\n\n/**\n * Resolves the effective `allowRuntimeOverride` flag from an input that may\n * carry either the new canonical name (`allowRuntimeOverride`) or the\n * deprecated alias (`allowRuntimeModelOverride`). The canonical name wins\n * when both are present. Returns `true` (permissive) when neither is set.\n *\n * Exported for test coverage.\n */\nexport function resolveAllowRuntimeOverride(input: {\n allowRuntimeOverride?: boolean\n allowRuntimeModelOverride?: boolean\n}): boolean {\n if (input.allowRuntimeOverride !== undefined) return input.allowRuntimeOverride !== false\n if (input.allowRuntimeModelOverride !== undefined) return input.allowRuntimeModelOverride !== false\n return true\n}\n\n/**\n * Creates an {@link AiModelFactory} bound to the DI container. The container\n * reference is accepted for API symmetry with other runtime helpers (and so\n * future work can read provider overrides registered on the container); the\n * current implementation only needs the registry + env. No breaking change\n * when later implementations DO consult the container.\n */\nexport function createModelFactory(\n _container: AwilixContainer,\n deps: CreateModelFactoryDependencies = {},\n): AiModelFactory {\n const registry: AiModelFactoryRegistry = deps.registry ?? llmProviderRegistry\n const env = deps.env ?? process.env\n\n return {\n resolveModel(input: AiModelFactoryInput): AiModelResolution {\n const hasModule = typeof input.moduleId === 'string' && input.moduleId.length > 0\n // When allowRuntimeOverride (or its deprecated alias allowRuntimeModelOverride)\n // is explicitly false, skip steps 1 (requestOverride) and 3 (tenantOverride).\n const runtimeOverridesAllowed = resolveAllowRuntimeOverride(input)\n\n // --- Step 1: requestOverride (HTTP query params) \u2014 gated by flag ---\n const requestModelRaw = runtimeOverridesAllowed\n ? normalizeOverride(input.requestOverride?.modelId ?? undefined)\n : null\n const requestProviderRaw = runtimeOverridesAllowed\n ? normalizeOverride(input.requestOverride?.providerId ?? undefined)\n : null\n const requestBaseUrlRaw = runtimeOverridesAllowed\n ? normalizeOverride(input.requestOverride?.baseURL ?? undefined)\n : null\n\n // --- Step 2: callerOverride (programmatic) ---\n const callerRaw = normalizeOverride(input.callerOverride)\n\n // --- Step 3: tenantOverride (DB row) \u2014 gated by flag ---\n const tenantModelRaw = runtimeOverridesAllowed\n ? normalizeOverride(input.tenantOverride?.modelId ?? undefined)\n : null\n const tenantProviderRaw = runtimeOverridesAllowed\n ? normalizeOverride(input.tenantOverride?.providerId ?? undefined)\n : null\n const tenantBaseUrlRaw = runtimeOverridesAllowed\n ? normalizeOverride(input.tenantOverride?.baseURL ?? undefined)\n : null\n\n // --- Steps 4+: env / agent / global ---\n const moduleModelRaw = hasModule\n ? readModuleModelEnvOverride(env, input.moduleId!)\n : null\n const agentModelRaw = normalizeOverride(input.agentDefaultModel)\n // OM_AI_MODEL is canonical; the legacy OPENCODE_MODEL is read as a\n // backward-compatibility fallback through readGlobalModelFromEnv.\n const globalModelRaw = readGlobalModelFromEnv(env)\n\n const providerOverrideRaw = normalizeOverride(input.providerOverride)\n const moduleProviderRaw = hasModule\n ? readModuleProviderEnvOverride(env, input.moduleId!)\n : null\n const agentDefaultProviderRaw = normalizeOverride(input.agentDefaultProvider)\n // OM_AI_PROVIDER is canonical; the legacy OPENCODE_PROVIDER is read as\n // a backward-compatibility fallback through readGlobalProviderFromEnv.\n const globalProviderRaw = readGlobalProviderFromEnv(env, registry)\n\n const requestProviderHint = normalizeProviderHint(requestProviderRaw, registry)\n const providerOverrideHint = normalizeProviderHint(providerOverrideRaw, registry)\n const tenantProviderHint = normalizeProviderHint(tenantProviderRaw, registry)\n const moduleProviderHint = normalizeProviderHint(moduleProviderRaw, registry)\n const agentDefaultProviderHint = normalizeProviderHint(agentDefaultProviderRaw, registry)\n\n // Parse each model-axis source with its same-tier provider hint so a\n // configured vendor-prefix gateway (OpenRouter, Requesty, LiteLLM) keeps\n // its `vendor/model` id intact instead of pinning the native `vendor`.\n const requestModelParsed = requestModelRaw ? parseTierModel(requestModelRaw, requestProviderHint, registry, env) : null\n const callerParsed = callerRaw ? parseTierModel(callerRaw, providerOverrideHint, registry, env) : null\n const tenantModelParsed = tenantModelRaw ? parseTierModel(tenantModelRaw, tenantProviderHint, registry, env) : null\n const moduleModelParsed = moduleModelRaw ? parseTierModel(moduleModelRaw, moduleProviderHint, registry, env) : null\n const agentModelParsed = agentModelRaw ? parseTierModel(agentModelRaw, agentDefaultProviderHint, registry, env) : null\n const globalModelParsed = globalModelRaw ? parseTierModel(globalModelRaw, globalProviderRaw, registry, env) : null\n\n // Walk the provider-axis seed list: slash hint beats plain provider at\n // the same step. We keep only the first (highest-priority) non-null hint.\n const providerHintCandidates: Array<string | null> = [\n requestModelParsed?.providerHint ?? null,\n requestProviderHint,\n callerParsed?.providerHint ?? null,\n providerOverrideHint,\n tenantModelParsed?.providerHint ?? null,\n tenantProviderHint,\n moduleModelParsed?.providerHint ?? null,\n moduleProviderHint,\n agentModelParsed?.providerHint ?? null,\n agentDefaultProviderHint,\n globalModelParsed?.providerHint ?? null,\n globalProviderRaw,\n ]\n const orderHint = providerHintCandidates.find((hint) => hint !== null) ?? null\n const order = orderHint ? [orderHint] : undefined\n\n const pairPlainProviderIfWinning = (providerHint: string | null): string | null =>\n providerHint && providerHint === orderHint ? providerHint : null\n\n let modelId: string\n let source: AiModelResolution['source']\n let pairedProviderHint: string | null = null\n if (requestModelParsed) {\n modelId = requestModelParsed.modelId\n source = 'request_override'\n pairedProviderHint = requestModelParsed.providerHint ?? pairPlainProviderIfWinning(requestProviderHint)\n } else if (callerParsed) {\n modelId = callerParsed.modelId\n source = 'caller_override'\n pairedProviderHint = callerParsed.providerHint ?? pairPlainProviderIfWinning(providerOverrideHint)\n } else if (tenantModelParsed) {\n modelId = tenantModelParsed.modelId\n source = 'tenant_override'\n pairedProviderHint = tenantModelParsed.providerHint ?? pairPlainProviderIfWinning(tenantProviderHint)\n } else if (moduleModelParsed) {\n modelId = moduleModelParsed.modelId\n source = 'module_env'\n pairedProviderHint = moduleModelParsed.providerHint ?? pairPlainProviderIfWinning(moduleProviderHint)\n } else if (agentModelParsed) {\n modelId = agentModelParsed.modelId\n source = 'agent_default'\n pairedProviderHint = agentModelParsed.providerHint ?? pairPlainProviderIfWinning(agentDefaultProviderHint)\n } else if (globalModelParsed) {\n modelId = globalModelParsed.modelId\n source = 'env_default'\n pairedProviderHint = globalModelParsed.providerHint ?? pairPlainProviderIfWinning(globalProviderRaw)\n } else {\n modelId = ''\n source = 'provider_default'\n }\n\n // --- Provider-axis: walk from highest to lowest priority for the seed.\n // A slash-qualified hint from a model source wins over a plain provider\n // source at the same priority step. We walk top-down and take the first\n // non-null hint.\n const provider = pairedProviderHint\n ? resolveRequiredProvider(pairedProviderHint, registry, env)\n : registry.resolveFirstConfigured({ env, order })\n if (!provider) {\n if (pairedProviderHint) {\n throw new AiModelFactoryError(\n 'no_provider_configured',\n requiredProviderMessage(pairedProviderHint, registry, env),\n )\n }\n throw new AiModelFactoryError(\n 'no_provider_configured',\n '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.',\n )\n }\n const apiKey = provider.resolveApiKey(env)\n if (!apiKey) {\n throw new AiModelFactoryError(\n 'api_key_missing',\n `LLM provider \"${provider.id}\" is advertised as configured but resolveApiKey() returned empty.`,\n )\n }\n\n // --- Model-axis: use the post-parse model id from the winning source.\n if (source === 'provider_default') {\n modelId = provider.defaultModel\n }\n\n // --- BaseURL-axis resolution (highest to lowest priority) ---\n // 1. requestOverride.baseURL (HTTP dispatcher) \u2014 gated by allowRuntimeOverride\n // 2. baseUrlOverride (programmatic caller)\n // 3. tenantOverride.baseURL (DB row) \u2014 gated by allowRuntimeOverride\n // 4. <MODULE>_AI_BASE_URL env\n // 5. agentDefaultBaseUrl\n // Steps 6-7 (preset env + preset default) are handled inside the adapter's\n // createModel when no explicit baseURL is passed.\n const resolvedBaseURL = requestBaseUrlRaw\n ?? normalizeOverride(input.baseUrlOverride)\n ?? tenantBaseUrlRaw\n ?? (hasModule ? normalizeOverride(env[moduleBaseUrlEnvVarName(input.moduleId!)]) : null)\n ?? normalizeOverride(input.agentDefaultBaseUrl)\n ?? undefined\n\n // --- Allowlist enforcement (Phase 1780-5 + 1780-6) -------------------\n // OM_AI_AVAILABLE_PROVIDERS / OM_AI_AVAILABLE_MODELS_<PROVIDER> clip\n // the resolution to an operator-approved set. The optional tenant\n // allowlist snapshot narrows the env outer constraint further. If the\n // resolved pair isn't allowed, fall back to a safe (provider, model)\n // \u2014 never throw, so a stale tenant override or chat picker can't take\n // the runtime down. The fallback is logged so the operator can see\n // what happened.\n const registryProviderIds = registry.list?.()?.map((p) => p.id) ?? []\n const tenantProviderIds = input.tenantAllowlist\n ? Object.keys(input.tenantAllowlist.allowedModelsByProvider ?? {})\n : []\n const knownProviderIds = Array.from(\n new Set([provider.id, ...registryProviderIds, ...tenantProviderIds]),\n )\n const effectiveAllowlist = intersectAllowlists(\n env,\n knownProviderIds,\n input.tenantAllowlist ?? null,\n )\n const allowlistResult = enforceAllowlist({\n env,\n registry,\n resolved: { provider, modelId },\n agentDefaultProvider: agentDefaultProviderRaw,\n agentDefaultModel: agentModelParsed?.modelId ?? agentModelRaw,\n effective: effectiveAllowlist,\n })\n\n const finalProvider = allowlistResult.provider\n const finalModelId = allowlistResult.modelId\n const finalSource = allowlistResult.fallback ? 'allowlist_fallback' : source\n const finalApiKey = allowlistResult.fallback\n ? finalProvider.resolveApiKey(env)\n : apiKey\n if (!finalApiKey) {\n throw new AiModelFactoryError(\n 'api_key_missing',\n `LLM provider \"${finalProvider.id}\" is advertised as configured but resolveApiKey() returned empty.`,\n )\n }\n\n const model = finalProvider.createModel({\n modelId: finalModelId,\n apiKey: finalApiKey,\n baseURL: resolvedBaseURL,\n })\n return {\n model,\n modelId: finalModelId,\n providerId: finalProvider.id,\n source: finalSource,\n ...(resolvedBaseURL !== undefined ? { baseURL: resolvedBaseURL } : {}),\n ...(allowlistResult.fallback\n ? {\n allowlistFallback: {\n originalProviderId: provider.id,\n originalModelId: modelId,\n reason: allowlistResult.fallback,\n },\n }\n : {}),\n }\n },\n }\n}\n\ninterface EnforceAllowlistInput {\n env: EnvLookup\n registry: AiModelFactoryRegistry\n resolved: { provider: LlmProvider; modelId: string }\n agentDefaultProvider: string | null\n agentDefaultModel: string | null\n effective: EffectiveAllowlist\n}\n\ninterface EnforceAllowlistResult {\n provider: LlmProvider\n modelId: string\n /** Populated only when the resolved pair was rejected. */\n fallback: string | null\n}\n\n/**\n * Clips a resolved `(provider, model)` to what the effective allowlist\n * permits (env intersected with optional tenant allowlist).\n *\n * Order of fallback when the resolved provider is not allowed:\n * 1. The agent's `defaultProvider` (if allowed and configured).\n * 2. The first allowed provider that is also configured in the registry.\n *\n * Order of fallback when the model is not allowed for the resolved provider:\n * 1. The agent's `defaultModel` (if allowed for that provider).\n * 2. The provider's `defaultModel` (if allowed).\n * 3. The first model from the effective allowlist for that provider.\n *\n * Both fall-back paths emit a logger warning so the operator can see why the\n * runtime did not honor the requested combination. The function never throws.\n */\nfunction enforceAllowlist(input: EnforceAllowlistInput): EnforceAllowlistResult {\n const { registry, resolved, agentDefaultProvider, agentDefaultModel, effective } = input\n let provider = resolved.provider\n let modelId = resolved.modelId\n let fallback: string | null = null\n\n if (effective.providers !== null && !isProviderAllowedInEffective(effective, provider.id)) {\n const replacement = pickAllowedProvider({\n registry,\n agentDefaultProvider,\n effective,\n })\n if (replacement) {\n const source = effective.tenantOverridesActive\n ? 'the effective allowlist (env \u2229 tenant)'\n : 'OM_AI_AVAILABLE_PROVIDERS'\n fallback = `Provider \"${provider.id}\" is not in ${source}; using \"${replacement.id}\" instead.`\n logger.warn('Allowlist provider fallback', { reason: fallback })\n provider = replacement\n modelId = pickAllowedModel({\n provider,\n preferred: agentDefaultModel,\n effective,\n })\n }\n // If no replacement is configured we keep the resolved provider \u2014 the\n // throw at the api-key gate will surface the misconfiguration to the\n // operator instead of silently masking it.\n }\n\n if (!isModelAllowedForProviderInEffective(effective, provider.id, modelId)) {\n const replacementModel = pickAllowedModel({\n provider,\n preferred: agentDefaultModel,\n effective,\n })\n if (replacementModel !== modelId) {\n const source = effective.tenantOverridesActive\n ? `the effective allowlist (env \u2229 tenant) for \"${provider.id}\"`\n : `OM_AI_AVAILABLE_MODELS_${provider.id.toUpperCase()}`\n const reason = `Model \"${modelId}\" is not in ${source}; using \"${replacementModel}\" instead.`\n logger.warn('Allowlist model fallback', { reason })\n fallback = fallback ? `${fallback} ${reason}` : reason\n modelId = replacementModel\n }\n }\n\n return { provider, modelId, fallback }\n}\n\nfunction pickAllowedProvider(input: {\n registry: AiModelFactoryRegistry\n agentDefaultProvider: string | null\n effective: EffectiveAllowlist\n}): LlmProvider | null {\n const { registry, agentDefaultProvider, effective } = input\n if (agentDefaultProvider) {\n if (isProviderAllowedInEffective(effective, agentDefaultProvider)) {\n const provider = registry.get?.(agentDefaultProvider)\n if (provider && provider.isConfigured(process.env as EnvLookup)) return provider\n }\n }\n const allowed = effective.providers\n if (!allowed) return null\n for (const id of allowed) {\n const provider = registry.get?.(id)\n if (provider && provider.isConfigured(process.env as EnvLookup)) return provider\n }\n return null\n}\n\nfunction pickAllowedModel(input: {\n provider: LlmProvider\n preferred: string | null\n effective: EffectiveAllowlist\n}): string {\n const { provider, preferred, effective } = input\n const allowed = effective.modelsByProvider[provider.id]\n if (allowed === undefined) {\n return preferred && preferred.length > 0 ? preferred : provider.defaultModel\n }\n if (preferred && allowed.includes(preferred)) return preferred\n if (allowed.includes(provider.defaultModel)) return provider.defaultModel\n return allowed[0] ?? provider.defaultModel\n}\n"],
|
|
5
|
-
"mappings": "AA+DA,SAAS,oBAAoB;AAG7B,SAAS,2BAA2B;AACpC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAGK;AAEP,MAAM,SAAS,aAAa,cAAc;
|
|
4
|
+
"sourcesContent": ["/**\n * Shared AI model factory.\n *\n * Consolidates the previously-per-module model-creation plumbing (inbox_ops's\n * `llmProvider.ts`, the agent-runtime's inline `resolveAgentModel`) behind a\n * single DI-friendly port. Every AI-runtime caller (chat, object, inbox-ops\n * extraction, future agents) resolves the `LanguageModelV1` it hands to the\n * Vercel AI SDK through `createModelFactory(container).resolveModel(...)` so\n * all of them share one resolution order:\n *\n * 1. `callerOverride` (non-empty string) \u2014 highest precedence, e.g. the\n * `modelOverride` field on `runAiAgentText`/`runAiAgentObject`.\n * Accepts a slash-qualified `<provider>/<model>` shorthand (Phase 1).\n * 2. Env variable `OM_AI_<MODULE>_MODEL` (uppercased `moduleId`) when\n * `moduleId` is provided. Example:\n * `OM_AI_INBOX_OPS_MODEL=claude-haiku-4-5`,\n * `OM_AI_CATALOG_MODEL=gpt-4o-mini`. The legacy\n * `<MODULE>_AI_MODEL` form (e.g. `INBOX_OPS_AI_MODEL`) is read as a\n * backward-compatibility fallback when the canonical name is unset.\n * Accepts a slash-qualified shorthand (Phase 1).\n * 3. `agentDefaultModel` \u2014 typically `AiAgentDefinition.defaultModel`.\n * Accepts a slash-qualified `<provider>/<model>` shorthand (Phase 1).\n * 4. Global env `OM_AI_MODEL` (canonical) with `OPENCODE_MODEL` kept as\n * a backward-compatibility fallback. Accepts either a plain model id\n * (`gpt-5-mini`) or a slash-qualified id (`openai/gpt-5-mini`).\n * Slash qualifiers consume the provider axis at the same step. When the\n * selected model source carries a provider hint, the provider/model pair\n * is atomic: an unconfigured hinted provider fails instead of sending\n * the model id to a different configured provider.\n * 5. The configured provider's own default model id\n * (`provider.defaultModel`).\n *\n * Every model-axis source is parsed through {@link parseSlashShorthand}.\n * Resolution walks the chain top-down and takes the first non-null hint as\n * the registry-walk seed. If the winning model source also supplies the\n * winning provider hint \u2014 either through `<provider>/<model>` or through the\n * same-source provider field/env var \u2014 that pair is resolved exactly and\n * never mixed with a fallback provider.\n *\n * Provider-axis seed order (highest priority first):\n * 1. Slash-prefix from `callerOverride` (Phase 1).\n * 2. `providerOverride` \u2014 request-time provider override (Phase 1).\n * 3. Slash-prefix from `OM_AI_<MODULE>_MODEL` (legacy `<MODULE>_AI_MODEL`) (Phase 1).\n * 4. `OM_AI_<MODULE>_PROVIDER` env (legacy `<MODULE>_AI_PROVIDER`) (Phase 1).\n * 5. Slash-prefix from `agentDefaultModel` (Phase 1).\n * 6. `agentDefaultProvider` \u2014 `AiAgentDefinition.defaultProvider` (Phase 1).\n * 7. Slash-prefix from `OM_AI_MODEL` (legacy `OPENCODE_MODEL`) (Phase 0).\n * 8. `OM_AI_PROVIDER` (legacy `OPENCODE_PROVIDER`) (Phase 0).\n *\n * The `OM_AI_*` env knobs are canonical; the legacy `OPENCODE_PROVIDER` /\n * `OPENCODE_MODEL` envs stay bound to the OpenCode Code Mode stack and are\n * also honored as backward-compatibility fallbacks here.\n *\n * The factory throws {@link AiModelFactoryError} when no provider is\n * configured \u2014 every current call site already expects the throw (see the\n * bare `throw new Error('No LLM provider is configured...')` in\n * `agent-runtime.ts` prior to the consolidation).\n *\n * @see packages/shared/src/lib/ai/llm-provider-registry.ts\n * @see packages/ai-assistant/src/modules/ai_assistant/lib/agent-runtime.ts\n * @see packages/core/src/modules/inbox_ops/lib/llmProvider.ts\n */\n\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { AwilixContainer } from 'awilix'\nimport type { EnvLookup, LlmProvider } from '@open-mercato/shared/lib/ai/llm-provider'\nimport { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'\nimport {\n intersectAllowlists,\n canonicalProviderId,\n isModelAllowedForProviderInEffective,\n isProviderAllowedInEffective,\n providerIdAliases,\n type EffectiveAllowlist,\n type TenantAllowlistSnapshot,\n} from './model-allowlist'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * Minimal AI SDK LanguageModel shape \u2014 the factory exposes the protocol-\n * agnostic `unknown`-typed return from {@link LlmProvider.createModel} under a\n * dedicated alias so callers can document intent without importing the AI SDK\n * here. Call sites that hand the result to `generateText` / `streamText` /\n * `generateObject` / `streamObject` continue to cast to the SDK's\n * `LanguageModelV1` / `LanguageModel` union exactly as they already do.\n */\nexport type AiModelInstance = unknown\n\n/**\n * Input accepted by {@link AiModelFactory.resolveModel}. All fields are\n * optional \u2014 passing an empty input resolves the provider default.\n */\nexport interface AiModelFactoryInput {\n /**\n * Owning module id (matches `Module.id`). When set, the factory checks\n * `OM_AI_<MODULE>_MODEL` (uppercased) as the env-override source, with\n * the legacy `<MODULE>_AI_MODEL` form honored as a backward-compatibility\n * fallback. Example: `moduleId: 'inbox_ops'` \u2192 canonical env var\n * `OM_AI_INBOX_OPS_MODEL` (legacy `INBOX_OPS_AI_MODEL`).\n *\n * Also enables the `OM_AI_<MODULE>_PROVIDER` env axis (legacy\n * `<MODULE>_AI_PROVIDER` honored as a backward-compatibility fallback).\n */\n moduleId?: string\n /**\n * Agent-level default, typically `AiAgentDefinition.defaultModel`. Used\n * when neither `callerOverride` nor the module env override is present.\n * Accepts a slash-qualified `<provider>/<model>` shorthand (Phase 1).\n */\n agentDefaultModel?: string\n /**\n * Agent-level default provider, typically `AiAgentDefinition.defaultProvider`.\n * Named provider id. When paired with an agent default model, the pair is\n * resolved exactly and fails if the provider is unconfigured. When used as\n * a provider preference without an agent default model, it can fall through\n * to the next configured provider. Sits between `OM_AI_<MODULE>_PROVIDER`\n * and the global `OM_AI_PROVIDER` in the provider-axis seed list above.\n *\n * Phase 1 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n agentDefaultProvider?: string\n /**\n * Per-call override (e.g. `runAiAgentText({ modelOverride })`). Wins over\n * every other source when it is a non-empty trimmed string. Empty strings\n * are treated as \"no override\" so the next source in the chain wins \u2014\n * callers MUST NOT need a separate \"clear override\" API.\n */\n callerOverride?: string\n /**\n * Request-time provider override \u2014 wins for the provider axis at the same\n * priority as `callerOverride` for the model axis. A non-empty string\n * that does not match any registered provider id is silently ignored and\n * the factory falls through to the next provider source.\n *\n * Phase 1 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n providerOverride?: string\n /**\n * Agent-level default base URL, typically `AiAgentDefinition.defaultBaseUrl`.\n * Sits between the `<MODULE>_AI_BASE_URL` env var and the preset's own\n * `baseURLEnvKeys` in the resolution chain.\n *\n * Phase 2 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n agentDefaultBaseUrl?: string\n /**\n * Per-call base URL override that wins over every other source. Intended\n * for programmatic callers only \u2014 the HTTP query-param baseUrl and the\n * AI_RUNTIME_BASEURL_ALLOWLIST arrive in Phase 4a.\n *\n * Phase 2 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n baseUrlOverride?: string\n /**\n * Per-tenant default loaded from `ai_agent_runtime_overrides` by the agent\n * runtime (best-effort, fail-open). Sits at step 3 of the resolution chain\n * between the caller/request override (step 1\u20132) and the module-env axis\n * (step 4).\n *\n * Honored ONLY when `allowRuntimeOverride !== false` on the agent definition\n * (checked via `resolveAllowRuntimeOverride`). The agent runtime is\n * responsible for hydration \u2014 the factory does NOT load the row itself.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n tenantOverride?: {\n providerId?: string | null\n modelId?: string | null\n baseURL?: string | null\n }\n /**\n * Per-request override forwarded from the HTTP dispatcher query params\n * (`?provider=`, `?model=`, `?baseUrl=`). Sits at step 1 of the resolution\n * chain \u2014 wins over everything else for that turn.\n *\n * Honored ONLY when `allowRuntimeOverride !== false` on the agent (checked\n * via `resolveAllowRuntimeOverride`). The dispatcher validates all three\n * values before setting this input.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n requestOverride?: {\n providerId?: string | null\n modelId?: string | null\n baseURL?: string | null\n }\n /**\n * When false, steps 1 (requestOverride) and 3 (tenantOverride) of the\n * resolution chain are skipped. Agents that pin a specific model for\n * correctness reasons set `AiAgentDefinition.allowRuntimeOverride = false`.\n * Default behavior (omitted) is permissive (= true).\n *\n * Canonical field (renamed from `allowRuntimeModelOverride` in Phase 4 of\n * spec `2026-04-28-ai-agents-agentic-loop-controls`). The deprecated alias\n * `allowRuntimeModelOverride` is still accepted via the resolution helper\n * {@link resolveAllowRuntimeOverride}.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n allowRuntimeOverride?: boolean\n /**\n * @deprecated Use `allowRuntimeOverride` instead.\n *\n * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.\n */\n allowRuntimeModelOverride?: boolean\n /**\n * Optional tenant allowlist snapshot (Phase 1780-6). When supplied, the\n * factory clips the resolved (provider, model) to the intersection of the\n * env allowlist (`OM_AI_AVAILABLE_*`) and this tenant allowlist. Pass `null`\n * or omit to fall back to env-only enforcement.\n *\n * The settings PUT route validates writes against the env allowlist before\n * persisting, so the snapshot here is trusted to be a subset of env. The\n * factory still defends against drift (env tightened after write) by\n * intersecting at resolution time.\n */\n tenantAllowlist?: TenantAllowlistSnapshot | null\n /**\n * Optional opaque end-user identifier (tenant-salted HMAC). When set and the\n * resolved provider implements {@link LlmProvider.mapEndUserIdentifier}, the\n * factory returns the provider-specific `providerOptions` fragment on the\n * resolution so the runtime can attach it to the model call. No-op for\n * providers without a mapping or when no identifier is supplied.\n *\n * @see .ai/specs/2026-06-04-ai-input-moderation-and-safety-identifiers.md\n */\n endUserIdentifier?: string\n}\n\n/**\n * Materialized output returned by {@link AiModelFactory.resolveModel}.\n */\nexport interface AiModelResolution {\n /**\n * Concrete AI SDK model instance ready to pass to\n * `generateText`/`streamText`/`generateObject`/`streamObject`. Typed as\n * {@link AiModelInstance} to avoid coupling this port to a specific SDK\n * major version.\n */\n model: AiModelInstance\n /** Resolved upstream model id (e.g. `claude-haiku-4-5-20251001`). */\n modelId: string\n /** Stable provider id from {@link LlmProvider.id}. */\n providerId: string\n /**\n * Which source won resolution. Useful for logs and tests; never exposed\n * as a public contract beyond these enum values.\n *\n * - `env_default` indicates `OM_AI_MODEL` (preferred) or the legacy\n * `OPENCODE_MODEL` fallback supplied the model id.\n */\n source:\n | 'request_override'\n | 'caller_override'\n | 'tenant_override'\n | 'module_env'\n | 'agent_default'\n | 'env_default'\n | 'provider_default'\n | 'allowlist_fallback'\n /**\n * Resolved base URL passed to the adapter (if any). Undefined when the\n * adapter will use its built-in default. Included for observability and\n * test assertions; never exposed over HTTP (Phase 4a adds the allowlist).\n */\n baseURL?: string\n /**\n * Populated when the env-driven OM_AI_AVAILABLE_PROVIDERS /\n * OM_AI_AVAILABLE_MODELS_<PROVIDER> allowlist rejected the originally\n * resolved (provider, model) and the factory fell back to a safe pair.\n * Includes the rejected ids and a human-readable reason so the UI / logs\n * can surface why the runtime did not honor the requested combination.\n */\n allowlistFallback?: {\n originalProviderId: string\n originalModelId: string\n reason: string\n }\n /**\n * Provider-specific `providerOptions` fragment produced by mapping the input\n * {@link AiModelFactoryInput.endUserIdentifier} through the resolved\n * provider's {@link LlmProvider.mapEndUserIdentifier}. Undefined when no\n * identifier was supplied or the provider has no mapping. The runtime merges\n * this into the AI SDK `providerOptions` for the model call.\n */\n providerOptions?: Record<string, unknown>\n /**\n * Mirror of the resolved provider's {@link LlmProvider.supportsInputModeration}\n * capability flag. The runtime uses this to decide whether to run the input\n * pre-moderation gate before the model call.\n */\n supportsInputModeration?: boolean\n}\n\n/**\n * Port exposed by {@link createModelFactory}. Stateless \u2014 the factory\n * re-reads the registry + env on every `resolveModel` call so hot-reload\n * and test overrides work without needing factory re-creation.\n */\nexport interface AiModelFactory {\n resolveModel(input: AiModelFactoryInput): AiModelResolution\n}\n\n/**\n * Typed error thrown by the factory when it cannot materialize a model.\n *\n * `code` is a stable string union so downstream callers can branch without\n * parsing error messages. `AiModelFactoryError`s bubble through\n * `runAiAgentText`/`runAiAgentObject` unchanged \u2014 the agent runtime does\n * NOT catch them, matching the pre-consolidation behavior of the inline\n * resolver.\n */\nexport type AiModelFactoryErrorCode =\n | 'no_provider_configured'\n | 'api_key_missing'\n\nexport class AiModelFactoryError extends Error {\n readonly code: AiModelFactoryErrorCode\n\n constructor(code: AiModelFactoryErrorCode, message: string) {\n super(message)\n this.name = 'AiModelFactoryError'\n this.code = code\n }\n}\n\n/**\n * Subset of {@link import('@open-mercato/shared/lib/ai/llm-provider-registry').LlmProviderRegistry}\n * the factory consumes. Defined locally so test doubles only need to mock\n * the methods the factory actually calls.\n */\nexport interface AiModelFactoryRegistry {\n resolveFirstConfigured(options?: {\n env?: EnvLookup\n order?: readonly string[]\n }): LlmProvider | null\n /**\n * Optional registry lookup used by the slash-shorthand parser to validate\n * a provider hint. When absent, slash parsing is disabled and the entire\n * model token is treated as a model id (mirrors the pre-Phase-0\n * behavior).\n */\n get?(id: string): LlmProvider | null\n /**\n * Optional registry enumeration used by the Phase 1780-6 allowlist\n * intersection so the env model lists are pre-loaded for every provider\n * (and not just the resolved one). Test doubles MAY omit this \u2014 the\n * factory still defends correctly by also seeding the resolved provider's\n * id directly into `intersectAllowlists(...)`.\n */\n list?(): readonly LlmProvider[]\n}\n\n/**\n * Internal dependencies of the factory. Exposed for tests only; production\n * callers rely on the defaults wired by {@link createModelFactory}.\n */\nexport interface CreateModelFactoryDependencies {\n /**\n * Registry used to resolve the first configured provider. Defaults to the\n * singleton `llmProviderRegistry`. Implementations MAY honor the optional\n * `order` argument to prefer the operator-selected provider.\n */\n registry?: AiModelFactoryRegistry\n /** Env lookup for `OM_AI_<MODULE>_MODEL` + provider credentials. */\n env?: EnvLookup\n}\n\nfunction normalizeOverride(value: string | undefined): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : null\n}\n\n/**\n * Reads the operator-selected provider id from the unified env vars.\n * Returns `null` when neither `OM_AI_PROVIDER` nor the legacy\n * `OPENCODE_PROVIDER` resolves to a known provider \u2014 in that case the\n * registry falls back to its default registration walk.\n */\nfunction readGlobalProviderFromEnv(\n env: EnvLookup,\n registry: Pick<AiModelFactoryRegistry, 'get'>,\n): string | null {\n const candidates = [normalizeOverride(env.OM_AI_PROVIDER), normalizeOverride(env.OPENCODE_PROVIDER)]\n for (const candidate of candidates) {\n if (!candidate) continue\n if (!registry.get) return providerIdAliases(candidate)[0] ?? candidate\n for (const alias of providerIdAliases(candidate)) {\n if (registry.get(alias)) return alias\n }\n }\n return null\n}\n\n/**\n * Reads the global model hint from the unified env vars. `OM_AI_MODEL`\n * wins over the legacy `OPENCODE_MODEL`.\n */\nfunction readGlobalModelFromEnv(env: EnvLookup): string | null {\n return normalizeOverride(env.OM_AI_MODEL) ?? normalizeOverride(env.OPENCODE_MODEL)\n}\n\n/** Canonical per-module model env. Example: `OM_AI_INBOX_OPS_MODEL`. */\nfunction moduleModelEnvVarName(moduleId: string): string {\n return `OM_AI_${moduleId.toUpperCase()}_MODEL`\n}\n\n/**\n * Legacy per-module model env (pre-OM_AI_* rename). Example:\n * `INBOX_OPS_AI_MODEL`. Read as a backward-compatibility fallback only.\n */\nfunction legacyModuleModelEnvVarName(moduleId: string): string {\n return `${moduleId.toUpperCase()}_AI_MODEL`\n}\n\nfunction readModuleModelEnvOverride(env: EnvLookup, moduleId: string): string | null {\n return (\n normalizeOverride(env[moduleModelEnvVarName(moduleId)]) ??\n normalizeOverride(env[legacyModuleModelEnvVarName(moduleId)])\n )\n}\n\n/** Canonical per-module provider env. Example: `OM_AI_INBOX_OPS_PROVIDER`. */\nfunction moduleProviderEnvVarName(moduleId: string): string {\n return `OM_AI_${moduleId.toUpperCase()}_PROVIDER`\n}\n\n/**\n * Legacy per-module provider env (pre-OM_AI_* rename). Example:\n * `INBOX_OPS_AI_PROVIDER`. Read as a backward-compatibility fallback only.\n */\nfunction legacyModuleProviderEnvVarName(moduleId: string): string {\n return `${moduleId.toUpperCase()}_AI_PROVIDER`\n}\n\nfunction readModuleProviderEnvOverride(env: EnvLookup, moduleId: string): string | null {\n return (\n normalizeOverride(env[moduleProviderEnvVarName(moduleId)]) ??\n normalizeOverride(env[legacyModuleProviderEnvVarName(moduleId)])\n )\n}\n\nfunction normalizeProviderHint(\n providerId: string | null,\n registry: AiModelFactoryRegistry,\n): string | null {\n if (!providerId) return null\n const knownProviderIds = registry.list?.().map((provider) => provider.id) ?? []\n if (knownProviderIds.length > 0) {\n return canonicalProviderId(providerId, knownProviderIds)\n }\n return providerIdAliases(providerId)[0] ?? providerId\n}\n\nfunction resolveRequiredProvider(\n providerId: string,\n registry: AiModelFactoryRegistry,\n env: EnvLookup,\n): LlmProvider | null {\n const resolved = registry.resolveFirstConfigured({ env, order: [providerId] })\n if (resolved?.id === providerId) return resolved\n\n const direct = registry.get?.(providerId) ?? null\n if (direct) return direct.isConfigured(env) ? direct : null\n return null\n}\n\nfunction requiredProviderMessage(providerId: string, registry: AiModelFactoryRegistry, env: EnvLookup): string {\n const provider = registry.get?.(providerId) ?? null\n const envKey = provider?.getConfiguredEnvKey?.(env)\n const credentialHint = envKey\n ? ` Set ${envKey} to use this provider.`\n : ' Configure the matching provider API key to use this provider.'\n return `The resolved model is pinned to provider \"${providerId}\", but that provider is not configured.${credentialHint} The runtime refuses to send provider-specific model ids to a different provider.`\n}\n\nfunction moduleBaseUrlEnvVarName(moduleId: string): string {\n return `${moduleId.toUpperCase()}_AI_BASE_URL`\n}\n\n/**\n * Splits a slash-qualified model token (e.g. `openai/gpt-5-mini`) into\n * `{ providerHint, modelId }` when the prefix matches a registered provider\n * id, otherwise returns the entire token as the model id and a null hint.\n *\n * The registry-membership guard avoids mis-splitting model ids that already\n * contain slashes (DeepInfra: `meta-llama/Llama-3.3-70B-Instruct-Turbo`,\n * `zai-org/GLM-5.1`). When the registry does not expose `get`, slash\n * parsing is disabled \u2014 callers without a configured registry behave as if\n * the entire token were a plain model id.\n *\n * Exported for test coverage; production callers go through\n * {@link createModelFactory}.\n */\nexport function parseSlashShorthand(\n token: string,\n registry: Pick<AiModelFactoryRegistry, 'get'>,\n): { providerHint: string | null; modelId: string } {\n const slashIndex = token.indexOf('/')\n if (slashIndex < 0) return { providerHint: null, modelId: token }\n const before = token.slice(0, slashIndex)\n const after = token.slice(slashIndex + 1)\n if (!before || !after) return { providerHint: null, modelId: token }\n if (!registry.get) return { providerHint: null, modelId: token }\n const provider = registry.get(before)\n if (!provider) return { providerHint: null, modelId: token }\n return { providerHint: before, modelId: after }\n}\n\n/** Drops one leading `${providerId}/` segment from `token` when present. */\nfunction stripLeadingProviderPrefix(token: string, providerId: string): string {\n const prefix = `${providerId}/`\n return token.startsWith(prefix) ? token.slice(prefix.length) : token\n}\n\n/**\n * True when `providerId` names a registered provider that (a) is an\n * OpenAI-compatible gateway using `vendor/model` ids and (b) is configured in\n * `env`. Only a *configured* gateway suppresses slash-splitting \u2014 an\n * unconfigured gateway hint must not steal a model token from a native adapter\n * that could still serve it.\n */\nfunction isConfiguredVendorPrefixGateway(\n providerId: string | null,\n registry: Pick<AiModelFactoryRegistry, 'get'>,\n env: EnvLookup,\n): boolean {\n if (!providerId || !registry.get) return false\n const provider = registry.get(providerId)\n return provider?.usesVendorPrefixedModelIds === true && provider.isConfigured(env)\n}\n\n/**\n * Parses one resolution tier's model token, honoring a same-tier gateway\n * provider hint. When the tier explicitly selects a *configured* vendor-prefix\n * gateway, the token's leading `vendor/` is part of the gateway model id (not a\n * native-provider pin): the gateway becomes the provider hint and at most one\n * leading `${gateway}/` prefix is stripped (so the documented\n * `openrouter/anthropic/\u2026` workaround still resolves without doubling).\n * Otherwise it falls back to {@link parseSlashShorthand}.\n *\n * Exported for test coverage; production callers go through\n * {@link createModelFactory}.\n */\nexport function parseTierModel(\n token: string,\n tierProviderHint: string | null,\n registry: Pick<AiModelFactoryRegistry, 'get'>,\n env: EnvLookup,\n): { providerHint: string | null; modelId: string } {\n if (tierProviderHint && isConfiguredVendorPrefixGateway(tierProviderHint, registry, env)) {\n return {\n providerHint: tierProviderHint,\n modelId: stripLeadingProviderPrefix(token, tierProviderHint),\n }\n }\n return parseSlashShorthand(token, registry)\n}\n\n/**\n * Resolves the effective `allowRuntimeOverride` flag from an input that may\n * carry either the new canonical name (`allowRuntimeOverride`) or the\n * deprecated alias (`allowRuntimeModelOverride`). The canonical name wins\n * when both are present. Returns `true` (permissive) when neither is set.\n *\n * Exported for test coverage.\n */\nexport function resolveAllowRuntimeOverride(input: {\n allowRuntimeOverride?: boolean\n allowRuntimeModelOverride?: boolean\n}): boolean {\n if (input.allowRuntimeOverride !== undefined) return input.allowRuntimeOverride !== false\n if (input.allowRuntimeModelOverride !== undefined) return input.allowRuntimeModelOverride !== false\n return true\n}\n\n/**\n * Creates an {@link AiModelFactory} bound to the DI container. The container\n * reference is accepted for API symmetry with other runtime helpers (and so\n * future work can read provider overrides registered on the container); the\n * current implementation only needs the registry + env. No breaking change\n * when later implementations DO consult the container.\n */\nexport function createModelFactory(\n _container: AwilixContainer,\n deps: CreateModelFactoryDependencies = {},\n): AiModelFactory {\n const registry: AiModelFactoryRegistry = deps.registry ?? llmProviderRegistry\n const env = deps.env ?? process.env\n\n return {\n resolveModel(input: AiModelFactoryInput): AiModelResolution {\n const hasModule = typeof input.moduleId === 'string' && input.moduleId.length > 0\n // When allowRuntimeOverride (or its deprecated alias allowRuntimeModelOverride)\n // is explicitly false, skip steps 1 (requestOverride) and 3 (tenantOverride).\n const runtimeOverridesAllowed = resolveAllowRuntimeOverride(input)\n\n // --- Step 1: requestOverride (HTTP query params) \u2014 gated by flag ---\n const requestModelRaw = runtimeOverridesAllowed\n ? normalizeOverride(input.requestOverride?.modelId ?? undefined)\n : null\n const requestProviderRaw = runtimeOverridesAllowed\n ? normalizeOverride(input.requestOverride?.providerId ?? undefined)\n : null\n const requestBaseUrlRaw = runtimeOverridesAllowed\n ? normalizeOverride(input.requestOverride?.baseURL ?? undefined)\n : null\n\n // --- Step 2: callerOverride (programmatic) ---\n const callerRaw = normalizeOverride(input.callerOverride)\n\n // --- Step 3: tenantOverride (DB row) \u2014 gated by flag ---\n const tenantModelRaw = runtimeOverridesAllowed\n ? normalizeOverride(input.tenantOverride?.modelId ?? undefined)\n : null\n const tenantProviderRaw = runtimeOverridesAllowed\n ? normalizeOverride(input.tenantOverride?.providerId ?? undefined)\n : null\n const tenantBaseUrlRaw = runtimeOverridesAllowed\n ? normalizeOverride(input.tenantOverride?.baseURL ?? undefined)\n : null\n\n // --- Steps 4+: env / agent / global ---\n const moduleModelRaw = hasModule\n ? readModuleModelEnvOverride(env, input.moduleId!)\n : null\n const agentModelRaw = normalizeOverride(input.agentDefaultModel)\n // OM_AI_MODEL is canonical; the legacy OPENCODE_MODEL is read as a\n // backward-compatibility fallback through readGlobalModelFromEnv.\n const globalModelRaw = readGlobalModelFromEnv(env)\n\n const providerOverrideRaw = normalizeOverride(input.providerOverride)\n const moduleProviderRaw = hasModule\n ? readModuleProviderEnvOverride(env, input.moduleId!)\n : null\n const agentDefaultProviderRaw = normalizeOverride(input.agentDefaultProvider)\n // OM_AI_PROVIDER is canonical; the legacy OPENCODE_PROVIDER is read as\n // a backward-compatibility fallback through readGlobalProviderFromEnv.\n const globalProviderRaw = readGlobalProviderFromEnv(env, registry)\n\n const requestProviderHint = normalizeProviderHint(requestProviderRaw, registry)\n const providerOverrideHint = normalizeProviderHint(providerOverrideRaw, registry)\n const tenantProviderHint = normalizeProviderHint(tenantProviderRaw, registry)\n const moduleProviderHint = normalizeProviderHint(moduleProviderRaw, registry)\n const agentDefaultProviderHint = normalizeProviderHint(agentDefaultProviderRaw, registry)\n\n // Parse each model-axis source with its same-tier provider hint so a\n // configured vendor-prefix gateway (OpenRouter, Requesty, LiteLLM) keeps\n // its `vendor/model` id intact instead of pinning the native `vendor`.\n const requestModelParsed = requestModelRaw ? parseTierModel(requestModelRaw, requestProviderHint, registry, env) : null\n const callerParsed = callerRaw ? parseTierModel(callerRaw, providerOverrideHint, registry, env) : null\n const tenantModelParsed = tenantModelRaw ? parseTierModel(tenantModelRaw, tenantProviderHint, registry, env) : null\n const moduleModelParsed = moduleModelRaw ? parseTierModel(moduleModelRaw, moduleProviderHint, registry, env) : null\n const agentModelParsed = agentModelRaw ? parseTierModel(agentModelRaw, agentDefaultProviderHint, registry, env) : null\n const globalModelParsed = globalModelRaw ? parseTierModel(globalModelRaw, globalProviderRaw, registry, env) : null\n\n // Walk the provider-axis seed list: slash hint beats plain provider at\n // the same step. We keep only the first (highest-priority) non-null hint.\n const providerHintCandidates: Array<string | null> = [\n requestModelParsed?.providerHint ?? null,\n requestProviderHint,\n callerParsed?.providerHint ?? null,\n providerOverrideHint,\n tenantModelParsed?.providerHint ?? null,\n tenantProviderHint,\n moduleModelParsed?.providerHint ?? null,\n moduleProviderHint,\n agentModelParsed?.providerHint ?? null,\n agentDefaultProviderHint,\n globalModelParsed?.providerHint ?? null,\n globalProviderRaw,\n ]\n const orderHint = providerHintCandidates.find((hint) => hint !== null) ?? null\n const order = orderHint ? [orderHint] : undefined\n\n const pairPlainProviderIfWinning = (providerHint: string | null): string | null =>\n providerHint && providerHint === orderHint ? providerHint : null\n\n let modelId: string\n let source: AiModelResolution['source']\n let pairedProviderHint: string | null = null\n if (requestModelParsed) {\n modelId = requestModelParsed.modelId\n source = 'request_override'\n pairedProviderHint = requestModelParsed.providerHint ?? pairPlainProviderIfWinning(requestProviderHint)\n } else if (callerParsed) {\n modelId = callerParsed.modelId\n source = 'caller_override'\n pairedProviderHint = callerParsed.providerHint ?? pairPlainProviderIfWinning(providerOverrideHint)\n } else if (tenantModelParsed) {\n modelId = tenantModelParsed.modelId\n source = 'tenant_override'\n pairedProviderHint = tenantModelParsed.providerHint ?? pairPlainProviderIfWinning(tenantProviderHint)\n } else if (moduleModelParsed) {\n modelId = moduleModelParsed.modelId\n source = 'module_env'\n pairedProviderHint = moduleModelParsed.providerHint ?? pairPlainProviderIfWinning(moduleProviderHint)\n } else if (agentModelParsed) {\n modelId = agentModelParsed.modelId\n source = 'agent_default'\n pairedProviderHint = agentModelParsed.providerHint ?? pairPlainProviderIfWinning(agentDefaultProviderHint)\n } else if (globalModelParsed) {\n modelId = globalModelParsed.modelId\n source = 'env_default'\n pairedProviderHint = globalModelParsed.providerHint ?? pairPlainProviderIfWinning(globalProviderRaw)\n } else {\n modelId = ''\n source = 'provider_default'\n }\n\n // --- Provider-axis: walk from highest to lowest priority for the seed.\n // A slash-qualified hint from a model source wins over a plain provider\n // source at the same priority step. We walk top-down and take the first\n // non-null hint.\n const provider = pairedProviderHint\n ? resolveRequiredProvider(pairedProviderHint, registry, env)\n : registry.resolveFirstConfigured({ env, order })\n if (!provider) {\n if (pairedProviderHint) {\n throw new AiModelFactoryError(\n 'no_provider_configured',\n requiredProviderMessage(pairedProviderHint, registry, env),\n )\n }\n throw new AiModelFactoryError(\n 'no_provider_configured',\n '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.',\n )\n }\n const apiKey = provider.resolveApiKey(env)\n if (!apiKey) {\n throw new AiModelFactoryError(\n 'api_key_missing',\n `LLM provider \"${provider.id}\" is advertised as configured but resolveApiKey() returned empty.`,\n )\n }\n\n // --- Model-axis: use the post-parse model id from the winning source.\n if (source === 'provider_default') {\n modelId = provider.defaultModel\n }\n\n // --- BaseURL-axis resolution (highest to lowest priority) ---\n // 1. requestOverride.baseURL (HTTP dispatcher) \u2014 gated by allowRuntimeOverride\n // 2. baseUrlOverride (programmatic caller)\n // 3. tenantOverride.baseURL (DB row) \u2014 gated by allowRuntimeOverride\n // 4. <MODULE>_AI_BASE_URL env\n // 5. agentDefaultBaseUrl\n // Steps 6-7 (preset env + preset default) are handled inside the adapter's\n // createModel when no explicit baseURL is passed.\n const resolvedBaseURL = requestBaseUrlRaw\n ?? normalizeOverride(input.baseUrlOverride)\n ?? tenantBaseUrlRaw\n ?? (hasModule ? normalizeOverride(env[moduleBaseUrlEnvVarName(input.moduleId!)]) : null)\n ?? normalizeOverride(input.agentDefaultBaseUrl)\n ?? undefined\n\n // --- Allowlist enforcement (Phase 1780-5 + 1780-6) -------------------\n // OM_AI_AVAILABLE_PROVIDERS / OM_AI_AVAILABLE_MODELS_<PROVIDER> clip\n // the resolution to an operator-approved set. The optional tenant\n // allowlist snapshot narrows the env outer constraint further. If the\n // resolved pair isn't allowed, fall back to a safe (provider, model)\n // \u2014 never throw, so a stale tenant override or chat picker can't take\n // the runtime down. The fallback is logged so the operator can see\n // what happened.\n const registryProviderIds = registry.list?.()?.map((p) => p.id) ?? []\n const tenantProviderIds = input.tenantAllowlist\n ? Object.keys(input.tenantAllowlist.allowedModelsByProvider ?? {})\n : []\n const knownProviderIds = Array.from(\n new Set([provider.id, ...registryProviderIds, ...tenantProviderIds]),\n )\n const effectiveAllowlist = intersectAllowlists(\n env,\n knownProviderIds,\n input.tenantAllowlist ?? null,\n )\n const allowlistResult = enforceAllowlist({\n env,\n registry,\n resolved: { provider, modelId },\n agentDefaultProvider: agentDefaultProviderRaw,\n agentDefaultModel: agentModelParsed?.modelId ?? agentModelRaw,\n effective: effectiveAllowlist,\n })\n\n const finalProvider = allowlistResult.provider\n const finalModelId = allowlistResult.modelId\n const finalSource = allowlistResult.fallback ? 'allowlist_fallback' : source\n const finalApiKey = allowlistResult.fallback\n ? finalProvider.resolveApiKey(env)\n : apiKey\n if (!finalApiKey) {\n throw new AiModelFactoryError(\n 'api_key_missing',\n `LLM provider \"${finalProvider.id}\" is advertised as configured but resolveApiKey() returned empty.`,\n )\n }\n\n const endUserIdentifier = normalizeOverride(input.endUserIdentifier)\n const providerOptions =\n endUserIdentifier && finalProvider.mapEndUserIdentifier\n ? finalProvider.mapEndUserIdentifier(endUserIdentifier)\n : undefined\n\n const model = finalProvider.createModel({\n modelId: finalModelId,\n apiKey: finalApiKey,\n baseURL: resolvedBaseURL,\n ...(endUserIdentifier ? { endUserIdentifier } : {}),\n })\n return {\n model,\n modelId: finalModelId,\n providerId: finalProvider.id,\n source: finalSource,\n supportsInputModeration: finalProvider.supportsInputModeration === true,\n ...(resolvedBaseURL !== undefined ? { baseURL: resolvedBaseURL } : {}),\n ...(providerOptions !== undefined ? { providerOptions } : {}),\n ...(allowlistResult.fallback\n ? {\n allowlistFallback: {\n originalProviderId: provider.id,\n originalModelId: modelId,\n reason: allowlistResult.fallback,\n },\n }\n : {}),\n }\n },\n }\n}\n\ninterface EnforceAllowlistInput {\n env: EnvLookup\n registry: AiModelFactoryRegistry\n resolved: { provider: LlmProvider; modelId: string }\n agentDefaultProvider: string | null\n agentDefaultModel: string | null\n effective: EffectiveAllowlist\n}\n\ninterface EnforceAllowlistResult {\n provider: LlmProvider\n modelId: string\n /** Populated only when the resolved pair was rejected. */\n fallback: string | null\n}\n\n/**\n * Clips a resolved `(provider, model)` to what the effective allowlist\n * permits (env intersected with optional tenant allowlist).\n *\n * Order of fallback when the resolved provider is not allowed:\n * 1. The agent's `defaultProvider` (if allowed and configured).\n * 2. The first allowed provider that is also configured in the registry.\n *\n * Order of fallback when the model is not allowed for the resolved provider:\n * 1. The agent's `defaultModel` (if allowed for that provider).\n * 2. The provider's `defaultModel` (if allowed).\n * 3. The first model from the effective allowlist for that provider.\n *\n * Both fall-back paths emit a logger warning so the operator can see why the\n * runtime did not honor the requested combination. The function never throws.\n */\nfunction enforceAllowlist(input: EnforceAllowlistInput): EnforceAllowlistResult {\n const { registry, resolved, agentDefaultProvider, agentDefaultModel, effective } = input\n let provider = resolved.provider\n let modelId = resolved.modelId\n let fallback: string | null = null\n\n if (effective.providers !== null && !isProviderAllowedInEffective(effective, provider.id)) {\n const replacement = pickAllowedProvider({\n registry,\n agentDefaultProvider,\n effective,\n })\n if (replacement) {\n const source = effective.tenantOverridesActive\n ? 'the effective allowlist (env \u2229 tenant)'\n : 'OM_AI_AVAILABLE_PROVIDERS'\n fallback = `Provider \"${provider.id}\" is not in ${source}; using \"${replacement.id}\" instead.`\n logger.warn('Allowlist provider fallback', { reason: fallback })\n provider = replacement\n modelId = pickAllowedModel({\n provider,\n preferred: agentDefaultModel,\n effective,\n })\n }\n // If no replacement is configured we keep the resolved provider \u2014 the\n // throw at the api-key gate will surface the misconfiguration to the\n // operator instead of silently masking it.\n }\n\n if (!isModelAllowedForProviderInEffective(effective, provider.id, modelId)) {\n const replacementModel = pickAllowedModel({\n provider,\n preferred: agentDefaultModel,\n effective,\n })\n if (replacementModel !== modelId) {\n const source = effective.tenantOverridesActive\n ? `the effective allowlist (env \u2229 tenant) for \"${provider.id}\"`\n : `OM_AI_AVAILABLE_MODELS_${provider.id.toUpperCase()}`\n const reason = `Model \"${modelId}\" is not in ${source}; using \"${replacementModel}\" instead.`\n logger.warn('Allowlist model fallback', { reason })\n fallback = fallback ? `${fallback} ${reason}` : reason\n modelId = replacementModel\n }\n }\n\n return { provider, modelId, fallback }\n}\n\nfunction pickAllowedProvider(input: {\n registry: AiModelFactoryRegistry\n agentDefaultProvider: string | null\n effective: EffectiveAllowlist\n}): LlmProvider | null {\n const { registry, agentDefaultProvider, effective } = input\n if (agentDefaultProvider) {\n if (isProviderAllowedInEffective(effective, agentDefaultProvider)) {\n const provider = registry.get?.(agentDefaultProvider)\n if (provider && provider.isConfigured(process.env as EnvLookup)) return provider\n }\n }\n const allowed = effective.providers\n if (!allowed) return null\n for (const id of allowed) {\n const provider = registry.get?.(id)\n if (provider && provider.isConfigured(process.env as EnvLookup)) return provider\n }\n return null\n}\n\nfunction pickAllowedModel(input: {\n provider: LlmProvider\n preferred: string | null\n effective: EffectiveAllowlist\n}): string {\n const { provider, preferred, effective } = input\n const allowed = effective.modelsByProvider[provider.id]\n if (allowed === undefined) {\n return preferred && preferred.length > 0 ? preferred : provider.defaultModel\n }\n if (preferred && allowed.includes(preferred)) return preferred\n if (allowed.includes(provider.defaultModel)) return provider.defaultModel\n return allowed[0] ?? provider.defaultModel\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
2
|
+
import { AiModerationFlagRepository } from "../data/repositories/AiModerationFlagRepository.js";
|
|
3
|
+
import { emitAiAssistantEvent } from "../events.js";
|
|
4
|
+
const logger = createLogger("ai_assistant");
|
|
5
|
+
async function recordModerationFlag(input, container) {
|
|
6
|
+
if (!container) return;
|
|
7
|
+
if (!input.tenantId) return;
|
|
8
|
+
let createdId = null;
|
|
9
|
+
try {
|
|
10
|
+
const em = container.resolve("em");
|
|
11
|
+
const repo = new AiModerationFlagRepository(em.fork());
|
|
12
|
+
const flag = await repo.create({
|
|
13
|
+
tenantId: input.tenantId,
|
|
14
|
+
organizationId: input.organizationId,
|
|
15
|
+
agentId: input.agentId,
|
|
16
|
+
userId: input.userId,
|
|
17
|
+
providerId: input.providerId,
|
|
18
|
+
modelId: input.modelId,
|
|
19
|
+
categories: input.categories
|
|
20
|
+
});
|
|
21
|
+
createdId = flag.id;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
logger.error("Failed to persist moderation flag (rejection still applies)", { err: error });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const flaggedCategories = Object.entries(input.categories).filter(([, value]) => value.flagged).map(([name]) => name);
|
|
28
|
+
await emitAiAssistantEvent("ai_assistant.moderation_flag.created", {
|
|
29
|
+
id: createdId,
|
|
30
|
+
tenantId: input.tenantId,
|
|
31
|
+
organizationId: input.organizationId,
|
|
32
|
+
agentId: input.agentId,
|
|
33
|
+
userId: input.userId,
|
|
34
|
+
categories: flaggedCategories
|
|
35
|
+
});
|
|
36
|
+
} catch (error) {
|
|
37
|
+
logger.error("Failed to emit moderation_flag.created (audit row persisted)", { err: error });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export {
|
|
41
|
+
recordModerationFlag
|
|
42
|
+
};
|
|
43
|
+
//# sourceMappingURL=moderation-flag-recorder.js.map
|