diff --git a/Directory.Packages.props b/Directory.Packages.props index 0f3b354d..7c8446a4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,20 +4,20 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + @@ -27,10 +27,10 @@ - + - + @@ -59,6 +59,9 @@ + + + @@ -112,10 +115,12 @@ - - - - + + + + + + @@ -131,7 +136,7 @@ - + diff --git a/Makefile b/Makefile index 145096f2..ed49963b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-core build-demos prod-dry-run prod-dry-run-agent-server restore-core test test-prod-style test-framework-prod-first test-prime-time test-prime-time-full test-cross-platform test-portable test-multi-env test-all-platforms test-all-platforms-ephemeral ci-verify kernel-gate kernel-gate-tier-b kernel-gate-tier-c kernel-gate-tier-d kernel-gate-tier-e kernel-gate-full application-gate application-gate-tier-a application-gate-tier-b application-gate-tier-c application-gate-tier-d application-gate-full composition-mesh-gate composition-mesh-gate-tier-a composition-mesh-gate-tier-b composition-mesh-gate-tier-c composition-mesh-gate-tier-d composition-mesh-gate-full dependency-boundary-gate ship-gate ship-gate-tier-a ship-gate-tier-b ship-gate-tier-c ship-gate-tier-d ship-gate-full ops-gate ops-gate-tier-a ops-gate-tier-b ops-gate-tier-c ops-gate-tier-d ops-gate-tier-e ops-gate-full security-gate security-gate-tier-a security-gate-tier-b security-gate-tier-c security-gate-tier-d security-gate-tier-e security-gate-full rc-gate rc-gate-tier-a rc-gate-tier-b rc-gate-tier-c rc-gate-tier-d rc-gate-tier-e rc-gate-full perf-gate perf-gate-tier-a perf-gate-tier-b perf-gate-tier-c perf-gate-tier-d perf-gate-full compat-gate compat-gate-tier-a compat-gate-tier-b compat-gate-tier-c compat-gate-full dr-gate dr-gate-tier-a dr-gate-tier-b dr-gate-tier-c dr-gate-full waterproofing-gate-full nexo-ready-gate bootstrap-mesh-lab-env validate-safe review-summary clean-test-artifacts test-readiness-gate release-preflight release-gate release-dispatch release-staging verify-staging release-staging-and-verify verify-external-product-shape mesh-lab-e2e mesh-lab-e2e-workers mesh-lab-e2e-deep mesh-lab-e2e-stress mesh-lab-up mesh-lab-verify mesh-lab-verify-deep mesh-lab-verify-entitlements mesh-lab-verify-governance mesh-lab-verify-director-cli mesh-lab-verify-persistence mesh-lab-verify-network-negative mesh-lab-verify-post-stress mesh-lab-stress mesh-lab-down test-mesh-lab +.PHONY: build build-core build-demos prod-dry-run prod-dry-run-agent-server restore-core test test-prod-style test-framework-prod-first test-prime-time test-prime-time-full test-cross-platform test-portable test-multi-env test-all-platforms test-all-platforms-ephemeral ci-verify meai-pipeline-gate kernel-gate kernel-gate-tier-b kernel-gate-tier-c kernel-gate-tier-d kernel-gate-tier-e kernel-gate-full application-gate application-gate-tier-a application-gate-tier-b application-gate-tier-c application-gate-tier-d application-gate-full composition-mesh-gate composition-mesh-gate-tier-a composition-mesh-gate-tier-b composition-mesh-gate-tier-c composition-mesh-gate-tier-d composition-mesh-gate-full dependency-boundary-gate ship-gate ship-gate-tier-a ship-gate-tier-b ship-gate-tier-c ship-gate-tier-d ship-gate-full ops-gate ops-gate-tier-a ops-gate-tier-b ops-gate-tier-c ops-gate-tier-d ops-gate-tier-e ops-gate-full security-gate security-gate-tier-a security-gate-tier-b security-gate-tier-c security-gate-tier-d security-gate-tier-e security-gate-full rc-gate rc-gate-tier-a rc-gate-tier-b rc-gate-tier-c rc-gate-tier-d rc-gate-tier-e rc-gate-full perf-gate perf-gate-tier-a perf-gate-tier-b perf-gate-tier-c perf-gate-tier-d perf-gate-full compat-gate compat-gate-tier-a compat-gate-tier-b compat-gate-tier-c compat-gate-full dr-gate dr-gate-tier-a dr-gate-tier-b dr-gate-tier-c dr-gate-full waterproofing-gate-full nexo-ready-gate bootstrap-mesh-lab-env validate-safe review-summary clean-test-artifacts test-readiness-gate release-preflight release-gate release-dispatch release-staging verify-staging release-staging-and-verify verify-external-product-shape mesh-lab-e2e mesh-lab-e2e-workers mesh-lab-e2e-deep mesh-lab-e2e-stress mesh-lab-up mesh-lab-verify mesh-lab-verify-deep mesh-lab-verify-entitlements mesh-lab-verify-governance mesh-lab-verify-director-cli mesh-lab-verify-persistence mesh-lab-verify-network-negative mesh-lab-verify-post-stress mesh-lab-stress mesh-lab-down test-mesh-lab # External product shape: packed Nexo.* feed → authored brick + thin host + HTTP client (no repo refs). verify-external-product-shape: @@ -177,6 +177,11 @@ testing-strategy-gate: dependency-boundary-gate: bash scripts/dependency-boundary-gate.sh +# MEAI governed pipeline + VectorData RAG architecture tests (net8). +meai-pipeline-gate: + dotnet test src/Nexo.Tests.AI.Pipeline/Nexo.Tests.AI.Pipeline.csproj -f net8.0 -c Release --nologo \ + --blame-hang-timeout 120s --blame-hang-dump-type none + kernel-gate: dotnet build Nexo.Runtime.sln -v minimal dotnet build src/Nexo.Tests.Infrastructure/Nexo.Tests.Infrastructure.csproj -v minimal @@ -186,6 +191,7 @@ kernel-gate: NEXO_ALLOW_MOCK=1 dotnet test src/Nexo.Tests.Infrastructure/Nexo.Tests.Infrastructure.csproj -f net8.0 --no-build \ --filter "FullyQualifiedName~PipelineTemplateValidatorTests|FullyQualifiedName~PipelineLifecycleE2ETests" \ --blame-hang-timeout 120s --blame-hang-dump-type none + $(MAKE) meai-pipeline-gate @if [ "$${KERNEL_GATE_PRODSTYLE:-0}" = "1" ]; then $(MAKE) test-prod-style; fi @if [ "$${KERNEL_GATE_MESH:-0}" = "1" ]; then $(MAKE) mesh-lab-verify; fi diff --git a/Nexo.Kernel.sln b/Nexo.Kernel.sln index 856fc836..2a7f05c7 100644 --- a/Nexo.Kernel.sln +++ b/Nexo.Kernel.sln @@ -61,6 +61,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Spatial.Platform.XREAL EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Spatial.Platform.VisionPro", "src\Nexo.Spatial.Platform.VisionPro\Nexo.Spatial.Platform.VisionPro.csproj", "{AF198EA0-BF30-4C9A-BED5-43E0C3F047F1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.AI.Pipeline", "src\Nexo.AI.Pipeline\Nexo.AI.Pipeline.csproj", "{8115FD2E-46AF-4A38-914D-B4D00AEE5414}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Tests.AI.Pipeline", "src\Nexo.Tests.AI.Pipeline\Nexo.Tests.AI.Pipeline.csproj", "{0B55F79C-992F-4664-AF04-DFDD55D9812F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -407,6 +411,30 @@ Global {AF198EA0-BF30-4C9A-BED5-43E0C3F047F1}.Release|x64.Build.0 = Release|Any CPU {AF198EA0-BF30-4C9A-BED5-43E0C3F047F1}.Release|x86.ActiveCfg = Release|Any CPU {AF198EA0-BF30-4C9A-BED5-43E0C3F047F1}.Release|x86.Build.0 = Release|Any CPU + {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Debug|x64.ActiveCfg = Debug|Any CPU + {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Debug|x64.Build.0 = Debug|Any CPU + {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Debug|x86.ActiveCfg = Debug|Any CPU + {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Debug|x86.Build.0 = Debug|Any CPU + {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Release|Any CPU.Build.0 = Release|Any CPU + {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Release|x64.ActiveCfg = Release|Any CPU + {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Release|x64.Build.0 = Release|Any CPU + {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Release|x86.ActiveCfg = Release|Any CPU + {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Release|x86.Build.0 = Release|Any CPU + {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Debug|x64.ActiveCfg = Debug|Any CPU + {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Debug|x64.Build.0 = Debug|Any CPU + {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Debug|x86.ActiveCfg = Debug|Any CPU + {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Debug|x86.Build.0 = Debug|Any CPU + {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Release|Any CPU.Build.0 = Release|Any CPU + {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Release|x64.ActiveCfg = Release|Any CPU + {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Release|x64.Build.0 = Release|Any CPU + {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Release|x86.ActiveCfg = Release|Any CPU + {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -440,5 +468,7 @@ Global {075FBDC0-DF1B-46B2-A74E-70C1A6CCF6B1} = {31297073-3FD6-401C-A313-89BE7C850D6B} {B3BA94D1-49D6-4781-8BF7-7939560DD859} = {31297073-3FD6-401C-A313-89BE7C850D6B} {AF198EA0-BF30-4C9A-BED5-43E0C3F047F1} = {31297073-3FD6-401C-A313-89BE7C850D6B} + {8115FD2E-46AF-4A38-914D-B4D00AEE5414} = {31297073-3FD6-401C-A313-89BE7C850D6B} + {0B55F79C-992F-4664-AF04-DFDD55D9812F} = {31297073-3FD6-401C-A313-89BE7C850D6B} EndGlobalSection EndGlobal diff --git a/Nexo.Runtime.sln b/Nexo.Runtime.sln index 5185df0d..93053a92 100644 --- a/Nexo.Runtime.sln +++ b/Nexo.Runtime.sln @@ -39,6 +39,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.BackgroundAgents.HostR EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Hosting", "src\Nexo.Hosting\Nexo.Hosting.csproj", "{852A6EFA-21A3-47A3-8EC8-35A0B06CA36A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.AI.Pipeline", "src\Nexo.AI.Pipeline\Nexo.AI.Pipeline.csproj", "{50497523-BE3C-474B-B18F-484FADF6CE30}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Tests.AI.Pipeline", "src\Nexo.Tests.AI.Pipeline\Nexo.Tests.AI.Pipeline.csproj", "{DF6026C1-BA7D-4B91-9264-B505DF100551}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -253,6 +257,30 @@ Global {852A6EFA-21A3-47A3-8EC8-35A0B06CA36A}.Release|x64.Build.0 = Release|Any CPU {852A6EFA-21A3-47A3-8EC8-35A0B06CA36A}.Release|x86.ActiveCfg = Release|Any CPU {852A6EFA-21A3-47A3-8EC8-35A0B06CA36A}.Release|x86.Build.0 = Release|Any CPU + {50497523-BE3C-474B-B18F-484FADF6CE30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50497523-BE3C-474B-B18F-484FADF6CE30}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50497523-BE3C-474B-B18F-484FADF6CE30}.Debug|x64.ActiveCfg = Debug|Any CPU + {50497523-BE3C-474B-B18F-484FADF6CE30}.Debug|x64.Build.0 = Debug|Any CPU + {50497523-BE3C-474B-B18F-484FADF6CE30}.Debug|x86.ActiveCfg = Debug|Any CPU + {50497523-BE3C-474B-B18F-484FADF6CE30}.Debug|x86.Build.0 = Debug|Any CPU + {50497523-BE3C-474B-B18F-484FADF6CE30}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50497523-BE3C-474B-B18F-484FADF6CE30}.Release|Any CPU.Build.0 = Release|Any CPU + {50497523-BE3C-474B-B18F-484FADF6CE30}.Release|x64.ActiveCfg = Release|Any CPU + {50497523-BE3C-474B-B18F-484FADF6CE30}.Release|x64.Build.0 = Release|Any CPU + {50497523-BE3C-474B-B18F-484FADF6CE30}.Release|x86.ActiveCfg = Release|Any CPU + {50497523-BE3C-474B-B18F-484FADF6CE30}.Release|x86.Build.0 = Release|Any CPU + {DF6026C1-BA7D-4B91-9264-B505DF100551}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DF6026C1-BA7D-4B91-9264-B505DF100551}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DF6026C1-BA7D-4B91-9264-B505DF100551}.Debug|x64.ActiveCfg = Debug|Any CPU + {DF6026C1-BA7D-4B91-9264-B505DF100551}.Debug|x64.Build.0 = Debug|Any CPU + {DF6026C1-BA7D-4B91-9264-B505DF100551}.Debug|x86.ActiveCfg = Debug|Any CPU + {DF6026C1-BA7D-4B91-9264-B505DF100551}.Debug|x86.Build.0 = Debug|Any CPU + {DF6026C1-BA7D-4B91-9264-B505DF100551}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DF6026C1-BA7D-4B91-9264-B505DF100551}.Release|Any CPU.Build.0 = Release|Any CPU + {DF6026C1-BA7D-4B91-9264-B505DF100551}.Release|x64.ActiveCfg = Release|Any CPU + {DF6026C1-BA7D-4B91-9264-B505DF100551}.Release|x64.Build.0 = Release|Any CPU + {DF6026C1-BA7D-4B91-9264-B505DF100551}.Release|x86.ActiveCfg = Release|Any CPU + {DF6026C1-BA7D-4B91-9264-B505DF100551}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -275,5 +303,7 @@ Global {215BF0F8-C5B4-4310-BDFF-E6930BDB5E6E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {8DB1D6CC-D71C-4060-B481-2C1362ACB719} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {852A6EFA-21A3-47A3-8EC8-35A0B06CA36A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {50497523-BE3C-474B-B18F-484FADF6CE30} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {DF6026C1-BA7D-4B91-9264-B505DF100551} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/Nexo.sln b/Nexo.sln index a6e26022..344257f9 100644 --- a/Nexo.sln +++ b/Nexo.sln @@ -139,6 +139,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Provenance.Graph.Tests EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Provenance.Demo", "tools\Nexo.Provenance.Demo\Nexo.Provenance.Demo.csproj", "{1D8336EA-841C-41D3-8004-B961477D2160}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.AI.Pipeline", "src\Nexo.AI.Pipeline\Nexo.AI.Pipeline.csproj", "{AE1A6E76-6D57-4A38-9EB7-989083F7DD92}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Tests.AI.Pipeline", "src\Nexo.Tests.AI.Pipeline\Nexo.Tests.AI.Pipeline.csproj", "{41A42E00-05DC-40E7-9BC5-61B145A4614D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -905,6 +909,30 @@ Global {1D8336EA-841C-41D3-8004-B961477D2160}.Release|x64.Build.0 = Release|Any CPU {1D8336EA-841C-41D3-8004-B961477D2160}.Release|x86.ActiveCfg = Release|Any CPU {1D8336EA-841C-41D3-8004-B961477D2160}.Release|x86.Build.0 = Release|Any CPU + {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Debug|x64.ActiveCfg = Debug|Any CPU + {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Debug|x64.Build.0 = Debug|Any CPU + {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Debug|x86.ActiveCfg = Debug|Any CPU + {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Debug|x86.Build.0 = Debug|Any CPU + {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Release|Any CPU.Build.0 = Release|Any CPU + {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Release|x64.ActiveCfg = Release|Any CPU + {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Release|x64.Build.0 = Release|Any CPU + {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Release|x86.ActiveCfg = Release|Any CPU + {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Release|x86.Build.0 = Release|Any CPU + {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Debug|x64.ActiveCfg = Debug|Any CPU + {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Debug|x64.Build.0 = Debug|Any CPU + {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Debug|x86.ActiveCfg = Debug|Any CPU + {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Debug|x86.Build.0 = Debug|Any CPU + {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Release|Any CPU.Build.0 = Release|Any CPU + {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Release|x64.ActiveCfg = Release|Any CPU + {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Release|x64.Build.0 = Release|Any CPU + {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Release|x86.ActiveCfg = Release|Any CPU + {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -974,6 +1002,8 @@ Global {B42C5AE2-1208-49CB-A3E8-DF58B1088052} = {9D4F8B1A-0B6E-4A3E-8A6A-0DE12C7C6E2F} {935D3EE2-2EE3-4651-BF2D-A75151FE1B81} = {9D4F8B1A-0B6E-4A3E-8A6A-0DE12C7C6E2F} {1D8336EA-841C-41D3-8004-B961477D2160} = {9D4F8B1A-0B6E-4A3E-8A6A-0DE12C7C6E2F} + {AE1A6E76-6D57-4A38-9EB7-989083F7DD92} = {9D4F8B1A-0B6E-4A3E-8A6A-0DE12C7C6E2F} + {41A42E00-05DC-40E7-9BC5-61B145A4614D} = {9D4F8B1A-0B6E-4A3E-8A6A-0DE12C7C6E2F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {12345678-1234-1234-1234-123456789ABC} diff --git a/docs/governed-pipeline.md b/docs/governed-pipeline.md new file mode 100644 index 00000000..6e229268 --- /dev/null +++ b/docs/governed-pipeline.md @@ -0,0 +1,54 @@ +# Governed MEAI pipeline + +Nexo’s default model path is **Microsoft.Extensions.AI (MEAI)** with a fixed governance stack. +Legacy `IProviderFactory` chat via `ProviderBackedModel` remains available as an **opt-out** only. + +## Feature flag + +| Source | Enable | Disable | +|--------|--------|---------| +| Default (Phase 6+) | **on** | — | +| Config | `Nexo:UseMeaiPipeline=true` | `false` / `0` | +| Env | `NEXO_USE_MEAI_PIPELINE=1\|true` | `0\|false` | +| Hosting | `NexoHostingOptions.UseMeaiPipeline = true` | `= false` | + +When enabled, Hosting Phase 13b registers keyed/routed clients and Phase 13 uses `MeaiBackedModel` as the agentic leaf under `HotSwappableModel`. + +## Stack order (do not reorder) + +Per-target clients use `UseNexoGovernance(targetKey)`: + +1. **PolicyGate** — allow/deny target (local allowed; cloud deny unless allow-listed) +2. **Sanitizing** — PII/secret disposition per target (`Pass` local; `BlockOnSecretRedactOnPii` cloud) +3. **Auditing** — counts/categories/latency only; never content +4. **Provider** — Ollama HTTP, LLamaSharp (`local:onnx`), or Bedrock MEAI + +The **router** (`RoutingChatClient`) sits outside per-target stacks and is itself wrapped in `AuditingChatClient` (`router:default`). + +Embeddings use the same AsyncLocal-aware nesting: **Sanitizing → Auditing → generator**. + +## Target keys + +| Key | Role | +|-----|------| +| `local:ollama` | Local Ollama HTTP | +| `local:onnx` | Local LLamaSharp GGUF (product key; not ONNX Runtime) | +| `cloud:bedrock:fast\|balanced\|heavy` | AWS Bedrock (policy allow-listed when Bedrock enabled) | + +Capability hint: `ChatOptions.AdditionalProperties["nexo.route.capability"]` = `fast` / `balanced` / `heavy`. + +Raw provider types (`OllamaHttpChatClient`, Bedrock SDK client, LLamaSharp session) are **never** registered in DI. + +## RAG + +- Default `IRAGService` → `MeaiVectorDataRagAdapter` over `VectorDataRagService` (in-process VectorData store + governed embeddings). +- Reindex: call `VectorDataRagService.ReindexAsync` from Hosting DI (CLI `reindex-meai` lands on an `application/*` PR per layer-boundary rules). +- Legacy `RAGService` / store types remain in-tree for opt-out / migration but are not the Hosting default. + +## Operator notes + +- Bedrock: `Nexo:Meai:Bedrock:Enabled` + region/model ids; credentials via the default AWS chain (same as DynamoDB ingress). +- Opting out restores `ProviderBackedModel` for `IModel` but keeps VectorData `IRAGService` unless Hosting phases are customized. +- Architecture tests: `make meai-pipeline-gate` (`src/Nexo.Tests.AI.Pipeline`). + +See also: `docs/meai-migration-notes.md`. diff --git a/docs/meai-migration-notes.md b/docs/meai-migration-notes.md new file mode 100644 index 00000000..f6b197b3 --- /dev/null +++ b/docs/meai-migration-notes.md @@ -0,0 +1,445 @@ +# MEAI Migration Notes — Phase 0 Discovery + +**Status:** Phase 0 complete (read-only discovery) +**Date:** 2026-07-14 +**Repo TFM today:** host/library projects are **`net8.0`** (SDK pinned to `9.0.100` in `global.json`). Plan asks for **.NET 9**; Phase 1 should introduce `Nexo.AI.Pipeline` as `net9.0` (or dual-target) and confirm host upgrade scope separately. +**MEAI today:** **none** — no `Microsoft.Extensions.AI*` packages, no `IChatClient`. + +This document is the map for Phases 1–6. Later phases must update this file when discoveries invalidate assumptions. + +--- + +## 1. Model invocation today + +### Primary seam: `IModel` → `IProviderFactory` + +``` +Agents / ToolCallingAgent / Orchestration + │ + ▼ + IModel.CompleteAsync(ModelInput) + │ + OrchestrationRuntimeModelDecorator + → HotSwappableModel + → ProviderBackedModel + │ + ▼ + IProviderFactory.ExecuteLLMAsync / Vision / Video + │ + ├── openai / openai_compat / azure → HttpClient chat completions + ├── ollama → OllamaProvider → POST /api/chat + ├── local → LocalModelProvider (LLamaSharp + GGUF) + ├── video → VIDEO_SERVICE_URL HTTP + └── mock / offline / echo → MockScaffoldingResponder +``` + +| Type | Path | Role | +|------|------|------| +| `IModel` | `src/Nexo.Abstractions/IModel.cs` | Agent-facing completion API | +| `ModelInput` / `ModelOutput` | `src/Nexo.Abstractions/ModelInput.cs`, `ModelOutput.cs` | Message / completion DTOs | +| `IProviderFactory` | `src/Nexo.Infrastructure/Execution/IProviderFactory.cs` | Gateway for all provider HTTP / local calls | +| `ProviderFactory` | `src/Nexo.Infrastructure/Execution/ProviderFactory.cs` | **Central invoker** — OpenAI, Azure, openai_compat, Ollama, local, video, mock | +| `ProviderBackedModel` | `src/Nexo.Infrastructure/Execution/Models/ProviderBackedModel.cs` | `IModel` → parses `nexo.model.provider=` / `nexo.model.name=` → factory | +| `HotSwappableModel` | `src/Nexo.Infrastructure/Execution/Models/HotSwappableModel.cs` | Runtime swap; respects `NEXO_MODEL_PROVIDER` | +| `OrchestrationRuntimeModelDecorator` | `src/Nexo.Orchestration/Models/OrchestrationRuntimeModelDecorator.cs` | Outer `IModel`; injects orchestration runtime spec | +| `OrchestrationHotSwappableModel` | `src/Nexo.Orchestration/Models/OrchestrationHotSwappableModel.cs` | Primary/fallback swap (orchestration layer) | +| `AgentScopedModel` | `src/Nexo.Orchestration/Models/AgentScopedModel.cs` | Per-agent provider/name directives | +| `OllamaProvider` | `src/Nexo.Infrastructure/Execution/Ollama/OllamaProvider.cs` | Ollama HTTP client (`/api/chat`, health, tags) | +| `LocalModelProvider` | `src/Nexo.Infrastructure/Execution/LocalModelProvider.cs` | **In-process LLamaSharp GGUF** (`NEXO_LOCAL_MODEL_PATH`) — **not ONNX Runtime** | +| `OpenAiCompatibleEndpoint` | `src/Nexo.Infrastructure/Execution/OpenAiCompatibleEndpoint.cs` | URL normalization for `/v1/chat/completions` | +| `AdaptiveProviderFactory` | `src/Nexo.Infrastructure/Execution/AdaptiveProviderFactory.cs` | Chooses provider via `ILoadPolicy` | +| `PreferenceLoadPolicy` / `ILoadPolicy` | `src/Nexo.Infrastructure/Execution/LoadPolicy/` | Local-vs-cloud preference (`NEXO_LOAD_PREFERENCE`) | +| `MockScaffoldingResponder` | `src/Nexo.Infrastructure/Execution/MockScaffoldingResponder.cs` | Deterministic mock/offline responses | +| `OllamaEphemeralLifecycle` | `src/Nexo.Infrastructure/Execution/Ephemeral/OllamaEphemeralLifecycle.cs` | Ephemeral Docker Ollama per session | + +**Important correction vs plan wording:** “ONNX / offline target” in docs/`BackendType.OnnxRuntime` is largely a **placeholder**. Real offline inference is **LLamaSharp + GGUF** via `LocalModelProvider`. Phase 1 should wrap **`local` (LLamaSharp)** as `local:onnx` *only if* product naming requires that key; prefer key **`local:llamasharp`** (or map `local:onnx` → LLamaSharp with a comment) so policy docs stay honest. + +### Secondary seam: NCR `IModelServingBackend` + +| Type | Path | Role | +|------|------|------| +| `IModelServingBackend` | `src/Nexo.Core.Application/NodeCapabilityRuntime/Ports/IModelServingBackend.cs` | `RunInferenceAsync`, load/unload/pull | +| `OllamaModelServingBackend` | `src/Nexo.Infrastructure/NodeCapabilityRuntime/Backends/OllamaModelServingBackend.cs` | Desktop NCR → Ollama HTTP | +| `NullModelServingBackend` | `src/Nexo.Infrastructure/NodeCapabilityRuntime/Backends/NullModelServingBackend.cs` | No-op; reports `BackendType.OnnxRuntime` | +| `BackendType` | `src/Nexo.Core.Application/NodeCapabilityRuntime/Models/BackendType.cs` | `Ollama`, `LlamaCppMobile`, `OnnxRuntime` (only Ollama implemented) | +| `NodeCapabilityRuntime` | `src/Nexo.Infrastructure/NodeCapabilityRuntime/NodeCapabilityRuntime.cs` | Model selection / ensure-ready | +| `NcrAgenticBrickEngine` | `src/Nexo.Infrastructure/Execution/Agentic/NcrAgenticBrickEngine.cs` | Agentic bricks → NCR lifecycle | + +### Capability / job routing (not chat-client routing) + +| Type | Path | Role | +|------|------|------| +| `ExecutionTarget` | `src/Nexo.Core.Application/Execution/Routing/ExecutionTarget.cs` | Local vs remote job target | +| `ICapabilityRouter` / `NcrCapabilityRouter` | Application port + `src/Nexo.Infrastructure/Execution/Routing/NcrCapabilityRouter.cs` | Local / peer / RunPod | +| `ProviderFactoryLocalExecutor` | `src/Nexo.Infrastructure/Execution/Routing/ProviderFactoryLocalExecutor.cs` | Local jobs → `ExecuteLLMAsync` | +| `IEndpointRouter` / `CompositeEndpointRouter` | Abstractions + Orchestration | Agent **transport** endpoints (not LLM providers) | + +### Direct `IProviderFactory` callers (bypass `IModel`) + +| Type | Path | +|------|------| +| `ProviderGeneratorModel` | `src/Nexo.Infrastructure/Adaptation/Generation/ProviderGeneratorModel.cs` | +| `ProviderCompositionGeneratorModel` | `src/Nexo.Infrastructure/Certification/Composition/ProviderCompositionGeneratorModel.cs` | +| `ContentGenerator` | `src/Nexo.Infrastructure/Export/ContentGenerator.cs` | +| `OWASPScannerBrick` | `src/Nexo.Bricks.Owasp/Security/OWASPScannerBrick.cs` | +| `ProviderFactoryLocalExecutor` | (above) | + +### Explicitly absent + +| Search | Result | +|--------|--------| +| AWS Bedrock | **Zero** code references | +| `Microsoft.Extensions.AI` / `IChatClient` | **Zero** | +| Anthropic HTTP | Domain enum only; not in `ProviderFactory` | +| Real ONNX Runtime GenAI | Enum/placeholder only | + +--- + +## 2. Sanitization / PII / secret filtering + +| Type | Path | Role | +|------|------|------| +| `ICloudSanitizationProxy` / `CloudSanitizationProxy` | `src/Nexo.BackgroundAgents/Trust/` | Sanitizes outbound prompts before cloud; PII block/redact + taxonomy | +| `SanitizingProviderFactory` | `src/Nexo.BackgroundAgents/Trust/SanitizingProviderFactory.cs` | **`IProviderFactory` decorator** — runs proxy then delegates | +| `OutgoingContext` / `SanitizationResult` | same folder | Input/result models | +| `SanitizationAuditEntry` (+ DTO) | Trust + `src/Nexo.Core.Application/Trust/Ports/` | Redaction audit row (counts/categories — no raw secrets by design) | +| `ISanitizationAuditLog` | Trust | `LogRedaction` / `GetRecent` | +| `ISensitiveContentFilter` / `SensitiveContentFilter` | `src/Nexo.BackgroundAgents/WebSearch/` | Regex email/phone/SSN/API-key/CC; `RedactPii` / `ShouldBlockQuery` | +| `IDataTaxonomy` / `DataTaxonomy` + JSON | `src/Nexo.BackgroundAgents/DataSensitivity/` | Data-type → sensitivity (e.g. api-keys → Secret) | +| `IDataSensitivityRegistry` / levels | same | Public→TopSecret; drives exfiltration + RAG filters | +| `DataExfiltrationPolicy` | `src/Nexo.BackgroundAgents/Security/DataExfiltrationPolicy.cs` | Tool-call policy: blocks LLM/search when sensitivity forbids | +| `SupportDiagnosticsExporter` | `application/src/Nexo.API/Security/` | Redacts sensitive **config** keys (not LLM prompts) | + +**Behavior today (CloudSanitizationProxy):** air-gapped → pass-through; else PII detected → **block**; filterable PII → **redact**; taxonomy may further constrain. Not yet policy-pack-driven per destination target (Phase 2 must make this policy-driven: redact / block / pass by target). + +**Noise:** `SanitizeXmlName`, `SanitizeIdentifier`, Unity `SanitizeClassName` — unrelated to LLM egress. + +--- + +## 3. Audit sinks + +### Barrier audit pipeline + +| Type | Path | Role | +|------|------|------| +| `IBarrierAuditSink` / `IBarrierAuditLog` | `src/Nexo.Abstractions/Barriers/` | Pluggable barrier audit | +| `StructuredBarrierAuditLog` | `src/Nexo.Runtime/Barriers/` | Fans out to all sinks | +| `FileBarrierAuditSink` | `src/Nexo.Runtime/Barriers/Sinks/` | `Nexo:Audit:Sinks` contains `File` | +| `StructuredLogBarrierAuditSink` | same | ILogger sink | +| `NoOpBarrierAuditSink` | same | Default / discard | +| Registration | `src/Nexo.Runtime/RuntimeServiceCollectionExtensions.cs` → `AddBarrierAuditSinks` | Bound from `Nexo:Audit:*` | + +### Trust / data-decision audit (LLM sanitization lives here) + +| Type | Path | Role | +|------|------|------| +| `IDataDecisionAuditLog` | `src/Nexo.Core.Application/Trust/Ports/` | Unified: sanitization, boundary, classification, etc. | +| `DataDecisionAuditLog` / `LiteDbDataDecisionAuditLog` | `src/Nexo.BackgroundAgents/Trust/` | In-memory or LiteDB (`NEXO_TRUST_AUDIT_DB`) | +| CLI `TrustCommand.AuditAsync` | `application/src/Nexo.CLI/Commands/TrustCommand.cs` | Export/show audit | + +### Related (not model-call audit) + +- `IAdaptationAuditLog` / `LiteDbAdaptationAuditLog` — adaptation decisions +- GameDirector `AuditRecord` / MCP `GetAuditTrailTool` — commercial activity feed + +**Phase 2 implication:** `AuditingChatClient` should write to **`IDataDecisionAuditLog` / sanitization audit** (and optionally emit a barrier correlation id). Do not invent a third audit store; barrier sinks are for barrier lifecycle, not model invocations. + +--- + +## 4. Policy packs — load & evaluate + +### Trust policy packs (observation / regulated packs) + +| Type | Path | Role | +|------|------|------| +| `ITrustPolicyPackRegistry` / `TrustPolicyPackRegistry` | Ports + `src/Nexo.Infrastructure/Trust/` | Load `*.json`, activate pack | +| `TrustPolicyPack` (+ info/status/rules models) | `src/Nexo.Core.Application/Trust/Models/` | Pack schema | +| On-disk packs | `config/trust-packs/{strict-enterprise,internal-only,air-gapped,active-pack}.json` | Pack content + activation | +| `IAccessBoundary` / `AccessBoundary` | Infrastructure Trust | `ApplyPolicyPack`; observation gates | +| `IObservationGate` / `ObservationGate` | same | `ShouldObserve` from active boundary | +| CLI | `TrustCommand` pack list/describe/apply | Operator UX | + +**Env:** `NEXO_TRUST_POLICY_PACKS_PATH`, `NEXO_TRUST_ENABLED`, `NEXO_TRUST_AUDIT_DB`. + +### Tool-call policy engine (separate from packs) + +| Type | Path | Role | +|------|------|------| +| `IPolicy` | `src/Nexo.Abstractions/IPolicy.cs` | Approve/deny tool calls | +| `PolicyEngine` | `src/Nexo.Runtime/PolicyEngine.cs` | Evaluate all `IPolicy`, sign deltas | +| `BackgroundAgentPolicyEngineFactory` | `src/Nexo.BackgroundAgents/Security/` | Builds engine with `DataExfiltrationPolicy` | +| `AllowAllPolicy`, path/sandbox policies | `src/Nexo.Policies/`, `src/Nexo.Policies.Dev/` | Built-in tool policies | + +**No `PolicyGate` type exists today.** Phase 2 `PolicyGateChatClient` is new; it should consult trust/data-classification → allowed execution targets (extend packs or add a new pack section for target keys — design in Phase 2). + +### Trust tiers (mesh / fleet — not RAG trust tags) + +| Type | Path | Role | +|------|------|------| +| `PeerTrustTier` | `src/Nexo.Core.Application/Mesh/Models/` | Unknown/Untrusted/Trusted | +| `MeshTrustPolicyConfiguration` | Mesh | `NEXO_MESH_TRUST_POLICY` | +| Fleet trust | commercial Fleet contracts | Placement eligibility | + +RAG sensitivity is **`IDataSensitivityRegistry` levels**, not `PeerTrustTier`. Phase 5 “trust-tier tag” maps to sensitivity level names. + +--- + +## 5. RAG / embedding / vector storage + +| Type | Path | Role | +|------|------|------| +| `IEmbeddingGenerator` | `src/Nexo.BackgroundAgents/RAG/IEmbeddingGenerator.cs` | **Nexo-local** `GenerateAsync → float[]` — **name collision with MEAI** | +| `TokenEmbeddingGenerator` | same | Deterministic bag-of-words (dim 64 from `NexoDefaults`) | +| `IVectorStore` / `InMemoryVectorStore` | same | Default DI store | +| `SqliteVectorStore` | same | Implemented + tested; **not** registered in `AddBackgroundAgentsRAG` | +| `IRAGService` / `RAGService` | same | Embed + index/search façade | +| `IKnowledgeBaseIndexer` / `KnowledgeBaseIndexer` | same | File → RAG indexing | +| `RAGTool` | same | Agent tool `rag_search` | +| `RAGConfig` | `src/Nexo.BackgroundAgents/Configuration/RAGConfig.cs` | Docs mention sqlite/postgres/qdrant; only in-memory wired | +| `DecompositionRetriever` | `src/Nexo.Orchestration/Architect/` | Keyword “RAG” over examples — **no embeddings** | +| Tests | `src/Nexo.Tests.BackgroundAgents/RAG/*` | Coverage for stores, embeddings, tool | + +**DI:** `AddBackgroundAgentsRAG()` → `TokenEmbeddingGenerator`, `InMemoryVectorStore`, `RAGService`, `KnowledgeBaseIndexer` (kernel Phase 11 when `IncludeBackgroundAgentRag`). + +**Phase 5 note:** rename or alias Nexo’s `IEmbeddingGenerator` when adopting MEAI’s `IEmbeddingGenerator>` to avoid type collisions (qualify namespaces). + +--- + +## 6. DI registration points (CLI + API) + +### Hosts + +| Host | Project | Entry | +|------|---------|-------| +| CLI | `application/src/Nexo.CLI/` | `Program.cs` → `AddNexoRuntimeRouting` + `AddNexo()` | +| API | `application/src/Nexo.API/` | `Program.cs` → same + API-only ingress (SNS/DynamoDB) | + +No `Startup.cs`. Feature flags are **Options + env**, not Microsoft.FeatureManagement. + +### Shared composition root + +| File | Role | +|------|------| +| `src/Nexo.Hosting/NexoServiceCollectionExtensions.cs` | `AddNexo` / `AddNexoProfile` | +| `src/Nexo.Hosting/NexoKernelRegistrar.cs` + `.Phases.cs` | Ordered phases | +| `src/Nexo.Hosting/ModuleSelection.cs` | Profile gates (`IncludeBackgroundAgentRag`, `IncludeTrustServices`, …) | +| `src/Nexo.Hosting/NexoHostingOptions.cs` | `TrustEnabled`, hosted-agent flags, etc. | + +### AI-relevant kernel phases + +| Phase | What | +|-------|------| +| **11** | `AddBackgroundAgents` + optional `AddBackgroundAgentsRAG` | +| **13** | `HotSwappableModel` + `IModel` = `OrchestrationRuntimeModelDecorator` | +| **14** | Optional `IEphemeralModelLifecycle` → Ollama ephemeral | +| **15** | Trust + `IProviderFactory` 3-way branch (adaptive / sanitizing / plain) | + +### Other DI + +| Extension | Path | +|-----------|------| +| `AddTrustServices` | `src/Nexo.BackgroundAgents/ServiceCollectionExtensions.cs` | +| `AddAccessBoundary` | `src/Nexo.Infrastructure/Trust/Sdk/Extensions/TrustServiceCollectionExtensions.cs` | +| `AddBarrierAuditSinks` | `src/Nexo.Runtime/RuntimeServiceCollectionExtensions.cs` (`Nexo:Audit:*`) | +| NCR + Ollama backend | `src/Nexo.Hosting/NexoServiceCollectionExtensions.NodeCapabilityRuntime.cs` | + +### Planned feature flag + +`Nexo:UseMeaiPipeline` — **does not exist yet**. Follow existing pattern: bind bool from config section + optional env override; default **off** until Phase 6. Suggested env alias: `NEXO_USE_MEAI_PIPELINE=1`. + +### AWS credentials (for Phase 4 Bedrock reuse) + +| Piece | Path | Notes | +|-------|------|-------| +| DynamoDB store | `src/Nexo.Ingress.DynamoDb/` | `new AmazonDynamoDBClient()` — **default credential/region chain** | +| Options | `src/Nexo.Contracts/SmsIngressDynamoDbOptions.cs` | Table name only (`Nexo:SmsIngressDynamoDb`) | +| SNS | `src/Nexo.Ingress.AwsSns/` | Signature verify only — **no AWS SDK client** | +| Packages | `Directory.Packages.props` | `AWSSDK.DynamoDBv2` 3.7.400, Core/S3/Lambda 3.7.305.12 — **no Bedrock** | + +--- + +## Proposed mapping: existing type → MEAI concept + +| Existing type | MEAI concept | Notes | +|---------------|--------------|-------| +| `IModel` / `ProviderBackedModel` | Consumer of `IChatClient` (adapter) | Keep `IModel` until Phase 6; impl can call MEAI when flag on | +| `IProviderFactory` / `ProviderFactory` | Provider `IChatClient`s behind keyed DI | Split per target; do not register raw factory/clients publicly | +| `OllamaProvider` | `IChatClient` via **OllamaSharp** (or thin adapter) | Key: `local:ollama` | +| `LocalModelProvider` (LLamaSharp) | Custom `IChatClient` adapter | Key: `local:onnx` alias or `local:llamasharp` — see §1 | +| `SanitizingProviderFactory` + `ICloudSanitizationProxy` | `SanitizingChatClient : DelegatingChatClient` | Move policy-driven redact/block/pass here | +| `ISensitiveContentFilter` | Used inside `SanitizingChatClient` | Reuse; don’t rewrite filters | +| `IDataDecisionAuditLog` / sanitization audit | `AuditingChatClient : DelegatingChatClient` | Emit counts/categories only | +| `ITrustPolicyPackRegistry` + target allow-list (new) | `PolicyGateChatClient : DelegatingChatClient` | New gate over (caller, target, model) | +| `AdaptiveProviderFactory` / `ILoadPolicy` / `NcrCapabilityRouter` | `RoutingChatClient : IChatClient` | Phase 3; local-first + policy × availability | +| *(none)* Bedrock | `BedrockChatClient : IChatClient` | Phase 4; keys `cloud:bedrock:{fast,balanced,heavy}` | +| Nexo `IEmbeddingGenerator` / `TokenEmbeddingGenerator` | MEAI `IEmbeddingGenerator>` | Phase 5; rename Nexo interface or fully qualify | +| `IVectorStore` / `InMemoryVectorStore` / `SqliteVectorStore` | `VectorStore` / `VectorStoreCollection` (Microsoft.Extensions.VectorData) | Keep old read-only until Phase 6 | +| `RAGService` / `KnowledgeBaseIndexer` | Facades over VectorData + embedding generator | Preserve sensitivity ≤ caller filter | +| Raw `OllamaApiClient` / Bedrock / ONNX session | **Never** resolve from DI | Only decorated `IChatClient` pipeline is public | + +### Fixed governance composition (Phase 2) + +``` +UseNexoGovernance() → + PolicyGate → Sanitizing → Auditing → [UseFunctionInvocation()] → provider IChatClient +``` + +Router (Phase 3) sits **outside** per-target stacks and is itself wrapped in Auditing. + +--- + +## Gaps & risks for later phases + +1. **TFM mismatch:** plan = .NET 9; repo libraries/hosts = `net8.0`. `Microsoft.Extensions.*` already at **10.0.8** in CPM — MEAI packages should align carefully. +2. **Dual invocation paths:** `IModel`/`IProviderFactory` and NCR `IModelServingBackend` — decide whether NCR remains parallel or folds into MEAI (recommend: Phase 1 wraps chat path only; NCR later). +3. **Bypass surface:** many direct `IProviderFactory` callers — flag must route them or Phase 6 cleanup will leave holes. +4. **“ONNX” naming** vs LLamaSharp reality — document in policy keys to avoid operator confusion. +5. **Sanitization not per-target today** — Proxy is cloud-oriented; local pass-through must become explicit policy. +6. **Two audit models** — prefer trust data-decision audit for MEAI middleware; barrier sinks for barriers. +7. **Package asks (plan allows these):** + - Phase 1: `Microsoft.Extensions.AI.Abstractions`, `Microsoft.Extensions.AI`, `OllamaSharp` + - Phase 4: `AWSSDK.BedrockRuntime` (+ AWS MEAI adapter if available) + - Phase 5: `Microsoft.Extensions.VectorData.Abstractions` + one concrete store +8. **No Bedrock / no Anthropic** yet; Amazon credentials path is default chain only. +9. **RAG sensitivity ≠ mesh trust tier** — map Phase 5 tags to `IDataSensitivityRegistry`. + +--- + +## Neighbor projects for `src/Nexo.AI.Pipeline` + +| Project | Why | +|---------|-----| +| `Nexo.Abstractions` | `IModel`, barriers, tools | +| `Nexo.BackgroundAgents` | Trust sanitization, RAG (reuse; avoid circular refs — prefer ports/interfaces) | +| `Nexo.Infrastructure` | ProviderFactory, Ollama, LLamaSharp (adapters wrap, don’t rewrite) | +| `Nexo.Hosting` | Feature-flagged `ChatClientBuilder` registration | +| `Nexo.Core.Application` | Trust ports, NCR ports | +| `Nexo.Adapters.Models` | Lightweight model adapters peer | +| Tests: `Nexo.Tests.BackgroundAgents`, new `Nexo.Tests.AI.Pipeline` | Unit + composition + architecture tests | + +**Suggested dependency direction:** `Nexo.AI.Pipeline` depends on Abstractions + Application ports + MEAI packages; Infrastructure/BackgroundAgents provide adapters registered from Hosting. Avoid Pipeline → Hosting. + +--- + +## Phase checklist + +| Phase | Deliverable | Status | +|-------|-------------|--------| +| 0 | This notes file | **Done** | +| 1 | `Nexo.AI.Pipeline` + Ollama/LLamaSharp `IChatClient` + flag off | **Done** | +| 2 | PolicyGate / Sanitizing / Auditing middleware + DI architecture tests | **Done** | +| 3 | `RoutingChatClient` + policy × availability matrix tests | **Done** | +| 4 | Bedrock tiered targets + env-gated integration test | **Done** | +| 5 | VectorData RAG + embedding middleware + reindex CLI | **Done** | +| 6 | Flag default on; delete legacy; `docs/governed-pipeline.md` | **Done** | + +--- + +## Phase 1 implementation notes (2026-07-14) + +Landing branch: `cursor/meai-phase1-pipeline-5a04` + +### Delivered +- New project `src/Nexo.AI.Pipeline` (TFMs `net8.0;net9.0`) + tests `src/Nexo.Tests.AI.Pipeline` +- Keyed `IChatClient` targets: `local:ollama` (`OllamaHttpChatClient`), `local:onnx` (`LlamaSharpChatClient`) +- Hosting Phase **13b** registers the pipeline only when `Nexo:UseMeaiPipeline` / `NEXO_USE_MEAI_PIPELINE` / `NexoHostingOptions.UseMeaiPipeline` is true (**default off**) +- Raw `OllamaHttpChatClient` / `LlamaSharpChatClient` are **not** registered in DI — only keyed `IChatClient` via `AddKeyedChatClient` +- Packages: `Microsoft.Extensions.AI` + `Abstractions` **10.7.0**; CPM bumped related `Microsoft.Extensions.*` / `System.Text.*` **10.0.8 → 10.0.9** for MEAI + +### Discovery changes for later phases +1. **OllamaSharp deferred:** package 5.4.25 ships a Roslyn 5 analyzer incompatible with this repo's pinned C# 12 / compiler 4.14. Phase 1 uses a thin `OllamaHttpChatClient` over `/api/chat` instead (plan-allowed). Revisit OllamaSharp when the repo moves to a Roslyn 5-capable toolchain. +2. **`local:onnx` = LLamaSharp GGUF** confirmed in code comments + options; not ONNX Runtime GenAI. +3. Host libraries remain **net8.0**; Pipeline dual-targets so Hosting can consume net8 while still shipping net9. +4. Governance middleware (`UseNexoGovernance`) is **Phase 2** — Phase 1 registers bare keyed clients through `ChatClientBuilder` with no policy/sanitize/audit stack yet. + +--- + +## Phase 2 implementation notes (2026-07-14) + +Landing branch: `cursor/meai-phase2-governance-5a04` + +### Delivered +- `UseNexoGovernance(targetKey)` — fixed order **PolicyGate → Sanitizing → Auditing → provider** +- `PolicyViolationException` with structured `Code` / target / details (no raw secrets) +- Ports: `IChatTargetAccessPolicy`, `IChatMessageSanitizer`, `ITargetSanitizePolicy`, `IChatInvocationAuditor` +- Defaults: local allow / cloud deny; local sanitize=Pass; cloud sanitize=BlockOnSecretRedactOnPii +- `AddNexoMeaiPipeline` always applies `UseNexoGovernance` (hosts cannot register ungoverned keyed clients through this API) +- Unit tests: deny short-circuit, PII redact before spy, audit on success/fault/cancel, composition order, architecture (resolved client is `PolicyGateChatClient`) + +### Follow-ups for later phases +- Wire adapters to existing `ICloudSanitizationProxy` / `IDataDecisionAuditLog` / trust packs (ports are ready) +- Phase 3 router wraps governed per-target pipelines and audits route decisions + +--- + +## Phase 3 implementation notes (2026-07-14) + +Landing branch: `cursor/meai-phase3-router-5a04` + +### Delivered +- `RoutingChatClient` + `LocalFirstChatRouter` + `DefaultRouteCandidateTable` + `ITargetAvailability` +- Capability hints via `ChatOptions.AdditionalProperties["nexo.route.capability"]` (fast/balanced/heavy) +- Local-first escalation; cloud candidates only when policy allow-lists cloud keys +- Router emits audit records (`router:default`) with candidates + reason; default DI wraps router in `AuditingChatClient` +- Table-driven policy × availability matrix tests + scenario tests (cloud forbidden / local down fallback / hard fail) + +### Follow-ups +- Phase 4 registers real `cloud:bedrock:*` governed clients; stubs already reserved in the candidate table + +--- + +## Phase 4 implementation notes (2026-07-14) + +Landing branch: `cursor/meai-phase4-bedrock-5a04` + +### Delivered +- Packages: `AWSSDK.BedrockRuntime` + `AWSSDK.Extensions.Bedrock.MEAI` (AWS MEAI `AsIChatClient`) +- Targets `cloud:bedrock:fast|balanced|heavy` with config model ids (`Nexo:Meai:Bedrock`) +- Credentials: `new AmazonBedrockRuntimeClient()` — same default chain as DynamoDB ingress; client **not** in DI +- Cloud sanitize defaults already strict (`BlockOnSecretRedactOnPii`); Bedrock enable auto-allow-lists cloud target keys +- Unit tests with fake transport; live test gated by `NEXO_TEST_BEDROCK=1` + +--- + +## Phase 5 implementation notes (2026-07-14) + +Landing branch: `cursor/meai-phase5-vectordata-5a04` + +### Delivered +- Package: `Microsoft.Extensions.VectorData.Abstractions` **10.7.0** + in-process `InProcessVectorStore` / `InProcessChunkCollection` (no preview SK connector — version mismatch with VectorData 10.7) +- `ChunkRecord`, `TrustTierOrder`, `VectorDataRagService` (index / search with caller-tier filter / reindex) +- Governed embeddings: `TokenHashEmbeddingGenerator` → **Auditing** → **Sanitizing** (outer) — same AsyncLocal visibility pattern as chat +- Hosting Phase 13b: VectorData RAG + governance defaults always; chat pipeline still flag-gated +- CLI: `nexo background-agent rag reindex-meai` (`MeaiRagReindexCommand`) — leaves legacy RAG store read-only +- AWS CPM aligned to v4 so Bedrock MEAI + DynamoDB co-restore: Core `4.0.100.4`, DynamoDBv2 `4.0.101.1`, S3 `4.0.101`, Lambda `4.0.103` + +### Follow-ups for Phase 6 +- Default `Nexo:UseMeaiPipeline` on; remove legacy `IProviderFactory` chat path and legacy RAG write path +- Publish `docs/governed-pipeline.md` + architecture tests in CI +- Optional: swap in-process VectorData store for a durable connector when one matches Abstractions 10.7 +- **CLI reindex:** deferred to an `application/*` PR (layer-boundary: master cannot change `application/`) — use `VectorDataRagService.ReindexAsync` via Hosting DI until then + +--- + +## Phase 6 implementation notes (2026-07-14) + +Landing branch: `cursor/meai-phase6-cutover-5a04` + +### Delivered +- Feature flag **defaults ON**; opt out with `Nexo:UseMeaiPipeline=false` / `NEXO_USE_MEAI_PIPELINE=0` +- `MeaiBackedModel` (`IModel` → governed `IChatClient`); Hosting Phase 13 uses it as the HotSwappable agentic leaf when MEAI is on +- `HotSwappableModel` accepts `IModel` (not `ProviderBackedModel` only) +- Default `IRAGService` → `MeaiVectorDataRagAdapter` over `VectorDataRagService` +- Operator doc: `docs/governed-pipeline.md` +- CI: `make meai-pipeline-gate` hooked into `make kernel-gate` +- Pack graph: `Nexo.AI.Pipeline` added to `pack-nexo-hosting-graph.{sh,ps1}` +- ProdStyle Hosting smoke: default MEAI + VectorData RAG wiring / opt-out + +### Soft-gated (kept) +- `IProviderFactory` / `ProviderFactory` / `SanitizingProviderFactory` for direct non-chat callers (NCR, bricks, content generators) +- Legacy `RAGService` types remain for opt-out/custom hosts + +### Remaining debt +- Migrate remaining direct `IProviderFactory.ExecuteLLMAsync` callers onto MEAI or shared policy helpers +- Durable VectorData connector when GA versions align +- Application-layer CLI: `nexo background-agent rag reindex-meai` (blocked on master by layer-boundary; land on `application/*`) diff --git a/scripts/pack-nexo-hosting-graph.ps1 b/scripts/pack-nexo-hosting-graph.ps1 index e4f13da5..8700f789 100644 --- a/scripts/pack-nexo-hosting-graph.ps1 +++ b/scripts/pack-nexo-hosting-graph.ps1 @@ -38,6 +38,7 @@ Pack-Project "src/Nexo.Certification.Physical/Nexo.Certification.Physical.csproj Pack-Project "src/Nexo.Infrastructure/Nexo.Infrastructure.csproj" Pack-Project "src/Nexo.Orchestration/Nexo.Orchestration.csproj" Pack-Project "src/Nexo.BackgroundAgents/Nexo.BackgroundAgents.csproj" +Pack-Project "src/Nexo.AI.Pipeline/Nexo.AI.Pipeline.csproj" Pack-Project "src/Nexo.Hosting/Nexo.Hosting.csproj" $cfg = Join-Path $OutputDir "PackBundle.NuGet.Config" diff --git a/scripts/pack-nexo-hosting-graph.sh b/scripts/pack-nexo-hosting-graph.sh index 77786f29..31a73842 100755 --- a/scripts/pack-nexo-hosting-graph.sh +++ b/scripts/pack-nexo-hosting-graph.sh @@ -34,6 +34,7 @@ pack src/Nexo.Certification.Physical/Nexo.Certification.Physical.csproj pack src/Nexo.Infrastructure/Nexo.Infrastructure.csproj pack src/Nexo.Orchestration/Nexo.Orchestration.csproj pack src/Nexo.BackgroundAgents/Nexo.BackgroundAgents.csproj +pack src/Nexo.AI.Pipeline/Nexo.AI.Pipeline.csproj pack src/Nexo.Hosting/Nexo.Hosting.csproj CFG="${OUT}/PackBundle.NuGet.Config" diff --git a/src/Nexo.AI.Pipeline/BedrockMeaiOptions.cs b/src/Nexo.AI.Pipeline/BedrockMeaiOptions.cs new file mode 100644 index 00000000..51298f72 --- /dev/null +++ b/src/Nexo.AI.Pipeline/BedrockMeaiOptions.cs @@ -0,0 +1,23 @@ +namespace Nexo.AI.Pipeline; + +/// +/// Configurable AWS Bedrock model ids for tiered cloud targets. +/// Model families change; keep ids in config, not code. +/// +public sealed class BedrockMeaiOptions +{ + /// When true, register cloud:bedrock:* governed clients. + public bool Enabled { get; set; } + + /// Optional AWS region (falls back to default credential/region chain). + public string? Region { get; set; } + + /// Model id for cloud:bedrock:fast. + public string FastModelId { get; set; } = "amazon.nova-micro-v1:0"; + + /// Model id for cloud:bedrock:balanced. + public string BalancedModelId { get; set; } = "amazon.nova-lite-v1:0"; + + /// Model id for cloud:bedrock:heavy. + public string HeavyModelId { get; set; } = "amazon.nova-pro-v1:0"; +} diff --git a/src/Nexo.AI.Pipeline/Clients/AwsBedrockChatClientFactory.cs b/src/Nexo.AI.Pipeline/Clients/AwsBedrockChatClientFactory.cs new file mode 100644 index 00000000..4b255b82 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Clients/AwsBedrockChatClientFactory.cs @@ -0,0 +1,44 @@ +using Amazon; +using Amazon.BedrockRuntime; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Options; + +namespace Nexo.AI.Pipeline.Clients; + +/// +/// Creates Bedrock-backed instances without registering +/// / in DI. +/// Uses the same default AWS credential/region chain as DynamoDB SMS ingress. +/// +public interface IBedrockChatClientFactory +{ + /// Creates an for the given Bedrock model id. + IChatClient Create(string modelId); +} + +/// +/// Production factory over AWSSDK Bedrock MEAI adapter. +/// +public sealed class AwsBedrockChatClientFactory : IBedrockChatClientFactory +{ + private readonly BedrockMeaiOptions _options; + + /// Creates the factory from pipeline options. + public AwsBedrockChatClientFactory(IOptions options) + { + _options = options.Value.Bedrock; + } + + /// + public IChatClient Create(string modelId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modelId); + + // Construct privately — never AddSingleton. + IAmazonBedrockRuntime runtime = string.IsNullOrWhiteSpace(_options.Region) + ? new AmazonBedrockRuntimeClient() + : new AmazonBedrockRuntimeClient(RegionEndpoint.GetBySystemName(_options.Region.Trim())); + + return runtime.AsIChatClient(modelId); + } +} diff --git a/src/Nexo.AI.Pipeline/Clients/FakeChatClient.cs b/src/Nexo.AI.Pipeline/Clients/FakeChatClient.cs new file mode 100644 index 00000000..ef16030b --- /dev/null +++ b/src/Nexo.AI.Pipeline/Clients/FakeChatClient.cs @@ -0,0 +1,63 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace Nexo.AI.Pipeline.Clients; + +/// +/// Test / offline double that returns a fixed assistant reply. +/// +public sealed class FakeChatClient : IChatClient +{ + private readonly string _response; + private readonly string _modelId; + + /// Creates a fake client that always returns . + public FakeChatClient(string response = "fake-response", string modelId = "fake") + { + _response = response; + _modelId = modelId; + } + + /// + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, _response)) + { + ModelId = options?.ModelId ?? _modelId, + }); + } + + /// + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, _response) + { + ModelId = options?.ModelId ?? _modelId, + }; + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) + { + if (serviceType == typeof(ChatClientMetadata)) + { + return new ChatClientMetadata("fake", providerUri: null, defaultModelId: _modelId); + } + + return serviceType.IsInstanceOfType(this) ? this : null; + } + + /// + public void Dispose() + { + } +} diff --git a/src/Nexo.AI.Pipeline/Clients/LlamaSharpChatClient.cs b/src/Nexo.AI.Pipeline/Clients/LlamaSharpChatClient.cs new file mode 100644 index 00000000..76324e65 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Clients/LlamaSharpChatClient.cs @@ -0,0 +1,168 @@ +using System.Runtime.CompilerServices; +using System.Text; +using LLama; +using LLama.Common; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Options; + +namespace Nexo.AI.Pipeline.Clients; + +/// +/// adapter over in-process LLamaSharp GGUF inference. +/// Registered under keyed DI name local:onnx (see migration notes: not ONNX Runtime). +/// +public sealed class LlamaSharpChatClient : IChatClient +{ + private static readonly object Gate = new(); + private static LLamaWeights? s_weights; + private static LLamaContext? s_context; + private static string? s_loadedPath; + + private readonly MeaiPipelineOptions _options; + + /// Creates a LLamaSharp-backed chat client. + public LlamaSharpChatClient(IOptions options) + { + _options = options.Value; + } + + /// + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var text = await CompleteAsync(messages, options, cancellationToken).ConfigureAwait(false); + return new ChatResponse(new ChatMessage(ChatRole.Assistant, text)) + { + ModelId = options?.ModelId ?? "llamasharp-gguf", + }; + } + + /// + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // LocalModelProvider-style inference is session-based; surface as a single streamed chunk. + var text = await CompleteAsync(messages, options, cancellationToken).ConfigureAwait(false); + yield return new ChatResponseUpdate(ChatRole.Assistant, text) + { + ModelId = options?.ModelId ?? "llamasharp-gguf", + }; + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) + { + if (serviceType == typeof(ChatClientMetadata)) + { + return new ChatClientMetadata("llamasharp", providerUri: null, defaultModelId: "llamasharp-gguf"); + } + + return serviceType.IsInstanceOfType(this) ? this : null; + } + + /// + public void Dispose() + { + // Weights/context are process-scoped (shared with Hosting LocalModelProvider pattern). + } + + private async Task CompleteAsync( + IEnumerable messages, + ChatOptions? options, + CancellationToken cancellationToken) + { + var path = ResolveModelPath(); + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + throw new InvalidOperationException( + "Local model not configured. Set Nexo:Meai:LocalModelPath or NEXO_LOCAL_MODEL_PATH to a GGUF file."); + } + + EnsureLoaded(path, _options.LocalContextSize); + + if (s_weights is null || s_context is null) + { + throw new InvalidOperationException("Failed to load local GGUF model."); + } + + var prompt = BuildPrompt(messages); + var executor = new InteractiveExecutor(s_context); + var session = new ChatSession(executor); + var maxTokens = options?.MaxOutputTokens ?? _options.LocalMaxTokens; + var inferenceParams = new InferenceParams + { + MaxTokens = maxTokens, + AntiPrompts = new List { "User:", "user:" }, + }; + + var sb = new StringBuilder(); + await foreach (var token in session.ChatAsync( + new ChatHistory.Message(AuthorRole.User, prompt), + inferenceParams, + cancellationToken).ConfigureAwait(false)) + { + sb.Append(token); + } + + return sb.ToString(); + } + + private string? ResolveModelPath() + { + var path = _options.LocalModelPath; + if (string.IsNullOrWhiteSpace(path)) + { + path = Environment.GetEnvironmentVariable("NEXO_LOCAL_MODEL_PATH"); + } + + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + path = Environment.ExpandEnvironmentVariables(path.Trim()); + return Path.IsPathRooted(path) ? path : Path.GetFullPath(path); + } + + private static string BuildPrompt(IEnumerable messages) + { + var sb = new StringBuilder(); + foreach (var message in messages) + { + var role = message.Role == ChatRole.System ? "System" + : message.Role == ChatRole.Assistant ? "Assistant" + : "User"; + sb.Append(role).Append(": ").Append(message.Text).Append('\n'); + } + + return sb.ToString(); + } + + private static void EnsureLoaded(string path, int contextSize) + { + if (s_weights is not null && string.Equals(s_loadedPath, path, StringComparison.Ordinal)) + { + return; + } + + lock (Gate) + { + if (s_weights is not null && string.Equals(s_loadedPath, path, StringComparison.Ordinal)) + { + return; + } + + var parameters = new ModelParams(path) + { + ContextSize = (uint)Math.Max(256, contextSize), + }; + s_weights = LLamaWeights.LoadFromFile(parameters); + s_context = s_weights.CreateContext(parameters); + s_loadedPath = path; + } + } +} diff --git a/src/Nexo.AI.Pipeline/Clients/OllamaHttpChatClient.cs b/src/Nexo.AI.Pipeline/Clients/OllamaHttpChatClient.cs new file mode 100644 index 00000000..5d59533c --- /dev/null +++ b/src/Nexo.AI.Pipeline/Clients/OllamaHttpChatClient.cs @@ -0,0 +1,202 @@ +using System.Net.Http.Json; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Options; + +namespace Nexo.AI.Pipeline.Clients; + +/// +/// Thin over Ollama's native /api/chat HTTP API. +/// Avoids registering any Ollama SDK client in DI. +/// +public sealed class OllamaHttpChatClient : IChatClient, IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly HttpClient _http; + private readonly string _defaultModel; + private readonly bool _ownsHttp; + + /// Creates an Ollama HTTP chat client from pipeline options. + public OllamaHttpChatClient(IOptions options) + : this(CreateHttpClient(options.Value), ResolveModel(options.Value), ownsHttp: true) + { + } + + /// Creates an Ollama HTTP chat client with an injected (tests). + public OllamaHttpChatClient(HttpClient http, string defaultModel, bool ownsHttp = false) + { + _http = http ?? throw new ArgumentNullException(nameof(http)); + _defaultModel = string.IsNullOrWhiteSpace(defaultModel) ? "llama3.1:latest" : defaultModel; + _ownsHttp = ownsHttp; + } + + /// + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var payload = BuildRequest(messages, options, stream: false); + using var response = await _http.PostAsJsonAsync("api/chat", payload, JsonOptions, cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) + .ConfigureAwait(false); + var text = body?.Message?.Content ?? string.Empty; + return new ChatResponse(new ChatMessage(ChatRole.Assistant, text)) + { + ModelId = body?.Model ?? options?.ModelId ?? _defaultModel, + }; + } + + /// + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var payload = BuildRequest(messages, options, stream: true); + using var request = new HttpRequestMessage(HttpMethod.Post, "api/chat") + { + Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json"), + }; + using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var reader = new StreamReader(stream); + while (!reader.EndOfStream) + { + cancellationToken.ThrowIfCancellationRequested(); + var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var chunk = JsonSerializer.Deserialize(line, JsonOptions); + var content = chunk?.Message?.Content; + if (!string.IsNullOrEmpty(content)) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, content) + { + ModelId = chunk?.Model ?? options?.ModelId ?? _defaultModel, + }; + } + + if (chunk?.Done == true) + { + yield break; + } + } + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) + { + if (serviceType == typeof(ChatClientMetadata)) + { + return new ChatClientMetadata("ollama", _http.BaseAddress, _defaultModel); + } + + return serviceType.IsInstanceOfType(this) ? this : null; + } + + /// + public void Dispose() + { + if (_ownsHttp) + { + _http.Dispose(); + } + } + + private object BuildRequest(IEnumerable messages, ChatOptions? options, bool stream) + { + var model = string.IsNullOrWhiteSpace(options?.ModelId) ? _defaultModel : options!.ModelId!; + var ollamaMessages = messages.Select(m => new + { + role = MapRole(m.Role), + content = m.Text ?? string.Empty, + }).ToList(); + + return new + { + model, + messages = ollamaMessages, + stream, + options = options?.Temperature is null && options?.MaxOutputTokens is null + ? null + : new + { + temperature = options?.Temperature, + num_predict = options?.MaxOutputTokens, + }, + }; + } + + private static string MapRole(ChatRole role) + { + if (role == ChatRole.System) return "system"; + if (role == ChatRole.Assistant) return "assistant"; + if (role == ChatRole.Tool) return "tool"; + return "user"; + } + + private static HttpClient CreateHttpClient(MeaiPipelineOptions options) + { + var baseUrl = ResolveBaseUrl(options); + return new HttpClient + { + BaseAddress = new Uri(baseUrl.EndsWith('/') ? baseUrl : baseUrl + "/"), + Timeout = TimeSpan.FromSeconds(300), + }; + } + + private static string ResolveBaseUrl(MeaiPipelineOptions options) + { + var env = Environment.GetEnvironmentVariable("NEXO_OLLAMA_BASE_URL"); + if (!string.IsNullOrWhiteSpace(env)) + { + return env.Trim().TrimEnd('/'); + } + + return string.IsNullOrWhiteSpace(options.OllamaBaseUrl) + ? "http://localhost:11434" + : options.OllamaBaseUrl.Trim().TrimEnd('/'); + } + + private static string ResolveModel(MeaiPipelineOptions options) + { + var env = Environment.GetEnvironmentVariable("NEXO_OLLAMA_MODEL"); + if (!string.IsNullOrWhiteSpace(env)) + { + return env.Trim(); + } + + return string.IsNullOrWhiteSpace(options.OllamaModel) ? "llama3.1:latest" : options.OllamaModel.Trim(); + } + + private sealed class OllamaChatResponse + { + public string? Model { get; set; } + public OllamaMessage? Message { get; set; } + public bool Done { get; set; } + } + + private sealed class OllamaMessage + { + public string? Role { get; set; } + public string? Content { get; set; } + } +} diff --git a/src/Nexo.AI.Pipeline/Clients/SpyChatClient.cs b/src/Nexo.AI.Pipeline/Clients/SpyChatClient.cs new file mode 100644 index 00000000..effbb5b6 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Clients/SpyChatClient.cs @@ -0,0 +1,64 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace Nexo.AI.Pipeline.Clients; + +/// +/// Test spy that records invocations and returns a fixed response. +/// +public sealed class SpyChatClient : IChatClient +{ + private readonly string _response; + private readonly ConcurrentQueue> _calls = new(); + + /// Creates a spy client. + public SpyChatClient(string response = "spy-ok") + { + _response = response; + } + + /// Captured outbound message lists (one per call). + public IReadOnlyList> Calls => _calls.ToArray(); + + /// Number of times the spy was invoked. + public int CallCount => _calls.Count; + + /// + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + _calls.Enqueue(messages.ToList()); + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, _response)) + { + ModelId = options?.ModelId ?? "spy", + }); + } + + /// + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + _calls.Enqueue(messages.ToList()); + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, _response) + { + ModelId = options?.ModelId ?? "spy", + }; + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType.IsInstanceOfType(this) ? this : null; + + /// + public void Dispose() + { + } +} diff --git a/src/Nexo.AI.Pipeline/Embeddings/GovernedEmbeddingGenerator.cs b/src/Nexo.AI.Pipeline/Embeddings/GovernedEmbeddingGenerator.cs new file mode 100644 index 00000000..2a837bfe --- /dev/null +++ b/src/Nexo.AI.Pipeline/Embeddings/GovernedEmbeddingGenerator.cs @@ -0,0 +1,109 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; +using Nexo.AI.Pipeline.Governance; + +namespace Nexo.AI.Pipeline.Embeddings; + +/// +/// Sanitizes outbound embedding inputs (queries and documents) before generation. +/// +public sealed class SanitizingEmbeddingGenerator : DelegatingEmbeddingGenerator> +{ + private readonly IChatMessageSanitizer _sanitizer; + private readonly SanitizeDisposition _disposition; + + /// Creates a sanitizing embedding decorator. + public SanitizingEmbeddingGenerator( + IEmbeddingGenerator> inner, + IChatMessageSanitizer sanitizer, + SanitizeDisposition disposition = SanitizeDisposition.Redact) + : base(inner) + { + _sanitizer = sanitizer ?? throw new ArgumentNullException(nameof(sanitizer)); + _disposition = disposition; + } + + /// + public override async Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + var sanitized = new List(); + foreach (var value in values) + { + var messages = new List { new(ChatRole.User, value ?? string.Empty) }; + var result = _sanitizer.Sanitize(messages, targetKey: "embed:local", _disposition, cancellationToken); + if (!result.Allowed) + { + throw new PolicyViolationException( + code: "sanitization_blocked", + message: result.BlockReason ?? "Embedding input blocked by sanitization policy.", + targetKey: "embed:local"); + } + + sanitized.Add(result.Messages![0].Text ?? string.Empty); + // Set before awaiting the audit/provider layer so the inner auditor can read it. + SanitizationCallContext.Result = result; + } + + return await base.GenerateAsync(sanitized, options, cancellationToken).ConfigureAwait(false); + } +} + +/// +/// Audits embedding generation calls (counts only; never logs content). +/// +public sealed class AuditingEmbeddingGenerator : DelegatingEmbeddingGenerator> +{ + private readonly IChatInvocationAuditor _auditor; + + /// Creates an auditing embedding decorator. + public AuditingEmbeddingGenerator( + IEmbeddingGenerator> inner, + IChatInvocationAuditor auditor) + : base(inner) + { + _auditor = auditor ?? throw new ArgumentNullException(nameof(auditor)); + } + + /// + public override async Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + var materialised = values.ToList(); + var sw = Stopwatch.StartNew(); + try + { + var result = await base.GenerateAsync(materialised, options, cancellationToken).ConfigureAwait(false); + sw.Stop(); + var sanitize = SanitizationCallContext.Result; + _auditor.Record(new ChatInvocationAuditRecord + { + TargetKey = "embed:local", + Outcome = "success", + PolicyDecisions = new[] { "event=embedding", $"inputs={materialised.Count}" }, + RedactionCount = sanitize?.RedactionCount ?? 0, + RedactionCategories = sanitize?.Categories ?? Array.Empty(), + LatencyMs = sw.ElapsedMilliseconds, + }); + return result; + } + catch (Exception) + { + sw.Stop(); + _auditor.Record(new ChatInvocationAuditRecord + { + TargetKey = "embed:local", + Outcome = "fault", + PolicyDecisions = new[] { "event=embedding" }, + ReasonCode = "fault", + LatencyMs = sw.ElapsedMilliseconds, + }); + throw; + } + } +} diff --git a/src/Nexo.AI.Pipeline/Embeddings/TokenHashEmbeddingGenerator.cs b/src/Nexo.AI.Pipeline/Embeddings/TokenHashEmbeddingGenerator.cs new file mode 100644 index 00000000..0bd7bb0e --- /dev/null +++ b/src/Nexo.AI.Pipeline/Embeddings/TokenHashEmbeddingGenerator.cs @@ -0,0 +1,86 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; + +namespace Nexo.AI.Pipeline.Embeddings; + +/// +/// Deterministic local embedding generator (token-hash bag-of-words), MEAI-shaped. +/// Does not collide with Nexo.BackgroundAgents.RAG.IEmbeddingGenerator. +/// +public sealed class TokenHashEmbeddingGenerator : IEmbeddingGenerator> +{ + private readonly int _dimensions; + + /// Creates a generator with the given dimensionality (default 64). + public TokenHashEmbeddingGenerator(int dimensions = ChunkRecordDimensions.Default) + { + if (dimensions <= 0) + { + throw new ArgumentOutOfRangeException(nameof(dimensions)); + } + + _dimensions = dimensions; + } + + /// + public Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + var list = new List>(); + foreach (var value in values) + { + cancellationToken.ThrowIfCancellationRequested(); + list.Add(new Embedding(Embed(value ?? string.Empty))); + } + + return Task.FromResult(new GeneratedEmbeddings>(list)); + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType.IsInstanceOfType(this) ? this : null; + + /// + public void Dispose() + { + } + + private float[] Embed(string text) + { + var vector = new float[_dimensions]; + var tokens = text.Split([' ', '\t', '\r', '\n', ',', '.', ';', ':', '!', '?'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var token in tokens) + { + var hash = token.ToLowerInvariant().GetHashCode(); + var index = Math.Abs(hash) % _dimensions; + vector[index] += 1f; + } + + // L2 normalize + double sumSq = 0; + for (var i = 0; i < vector.Length; i++) + { + sumSq += vector[i] * vector[i]; + } + + if (sumSq > double.Epsilon) + { + var norm = (float)Math.Sqrt(sumSq); + for (var i = 0; i < vector.Length; i++) + { + vector[i] /= norm; + } + } + + return vector; + } +} + +/// Shared embedding dimension constant to avoid circular refs with Rag namespace. +internal static class ChunkRecordDimensions +{ + public const int Default = 64; +} diff --git a/src/Nexo.AI.Pipeline/Governance/AuditingChatClient.cs b/src/Nexo.AI.Pipeline/Governance/AuditingChatClient.cs new file mode 100644 index 00000000..96789776 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Governance/AuditingChatClient.cs @@ -0,0 +1,173 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace Nexo.AI.Pipeline.Governance; + +/// +/// Emits an audit record for every invocation. Streaming aggregates into one record at completion +/// (or a distinct record on cancellation/fault). +/// +public sealed class AuditingChatClient : DelegatingChatClient +{ + private readonly IChatInvocationAuditor _auditor; + private readonly string _targetKey; + private readonly Func? _correlationIdAccessor; + + /// Creates an auditing decorator. + public AuditingChatClient( + IChatClient innerClient, + IChatInvocationAuditor auditor, + string targetKey, + Func? correlationIdAccessor = null) + : base(innerClient) + { + _auditor = auditor ?? throw new ArgumentNullException(nameof(auditor)); + _targetKey = targetKey ?? throw new ArgumentNullException(nameof(targetKey)); + _correlationIdAccessor = correlationIdAccessor; + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var sw = Stopwatch.StartNew(); + try + { + var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + sw.Stop(); + Emit("success", response.ModelId ?? options?.ModelId, sw.ElapsedMilliseconds, response.Usage); + return response; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + sw.Stop(); + Emit("cancelled", options?.ModelId, sw.ElapsedMilliseconds, reasonCode: "cancelled"); + throw; + } + catch (PolicyViolationException ex) + { + sw.Stop(); + Emit("denied", options?.ModelId ?? ex.ModelId, sw.ElapsedMilliseconds, reasonCode: ex.Code); + throw; + } + catch (Exception) + { + sw.Stop(); + Emit("fault", options?.ModelId, sw.ElapsedMilliseconds, reasonCode: "fault"); + throw; + } + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var sw = Stopwatch.StartNew(); + string? modelId = options?.ModelId; + UsageDetails? usage = null; + var completed = false; + + IAsyncEnumerator? enumerator = null; + try + { + enumerator = base.GetStreamingResponseAsync(messages, options, cancellationToken) + .GetAsyncEnumerator(cancellationToken); + + while (true) + { + bool moved; + try + { + moved = await enumerator.MoveNextAsync().ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + sw.Stop(); + Emit("cancelled", modelId, sw.ElapsedMilliseconds, usage, "cancelled"); + throw; + } + catch (PolicyViolationException ex) + { + sw.Stop(); + Emit("denied", modelId ?? ex.ModelId, sw.ElapsedMilliseconds, usage, ex.Code); + throw; + } + catch (Exception) + { + sw.Stop(); + Emit("fault", modelId, sw.ElapsedMilliseconds, usage, "fault"); + throw; + } + + if (!moved) + { + break; + } + + var update = enumerator.Current; + if (!string.IsNullOrWhiteSpace(update.ModelId)) + { + modelId = update.ModelId; + } + + foreach (var content in update.Contents) + { + if (content is UsageContent usageContent) + { + usage = usageContent.Details; + } + } + + yield return update; + } + + completed = true; + sw.Stop(); + Emit("success", modelId, sw.ElapsedMilliseconds, usage); + } + finally + { + if (enumerator is not null) + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + + _ = completed; + } + } + + private void Emit( + string outcome, + string? modelId, + long latencyMs, + UsageDetails? usage = null, + string? reasonCode = null) + { + var sanitize = SanitizationCallContext.Result; + var decisions = new List { $"target={_targetKey}", $"outcome={outcome}" }; + if (sanitize is not null) + { + decisions.Add($"sanitize_redactions={sanitize.RedactionCount}"); + } + + _auditor.Record(new ChatInvocationAuditRecord + { + TargetKey = _targetKey, + ModelId = modelId, + Outcome = outcome, + PolicyDecisions = decisions, + RedactionCount = sanitize?.RedactionCount ?? 0, + RedactionCategories = sanitize?.Categories ?? Array.Empty(), + InputTokenCount = usage?.InputTokenCount, + OutputTokenCount = usage?.OutputTokenCount, + LatencyMs = latencyMs, + CorrelationId = _correlationIdAccessor?.Invoke(), + ReasonCode = reasonCode, + }); + } +} diff --git a/src/Nexo.AI.Pipeline/Governance/DefaultChatMessageSanitizer.cs b/src/Nexo.AI.Pipeline/Governance/DefaultChatMessageSanitizer.cs new file mode 100644 index 00000000..b889f4dd --- /dev/null +++ b/src/Nexo.AI.Pipeline/Governance/DefaultChatMessageSanitizer.cs @@ -0,0 +1,129 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.AI; + +namespace Nexo.AI.Pipeline.Governance; + +/// +/// Built-in PII/secret filter used when no host-specific sanitizer is registered. +/// Mirrors the categories covered by Nexo's SensitiveContentFilter. +/// +public sealed partial class DefaultChatMessageSanitizer : IChatMessageSanitizer +{ + /// + public MessageSanitizationResult Sanitize( + IList messages, + string targetKey, + SanitizeDisposition disposition, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + _ = targetKey; + + if (disposition == SanitizeDisposition.Pass) + { + return MessageSanitizationResult.Allow(messages); + } + + var categories = new HashSet(StringComparer.OrdinalIgnoreCase); + var redactionCount = 0; + var sanitized = new List(messages.Count); + + foreach (var message in messages) + { + var original = message.Text ?? string.Empty; + Detect(original, categories, out var hasSecret, out var hasPii); + + if (disposition == SanitizeDisposition.BlockOnPiiOrSecret && (hasSecret || hasPii)) + { + return MessageSanitizationResult.Block( + "Outbound content contains PII or secrets; blocked per policy.", + categories.ToList()); + } + + if (disposition == SanitizeDisposition.BlockOnSecretRedactOnPii && hasSecret) + { + return MessageSanitizationResult.Block( + "Outbound content contains secrets; blocked per policy.", + categories.ToList()); + } + + if (disposition is SanitizeDisposition.Redact + or SanitizeDisposition.BlockOnSecretRedactOnPii + or SanitizeDisposition.BlockOnPiiOrSecret) + { + var filtered = Redact(original); + if (!string.Equals(filtered, original, StringComparison.Ordinal)) + { + redactionCount++; + sanitized.Add(new ChatMessage(message.Role, filtered)); + continue; + } + } + + sanitized.Add(message); + } + + return MessageSanitizationResult.Allow(sanitized, redactionCount, categories.ToList()); + } + + private static void Detect(string text, ISet categories, out bool hasSecret, out bool hasPii) + { + hasSecret = false; + hasPii = false; + + if (ApiKeyRegex().IsMatch(text)) + { + hasSecret = true; + categories.Add("api-key"); + } + + if (EmailRegex().IsMatch(text)) + { + hasPii = true; + categories.Add("email"); + } + + if (PhoneRegex().IsMatch(text)) + { + hasPii = true; + categories.Add("phone"); + } + + if (SsnRegex().IsMatch(text)) + { + hasPii = true; + categories.Add("ssn"); + } + + if (CreditCardRegex().IsMatch(text)) + { + hasPii = true; + categories.Add("credit-card"); + } + } + + private static string Redact(string text) + { + text = ApiKeyRegex().Replace(text, "[REDACTED_API_KEY]"); + text = EmailRegex().Replace(text, "[REDACTED_EMAIL]"); + text = PhoneRegex().Replace(text, "[REDACTED_PHONE]"); + text = SsnRegex().Replace(text, "[REDACTED_SSN]"); + text = CreditCardRegex().Replace(text, "[REDACTED_CC]"); + return text; + } + + [GeneratedRegex(@"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex EmailRegex(); + + [GeneratedRegex(@"\b(?:\+?1[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)\d{3}[-.\s]?\d{4}\b", RegexOptions.CultureInvariant)] + private static partial Regex PhoneRegex(); + + [GeneratedRegex(@"\b\d{3}-\d{2}-\d{4}\b", RegexOptions.CultureInvariant)] + private static partial Regex SsnRegex(); + + [GeneratedRegex(@"\b(?:sk-|AKIA|ghp_|xox[baprs]-)[A-Za-z0-9\-_]{8,}\b", RegexOptions.CultureInvariant)] + private static partial Regex ApiKeyRegex(); + + [GeneratedRegex(@"\b(?:\d[ -]*?){13,19}\b", RegexOptions.CultureInvariant)] + private static partial Regex CreditCardRegex(); +} diff --git a/src/Nexo.AI.Pipeline/Governance/DefaultChatTargetAccessPolicy.cs b/src/Nexo.AI.Pipeline/Governance/DefaultChatTargetAccessPolicy.cs new file mode 100644 index 00000000..7181dd65 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Governance/DefaultChatTargetAccessPolicy.cs @@ -0,0 +1,47 @@ +namespace Nexo.AI.Pipeline.Governance; + +/// +/// Default local-first access policy: allows all local:* targets; denies cloud:* +/// unless explicitly listed in . +/// +public sealed class DefaultChatTargetAccessPolicy : IChatTargetAccessPolicy +{ + /// Optional allow-list of cloud target keys. + public ISet AllowedCloudTargets { get; } = + new HashSet(StringComparer.OrdinalIgnoreCase); + + /// + public bool IsAllowed(string? callerIdentity, string targetKey, string? modelId, out string? denyReason) + { + _ = callerIdentity; + _ = modelId; + + if (string.IsNullOrWhiteSpace(targetKey)) + { + denyReason = "Target key is required."; + return false; + } + + if (targetKey.StartsWith("local:", StringComparison.OrdinalIgnoreCase) + || targetKey.StartsWith("peer:", StringComparison.OrdinalIgnoreCase)) + { + denyReason = null; + return true; + } + + if (targetKey.StartsWith("cloud:", StringComparison.OrdinalIgnoreCase)) + { + if (AllowedCloudTargets.Contains(targetKey)) + { + denyReason = null; + return true; + } + + denyReason = $"Cloud target '{targetKey}' is not permitted by the active policy pack."; + return false; + } + + denyReason = $"Unknown target trust tier for '{targetKey}'."; + return false; + } +} diff --git a/src/Nexo.AI.Pipeline/Governance/IChatInvocationAuditor.cs b/src/Nexo.AI.Pipeline/Governance/IChatInvocationAuditor.cs new file mode 100644 index 00000000..b0ae44c8 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Governance/IChatInvocationAuditor.cs @@ -0,0 +1,52 @@ +namespace Nexo.AI.Pipeline.Governance; + +/// +/// Audit record for a single model invocation (no raw prompt/response content). +/// +public sealed class ChatInvocationAuditRecord +{ + /// UTC timestamp. + public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; + + /// Target key (e.g. local:ollama). + public required string TargetKey { get; init; } + + /// Model id when known. + public string? ModelId { get; init; } + + /// Outcome: success, fault, cancelled, denied. + public required string Outcome { get; init; } + + /// Policy decisions applied (e.g. allowed, sanitize=redact). + public IReadOnlyList PolicyDecisions { get; init; } = Array.Empty(); + + /// Redaction count (content never included). + public int RedactionCount { get; init; } + + /// Redaction categories. + public IReadOnlyList RedactionCategories { get; init; } = Array.Empty(); + + /// Input token count when available. + public long? InputTokenCount { get; init; } + + /// Output token count when available. + public long? OutputTokenCount { get; init; } + + /// Latency in milliseconds. + public long LatencyMs { get; init; } + + /// Optional correlation / barrier identity. + public string? CorrelationId { get; init; } + + /// Fault or deny reason code (no secrets). + public string? ReasonCode { get; init; } +} + +/// +/// Receives model-invocation audit records. +/// +public interface IChatInvocationAuditor +{ + /// Persists or fans out an audit record. + void Record(ChatInvocationAuditRecord record); +} diff --git a/src/Nexo.AI.Pipeline/Governance/IChatMessageSanitizer.cs b/src/Nexo.AI.Pipeline/Governance/IChatMessageSanitizer.cs new file mode 100644 index 00000000..e62cfdd0 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Governance/IChatMessageSanitizer.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.AI; + +namespace Nexo.AI.Pipeline.Governance; + +/// +/// Result of sanitizing outbound chat messages. +/// +public sealed class MessageSanitizationResult +{ + private MessageSanitizationResult( + bool allowed, + IList? messages, + string? blockReason, + int redactionCount, + IReadOnlyList categories) + { + Allowed = allowed; + Messages = messages; + BlockReason = blockReason; + RedactionCount = redactionCount; + Categories = categories; + } + + /// Whether the call may proceed. + public bool Allowed { get; } + + /// Sanitized messages when allowed. + public IList? Messages { get; } + + /// Block reason when not allowed. + public string? BlockReason { get; } + + /// Number of redactions applied (never includes content). + public int RedactionCount { get; } + + /// Redaction/block categories (e.g. email, api-key). + public IReadOnlyList Categories { get; } + + /// Creates an allowed result. + public static MessageSanitizationResult Allow( + IList messages, + int redactionCount = 0, + IReadOnlyList? categories = null) => + new(true, messages, null, redactionCount, categories ?? Array.Empty()); + + /// Creates a blocked result. + public static MessageSanitizationResult Block(string reason, IReadOnlyList? categories = null) => + new(false, null, reason, 0, categories ?? Array.Empty()); +} + +/// +/// Sanitizes outbound messages for a destination target. +/// +public interface IChatMessageSanitizer +{ + /// Sanitizes all outbound messages according to the target's disposition. + MessageSanitizationResult Sanitize( + IList messages, + string targetKey, + SanitizeDisposition disposition, + CancellationToken cancellationToken = default); +} diff --git a/src/Nexo.AI.Pipeline/Governance/IChatTargetAccessPolicy.cs b/src/Nexo.AI.Pipeline/Governance/IChatTargetAccessPolicy.cs new file mode 100644 index 00000000..02b5dd7b --- /dev/null +++ b/src/Nexo.AI.Pipeline/Governance/IChatTargetAccessPolicy.cs @@ -0,0 +1,12 @@ +namespace Nexo.AI.Pipeline.Governance; + +/// +/// Evaluates whether a caller may invoke a given target/model. +/// +public interface IChatTargetAccessPolicy +{ + /// + /// Returns true when the invocation is permitted. + /// + bool IsAllowed(string? callerIdentity, string targetKey, string? modelId, out string? denyReason); +} diff --git a/src/Nexo.AI.Pipeline/Governance/ITargetSanitizePolicy.cs b/src/Nexo.AI.Pipeline/Governance/ITargetSanitizePolicy.cs new file mode 100644 index 00000000..5be65096 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Governance/ITargetSanitizePolicy.cs @@ -0,0 +1,28 @@ +namespace Nexo.AI.Pipeline.Governance; + +/// +/// Per-target governance settings resolved for sanitization middleware. +/// +public interface ITargetSanitizePolicy +{ + /// Resolves the sanitize disposition for a target key. + SanitizeDisposition GetDisposition(string targetKey); +} + +/// +/// Default dispositions: local pass-through; cloud block-on-secret / redact-on-PII. +/// +public sealed class DefaultTargetSanitizePolicy : ITargetSanitizePolicy +{ + /// + public SanitizeDisposition GetDisposition(string targetKey) + { + if (targetKey.StartsWith("cloud:", StringComparison.OrdinalIgnoreCase)) + { + return SanitizeDisposition.BlockOnSecretRedactOnPii; + } + + // local:* and peer:* — pass by default (policy packs can override later). + return SanitizeDisposition.Pass; + } +} diff --git a/src/Nexo.AI.Pipeline/Governance/InMemoryChatInvocationAuditor.cs b/src/Nexo.AI.Pipeline/Governance/InMemoryChatInvocationAuditor.cs new file mode 100644 index 00000000..799bcd7a --- /dev/null +++ b/src/Nexo.AI.Pipeline/Governance/InMemoryChatInvocationAuditor.cs @@ -0,0 +1,21 @@ +using System.Collections.Concurrent; + +namespace Nexo.AI.Pipeline.Governance; + +/// +/// In-memory auditor for tests and hosts that have not wired trust audit yet. +/// +public sealed class InMemoryChatInvocationAuditor : IChatInvocationAuditor +{ + private readonly ConcurrentQueue _records = new(); + + /// Snapshot of recorded audits (newest last). + public IReadOnlyList Records => _records.ToArray(); + + /// + public void Record(ChatInvocationAuditRecord record) + { + ArgumentNullException.ThrowIfNull(record); + _records.Enqueue(record); + } +} diff --git a/src/Nexo.AI.Pipeline/Governance/NexoGovernanceChatClientBuilderExtensions.cs b/src/Nexo.AI.Pipeline/Governance/NexoGovernanceChatClientBuilderExtensions.cs new file mode 100644 index 00000000..023d250c --- /dev/null +++ b/src/Nexo.AI.Pipeline/Governance/NexoGovernanceChatClientBuilderExtensions.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace Nexo.AI.Pipeline.Governance; + +/// +/// Fixed-order governance composition for Nexo chat clients. +/// +public static class NexoGovernanceChatClientBuilderExtensions +{ + /// + /// Applies PolicyGate → Sanitizing → Auditing around the inner provider client. + /// Hosts must use this extension so middleware cannot be mis-ordered. + /// + public static ChatClientBuilder UseNexoGovernance(this ChatClientBuilder builder, string targetKey) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(targetKey); + + // First Use = outermost → PolicyGate runs first. + builder.Use((inner, sp) => + { + var policy = sp.GetRequiredService(); + var auditor = sp.GetRequiredService(); + return new PolicyGateChatClient(inner, policy, auditor, targetKey); + }); + + builder.Use((inner, sp) => + { + var sanitizer = sp.GetRequiredService(); + var sanitizePolicy = sp.GetRequiredService(); + var auditor = sp.GetRequiredService(); + return new SanitizingChatClient(inner, sanitizer, sanitizePolicy, auditor, targetKey); + }); + + builder.Use((inner, sp) => + { + var auditor = sp.GetRequiredService(); + return new AuditingChatClient(inner, auditor, targetKey); + }); + + return builder; + } +} diff --git a/src/Nexo.AI.Pipeline/Governance/PolicyGateChatClient.cs b/src/Nexo.AI.Pipeline/Governance/PolicyGateChatClient.cs new file mode 100644 index 00000000..bb5b8bdf --- /dev/null +++ b/src/Nexo.AI.Pipeline/Governance/PolicyGateChatClient.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.AI; + +namespace Nexo.AI.Pipeline.Governance; + +/// +/// Outermost middleware: denies targets not permitted by the access policy. +/// +public sealed class PolicyGateChatClient : DelegatingChatClient +{ + private readonly IChatTargetAccessPolicy _policy; + private readonly IChatInvocationAuditor _auditor; + private readonly string _targetKey; + private readonly Func? _callerIdentityAccessor; + + /// Creates a policy gate around an inner client. + public PolicyGateChatClient( + IChatClient innerClient, + IChatTargetAccessPolicy policy, + IChatInvocationAuditor auditor, + string targetKey, + Func? callerIdentityAccessor = null) + : base(innerClient) + { + _policy = policy ?? throw new ArgumentNullException(nameof(policy)); + _auditor = auditor ?? throw new ArgumentNullException(nameof(auditor)); + _targetKey = targetKey ?? throw new ArgumentNullException(nameof(targetKey)); + _callerIdentityAccessor = callerIdentityAccessor; + } + + /// Target key this gate protects. + public string TargetKey => _targetKey; + + /// + public override Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + EnsureAllowed(options); + return base.GetResponseAsync(messages, options, cancellationToken); + } + + /// + public override IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + EnsureAllowed(options); + return base.GetStreamingResponseAsync(messages, options, cancellationToken); + } + + private void EnsureAllowed(ChatOptions? options) + { + var caller = _callerIdentityAccessor?.Invoke(); + if (_policy.IsAllowed(caller, _targetKey, options?.ModelId, out var reason)) + { + return; + } + + _auditor.Record(new ChatInvocationAuditRecord + { + TargetKey = _targetKey, + ModelId = options?.ModelId, + Outcome = "denied", + PolicyDecisions = new[] { $"target={_targetKey}", "decision=deny" }, + ReasonCode = "target_denied", + }); + + throw new PolicyViolationException( + code: "target_denied", + message: reason ?? $"Target '{_targetKey}' is not permitted.", + targetKey: _targetKey, + modelId: options?.ModelId); + } +} diff --git a/src/Nexo.AI.Pipeline/Governance/PolicyViolationException.cs b/src/Nexo.AI.Pipeline/Governance/PolicyViolationException.cs new file mode 100644 index 00000000..6c3c19ac --- /dev/null +++ b/src/Nexo.AI.Pipeline/Governance/PolicyViolationException.cs @@ -0,0 +1,34 @@ +namespace Nexo.AI.Pipeline.Governance; + +/// +/// Thrown when policy denies a model invocation or sanitization blocks the request. +/// +public sealed class PolicyViolationException : Exception +{ + /// Creates a structured policy violation. + public PolicyViolationException( + string code, + string message, + string? targetKey = null, + string? modelId = null, + IReadOnlyDictionary? details = null) + : base(message) + { + Code = code; + TargetKey = targetKey; + ModelId = modelId; + Details = details ?? new Dictionary(); + } + + /// Machine-readable reason code (e.g. target_denied, pii_blocked). + public string Code { get; } + + /// Target key that was evaluated, when known. + public string? TargetKey { get; } + + /// Model id that was evaluated, when known. + public string? ModelId { get; } + + /// Additional structured details (never contains raw secret/PII content). + public IReadOnlyDictionary Details { get; } +} diff --git a/src/Nexo.AI.Pipeline/Governance/SanitizationCallContext.cs b/src/Nexo.AI.Pipeline/Governance/SanitizationCallContext.cs new file mode 100644 index 00000000..6a321405 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Governance/SanitizationCallContext.cs @@ -0,0 +1,15 @@ +namespace Nexo.AI.Pipeline.Governance; + +/// +/// Flows the latest sanitization summary across the governance stack for the current async call. +/// +internal static class SanitizationCallContext +{ + private static readonly AsyncLocal Current = new(); + + public static MessageSanitizationResult? Result + { + get => Current.Value; + set => Current.Value = value; + } +} diff --git a/src/Nexo.AI.Pipeline/Governance/SanitizeDisposition.cs b/src/Nexo.AI.Pipeline/Governance/SanitizeDisposition.cs new file mode 100644 index 00000000..17fa931e --- /dev/null +++ b/src/Nexo.AI.Pipeline/Governance/SanitizeDisposition.cs @@ -0,0 +1,19 @@ +namespace Nexo.AI.Pipeline.Governance; + +/// +/// How outbound content is treated for a given target. +/// +public enum SanitizeDisposition +{ + /// Do not alter outbound content (typical for air-gapped/local-only targets). + Pass = 0, + + /// Redact PII/secrets and continue. + Redact = 1, + + /// Block the call when secrets are found; redact PII otherwise (strict cloud default). + BlockOnSecretRedactOnPii = 2, + + /// Block the call when any PII or secret is found. + BlockOnPiiOrSecret = 3, +} diff --git a/src/Nexo.AI.Pipeline/Governance/SanitizingChatClient.cs b/src/Nexo.AI.Pipeline/Governance/SanitizingChatClient.cs new file mode 100644 index 00000000..9bb95c52 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Governance/SanitizingChatClient.cs @@ -0,0 +1,91 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace Nexo.AI.Pipeline.Governance; + +/// +/// Sanitizes outbound messages before delegation. Streaming responses pass through untouched. +/// +public sealed class SanitizingChatClient : DelegatingChatClient +{ + private readonly IChatMessageSanitizer _sanitizer; + private readonly ITargetSanitizePolicy _policy; + private readonly IChatInvocationAuditor _auditor; + private readonly string _targetKey; + + /// Creates a sanitizing decorator. + public SanitizingChatClient( + IChatClient innerClient, + IChatMessageSanitizer sanitizer, + ITargetSanitizePolicy policy, + IChatInvocationAuditor auditor, + string targetKey) + : base(innerClient) + { + _sanitizer = sanitizer ?? throw new ArgumentNullException(nameof(sanitizer)); + _policy = policy ?? throw new ArgumentNullException(nameof(policy)); + _auditor = auditor ?? throw new ArgumentNullException(nameof(auditor)); + _targetKey = targetKey ?? throw new ArgumentNullException(nameof(targetKey)); + } + + /// Last sanitization summary for the current call path (for auditing). + public MessageSanitizationResult? LastResult { get; private set; } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var sanitized = SanitizeOrThrow(messages, cancellationToken); + return await base.GetResponseAsync(sanitized, options, cancellationToken).ConfigureAwait(false); + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var sanitized = SanitizeOrThrow(messages, cancellationToken); + await foreach (var update in base.GetStreamingResponseAsync(sanitized, options, cancellationToken) + .ConfigureAwait(false)) + { + yield return update; + } + } + + private IList SanitizeOrThrow( + IEnumerable messages, + CancellationToken cancellationToken) + { + var list = messages as IList ?? messages.ToList(); + var disposition = _policy.GetDisposition(_targetKey); + var result = _sanitizer.Sanitize(list, _targetKey, disposition, cancellationToken); + LastResult = result; + SanitizationCallContext.Result = result; + + if (!result.Allowed) + { + _auditor.Record(new ChatInvocationAuditRecord + { + TargetKey = _targetKey, + Outcome = "denied", + PolicyDecisions = new[] { $"target={_targetKey}", "decision=sanitize_block" }, + RedactionCount = 0, + RedactionCategories = result.Categories, + ReasonCode = "sanitization_blocked", + }); + + throw new PolicyViolationException( + code: "sanitization_blocked", + message: result.BlockReason ?? "Request blocked by sanitization policy.", + targetKey: _targetKey, + details: result.Categories.Count == 0 + ? null + : new Dictionary { ["categories"] = string.Join(',', result.Categories) }); + } + + return result.Messages!; + } +} diff --git a/src/Nexo.AI.Pipeline/MeaiPipelineOptions.cs b/src/Nexo.AI.Pipeline/MeaiPipelineOptions.cs new file mode 100644 index 00000000..996c738d --- /dev/null +++ b/src/Nexo.AI.Pipeline/MeaiPipelineOptions.cs @@ -0,0 +1,44 @@ +namespace Nexo.AI.Pipeline; + +/// +/// Configuration for the Microsoft.Extensions.AI pipeline. +/// Bound from Nexo:Meai (and feature flag Nexo:UseMeaiPipeline). +/// +public sealed class MeaiPipelineOptions +{ + /// Configuration section for nested MEAI options. + public const string SectionName = "Nexo:Meai"; + + /// Feature-flag configuration key. + public const string FeatureFlagKey = "Nexo:UseMeaiPipeline"; + + /// Environment variable that enables the MEAI pipeline when set to 1 or true. + public const string FeatureFlagEnvVar = "NEXO_USE_MEAI_PIPELINE"; + + /// Ollama base URL (default localhost:11434). + public string OllamaBaseUrl { get; set; } = "http://localhost:11434"; + + /// Default Ollama model id. + public string OllamaModel { get; set; } = "llama3.1:latest"; + + /// + /// Path to a GGUF model for the local:onnx (LLamaSharp) target. + /// Falls back to NEXO_LOCAL_MODEL_PATH when unset. + /// + public string? LocalModelPath { get; set; } + + /// Context size for LLamaSharp local inference. + public int LocalContextSize { get; set; } = 2048; + + /// Max tokens for LLamaSharp local inference. + public int LocalMaxTokens { get; set; } = 4096; + + /// AWS Bedrock tiered cloud targets. + public BedrockMeaiOptions Bedrock { get; set; } = new(); + + /// + /// Explicit cloud target keys the default access policy should allow + /// (e.g. cloud:bedrock:balanced). Empty = cloud denied. + /// + public List AllowedCloudTargets { get; set; } = new(); +} diff --git a/src/Nexo.AI.Pipeline/MeaiPipelineServiceCollectionExtensions.cs b/src/Nexo.AI.Pipeline/MeaiPipelineServiceCollectionExtensions.cs new file mode 100644 index 00000000..9901200b --- /dev/null +++ b/src/Nexo.AI.Pipeline/MeaiPipelineServiceCollectionExtensions.cs @@ -0,0 +1,347 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Nexo.AI.Pipeline.Clients; +using Nexo.AI.Pipeline.Embeddings; +using Nexo.AI.Pipeline.Governance; +using Nexo.AI.Pipeline.Rag; +using Nexo.AI.Pipeline.Routing; + +namespace Nexo.AI.Pipeline; + +/// +/// DI registration for the MEAI chat pipeline (feature-flagged). +/// +public static class MeaiPipelineServiceCollectionExtensions +{ + /// + /// Returns true when the MEAI pipeline should be registered. + /// Phase 6+: defaults to true. Opt out with config false/0 + /// or env NEXO_USE_MEAI_PIPELINE=0|false. + /// + public static bool IsMeaiPipelineEnabled(IConfiguration? configuration, bool? explicitEnable = null) + { + if (explicitEnable.HasValue) + { + return explicitEnable.Value; + } + + if (configuration is not null) + { + var flagged = configuration[MeaiPipelineOptions.FeatureFlagKey]; + if (!string.IsNullOrWhiteSpace(flagged)) + { + if (bool.TryParse(flagged, out var parsed)) + { + return parsed; + } + + if (string.Equals(flagged, "1", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (string.Equals(flagged, "0", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + } + + var env = Environment.GetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar); + if (string.Equals(env, "0", StringComparison.OrdinalIgnoreCase) + || string.Equals(env, "false", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (string.Equals(env, "1", StringComparison.OrdinalIgnoreCase) + || string.Equals(env, "true", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Phase 6 default: MEAI pipeline on. + return true; + } + + /// + /// Registers keyed local (and optional Bedrock) governed pipelines plus an auditing router. + /// Raw provider clients are never registered in DI. + /// + public static IServiceCollection AddNexoMeaiPipeline( + this IServiceCollection services, + IConfiguration? configuration = null, + Action? configure = null, + Func? ollamaInnerFactory = null, + Func? onnxInnerFactory = null, + Func? bedrockInnerFactory = null, + bool registerDefaultRouter = true) + { + var options = new MeaiPipelineOptions(); + if (configuration is not null) + { + BindSection(configuration.GetSection(MeaiPipelineOptions.SectionName), options); + } + + configure?.Invoke(options); + ApplyBedrockAllowListDefaults(options); + services.AddSingleton(Options.Create(options)); + + RegisterGovernanceDefaults(services, options); + RegisterRoutingDefaults(services); + services.TryAddSingleton(); + + Func defaultOllama = sp => + new OllamaHttpChatClient(sp.GetRequiredService>()); + Func defaultOnnx = sp => + new LlamaSharpChatClient(sp.GetRequiredService>()); + + services.AddKeyedChatClient( + MeaiTargetKeys.LocalOllama, + sp => (ollamaInnerFactory ?? defaultOllama)(sp)) + .UseNexoGovernance(MeaiTargetKeys.LocalOllama); + + services.AddKeyedChatClient( + MeaiTargetKeys.LocalOnnx, + sp => (onnxInnerFactory ?? defaultOnnx)(sp)) + .UseNexoGovernance(MeaiTargetKeys.LocalOnnx); + + if (options.Bedrock.Enabled) + { + RegisterBedrockTier(services, options, bedrockInnerFactory); + } + + RegisterVectorDataRag(services); + + if (registerDefaultRouter) + { + services.AddChatClient(sp => + { + var router = ActivatorUtilities.CreateInstance(sp); + var auditor = sp.GetRequiredService(); + return new AuditingChatClient(router, auditor, RoutingChatClient.RouterTargetKey); + }); + } + + return services; + } + + /// + /// Registers VectorData RAG (in-process store + governed embeddings). Legacy RAG remains until Phase 6. + /// + public static IServiceCollection RegisterVectorDataRag(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(sp => + { + var store = sp.GetRequiredService(); + return store.GetCollection(VectorDataRagService.DefaultCollectionName); + }); + services.TryAddSingleton>>(sp => + { + // Same stack as chat governance: Sanitizing → Auditing → provider. + // Auditing must sit inside Sanitizing so SanitizationCallContext is visible + // while Emit runs (AsyncLocal does not flow back to an outer awaiter). + IEmbeddingGenerator> inner = new TokenHashEmbeddingGenerator(); + var sanitizer = sp.GetRequiredService(); + var auditor = sp.GetRequiredService(); + inner = new AuditingEmbeddingGenerator(inner, auditor); + inner = new SanitizingEmbeddingGenerator(inner, sanitizer, SanitizeDisposition.Redact); + return inner; + }); + services.TryAddSingleton(); + return services; + } + + /// + /// Registers a governed keyed for an additional target. + /// + public static ChatClientBuilder AddNexoGovernedChatClient( + this IServiceCollection services, + string targetKey, + Func innerFactory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(targetKey); + RegisterGovernanceDefaults(services); + return services.AddKeyedChatClient(targetKey, innerFactory) + .UseNexoGovernance(targetKey); + } + + /// Registers default governance services if not already present. + public static IServiceCollection RegisterGovernanceDefaults(this IServiceCollection services) => + RegisterGovernanceDefaults(services, options: null); + + /// Registers default governance services, applying cloud allow-list from options. + public static IServiceCollection RegisterGovernanceDefaults( + this IServiceCollection services, + MeaiPipelineOptions? options) + { + services.TryAddSingleton(_ => + { + var policy = new DefaultChatTargetAccessPolicy(); + if (options?.AllowedCloudTargets is { Count: > 0 } allowed) + { + foreach (var key in allowed) + { + if (!string.IsNullOrWhiteSpace(key)) + { + policy.AllowedCloudTargets.Add(key.Trim()); + } + } + } + + return policy; + }); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + /// Registers default routing services if not already present. + public static IServiceCollection RegisterRoutingDefaults(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + private static void RegisterBedrockTier( + IServiceCollection services, + MeaiPipelineOptions options, + Func? bedrockInnerFactory) + { + RegisterTier( + services, + DefaultRouteCandidateTable.CloudBedrockFast, + options.Bedrock.FastModelId, + bedrockInnerFactory); + RegisterTier( + services, + DefaultRouteCandidateTable.CloudBedrockBalanced, + options.Bedrock.BalancedModelId, + bedrockInnerFactory); + RegisterTier( + services, + DefaultRouteCandidateTable.CloudBedrockHeavy, + options.Bedrock.HeavyModelId, + bedrockInnerFactory); + } + + private static void RegisterTier( + IServiceCollection services, + string targetKey, + string modelId, + Func? bedrockInnerFactory) + { + services.AddKeyedChatClient( + targetKey, + sp => + { + if (bedrockInnerFactory is not null) + { + return bedrockInnerFactory(sp, modelId); + } + + return sp.GetRequiredService().Create(modelId); + }) + .UseNexoGovernance(targetKey); + } + + private static void ApplyBedrockAllowListDefaults(MeaiPipelineOptions options) + { + if (!options.Bedrock.Enabled) + { + return; + } + + void Add(string key) + { + if (!options.AllowedCloudTargets.Contains(key, StringComparer.OrdinalIgnoreCase)) + { + options.AllowedCloudTargets.Add(key); + } + } + + Add(DefaultRouteCandidateTable.CloudBedrockFast); + Add(DefaultRouteCandidateTable.CloudBedrockBalanced); + Add(DefaultRouteCandidateTable.CloudBedrockHeavy); + } + + private static void BindSection(IConfiguration section, MeaiPipelineOptions options) + { + var ollamaBase = section["OllamaBaseUrl"]; + if (!string.IsNullOrWhiteSpace(ollamaBase)) + { + options.OllamaBaseUrl = ollamaBase; + } + + var ollamaModel = section["OllamaModel"]; + if (!string.IsNullOrWhiteSpace(ollamaModel)) + { + options.OllamaModel = ollamaModel; + } + + var localPath = section["LocalModelPath"]; + if (!string.IsNullOrWhiteSpace(localPath)) + { + options.LocalModelPath = localPath; + } + + if (int.TryParse(section["LocalContextSize"], out var ctx) && ctx > 0) + { + options.LocalContextSize = ctx; + } + + if (int.TryParse(section["LocalMaxTokens"], out var max) && max > 0) + { + options.LocalMaxTokens = max; + } + + var bedrock = section.GetSection("Bedrock"); + if (bedrock.Exists()) + { + if (bool.TryParse(bedrock["Enabled"], out var enabled)) + { + options.Bedrock.Enabled = enabled; + } + + var region = bedrock["Region"]; + if (!string.IsNullOrWhiteSpace(region)) + { + options.Bedrock.Region = region; + } + + var fast = bedrock["FastModelId"]; + if (!string.IsNullOrWhiteSpace(fast)) + { + options.Bedrock.FastModelId = fast; + } + + var balanced = bedrock["BalancedModelId"]; + if (!string.IsNullOrWhiteSpace(balanced)) + { + options.Bedrock.BalancedModelId = balanced; + } + + var heavy = bedrock["HeavyModelId"]; + if (!string.IsNullOrWhiteSpace(heavy)) + { + options.Bedrock.HeavyModelId = heavy; + } + } + + var allowed = section.GetSection("AllowedCloudTargets").GetChildren(); + foreach (var child in allowed) + { + if (!string.IsNullOrWhiteSpace(child.Value)) + { + options.AllowedCloudTargets.Add(child.Value.Trim()); + } + } + } +} diff --git a/src/Nexo.AI.Pipeline/MeaiTargetKeys.cs b/src/Nexo.AI.Pipeline/MeaiTargetKeys.cs new file mode 100644 index 00000000..33b874a4 --- /dev/null +++ b/src/Nexo.AI.Pipeline/MeaiTargetKeys.cs @@ -0,0 +1,16 @@ +namespace Nexo.AI.Pipeline; + +/// +/// Well-known keyed DI service keys for MEAI chat targets. +/// +public static class MeaiTargetKeys +{ + /// Local Ollama HTTP target. + public const string LocalOllama = "local:ollama"; + + /// + /// Local offline target. Product key retains local:onnx for policy continuity; + /// the implementation is LLamaSharp + GGUF (see migration notes). + /// + public const string LocalOnnx = "local:onnx"; +} diff --git a/src/Nexo.AI.Pipeline/Models/MeaiBackedModel.cs b/src/Nexo.AI.Pipeline/Models/MeaiBackedModel.cs new file mode 100644 index 00000000..7f3a2e13 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Models/MeaiBackedModel.cs @@ -0,0 +1,156 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Nexo.Abstractions; +using Nexo.AI.Pipeline.Routing; + +namespace Nexo.AI.Pipeline.Models; + +/// +/// adapter over the governed MEAI (router when registered). +/// Deterministic/offline providers are not handled here — callers (e.g. HotSwappableModel) fall back. +/// +public sealed class MeaiBackedModel : IModel +{ + private static readonly HashSet DeterministicProviders = new(StringComparer.OrdinalIgnoreCase) + { + "offline", "mock", "echo", "mock-json", "deterministic", + }; + + private readonly IChatClient _chatClient; + private readonly ILogger _logger; + + /// Creates an MEAI-backed . + public MeaiBackedModel(IChatClient chatClient, ILogger logger) + { + _chatClient = chatClient ?? throw new ArgumentNullException(nameof(chatClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task CompleteAsync(ModelInput input, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(input); + + var (provider, model, messages, capability) = Parse(input); + if (!string.IsNullOrWhiteSpace(provider) && DeterministicProviders.Contains(provider)) + { + throw new InvalidOperationException( + $"Provider '{provider}' is a deterministic/offline path; MEAI chat client is not used."); + } + + var options = new ChatOptions + { + ModelId = string.IsNullOrWhiteSpace(model) ? null : model, + }; + + if (!string.IsNullOrWhiteSpace(capability)) + { + options.AdditionalProperties = new AdditionalPropertiesDictionary + { + [RouteRequestHints.CapabilityTierKey] = capability, + }; + } + + _logger.LogDebug( + "MEAI IModel complete: provider={Provider} model={Model} capability={Capability} messages={Count}", + provider ?? "(default)", + model ?? "(default)", + capability ?? "(default)", + messages.Count); + + var response = await _chatClient.GetResponseAsync(messages, options, ct).ConfigureAwait(false); + return new ModelOutput(response.Text ?? string.Empty); + } + + private static (string? provider, string? model, IList messages, string? capability) Parse( + ModelInput input) + { + string? provider = null; + string? model = null; + string? capability = null; + var messages = new List(); + + foreach (var (role, content) in input.Messages) + { + if (string.Equals(role, "system", StringComparison.OrdinalIgnoreCase)) + { + var systemParts = new List(); + foreach (var line in (content ?? string.Empty).Split('\n')) + { + var trimmed = line.Trim(); + if (trimmed.StartsWith("nexo.model.provider=", StringComparison.OrdinalIgnoreCase)) + { + provider = trimmed["nexo.model.provider=".Length..].Trim(); + continue; + } + + if (trimmed.StartsWith("nexo.model.name=", StringComparison.OrdinalIgnoreCase)) + { + model = trimmed["nexo.model.name=".Length..].Trim(); + continue; + } + + if (trimmed.StartsWith("nexo.model.capability=", StringComparison.OrdinalIgnoreCase)) + { + capability = trimmed["nexo.model.capability=".Length..].Trim(); + continue; + } + + if (trimmed.StartsWith("nexo.model.prefer=", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + systemParts.Add(line); + } + + var systemText = string.Join('\n', systemParts).Trim(); + if (!string.IsNullOrEmpty(systemText)) + { + messages.Add(new ChatMessage(ChatRole.System, systemText)); + } + } + else if (string.Equals(role, "assistant", StringComparison.OrdinalIgnoreCase)) + { + messages.Add(new ChatMessage(ChatRole.Assistant, content ?? string.Empty)); + } + else + { + messages.Add(new ChatMessage(ChatRole.User, content ?? string.Empty)); + } + } + + if (string.IsNullOrWhiteSpace(capability) && !string.IsNullOrWhiteSpace(provider)) + { + capability = MapProviderToCapability(provider); + } + + if (messages.Count == 0) + { + messages.Add(new ChatMessage(ChatRole.User, string.Empty)); + } + + return (provider, model, messages, capability); + } + + private static string? MapProviderToCapability(string provider) + { + if (provider.Contains("bedrock", StringComparison.OrdinalIgnoreCase) + || provider.Contains("cloud", StringComparison.OrdinalIgnoreCase)) + { + if (provider.Contains("fast", StringComparison.OrdinalIgnoreCase)) + { + return "fast"; + } + + if (provider.Contains("heavy", StringComparison.OrdinalIgnoreCase)) + { + return "heavy"; + } + + return "balanced"; + } + + return null; + } +} diff --git a/src/Nexo.AI.Pipeline/Nexo.AI.Pipeline.csproj b/src/Nexo.AI.Pipeline/Nexo.AI.Pipeline.csproj new file mode 100644 index 00000000..7c85b434 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Nexo.AI.Pipeline.csproj @@ -0,0 +1,39 @@ + + + + net8.0;net9.0 + 12.0 + enable + enable + true + true + true + $(NoWarn);1591 + Nexo.AI.Pipeline + Nexo AI Pipeline + Microsoft.Extensions.AI governed chat pipeline for Nexo (local Ollama, local LLamaSharp/ONNX-key target, and future cloud targets). + nexo;ai;meai;ichatclient;ollama;llamasharp;bedrock + https://github.com/IanFrelinger/Nexo + git + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Nexo.AI.Pipeline/Rag/ChunkRecord.cs b/src/Nexo.AI.Pipeline/Rag/ChunkRecord.cs new file mode 100644 index 00000000..9d3a54bf --- /dev/null +++ b/src/Nexo.AI.Pipeline/Rag/ChunkRecord.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.VectorData; + +namespace Nexo.AI.Pipeline.Rag; + +/// +/// VectorData record for indexed knowledge chunks. +/// +public sealed class ChunkRecord +{ + /// Unique chunk key. + [VectorStoreKey] + public string Key { get; set; } = string.Empty; + + /// Source document URI or path. + [VectorStoreData(IsIndexed = true)] + public string SourceUri { get; set; } = string.Empty; + + /// Chunk text. + [VectorStoreData] + public string Text { get; set; } = string.Empty; + + /// + /// Trust / sensitivity tier tag (e.g. Public, Internal, Confidential, Secret, TopSecret). + /// Must be ≤ caller tier to be returned. + /// + [VectorStoreData(IsIndexed = true)] + public string TrustTier { get; set; } = "Public"; + + /// When the chunk was indexed (UTC). + [VectorStoreData] + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + /// When the chunk was last updated (UTC). + [VectorStoreData] + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + /// Dense embedding vector. + [VectorStoreVector(EmbeddingDimensions)] + public ReadOnlyMemory Embedding { get; set; } + + /// Default embedding dimensionality (matches NexoDefaults.EmbeddingDefaultDimension). + public const int EmbeddingDimensions = 64; +} diff --git a/src/Nexo.AI.Pipeline/Rag/InProcessVectorStore.cs b/src/Nexo.AI.Pipeline/Rag/InProcessVectorStore.cs new file mode 100644 index 00000000..d0e003cd --- /dev/null +++ b/src/Nexo.AI.Pipeline/Rag/InProcessVectorStore.cs @@ -0,0 +1,264 @@ +using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.VectorData; + +namespace Nexo.AI.Pipeline.Rag; + +/// +/// In-process for local-first RAG (GA Abstractions only; no preview connectors). +/// Keeps the legacy Nexo store untouched until Phase 6 cutover. +/// +public sealed class InProcessVectorStore : VectorStore +{ + private readonly ConcurrentDictionary _collections = + new(StringComparer.OrdinalIgnoreCase); + + /// + public override VectorStoreCollection GetCollection( + string name, + VectorStoreCollectionDefinition? definition = null) + { + if (typeof(TKey) != typeof(string) || typeof(TRecord) != typeof(ChunkRecord)) + { + throw new NotSupportedException("InProcessVectorStore currently supports VectorStoreCollection only."); + } + + var collection = (VectorStoreCollection)_collections.GetOrAdd( + name, + _ => new InProcessChunkCollection(name)); + return collection; + } + + /// + public override VectorStoreCollection> GetDynamicCollection( + string name, + VectorStoreCollectionDefinition definition) => + throw new NotSupportedException("Dynamic collections are not supported by InProcessVectorStore."); + + /// + public override IAsyncEnumerable ListCollectionNamesAsync(CancellationToken cancellationToken = default) => + _collections.Keys.ToAsyncEnumerable(); + + /// + public override Task CollectionExistsAsync(string name, CancellationToken cancellationToken = default) => + Task.FromResult(_collections.ContainsKey(name)); + + /// + public override Task EnsureCollectionDeletedAsync(string name, CancellationToken cancellationToken = default) + { + _collections.TryRemove(name, out _); + return Task.CompletedTask; + } + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) => + serviceType.IsInstanceOfType(this) ? this : null; +} + +/// +/// In-memory cosine-similarity collection for . +/// +public sealed class InProcessChunkCollection : VectorStoreCollection +{ + private readonly string _name; + private readonly ConcurrentDictionary _records = + new(StringComparer.Ordinal); + + /// Creates a named chunk collection. + public InProcessChunkCollection(string name) => + _name = string.IsNullOrWhiteSpace(name) ? "chunks" : name; + + /// + public override string Name => _name; + + /// + public override Task CollectionExistsAsync(CancellationToken cancellationToken = default) => + Task.FromResult(true); + + /// + public override Task EnsureCollectionExistsAsync(CancellationToken cancellationToken = default) => + Task.CompletedTask; + + /// + public override Task EnsureCollectionDeletedAsync(CancellationToken cancellationToken = default) + { + _records.Clear(); + return Task.CompletedTask; + } + + /// + public override Task GetAsync( + string key, + RecordRetrievalOptions? options = null, + CancellationToken cancellationToken = default) + { + _records.TryGetValue(key, out var record); + return Task.FromResult(record); + } + + /// + public override async IAsyncEnumerable GetAsync( + Expression> filter, + int top, + FilteredRecordRetrievalOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var predicate = filter.Compile(); + var taken = 0; + foreach (var record in _records.Values) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!predicate(record)) + { + continue; + } + + yield return record; + if (++taken >= top) + { + yield break; + } + + await Task.Yield(); + } + } + + /// + public override Task DeleteAsync(string key, CancellationToken cancellationToken = default) + { + _records.TryRemove(key, out _); + return Task.CompletedTask; + } + + /// + public override Task UpsertAsync(ChunkRecord record, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + if (string.IsNullOrWhiteSpace(record.Key)) + { + throw new ArgumentException("ChunkRecord.Key is required.", nameof(record)); + } + + record.UpdatedAt = DateTimeOffset.UtcNow; + _records[record.Key] = Clone(record); + return Task.CompletedTask; + } + + /// + public override async Task UpsertAsync( + IEnumerable records, + CancellationToken cancellationToken = default) + { + foreach (var record in records) + { + await UpsertAsync(record, cancellationToken).ConfigureAwait(false); + } + } + + /// + public override async IAsyncEnumerable> SearchAsync( + TInput searchValue, + int top, + VectorSearchOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var query = ToVector(searchValue); + Func? filter = options?.Filter?.Compile(); + var skip = options?.Skip ?? 0; + var threshold = options?.ScoreThreshold; + + var scored = new List<(ChunkRecord Record, double Score)>(); + foreach (var record in _records.Values) + { + cancellationToken.ThrowIfCancellationRequested(); + if (filter is not null && !filter(record)) + { + continue; + } + + var score = CosineSimilarity(query, record.Embedding.ToArray()); + if (threshold is not null && score < threshold.Value) + { + continue; + } + + scored.Add((record, score)); + } + + foreach (var item in scored.OrderByDescending(s => s.Score).Skip(skip).Take(top)) + { + yield return new VectorSearchResult(Clone(item.Record), item.Score); + await Task.Yield(); + } + } + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) => + serviceType.IsInstanceOfType(this) ? this : null; + + /// Number of records currently stored. + public int Count => _records.Count; + + private static float[] ToVector(TInput searchValue) + { + return searchValue switch + { + ReadOnlyMemory rom => rom.ToArray(), + float[] arr => arr, + Embedding emb => emb.Vector.ToArray(), + _ => throw new NotSupportedException($"Unsupported search input type: {typeof(TInput).Name}"), + }; + } + + private static double CosineSimilarity(float[] a, float[] b) + { + var len = Math.Min(a.Length, b.Length); + if (len == 0) + { + return 0; + } + + double dot = 0, na = 0, nb = 0; + for (var i = 0; i < len; i++) + { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + + if (na <= double.Epsilon || nb <= double.Epsilon) + { + return 0; + } + + return dot / (Math.Sqrt(na) * Math.Sqrt(nb)); + } + + private static ChunkRecord Clone(ChunkRecord r) => new() + { + Key = r.Key, + SourceUri = r.SourceUri, + Text = r.Text, + TrustTier = r.TrustTier, + CreatedAt = r.CreatedAt, + UpdatedAt = r.UpdatedAt, + Embedding = r.Embedding.ToArray(), + }; +} + +internal static class AsyncEnumerableExtensions +{ + public static async IAsyncEnumerable ToAsyncEnumerable( + this IEnumerable source, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var item in source) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return item; + await Task.Yield(); + } + } +} diff --git a/src/Nexo.AI.Pipeline/Rag/TrustTierOrder.cs b/src/Nexo.AI.Pipeline/Rag/TrustTierOrder.cs new file mode 100644 index 00000000..4296aa87 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Rag/TrustTierOrder.cs @@ -0,0 +1,32 @@ +namespace Nexo.AI.Pipeline.Rag; + +/// +/// Orders trust/sensitivity tier names for RAG filtering (lower = less sensitive). +/// Mirrors Nexo data-sensitivity primitives without referencing BackgroundAgents. +/// +public static class TrustTierOrder +{ + private static readonly Dictionary Ranks = new(StringComparer.OrdinalIgnoreCase) + { + ["Public"] = 0, + ["Internal"] = 1, + ["Confidential"] = 2, + ["Secret"] = 3, + ["TopSecret"] = 4, + }; + + /// Returns the numeric rank for a tier name (unknown ⇒ TopSecret for fail-closed). + public static int Rank(string? tierName) + { + if (string.IsNullOrWhiteSpace(tierName)) + { + return Ranks["Public"]; + } + + return Ranks.TryGetValue(tierName.Trim(), out var rank) ? rank : Ranks["TopSecret"]; + } + + /// True when record tier is at or below the caller's max tier. + public static bool IsAllowed(string? recordTier, string? callerMaxTier) => + Rank(recordTier) <= Rank(callerMaxTier); +} diff --git a/src/Nexo.AI.Pipeline/Rag/VectorDataRagService.cs b/src/Nexo.AI.Pipeline/Rag/VectorDataRagService.cs new file mode 100644 index 00000000..ddd08446 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Rag/VectorDataRagService.cs @@ -0,0 +1,140 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.VectorData; +using Nexo.AI.Pipeline.Embeddings; +using Nexo.AI.Pipeline.Governance; + +namespace Nexo.AI.Pipeline.Rag; + +/// +/// Indexes and searches via VectorData + governed embeddings. +/// +public sealed class VectorDataRagService +{ + public const string DefaultCollectionName = "nexo-chunks"; + + private readonly VectorStoreCollection _collection; + private readonly IEmbeddingGenerator> _embeddings; + private readonly IChatInvocationAuditor _auditor; + + /// Creates a RAG façade over a VectorData collection. + public VectorDataRagService( + VectorStoreCollection collection, + IEmbeddingGenerator> embeddings, + IChatInvocationAuditor auditor) + { + _collection = collection ?? throw new ArgumentNullException(nameof(collection)); + _embeddings = embeddings ?? throw new ArgumentNullException(nameof(embeddings)); + _auditor = auditor ?? throw new ArgumentNullException(nameof(auditor)); + } + + /// Indexes a chunk (embed + upsert). + public async Task IndexAsync( + string key, + string text, + string? sourceUri = null, + string trustTier = "Public", + CancellationToken cancellationToken = default) + { + var embedding = await EmbedAsync(text, cancellationToken).ConfigureAwait(false); + var now = DateTimeOffset.UtcNow; + await _collection.UpsertAsync(new ChunkRecord + { + Key = key, + Text = text, + SourceUri = sourceUri ?? string.Empty, + TrustTier = trustTier, + CreatedAt = now, + UpdatedAt = now, + Embedding = embedding, + }, cancellationToken).ConfigureAwait(false); + } + + /// + /// Searches for similar chunks, excluding records above . + /// + public async Task>> SearchAsync( + string query, + string callerMaxTrustTier, + int top = 5, + double? minScore = null, + CancellationToken cancellationToken = default) + { + var embedding = await EmbedAsync(query, cancellationToken).ConfigureAwait(false); + var maxRank = TrustTierOrder.Rank(callerMaxTrustTier); + var options = new VectorSearchOptions + { + Filter = r => TrustTierOrder.Rank(r.TrustTier) <= maxRank, + ScoreThreshold = minScore, + }; + + var results = new List>(); + await foreach (var hit in _collection.SearchAsync(embedding, top, options, cancellationToken) + .ConfigureAwait(false)) + { + results.Add(hit); + } + + _auditor.Record(new ChatInvocationAuditRecord + { + TargetKey = "rag:search", + Outcome = "success", + PolicyDecisions = new[] + { + "event=retrieval", + $"results={results.Count}", + $"caller_tier={callerMaxTrustTier}", + }, + }); + + return results; + } + + /// Re-indexes a batch of existing chunks into the VectorData store. + public async Task ReindexAsync( + IEnumerable<(string Key, string Text, string? SourceUri, string TrustTier)> chunks, + CancellationToken cancellationToken = default) + { + var count = 0; + foreach (var chunk in chunks) + { + await IndexAsync(chunk.Key, chunk.Text, chunk.SourceUri, chunk.TrustTier, cancellationToken) + .ConfigureAwait(false); + count++; + } + + _auditor.Record(new ChatInvocationAuditRecord + { + TargetKey = "rag:reindex", + Outcome = "success", + PolicyDecisions = new[] { "event=reindex", $"count={count}" }, + }); + return count; + } + + /// Removes a chunk by key. + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) => + _collection.DeleteAsync(key, cancellationToken); + + /// Clears the default collection. + public Task ClearAsync(CancellationToken cancellationToken = default) => + _collection.EnsureCollectionDeletedAsync(cancellationToken); + + /// Returns the number of indexed chunks when the store is in-process. + public Task GetDocumentCountAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_collection is InProcessChunkCollection inProcess) + { + return Task.FromResult(inProcess.Count); + } + + return Task.FromResult(-1); + } + + private async Task EmbedAsync(string text, CancellationToken cancellationToken) + { + var generated = await _embeddings.GenerateAsync([text], cancellationToken: cancellationToken) + .ConfigureAwait(false); + return generated[0].Vector.ToArray(); + } +} diff --git a/src/Nexo.AI.Pipeline/Routing/IRouteCandidateTable.cs b/src/Nexo.AI.Pipeline/Routing/IRouteCandidateTable.cs new file mode 100644 index 00000000..26f78947 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Routing/IRouteCandidateTable.cs @@ -0,0 +1,69 @@ +using Nexo.AI.Pipeline.Governance; + +namespace Nexo.AI.Pipeline.Routing; + +/// +/// Builds the ordered escalation candidate list for a request (local-first). +/// +public interface IRouteCandidateTable +{ + /// + /// Returns candidate target keys in preference order for the requested tier. + /// Cloud keys are only included when the caller may leave the machine. + /// + IReadOnlyList GetCandidates(RouteCapabilityTier tier, bool cloudPermitted); +} + +/// +/// Default local-first escalation: +/// fast → local:ollama, local:onnx; +/// balanced → local:ollama, local:onnx, cloud:bedrock:balanced; +/// heavy → local:ollama, local:onnx, cloud:bedrock:balanced, cloud:bedrock:heavy. +/// +public sealed class DefaultRouteCandidateTable : IRouteCandidateTable +{ + /// Cloud target key placeholders (Phase 4 registers the clients). + public const string CloudBedrockFast = "cloud:bedrock:fast"; + public const string CloudBedrockBalanced = "cloud:bedrock:balanced"; + public const string CloudBedrockHeavy = "cloud:bedrock:heavy"; + + /// + public IReadOnlyList GetCandidates(RouteCapabilityTier tier, bool cloudPermitted) + { + var list = new List + { + MeaiTargetKeys.LocalOllama, + MeaiTargetKeys.LocalOnnx, + }; + + if (!cloudPermitted) + { + return list; + } + + switch (tier) + { + case RouteCapabilityTier.Fast: + list.Add(CloudBedrockFast); + break; + case RouteCapabilityTier.Balanced: + list.Add(CloudBedrockBalanced); + break; + case RouteCapabilityTier.Heavy: + list.Add(CloudBedrockBalanced); + list.Add(CloudBedrockHeavy); + break; + } + + return list; + } +} + +/// +/// Selects a governed target pipeline using policy × availability × capability. +/// +public interface IChatRouter +{ + /// Chooses a target key and records candidates/reason for audit. + RouteDecision Select(string? callerIdentity, string? modelId, RouteCapabilityTier tier, string? preferredTarget); +} diff --git a/src/Nexo.AI.Pipeline/Routing/ITargetAvailability.cs b/src/Nexo.AI.Pipeline/Routing/ITargetAvailability.cs new file mode 100644 index 00000000..3279f5ca --- /dev/null +++ b/src/Nexo.AI.Pipeline/Routing/ITargetAvailability.cs @@ -0,0 +1,30 @@ +namespace Nexo.AI.Pipeline.Routing; + +/// +/// Reports whether an execution target is currently healthy/available. +/// +public interface ITargetAvailability +{ + /// Returns true when the target can accept work right now. + bool IsAvailable(string targetKey); +} + +/// +/// Mutable in-memory availability map (tests + simple hosts). +/// +public sealed class InMemoryTargetAvailability : ITargetAvailability +{ + private readonly Dictionary _map = + new(StringComparer.OrdinalIgnoreCase); + + /// Sets availability for a target key. + public InMemoryTargetAvailability Set(string targetKey, bool available) + { + _map[targetKey] = available; + return this; + } + + /// + public bool IsAvailable(string targetKey) => + !_map.TryGetValue(targetKey, out var available) || available; +} diff --git a/src/Nexo.AI.Pipeline/Routing/LocalFirstChatRouter.cs b/src/Nexo.AI.Pipeline/Routing/LocalFirstChatRouter.cs new file mode 100644 index 00000000..0b22549f --- /dev/null +++ b/src/Nexo.AI.Pipeline/Routing/LocalFirstChatRouter.cs @@ -0,0 +1,156 @@ +using Nexo.AI.Pipeline.Governance; + +namespace Nexo.AI.Pipeline.Routing; + +/// +/// Local-first router: prefers healthy policy-allowed local targets, then escalates. +/// +public sealed class LocalFirstChatRouter : IChatRouter +{ + private readonly IRouteCandidateTable _table; + private readonly ITargetAvailability _availability; + private readonly IChatTargetAccessPolicy _policy; + + /// Creates a local-first router. + public LocalFirstChatRouter( + IRouteCandidateTable table, + ITargetAvailability availability, + IChatTargetAccessPolicy policy) + { + _table = table ?? throw new ArgumentNullException(nameof(table)); + _availability = availability ?? throw new ArgumentNullException(nameof(availability)); + _policy = policy ?? throw new ArgumentNullException(nameof(policy)); + } + + /// + public RouteDecision Select( + string? callerIdentity, + string? modelId, + RouteCapabilityTier tier, + string? preferredTarget) + { + var cloudPermitted = IsCloudPermitted(callerIdentity, modelId); + var ordered = BuildOrderedCandidates(tier, cloudPermitted, preferredTarget); + var considered = new List(ordered.Count); + + foreach (var key in ordered) + { + var available = _availability.IsAvailable(key); + var allowed = _policy.IsAllowed(callerIdentity, key, modelId, out var denyReason); + string disposition; + if (!allowed) + { + disposition = $"skip:{RouteReasonCodes.PolicyDenied}:{denyReason}"; + considered.Add(new RouteCandidate + { + TargetKey = key, + Available = available, + PolicyAllowed = false, + Disposition = disposition, + }); + continue; + } + + if (!available) + { + disposition = $"skip:{RouteReasonCodes.LocalUnavailable}"; + considered.Add(new RouteCandidate + { + TargetKey = key, + Available = false, + PolicyAllowed = true, + Disposition = disposition, + }); + continue; + } + + var reason = ResolveReason(key, considered, preferredTarget, tier); + considered.Add(new RouteCandidate + { + TargetKey = key, + Available = true, + PolicyAllowed = true, + Disposition = $"choose:{reason}", + }); + + return new RouteDecision + { + ChosenTargetKey = key, + ReasonCode = reason, + CandidatesConsidered = considered, + RequestedTier = tier, + }; + } + + return new RouteDecision + { + ChosenTargetKey = string.Empty, + ReasonCode = RouteReasonCodes.NoCandidate, + CandidatesConsidered = considered, + RequestedTier = tier, + }; + } + + private bool IsCloudPermitted(string? callerIdentity, string? modelId) + { + // If any cloud target is policy-allowed, treat cloud as permitted for candidate inclusion. + foreach (var cloud in new[] + { + DefaultRouteCandidateTable.CloudBedrockFast, + DefaultRouteCandidateTable.CloudBedrockBalanced, + DefaultRouteCandidateTable.CloudBedrockHeavy, + }) + { + if (_policy.IsAllowed(callerIdentity, cloud, modelId, out _)) + { + return true; + } + } + + return false; + } + + private IReadOnlyList BuildOrderedCandidates( + RouteCapabilityTier tier, + bool cloudPermitted, + string? preferredTarget) + { + var baseList = _table.GetCandidates(tier, cloudPermitted).ToList(); + if (string.IsNullOrWhiteSpace(preferredTarget)) + { + return baseList; + } + + baseList.RemoveAll(k => string.Equals(k, preferredTarget, StringComparison.OrdinalIgnoreCase)); + baseList.Insert(0, preferredTarget); + return baseList; + } + + private static string ResolveReason( + string chosen, + IReadOnlyList prior, + string? preferredTarget, + RouteCapabilityTier tier) + { + if (!string.IsNullOrWhiteSpace(preferredTarget) + && string.Equals(chosen, preferredTarget, StringComparison.OrdinalIgnoreCase) + && prior.Count == 0) + { + return RouteReasonCodes.PreferredLocal; + } + + if (chosen.StartsWith("local:", StringComparison.OrdinalIgnoreCase) && prior.Count == 0) + { + return RouteReasonCodes.PreferredLocal; + } + + if (chosen.StartsWith("cloud:", StringComparison.OrdinalIgnoreCase)) + { + return tier == RouteCapabilityTier.Heavy + ? RouteReasonCodes.CapabilityEscalation + : RouteReasonCodes.Fallback; + } + + return prior.Count == 0 ? RouteReasonCodes.PreferredLocal : RouteReasonCodes.Fallback; + } +} diff --git a/src/Nexo.AI.Pipeline/Routing/RouteDecision.cs b/src/Nexo.AI.Pipeline/Routing/RouteDecision.cs new file mode 100644 index 00000000..e65fa96d --- /dev/null +++ b/src/Nexo.AI.Pipeline/Routing/RouteDecision.cs @@ -0,0 +1,51 @@ +namespace Nexo.AI.Pipeline.Routing; + +/// +/// Capability / cost tier requested by a caller (via ). +/// +public enum RouteCapabilityTier +{ + /// Default / cheapest local-capable work. + Fast = 0, + + /// Balanced quality/latency. + Balanced = 1, + + /// Heavy reasoning / large context — may escalate to cloud when allowed. + Heavy = 2, +} + +/// +/// Why a particular candidate was chosen or skipped. +/// +public static class RouteReasonCodes +{ + public const string PreferredLocal = "preferred_local"; + public const string LocalUnavailable = "local_unavailable"; + public const string PolicyDenied = "policy_denied"; + public const string CapabilityEscalation = "capability_escalation"; + public const string Fallback = "fallback"; + public const string NoCandidate = "no_candidate"; +} + +/// +/// Snapshot of a routing decision for audit. +/// +public sealed class RouteDecision +{ + public required string ChosenTargetKey { get; init; } + public required string ReasonCode { get; init; } + public required IReadOnlyList CandidatesConsidered { get; init; } + public RouteCapabilityTier RequestedTier { get; init; } +} + +/// +/// One candidate evaluated during routing. +/// +public sealed class RouteCandidate +{ + public required string TargetKey { get; init; } + public required bool Available { get; init; } + public required bool PolicyAllowed { get; init; } + public required string Disposition { get; init; } +} diff --git a/src/Nexo.AI.Pipeline/Routing/RouteRequestHints.cs b/src/Nexo.AI.Pipeline/Routing/RouteRequestHints.cs new file mode 100644 index 00000000..2d7889b0 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Routing/RouteRequestHints.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.AI; + +namespace Nexo.AI.Pipeline.Routing; + +/// +/// Resolves a capability tier from . +/// +public static class RouteRequestHints +{ + /// AdditionalProperties key for capability tier (fast/balanced/heavy). + public const string CapabilityTierKey = "nexo.route.capability"; + + /// AdditionalProperties key for an explicit preferred target key. + public const string PreferredTargetKey = "nexo.route.preferred_target"; + + /// Reads the requested capability tier (defaults to ). + public static RouteCapabilityTier GetCapabilityTier(ChatOptions? options) + { + if (options?.AdditionalProperties is null) + { + return RouteCapabilityTier.Fast; + } + + if (!options.AdditionalProperties.TryGetValue(CapabilityTierKey, out var raw) || raw is null) + { + return RouteCapabilityTier.Fast; + } + + var text = raw as string ?? raw.ToString(); + if (string.IsNullOrWhiteSpace(text)) + { + return RouteCapabilityTier.Fast; + } + + if (Enum.TryParse(text, ignoreCase: true, out var parsed)) + { + return parsed; + } + + return text.Trim().ToLowerInvariant() switch + { + "fast" => RouteCapabilityTier.Fast, + "balanced" => RouteCapabilityTier.Balanced, + "heavy" => RouteCapabilityTier.Heavy, + _ => RouteCapabilityTier.Fast, + }; + } + + /// Optional explicit preferred target override. + public static string? GetPreferredTarget(ChatOptions? options) + { + if (options?.AdditionalProperties is null) + { + return null; + } + + if (!options.AdditionalProperties.TryGetValue(PreferredTargetKey, out var raw) || raw is null) + { + return null; + } + + var text = raw as string ?? raw.ToString(); + return string.IsNullOrWhiteSpace(text) ? null : text.Trim(); + } +} diff --git a/src/Nexo.AI.Pipeline/Routing/RoutingChatClient.cs b/src/Nexo.AI.Pipeline/Routing/RoutingChatClient.cs new file mode 100644 index 00000000..7004a392 --- /dev/null +++ b/src/Nexo.AI.Pipeline/Routing/RoutingChatClient.cs @@ -0,0 +1,133 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Nexo.AI.Pipeline.Governance; + +namespace Nexo.AI.Pipeline.Routing; + +/// +/// Routes each call to a governed per-target pipeline. +/// Routing sits outside per-target stacks; wrap this client with +/// so route decisions are audited. +/// +public sealed class RoutingChatClient : IChatClient +{ + /// DI / audit key for the router itself. + public const string RouterTargetKey = "router:default"; + + private readonly IChatRouter _router; + private readonly IServiceProvider _services; + private readonly Func? _callerIdentityAccessor; + private readonly IChatInvocationAuditor? _auditor; + + /// Creates a routing client. + public RoutingChatClient( + IChatRouter router, + IServiceProvider services, + IChatInvocationAuditor? auditor = null, + Func? callerIdentityAccessor = null) + { + _router = router ?? throw new ArgumentNullException(nameof(router)); + _services = services ?? throw new ArgumentNullException(nameof(services)); + _auditor = auditor; + _callerIdentityAccessor = callerIdentityAccessor; + } + + /// Last route decision (for tests / introspection). + public RouteDecision? LastDecision { get; private set; } + + /// + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var (client, decision) = ResolveClient(options); + LastDecision = decision; + RecordRoute(decision); + return await client.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + } + + /// + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var (client, decision) = ResolveClient(options); + LastDecision = decision; + RecordRoute(decision); + await foreach (var update in client.GetStreamingResponseAsync(messages, options, cancellationToken) + .ConfigureAwait(false)) + { + yield return update; + } + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) + { + if (serviceType == typeof(ChatClientMetadata)) + { + return new ChatClientMetadata("nexo-router", providerUri: null, defaultModelId: RouterTargetKey); + } + + if (serviceType == typeof(RoutingChatClient)) + { + return this; + } + + return null; + } + + /// + public void Dispose() + { + } + + private (IChatClient Client, RouteDecision Decision) ResolveClient(ChatOptions? options) + { + var tier = RouteRequestHints.GetCapabilityTier(options); + var preferred = RouteRequestHints.GetPreferredTarget(options); + var decision = _router.Select(_callerIdentityAccessor?.Invoke(), options?.ModelId, tier, preferred); + + if (string.IsNullOrEmpty(decision.ChosenTargetKey) + || decision.ReasonCode == RouteReasonCodes.NoCandidate) + { + throw new PolicyViolationException( + code: RouteReasonCodes.NoCandidate, + message: "No permitted and available execution target can satisfy this request.", + modelId: options?.ModelId, + details: new Dictionary + { + ["tier"] = tier.ToString(), + ["candidates"] = string.Join(',', decision.CandidatesConsidered.Select(c => c.TargetKey)), + }); + } + + var client = _services.GetRequiredKeyedService(decision.ChosenTargetKey); + return (client, decision); + } + + private void RecordRoute(RouteDecision decision) + { + if (_auditor is null) + { + return; + } + + _auditor.Record(new ChatInvocationAuditRecord + { + TargetKey = RouterTargetKey, + Outcome = string.IsNullOrEmpty(decision.ChosenTargetKey) ? "denied" : "routed", + PolicyDecisions = new[] + { + $"chosen={decision.ChosenTargetKey}", + $"reason={decision.ReasonCode}", + $"tier={decision.RequestedTier}", + $"candidates={string.Join(';', decision.CandidatesConsidered.Select(c => $"{c.TargetKey}:{c.Disposition}"))}", + }, + ReasonCode = decision.ReasonCode, + }); + } +} diff --git a/src/Nexo.Hosting/Meai/MeaiVectorDataRagAdapter.cs b/src/Nexo.Hosting/Meai/MeaiVectorDataRagAdapter.cs new file mode 100644 index 00000000..eb237766 --- /dev/null +++ b/src/Nexo.Hosting/Meai/MeaiVectorDataRagAdapter.cs @@ -0,0 +1,71 @@ +using Nexo.AI.Pipeline.Rag; +using Nexo.BackgroundAgents.RAG; + +namespace Nexo.Hosting.Meai; + +/// +/// Adapts to the legacy surface +/// so tools/CLI keep working after Phase 6 cutover. +/// +public sealed class MeaiVectorDataRagAdapter : IRAGService +{ + private readonly VectorDataRagService _rag; + + /// Creates the adapter. + public MeaiVectorDataRagAdapter(VectorDataRagService rag) => + _rag = rag ?? throw new ArgumentNullException(nameof(rag)); + + /// + public async Task> SearchAsync( + string query, + int maxResults, + double minScore, + string? maxSensitivityLevelName, + CancellationToken cancellationToken = default) + { + var tier = string.IsNullOrWhiteSpace(maxSensitivityLevelName) ? "TopSecret" : maxSensitivityLevelName.Trim(); + var hits = await _rag.SearchAsync( + query ?? string.Empty, + callerMaxTrustTier: tier, + top: maxResults, + minScore: minScore, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + + return hits + .Select(h => new VectorSearchResult( + h.Record.Key, + h.Record.Text, + h.Score ?? 0d, + h.Record.TrustTier)) + .ToList(); + } + + /// + public Task IndexAsync( + string id, + string text, + string? sensitivityLevelName, + CancellationToken cancellationToken = default) => + _rag.IndexAsync( + id, + text ?? string.Empty, + sourceUri: null, + trustTier: string.IsNullOrWhiteSpace(sensitivityLevelName) ? "Public" : sensitivityLevelName.Trim(), + cancellationToken: cancellationToken); + + /// + public Task RemoveAsync(string id, CancellationToken cancellationToken = default) => + _rag.RemoveAsync(id, cancellationToken); + + /// + public Task ClearAsync(CancellationToken cancellationToken = default) => + _rag.ClearAsync(cancellationToken); + + /// + public async Task GetDocumentCountAsync(CancellationToken cancellationToken = default) + { + var count = await _rag.GetDocumentCountAsync(cancellationToken).ConfigureAwait(false); + return count < 0 ? 0 : count; + } +} diff --git a/src/Nexo.Hosting/Nexo.Hosting.csproj b/src/Nexo.Hosting/Nexo.Hosting.csproj index e4fcedb4..09445d9e 100644 --- a/src/Nexo.Hosting/Nexo.Hosting.csproj +++ b/src/Nexo.Hosting/Nexo.Hosting.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Nexo.Hosting/NexoKernelRegistrar.Phases.cs b/src/Nexo.Hosting/NexoKernelRegistrar.Phases.cs index 311488a1..b3de931b 100644 --- a/src/Nexo.Hosting/NexoKernelRegistrar.Phases.cs +++ b/src/Nexo.Hosting/NexoKernelRegistrar.Phases.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Nexo.Abstractions.Routing; +using Nexo.AI.Pipeline; using Nexo.BackgroundAgents; using Nexo.BackgroundAgents.Trust; using Nexo.Contracts; @@ -313,7 +314,7 @@ private static void RegisterPhase13_ModelDecoratorChain(NexoKernelRegistrationCo // ── Model decorator chain ────────────────────────────────────── // The IModel abstraction is built as a three-layer decorator: // - // 1. ProviderBackedModel – delegates to IProviderFactory + // 1. MeaiBackedModel (default) or ProviderBackedModel (opt-out) // 2. HotSwappableModel – allows runtime model switching // without restarting the host // 3. OrchestrationRuntimeModelDecorator @@ -324,15 +325,30 @@ private static void RegisterPhase13_ModelDecoratorChain(NexoKernelRegistrationCo // HotSwappableModel is registered as a concrete singleton so that // administrative endpoints can resolve it directly for hot-swap // operations, while IModel always returns the fully decorated chain. + // The factory runs after Phase 13b so MEAI IChatClient is available when enabled. services.AddSingleton(sp => { - IProviderFactory providerFactory = sp.GetRequiredService(); - var providerBacked = new Nexo.Infrastructure.Execution.Models.ProviderBackedModel( - providerFactory, - sp.GetRequiredService>()); - return new Nexo.Infrastructure.Execution.Models.HotSwappableModel( - providerBacked, - sp.GetRequiredService>()); + var logger = sp.GetRequiredService>(); + bool meai = MeaiPipelineServiceCollectionExtensions.IsMeaiPipelineEnabled( + ctx.Configuration, + ctx.Options.UseMeaiPipeline); + + Nexo.Abstractions.IModel agentic; + if (meai) + { + agentic = new Nexo.AI.Pipeline.Models.MeaiBackedModel( + sp.GetRequiredService(), + sp.GetRequiredService>()); + } + else + { + IProviderFactory providerFactory = sp.GetRequiredService(); + agentic = new Nexo.Infrastructure.Execution.Models.ProviderBackedModel( + providerFactory, + sp.GetRequiredService>()); + } + + return new Nexo.Infrastructure.Execution.Models.HotSwappableModel(agentic, logger); }); services.AddSingleton(sp => @@ -347,6 +363,37 @@ private static void RegisterPhase13_ModelDecoratorChain(NexoKernelRegistrationCo } + /// + /// Phase 13b: Microsoft.Extensions.AI pipeline (default on since Phase 6) + VectorData RAG as default IRAGService. + /// Opt out of MEAI chat with Nexo:UseMeaiPipeline=false / NEXO_USE_MEAI_PIPELINE=0. + /// Legacy IProviderFactory remains registered for direct non-chat callers. + /// + private static void RegisterPhase13b_MeaiPipeline(NexoKernelRegistrationContext ctx) + { + IServiceCollection services = ctx.Services; + NexoHostingOptions options = ctx.Options; + IConfiguration configuration = ctx.Configuration; + + MeaiPipelineServiceCollectionExtensions.RegisterGovernanceDefaults(services); + MeaiPipelineServiceCollectionExtensions.RegisterVectorDataRag(services); + + // Prefer VectorData-backed IRAGService (last registration wins over Phase 11 legacy). + services.AddSingleton(sp => + new Nexo.Hosting.Meai.MeaiVectorDataRagAdapter( + sp.GetRequiredService())); + + bool enabled = MeaiPipelineServiceCollectionExtensions.IsMeaiPipelineEnabled( + configuration, + options.UseMeaiPipeline); + + if (!enabled) + { + return; + } + + services.AddNexoMeaiPipeline(configuration); + } + /// Phase 14: ephemeral model and database lifecycle. private static void RegisterPhase14_EphemeralLifecycle(NexoKernelRegistrationContext ctx) { diff --git a/src/Nexo.Hosting/NexoKernelRegistrar.cs b/src/Nexo.Hosting/NexoKernelRegistrar.cs index de0f52b6..840fab85 100644 --- a/src/Nexo.Hosting/NexoKernelRegistrar.cs +++ b/src/Nexo.Hosting/NexoKernelRegistrar.cs @@ -64,6 +64,7 @@ public static void Register( RegisterPhase11_BackgroundAgentsRAG(ctx); RegisterPhase12_ObservationPipeline(ctx); RegisterPhase13_ModelDecoratorChain(ctx); + RegisterPhase13b_MeaiPipeline(ctx); RegisterPhase14_EphemeralLifecycle(ctx); RegisterPhase15_TrustProviderFactory3wayBranching(ctx); RegisterPhase16_ExecutionCoreWorkflow(ctx); diff --git a/src/Nexo.Hosting/Sdk/Options/NexoHostingOptions.cs b/src/Nexo.Hosting/Sdk/Options/NexoHostingOptions.cs index c6ae0d08..2e910910 100644 --- a/src/Nexo.Hosting/Sdk/Options/NexoHostingOptions.cs +++ b/src/Nexo.Hosting/Sdk/Options/NexoHostingOptions.cs @@ -28,6 +28,14 @@ public sealed class NexoHostingOptions /// public bool? TrustEnabled { get; set; } + /// + /// When true, registers the Microsoft.Extensions.AI governed chat pipeline + /// (keyed IChatClient targets) and wires IModel through MeaiBackedModel. + /// Default: ON (Phase 6). Opt out via Nexo:UseMeaiPipeline=false + /// / NEXO_USE_MEAI_PIPELINE=0 to restore the legacy IProviderFactory IModel leaf. + /// + public bool? UseMeaiPipeline { get; set; } + /// /// When true, registers background agents as IHostedService (for long-running hosts). /// Default: false (CLI mode; agents run on-demand). diff --git a/src/Nexo.Infrastructure/Execution/Models/HotSwappableModel.cs b/src/Nexo.Infrastructure/Execution/Models/HotSwappableModel.cs index 0c891fba..01f160e7 100644 --- a/src/Nexo.Infrastructure/Execution/Models/HotSwappableModel.cs +++ b/src/Nexo.Infrastructure/Execution/Models/HotSwappableModel.cs @@ -15,16 +15,20 @@ namespace Nexo.Infrastructure.Execution.Models; /// public sealed class HotSwappableModel : IModel { - private readonly ProviderBackedModel _agentic; + private readonly IModel _agentic; private readonly IModel _deterministic; private readonly ILogger _logger; - /// Initializes a new hot swappable model. + /// + /// Initializes a new hot swappable model. + /// is typically (legacy) + /// or an MEAI-backed when the MEAI pipeline is enabled. + /// public HotSwappableModel( - ProviderBackedModel agentic, + IModel agentic, ILogger logger) { - _agentic = agentic; + _agentic = agentic ?? throw new ArgumentNullException(nameof(agentic)); _deterministic = new EchoDeterministicModel(); _logger = logger; } diff --git a/src/Nexo.Tests.AI.Pipeline/BedrockTargetTests.cs b/src/Nexo.Tests.AI.Pipeline/BedrockTargetTests.cs new file mode 100644 index 00000000..449d3ff2 --- /dev/null +++ b/src/Nexo.Tests.AI.Pipeline/BedrockTargetTests.cs @@ -0,0 +1,154 @@ +using FluentAssertions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Nexo.AI.Pipeline; +using Nexo.AI.Pipeline.Clients; +using Nexo.AI.Pipeline.Governance; +using Nexo.AI.Pipeline.Routing; +using Xunit; + +namespace Nexo.Tests.AI.Pipeline; + +public sealed class BedrockTargetTests +{ + [Fact] + public async Task Policy_permitting_cloud_routes_to_bedrock_through_sanitization_with_audit() + { + var localSpy = new SpyChatClient("local"); + var bedrockSpy = new SpyChatClient("bedrock"); + var auditor = new InMemoryChatInvocationAuditor(); + var availability = new InMemoryTargetAvailability() + .Set(MeaiTargetKeys.LocalOllama, false) + .Set(MeaiTargetKeys.LocalOnnx, false) + .Set(DefaultRouteCandidateTable.CloudBedrockBalanced, true); + + var services = new ServiceCollection(); + services.AddSingleton(availability); + services.AddSingleton(auditor); + services.AddNexoMeaiPipeline( + configure: o => + { + o.Bedrock.Enabled = true; + o.Bedrock.BalancedModelId = "test-balanced-model"; + }, + ollamaInnerFactory: _ => localSpy, + onnxInnerFactory: _ => new SpyChatClient("onnx"), + bedrockInnerFactory: (_, modelId) => + { + modelId.Should().Be("test-balanced-model"); + return bedrockSpy; + }, + registerDefaultRouter: true); + + // Replace default availability with our forced-down locals. + services.AddSingleton(availability); + + await using var provider = services.BuildServiceProvider(); + // Resolve router via Auditing wrapper + var client = provider.GetRequiredService(); + + var options = new ChatOptions + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + [RouteRequestHints.CapabilityTierKey] = "balanced", + }, + }; + + // Force redact disposition for cloud via options already default BlockOnSecretRedactOnPii — + // prompt with PII should be redacted (not blocked) for cloud policy before bedrock spy. + var response = await client.GetResponseAsync("Reach alice@example.com", options); + response.Text.Should().Be("bedrock"); + localSpy.CallCount.Should().Be(0); + bedrockSpy.CallCount.Should().Be(1); + bedrockSpy.Calls[0][0].Text.Should().Contain("[REDACTED_EMAIL]"); + auditor.Records.Should().Contain(r => + r.TargetKey == RoutingChatClient.RouterTargetKey + && r.ReasonCode == RouteReasonCodes.Fallback); + auditor.Records.Should().Contain(r => + r.TargetKey == DefaultRouteCandidateTable.CloudBedrockBalanced + && r.Outcome == "success" + && r.RedactionCount >= 1); + } + + [Fact] + public async Task Policy_forbidding_cloud_keeps_prompt_local() + { + var localSpy = new SpyChatClient("local-ok"); + var bedrockSpy = new SpyChatClient("bedrock"); + var services = new ServiceCollection(); + services.AddNexoMeaiPipeline( + configure: o => + { + o.Bedrock.Enabled = false; // do not auto-allow cloud + }, + ollamaInnerFactory: _ => localSpy, + onnxInnerFactory: _ => new SpyChatClient("onnx"), + bedrockInnerFactory: (_, _) => bedrockSpy); + + await using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + var response = await client.GetResponseAsync("hello"); + response.Text.Should().Be("local-ok"); + bedrockSpy.CallCount.Should().Be(0); + provider.GetKeyedService(DefaultRouteCandidateTable.CloudBedrockBalanced) + .Should().BeNull(); + } + + [Fact] + public void Raw_bedrock_runtime_client_is_not_resolvable() + { + var services = new ServiceCollection(); + services.AddNexoMeaiPipeline( + configure: o => o.Bedrock.Enabled = true, + ollamaInnerFactory: _ => new FakeChatClient(), + onnxInnerFactory: _ => new FakeChatClient(), + bedrockInnerFactory: (_, _) => new FakeChatClient("b")); + + using var provider = services.BuildServiceProvider(); + provider.GetService().Should().BeNull(); + provider.GetService().Should().BeNull(); + provider.GetRequiredKeyedService(DefaultRouteCandidateTable.CloudBedrockFast) + .Should().BeOfType(); + } + + [Fact] + public async Task Integration_bedrock_live_gated_by_env() + { + if (!string.Equals(Environment.GetEnvironmentVariable("NEXO_TEST_BEDROCK"), "1", StringComparison.Ordinal)) + { + return; // gated — no AWS calls in CI by default + } + + var services = new ServiceCollection(); + services.AddNexoMeaiPipeline(configure: o => + { + o.Bedrock.Enabled = true; + // Model ids from env overrides if present + var balanced = Environment.GetEnvironmentVariable("NEXO_BEDROCK_BALANCED_MODEL"); + if (!string.IsNullOrWhiteSpace(balanced)) + { + o.Bedrock.BalancedModelId = balanced; + } + }); + + // Mark locals unavailable so router escalates. + services.AddSingleton( + new InMemoryTargetAvailability() + .Set(MeaiTargetKeys.LocalOllama, false) + .Set(MeaiTargetKeys.LocalOnnx, false)); + + await using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + var options = new ChatOptions + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + [RouteRequestHints.CapabilityTierKey] = "balanced", + }, + }; + + var response = await client.GetResponseAsync("Reply with the single word ok.", options); + response.Text.Should().NotBeNullOrWhiteSpace(); + } +} diff --git a/src/Nexo.Tests.AI.Pipeline/GovernanceMiddlewareTests.cs b/src/Nexo.Tests.AI.Pipeline/GovernanceMiddlewareTests.cs new file mode 100644 index 00000000..dc93110a --- /dev/null +++ b/src/Nexo.Tests.AI.Pipeline/GovernanceMiddlewareTests.cs @@ -0,0 +1,281 @@ +using FluentAssertions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Nexo.AI.Pipeline; +using Nexo.AI.Pipeline.Clients; +using Nexo.AI.Pipeline.Governance; +using Xunit; + +namespace Nexo.Tests.AI.Pipeline; + +public sealed class GovernanceMiddlewareTests +{ + private sealed class ForceRedactPolicy : ITargetSanitizePolicy + { + public SanitizeDisposition GetDisposition(string targetKey) => SanitizeDisposition.Redact; + } + + private sealed class ForceBlockPiiPolicy : ITargetSanitizePolicy + { + public SanitizeDisposition GetDisposition(string targetKey) => SanitizeDisposition.BlockOnPiiOrSecret; + } + + private sealed class DenyCloudPolicy : IChatTargetAccessPolicy + { + public bool IsAllowed(string? callerIdentity, string targetKey, string? modelId, out string? denyReason) + { + if (targetKey.StartsWith("cloud:", StringComparison.OrdinalIgnoreCase)) + { + denyReason = "cloud forbidden"; + return false; + } + + denyReason = null; + return true; + } + } + + [Fact] + public async Task Policy_deny_short_circuits_provider() + { + var spy = new SpyChatClient(); + var auditor = new InMemoryChatInvocationAuditor(); + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(auditor); + services.AddKeyedChatClient("cloud:bedrock:fast", _ => spy) + .UseNexoGovernance("cloud:bedrock:fast"); + + await using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredKeyedService("cloud:bedrock:fast"); + + var act = async () => await client.GetResponseAsync("hello"); + var ex = await act.Should().ThrowAsync(); + ex.Which.Code.Should().Be("target_denied"); + spy.CallCount.Should().Be(0); + auditor.Records.Should().Contain(r => r.Outcome == "denied" && r.ReasonCode == "target_denied"); + } + + [Fact] + public async Task Pii_is_redacted_before_reaching_spy() + { + var spy = new SpyChatClient(); + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddKeyedChatClient(MeaiTargetKeys.LocalOllama, _ => spy) + .UseNexoGovernance(MeaiTargetKeys.LocalOllama); + + await using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredKeyedService(MeaiTargetKeys.LocalOllama); + + await client.GetResponseAsync("Contact me at alice@example.com please"); + + spy.CallCount.Should().Be(1); + spy.Calls[0][0].Text.Should().Contain("[REDACTED_EMAIL]"); + spy.Calls[0][0].Text.Should().NotContain("alice@example.com"); + } + + [Fact] + public async Task Audit_record_produced_on_success() + { + var spy = new SpyChatClient("ok"); + var auditor = new InMemoryChatInvocationAuditor(); + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(auditor); + services.AddKeyedChatClient(MeaiTargetKeys.LocalOllama, _ => spy) + .UseNexoGovernance(MeaiTargetKeys.LocalOllama); + + await using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredKeyedService(MeaiTargetKeys.LocalOllama); + await client.GetResponseAsync("hello"); + + auditor.Records.Should().ContainSingle(r => r.Outcome == "success" && r.TargetKey == MeaiTargetKeys.LocalOllama); + } + + [Fact] + public async Task Audit_record_produced_on_fault() + { + var failing = new ThrowingChatClient(); + var auditor = new InMemoryChatInvocationAuditor(); + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(auditor); + services.AddKeyedChatClient(MeaiTargetKeys.LocalOllama, _ => failing) + .UseNexoGovernance(MeaiTargetKeys.LocalOllama); + + await using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredKeyedService(MeaiTargetKeys.LocalOllama); + + var act = async () => await client.GetResponseAsync("hello"); + await act.Should().ThrowAsync(); + auditor.Records.Should().Contain(r => r.Outcome == "fault"); + } + + [Fact] + public async Task Audit_record_produced_on_cancellation() + { + var hanging = new HangingChatClient(); + var auditor = new InMemoryChatInvocationAuditor(); + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(auditor); + services.AddKeyedChatClient(MeaiTargetKeys.LocalOllama, _ => hanging) + .UseNexoGovernance(MeaiTargetKeys.LocalOllama); + + await using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredKeyedService(MeaiTargetKeys.LocalOllama); + + using var cts = new CancellationTokenSource(); + var call = client.GetResponseAsync("hello", cancellationToken: cts.Token); + await Task.Delay(50); + cts.Cancel(); + + var act = async () => await call; + await act.Should().ThrowAsync(); + auditor.Records.Should().Contain(r => r.Outcome == "cancelled"); + } + + [Fact] + public void Governance_composition_order_is_policy_then_sanitize_then_audit() + { + var services = new ServiceCollection(); + services.RegisterGovernanceDefaults(); + services.AddKeyedChatClient(MeaiTargetKeys.LocalOllama, _ => new FakeChatClient()) + .UseNexoGovernance(MeaiTargetKeys.LocalOllama); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredKeyedService(MeaiTargetKeys.LocalOllama); + + client.Should().BeOfType(); + + var types = WalkInnerClients(client).Select(c => c.GetType()).ToList(); + types.Should().Equal( + typeof(PolicyGateChatClient), + typeof(SanitizingChatClient), + typeof(AuditingChatClient), + typeof(FakeChatClient)); + } + + private static IEnumerable WalkInnerClients(IChatClient root) + { + yield return root; + var current = root; + var prop = typeof(DelegatingChatClient).GetProperty( + "InnerClient", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + while (current is DelegatingChatClient) + { + current = (IChatClient)prop!.GetValue(current)!; + yield return current; + } + } + + [Fact] + public void Architecture_resolved_clients_are_governed_not_raw_providers() + { + var services = new ServiceCollection(); + services.AddNexoMeaiPipeline( + ollamaInnerFactory: _ => new FakeChatClient("a"), + onnxInnerFactory: _ => new FakeChatClient("b")); + + using var provider = services.BuildServiceProvider(); + + foreach (var key in new[] { MeaiTargetKeys.LocalOllama, MeaiTargetKeys.LocalOnnx }) + { + var client = provider.GetRequiredKeyedService(key); + client.Should().BeOfType(); + client.Should().NotBeOfType(); + client.Should().NotBeOfType(); + client.Should().NotBeOfType(); + } + + provider.GetService().Should().BeNull(); + provider.GetService().Should().BeNull(); + } + + [Fact] + public async Task Sanitization_block_does_not_call_provider() + { + var spy = new SpyChatClient(); + var auditor = new InMemoryChatInvocationAuditor(); + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(auditor); + services.AddKeyedChatClient(MeaiTargetKeys.LocalOllama, _ => spy) + .UseNexoGovernance(MeaiTargetKeys.LocalOllama); + + await using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredKeyedService(MeaiTargetKeys.LocalOllama); + + var act = async () => await client.GetResponseAsync("email alice@example.com"); + await act.Should().ThrowAsync(); + spy.CallCount.Should().Be(0); + auditor.Records.Should().Contain(r => r.ReasonCode == "sanitization_blocked"); + } + + private sealed class ThrowingChatClient : IChatClient + { + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("boom"); + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("boom"); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } + + private sealed class HangingChatClient : IChatClient + { + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.Infinite, cancellationToken); + return new ChatResponse(); + } + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) => + GetStreaming(cancellationToken); + + private static async IAsyncEnumerable GetStreaming( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Delay(Timeout.Infinite, cancellationToken); + yield break; + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } +} diff --git a/src/Nexo.Tests.AI.Pipeline/MeaiBackedModelTests.cs b/src/Nexo.Tests.AI.Pipeline/MeaiBackedModelTests.cs new file mode 100644 index 00000000..0032fb35 --- /dev/null +++ b/src/Nexo.Tests.AI.Pipeline/MeaiBackedModelTests.cs @@ -0,0 +1,57 @@ +using FluentAssertions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Nexo.Abstractions; +using Nexo.AI.Pipeline; +using Nexo.AI.Pipeline.Clients; +using Nexo.AI.Pipeline.Governance; +using Nexo.AI.Pipeline.Models; +using Xunit; + +namespace Nexo.Tests.AI.Pipeline; + +public sealed class MeaiBackedModelTests +{ + [Fact] + public async Task CompleteAsync_routes_through_governed_chat_client() + { + var spy = new SpyChatClient("meai-ok"); + var auditor = new InMemoryChatInvocationAuditor(); + var services = new ServiceCollection(); + services.AddSingleton(auditor); + services.AddNexoMeaiPipeline( + ollamaInnerFactory: _ => spy, + onnxInnerFactory: _ => new FakeChatClient("onnx"), + registerDefaultRouter: true); + + await using var provider = services.BuildServiceProvider(); + var model = new MeaiBackedModel( + provider.GetRequiredService(), + NullLogger.Instance); + + var output = await model.CompleteAsync( + new ModelInput(new[] { ("user", "hello from IModel") }), + CancellationToken.None); + + output.Text.Should().Be("meai-ok"); + spy.CallCount.Should().Be(1); + auditor.Records.Should().Contain(r => r.Outcome == "success"); + } + + [Fact] + public async Task Offline_provider_throws_for_hot_swap_fallback() + { + var model = new MeaiBackedModel(new FakeChatClient("unused"), NullLogger.Instance); + var act = async () => await model.CompleteAsync( + new ModelInput(new[] + { + ("system", "nexo.model.provider=offline"), + ("user", "hi"), + }), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*deterministic/offline*"); + } +} diff --git a/src/Nexo.Tests.AI.Pipeline/MeaiPipelineRegistrationTests.cs b/src/Nexo.Tests.AI.Pipeline/MeaiPipelineRegistrationTests.cs new file mode 100644 index 00000000..1810509c --- /dev/null +++ b/src/Nexo.Tests.AI.Pipeline/MeaiPipelineRegistrationTests.cs @@ -0,0 +1,159 @@ +using FluentAssertions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Nexo.AI.Pipeline; +using Nexo.AI.Pipeline.Clients; +using Xunit; + +namespace Nexo.Tests.AI.Pipeline; + +public sealed class MeaiPipelineRegistrationTests +{ + [Fact] + public void IsMeaiPipelineEnabled_defaults_to_true() + { + var previous = Environment.GetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar); + try + { + Environment.SetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar, null); + MeaiPipelineServiceCollectionExtensions.IsMeaiPipelineEnabled(configuration: null) + .Should().BeTrue(); + } + finally + { + Environment.SetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar, previous); + } + } + + [Fact] + public void IsMeaiPipelineEnabled_env_zero_opts_out() + { + var previous = Environment.GetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar); + try + { + Environment.SetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar, "0"); + MeaiPipelineServiceCollectionExtensions.IsMeaiPipelineEnabled(configuration: null) + .Should().BeFalse(); + } + finally + { + Environment.SetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar, previous); + } + } + + [Fact] + public void IsMeaiPipelineEnabled_reads_env_var() + { + var previous = Environment.GetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar); + try + { + Environment.SetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar, "1"); + MeaiPipelineServiceCollectionExtensions.IsMeaiPipelineEnabled(configuration: null) + .Should().BeTrue(); + } + finally + { + Environment.SetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar, previous); + } + } + + [Fact] + public void Feature_flag_config_key_enables_pipeline() + { + IConfiguration config = new ConfigurationBuilder() + .Add(new SimpleKvSource(new Dictionary + { + [MeaiPipelineOptions.FeatureFlagKey] = "true", + })) + .Build(); + + MeaiPipelineServiceCollectionExtensions.IsMeaiPipelineEnabled(config) + .Should().BeTrue(); + } + + private sealed class SimpleKvSource(Dictionary values) : IConfigurationSource + { + public IConfigurationProvider Build(IConfigurationBuilder builder) => new SimpleKvProvider(values); + } + + private sealed class SimpleKvProvider(Dictionary values) : ConfigurationProvider + { + public override void Load() => Data = new Dictionary(values, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public void Explicit_false_overrides_env() + { + var previous = Environment.GetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar); + try + { + Environment.SetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar, "1"); + MeaiPipelineServiceCollectionExtensions.IsMeaiPipelineEnabled(configuration: null, explicitEnable: false) + .Should().BeFalse(); + } + finally + { + Environment.SetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar, previous); + } + } + + [Fact] + public async Task Keyed_clients_resolve_and_respond_via_fakes() + { + var services = new ServiceCollection(); + services.AddNexoMeaiPipeline( + ollamaInnerFactory: _ => new FakeChatClient("ollama-ok"), + onnxInnerFactory: _ => new FakeChatClient("onnx-ok")); + + await using ServiceProvider provider = services.BuildServiceProvider(); + + IChatClient ollama = provider.GetRequiredKeyedService(MeaiTargetKeys.LocalOllama); + IChatClient onnx = provider.GetRequiredKeyedService(MeaiTargetKeys.LocalOnnx); + + ChatResponse ollamaResponse = await ollama.GetResponseAsync("hello"); + ChatResponse onnxResponse = await onnx.GetResponseAsync("hello"); + + ollamaResponse.Text.Should().Be("ollama-ok"); + onnxResponse.Text.Should().Be("onnx-ok"); + } + + [Fact] + public async Task Streaming_fake_client_yields_response() + { + var services = new ServiceCollection(); + services.AddNexoMeaiPipeline( + ollamaInnerFactory: _ => new FakeChatClient("stream-ok"), + onnxInnerFactory: _ => new FakeChatClient("stream-ok")); + + await using ServiceProvider provider = services.BuildServiceProvider(); + IChatClient client = provider.GetRequiredKeyedService(MeaiTargetKeys.LocalOllama); + + var chunks = new List(); + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync("hi")) + { + if (!string.IsNullOrEmpty(update.Text)) + { + chunks.Add(update.Text); + } + } + + chunks.Should().ContainSingle().Which.Should().Be("stream-ok"); + } + + [Fact] + public void Raw_provider_clients_are_not_resolvable_from_DI() + { + var services = new ServiceCollection(); + services.AddNexoMeaiPipeline( + ollamaInnerFactory: _ => new FakeChatClient(), + onnxInnerFactory: _ => new FakeChatClient()); + + using ServiceProvider provider = services.BuildServiceProvider(); + + provider.GetService().Should().BeNull(); + provider.GetKeyedService(MeaiTargetKeys.LocalOllama).Should().BeNull(); + provider.GetService().Should().BeNull(); + provider.GetKeyedService(MeaiTargetKeys.LocalOnnx).Should().BeNull(); + } +} diff --git a/src/Nexo.Tests.AI.Pipeline/Nexo.Tests.AI.Pipeline.csproj b/src/Nexo.Tests.AI.Pipeline/Nexo.Tests.AI.Pipeline.csproj new file mode 100644 index 00000000..c703e997 --- /dev/null +++ b/src/Nexo.Tests.AI.Pipeline/Nexo.Tests.AI.Pipeline.csproj @@ -0,0 +1,26 @@ + + + + net8.0;net9.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + diff --git a/src/Nexo.Tests.AI.Pipeline/RoutingChatClientTests.cs b/src/Nexo.Tests.AI.Pipeline/RoutingChatClientTests.cs new file mode 100644 index 00000000..93cb002f --- /dev/null +++ b/src/Nexo.Tests.AI.Pipeline/RoutingChatClientTests.cs @@ -0,0 +1,205 @@ +using FluentAssertions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Nexo.AI.Pipeline; +using Nexo.AI.Pipeline.Clients; +using Nexo.AI.Pipeline.Governance; +using Nexo.AI.Pipeline.Routing; +using Xunit; + +namespace Nexo.Tests.AI.Pipeline; + +public sealed class RoutingChatClientTests +{ + private sealed class AllowListPolicy : IChatTargetAccessPolicy + { + private readonly HashSet _allowed; + + public AllowListPolicy(params string[] allowed) => + _allowed = new HashSet(allowed, StringComparer.OrdinalIgnoreCase); + + public bool IsAllowed(string? callerIdentity, string targetKey, string? modelId, out string? denyReason) + { + if (_allowed.Contains(targetKey)) + { + denyReason = null; + return true; + } + + denyReason = $"not in allow-list: {targetKey}"; + return false; + } + } + + [Fact] + public async Task Policy_forbids_cloud_never_consults_cloud_spy() + { + var localSpy = new SpyChatClient("local"); + var cloudSpy = new SpyChatClient("cloud"); + var availability = new InMemoryTargetAvailability() + .Set(MeaiTargetKeys.LocalOllama, true) + .Set(MeaiTargetKeys.LocalOnnx, false) + .Set(DefaultRouteCandidateTable.CloudBedrockBalanced, true); + + var services = new ServiceCollection(); + services.AddSingleton(new AllowListPolicy(MeaiTargetKeys.LocalOllama, MeaiTargetKeys.LocalOnnx)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(availability); + services.AddSingleton(); + services.AddSingleton(); + + services.AddNexoGovernedChatClient(MeaiTargetKeys.LocalOllama, _ => localSpy); + services.AddNexoGovernedChatClient(MeaiTargetKeys.LocalOnnx, _ => new SpyChatClient("onnx")); + services.AddNexoGovernedChatClient(DefaultRouteCandidateTable.CloudBedrockBalanced, _ => cloudSpy); + + services.AddSingleton(sp => ActivatorUtilities.CreateInstance(sp)); + + await using var provider = services.BuildServiceProvider(); + var router = provider.GetRequiredService(); + + var options = new ChatOptions + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + [RouteRequestHints.CapabilityTierKey] = "balanced", + }, + }; + + var response = await router.GetResponseAsync("hello", options); + response.Text.Should().Be("local"); + localSpy.CallCount.Should().Be(1); + cloudSpy.CallCount.Should().Be(0); + router.LastDecision!.ChosenTargetKey.Should().Be(MeaiTargetKeys.LocalOllama); + router.LastDecision.CandidatesConsidered.Should() + .NotContain(c => c.TargetKey.StartsWith("cloud:", StringComparison.OrdinalIgnoreCase) + && c.Disposition.StartsWith("choose:", StringComparison.Ordinal)); + } + + [Fact] + public async Task Local_down_cloud_allowed_falls_back_with_audited_reason() + { + var localSpy = new SpyChatClient("local"); + var cloudSpy = new SpyChatClient("cloud"); + var auditor = new InMemoryChatInvocationAuditor(); + var availability = new InMemoryTargetAvailability() + .Set(MeaiTargetKeys.LocalOllama, false) + .Set(MeaiTargetKeys.LocalOnnx, false) + .Set(DefaultRouteCandidateTable.CloudBedrockBalanced, true); + + var services = new ServiceCollection(); + var policy = new DefaultChatTargetAccessPolicy(); + policy.AllowedCloudTargets.Add(DefaultRouteCandidateTable.CloudBedrockBalanced); + services.AddSingleton(policy); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(auditor); + services.AddSingleton(availability); + services.AddSingleton(); + services.AddSingleton(); + + services.AddNexoGovernedChatClient(MeaiTargetKeys.LocalOllama, _ => localSpy); + services.AddNexoGovernedChatClient(MeaiTargetKeys.LocalOnnx, _ => new SpyChatClient("onnx")); + services.AddNexoGovernedChatClient(DefaultRouteCandidateTable.CloudBedrockBalanced, _ => cloudSpy); + services.AddSingleton(sp => ActivatorUtilities.CreateInstance(sp)); + + await using var provider = services.BuildServiceProvider(); + var router = provider.GetRequiredService(); + + var options = new ChatOptions + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + [RouteRequestHints.CapabilityTierKey] = nameof(RouteCapabilityTier.Balanced), + }, + }; + + var response = await router.GetResponseAsync("hello", options); + response.Text.Should().Be("cloud"); + localSpy.CallCount.Should().Be(0); + cloudSpy.CallCount.Should().Be(1); + router.LastDecision!.ChosenTargetKey.Should().Be(DefaultRouteCandidateTable.CloudBedrockBalanced); + router.LastDecision.ReasonCode.Should().Be(RouteReasonCodes.Fallback); + auditor.Records.Should().Contain(r => + r.TargetKey == RoutingChatClient.RouterTargetKey + && r.ReasonCode == RouteReasonCodes.Fallback); + } + + [Fact] + public async Task Local_down_cloud_forbidden_fails_without_silent_degradation() + { + var cloudSpy = new SpyChatClient("cloud"); + var availability = new InMemoryTargetAvailability() + .Set(MeaiTargetKeys.LocalOllama, false) + .Set(MeaiTargetKeys.LocalOnnx, false) + .Set(DefaultRouteCandidateTable.CloudBedrockBalanced, true); + + var services = new ServiceCollection(); + services.AddSingleton(new AllowListPolicy(MeaiTargetKeys.LocalOllama, MeaiTargetKeys.LocalOnnx)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(availability); + services.AddSingleton(); + services.AddSingleton(); + + services.AddNexoGovernedChatClient(MeaiTargetKeys.LocalOllama, _ => new SpyChatClient("local")); + services.AddNexoGovernedChatClient(MeaiTargetKeys.LocalOnnx, _ => new SpyChatClient("onnx")); + services.AddNexoGovernedChatClient(DefaultRouteCandidateTable.CloudBedrockBalanced, _ => cloudSpy); + services.AddSingleton(sp => ActivatorUtilities.CreateInstance(sp)); + + await using var provider = services.BuildServiceProvider(); + var router = provider.GetRequiredService(); + + var act = async () => await router.GetResponseAsync("hello"); + var ex = await act.Should().ThrowAsync(); + ex.Which.Code.Should().Be(RouteReasonCodes.NoCandidate); + cloudSpy.CallCount.Should().Be(0); + } + + public static IEnumerable PolicyAvailabilityMatrix() + { + // cloudAllowed, ollamaUp, onnxUp, cloudUp, tier, expectedTargetOrEmpty, expectedReason + yield return new object[] { false, true, true, true, RouteCapabilityTier.Fast, MeaiTargetKeys.LocalOllama, RouteReasonCodes.PreferredLocal }; + yield return new object[] { false, false, true, true, RouteCapabilityTier.Fast, MeaiTargetKeys.LocalOnnx, RouteReasonCodes.Fallback }; + yield return new object[] { false, false, false, true, RouteCapabilityTier.Balanced, "", RouteReasonCodes.NoCandidate }; + yield return new object[] { true, false, false, true, RouteCapabilityTier.Balanced, DefaultRouteCandidateTable.CloudBedrockBalanced, RouteReasonCodes.Fallback }; + yield return new object[] { true, false, false, true, RouteCapabilityTier.Heavy, DefaultRouteCandidateTable.CloudBedrockBalanced, RouteReasonCodes.CapabilityEscalation }; + yield return new object[] { true, false, false, false, RouteCapabilityTier.Heavy, "", RouteReasonCodes.NoCandidate }; + yield return new object[] { true, true, false, true, RouteCapabilityTier.Heavy, MeaiTargetKeys.LocalOllama, RouteReasonCodes.PreferredLocal }; + } + + [Theory] + [MemberData(nameof(PolicyAvailabilityMatrix))] + public void Matrix_selects_expected_target( + bool cloudAllowed, + bool ollamaUp, + bool onnxUp, + bool cloudUp, + RouteCapabilityTier tier, + string expectedTarget, + string expectedReason) + { + var availability = new InMemoryTargetAvailability() + .Set(MeaiTargetKeys.LocalOllama, ollamaUp) + .Set(MeaiTargetKeys.LocalOnnx, onnxUp) + .Set(DefaultRouteCandidateTable.CloudBedrockFast, cloudUp) + .Set(DefaultRouteCandidateTable.CloudBedrockBalanced, cloudUp) + .Set(DefaultRouteCandidateTable.CloudBedrockHeavy, cloudUp); + + var policy = new DefaultChatTargetAccessPolicy(); + if (cloudAllowed) + { + policy.AllowedCloudTargets.Add(DefaultRouteCandidateTable.CloudBedrockFast); + policy.AllowedCloudTargets.Add(DefaultRouteCandidateTable.CloudBedrockBalanced); + policy.AllowedCloudTargets.Add(DefaultRouteCandidateTable.CloudBedrockHeavy); + } + + var router = new LocalFirstChatRouter(new DefaultRouteCandidateTable(), availability, policy); + var decision = router.Select(callerIdentity: null, modelId: null, tier, preferredTarget: null); + + decision.ChosenTargetKey.Should().Be(expectedTarget); + decision.ReasonCode.Should().Be(expectedReason); + } +} diff --git a/src/Nexo.Tests.AI.Pipeline/VectorDataRagTests.cs b/src/Nexo.Tests.AI.Pipeline/VectorDataRagTests.cs new file mode 100644 index 00000000..1022dada --- /dev/null +++ b/src/Nexo.Tests.AI.Pipeline/VectorDataRagTests.cs @@ -0,0 +1,88 @@ +using FluentAssertions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Nexo.AI.Pipeline; +using Nexo.AI.Pipeline.Clients; +using Nexo.AI.Pipeline.Governance; +using Nexo.AI.Pipeline.Rag; +using Xunit; + +namespace Nexo.Tests.AI.Pipeline; + +public sealed class VectorDataRagTests +{ + [Fact] + public async Task Round_trip_index_and_search() + { + await using var provider = BuildProvider(); + var rag = provider.GetRequiredService(); + + await rag.IndexAsync("a", "alpha wolverine protocol", trustTier: "Public"); + await rag.IndexAsync("b", "beta unrelated notes", trustTier: "Public"); + + var hits = await rag.SearchAsync("wolverine protocol", callerMaxTrustTier: "Internal", top: 5); + hits.Should().NotBeEmpty(); + hits[0].Record.Key.Should().Be("a"); + } + + [Fact] + public async Task Trust_tier_filter_excludes_higher_tier_chunks() + { + await using var provider = BuildProvider(); + var rag = provider.GetRequiredService(); + + await rag.IndexAsync("pub", "shared playbook guidance", trustTier: "Public"); + await rag.IndexAsync("sec", "shared playbook guidance", trustTier: "Secret"); + + var hits = await rag.SearchAsync("playbook guidance", callerMaxTrustTier: "Internal", top: 10); + hits.Should().Contain(h => h.Record.Key == "pub"); + hits.Should().NotContain(h => h.Record.Key == "sec"); + } + + [Fact] + public async Task Embedding_calls_appear_in_audit() + { + var auditor = new InMemoryChatInvocationAuditor(); + await using var provider = BuildProvider(auditor); + var rag = provider.GetRequiredService(); + + await rag.IndexAsync("1", "audited embedding content with sk-TESTSECRETKEY12345678", trustTier: "Public"); + await rag.SearchAsync("audited embedding", callerMaxTrustTier: "Public"); + + auditor.Records.Should().Contain(r => r.TargetKey == "embed:local" && r.Outcome == "success"); + auditor.Records.Should().Contain(r => r.TargetKey == "rag:search" && r.Outcome == "success"); + auditor.Records.Should().Contain(r => r.TargetKey == "embed:local" && r.RedactionCount >= 1); + } + + [Fact] + public async Task Reindex_utility_writes_all_chunks() + { + await using var provider = BuildProvider(); + var rag = provider.GetRequiredService(); + + var count = await rag.ReindexAsync(new[] + { + ("1", "one", (string?)null, "Public"), + ("2", "two", "file://x", "Internal"), + }); + + count.Should().Be(2); + var hits = await rag.SearchAsync("one", "Public"); + hits.Should().Contain(h => h.Record.Key == "1"); + } + + private static ServiceProvider BuildProvider(InMemoryChatInvocationAuditor? auditor = null) + { + var services = new ServiceCollection(); + if (auditor is not null) + { + services.AddSingleton(auditor); + } + + services.AddNexoMeaiPipeline( + ollamaInnerFactory: _ => new FakeChatClient(), + onnxInnerFactory: _ => new FakeChatClient(), + registerDefaultRouter: false); + return services.BuildServiceProvider(); + } +} diff --git a/src/Nexo.Tests.Infrastructure/Tests/Hosting/HostingE2ESmokeTests.cs b/src/Nexo.Tests.Infrastructure/Tests/Hosting/HostingE2ESmokeTests.cs index 6448b7cf..8bc67e27 100644 --- a/src/Nexo.Tests.Infrastructure/Tests/Hosting/HostingE2ESmokeTests.cs +++ b/src/Nexo.Tests.Infrastructure/Tests/Hosting/HostingE2ESmokeTests.cs @@ -149,4 +149,44 @@ public async Task AddNexo_UsesEnvironmentDeploymentProfile_WhenOptionsDoNotOverr Environment.SetEnvironmentVariable(profileKey, previous); } } + + [Fact(Timeout = TestTimeouts.E2E)] + public async Task AddNexo_Defaults_To_Meai_Pipeline_And_VectorData_Rag() + { + await Task.CompletedTask; + var previous = Environment.GetEnvironmentVariable("NEXO_USE_MEAI_PIPELINE"); + try + { + Environment.SetEnvironmentVariable("NEXO_USE_MEAI_PIPELINE", null); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddNexo(); + using var sp = services.BuildServiceProvider(); + + sp.GetService().Should().NotBeNull( + "Phase 6 defaults the MEAI governed chat pipeline on"); + sp.GetRequiredService() + .Should().BeOfType(); + sp.GetRequiredService().Should().NotBeNull(); + sp.GetRequiredService().Should().NotBeNull(); + } + finally + { + Environment.SetEnvironmentVariable("NEXO_USE_MEAI_PIPELINE", previous); + } + } + + [Fact(Timeout = TestTimeouts.E2E)] + public async Task AddNexo_Meai_OptOut_Skips_ChatClient_But_Keeps_VectorData_Rag() + { + await Task.CompletedTask; + var services = new ServiceCollection(); + services.AddLogging(); + services.AddNexo(o => o.UseMeaiPipeline = false); + using var sp = services.BuildServiceProvider(); + + sp.GetService().Should().BeNull(); + sp.GetRequiredService() + .Should().BeOfType(); + } }