From af787523680d80b42813fb4050c78a255a36f21e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Jul 2026 17:59:22 +0000 Subject: [PATCH 1/3] docs: add MEAI migration Phase 0 discovery notes Map current model invocation, sanitization, audit, policy packs, RAG, and host DI seams to proposed Microsoft.Extensions.AI concepts for the governed pipeline migration. Co-authored-by: IanFrelinger --- docs/meai-migration-notes.md | 334 +++++++++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 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..d725c8e0 --- /dev/null +++ b/docs/meai-migration-notes.md @@ -0,0 +1,334 @@ +# 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 | Pending | +| 2 | PolicyGate / Sanitizing / Auditing middleware + DI architecture tests | Pending | +| 3 | `RoutingChatClient` + policy × availability matrix tests | Pending | +| 4 | Bedrock tiered targets + env-gated integration test | Pending | +| 5 | VectorData RAG + embedding middleware + reindex CLI | Pending | +| 6 | Flag default on; delete legacy; `docs/governed-pipeline.md` | Pending | From 35fe06d7f20f2591ef63d2224198e3b086cb9a93 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Jul 2026 18:06:11 +0000 Subject: [PATCH 2/3] feat(ai): adopt MEAI IChatClient pipeline behind feature flag Add Nexo.AI.Pipeline with keyed local:ollama and local:onnx chat clients, wire optional registration through hosting Phase 13b when Nexo:UseMeaiPipeline is enabled (default off), and cover registration with FakeChatClient tests. Legacy IProviderFactory path remains default. Co-authored-by: IanFrelinger --- Directory.Packages.props | 38 ++-- Nexo.Kernel.sln | 30 +++ Nexo.Runtime.sln | 30 +++ Nexo.sln | 30 +++ docs/meai-migration-notes.md | 22 +- .../Clients/FakeChatClient.cs | 63 ++++++ .../Clients/LlamaSharpChatClient.cs | 168 +++++++++++++++ .../Clients/OllamaHttpChatClient.cs | 202 ++++++++++++++++++ src/Nexo.AI.Pipeline/MeaiPipelineOptions.cs | 35 +++ ...MeaiPipelineServiceCollectionExtensions.cs | 124 +++++++++++ src/Nexo.AI.Pipeline/MeaiTargetKeys.cs | 16 ++ src/Nexo.AI.Pipeline/Nexo.AI.Pipeline.csproj | 31 +++ src/Nexo.Hosting/Nexo.Hosting.csproj | 1 + .../NexoKernelRegistrar.Phases.cs | 23 ++ src/Nexo.Hosting/NexoKernelRegistrar.cs | 1 + .../Sdk/Options/NexoHostingOptions.cs | 7 + .../MeaiPipelineRegistrationTests.cs | 143 +++++++++++++ .../Nexo.Tests.AI.Pipeline.csproj | 26 +++ 18 files changed, 971 insertions(+), 19 deletions(-) create mode 100644 src/Nexo.AI.Pipeline/Clients/FakeChatClient.cs create mode 100644 src/Nexo.AI.Pipeline/Clients/LlamaSharpChatClient.cs create mode 100644 src/Nexo.AI.Pipeline/Clients/OllamaHttpChatClient.cs create mode 100644 src/Nexo.AI.Pipeline/MeaiPipelineOptions.cs create mode 100644 src/Nexo.AI.Pipeline/MeaiPipelineServiceCollectionExtensions.cs create mode 100644 src/Nexo.AI.Pipeline/MeaiTargetKeys.cs create mode 100644 src/Nexo.AI.Pipeline/Nexo.AI.Pipeline.csproj create mode 100644 src/Nexo.Tests.AI.Pipeline/MeaiPipelineRegistrationTests.cs create mode 100644 src/Nexo.Tests.AI.Pipeline/Nexo.Tests.AI.Pipeline.csproj diff --git a/Directory.Packages.props b/Directory.Packages.props index 0f3b354d..7609254b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,20 +4,20 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + @@ -27,10 +27,10 @@ - + - + @@ -59,6 +59,8 @@ + + @@ -131,7 +133,7 @@ - + @@ -211,4 +213,4 @@ - \ No newline at end of file + 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/meai-migration-notes.md b/docs/meai-migration-notes.md index d725c8e0..c08698e2 100644 --- a/docs/meai-migration-notes.md +++ b/docs/meai-migration-notes.md @@ -326,9 +326,29 @@ Router (Phase 3) sits **outside** per-target stacks and is itself wrapped in Aud | Phase | Deliverable | Status | |-------|-------------|--------| | 0 | This notes file | **Done** | -| 1 | `Nexo.AI.Pipeline` + Ollama/LLamaSharp `IChatClient` + flag off | Pending | +| 1 | `Nexo.AI.Pipeline` + Ollama/LLamaSharp `IChatClient` + flag off | **Done** | | 2 | PolicyGate / Sanitizing / Auditing middleware + DI architecture tests | Pending | | 3 | `RoutingChatClient` + policy × availability matrix tests | Pending | | 4 | Bedrock tiered targets + env-gated integration test | Pending | | 5 | VectorData RAG + embedding middleware + reindex CLI | Pending | | 6 | Flag default on; delete legacy; `docs/governed-pipeline.md` | Pending | + +--- + +## 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. + 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/MeaiPipelineOptions.cs b/src/Nexo.AI.Pipeline/MeaiPipelineOptions.cs new file mode 100644 index 00000000..260ebf6c --- /dev/null +++ b/src/Nexo.AI.Pipeline/MeaiPipelineOptions.cs @@ -0,0 +1,35 @@ +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; +} diff --git a/src/Nexo.AI.Pipeline/MeaiPipelineServiceCollectionExtensions.cs b/src/Nexo.AI.Pipeline/MeaiPipelineServiceCollectionExtensions.cs new file mode 100644 index 00000000..8ac13627 --- /dev/null +++ b/src/Nexo.AI.Pipeline/MeaiPipelineServiceCollectionExtensions.cs @@ -0,0 +1,124 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Nexo.AI.Pipeline.Clients; + +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. + /// Resolution order: explicit → + /// Nexo:UseMeaiPipeline config → NEXO_USE_MEAI_PIPELINE env → 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) + && bool.TryParse(flagged, out var parsed)) + { + return parsed; + } + + if (string.Equals(flagged, "1", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + var env = Environment.GetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar); + return string.Equals(env, "1", StringComparison.OrdinalIgnoreCase) + || string.Equals(env, "true", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Registers keyed pipelines for local:ollama and local:onnx. + /// Raw provider clients (OllamaApiClient, LLamaSharp sessions) are never registered in DI. + /// + /// Service collection. + /// Optional configuration for options binding. + /// Optional options mutation. + /// + /// Optional replacement for the Ollama inner client (tests). When null, OllamaSharp is used. + /// + /// + /// Optional replacement for the local offline inner client (tests). When null, LLamaSharp is used. + /// + /// The service collection. + public static IServiceCollection AddNexoMeaiPipeline( + this IServiceCollection services, + IConfiguration? configuration = null, + Action? configure = null, + Func? ollamaInnerFactory = null, + Func? onnxInnerFactory = null) + { + var options = new MeaiPipelineOptions(); + if (configuration is not null) + { + BindSection(configuration.GetSection(MeaiPipelineOptions.SectionName), options); + } + + configure?.Invoke(options); + services.AddSingleton(Options.Create(options)); + + // Keyed pipelines via MEAI ChatClientBuilder — inner factories stay private. + Func defaultOllama = sp => + new OllamaHttpChatClient(sp.GetRequiredService>()); + Func defaultOnnx = sp => + new LlamaSharpChatClient(sp.GetRequiredService>()); + + services.AddKeyedChatClient( + MeaiTargetKeys.LocalOllama, + sp => (ollamaInnerFactory ?? defaultOllama)(sp)); + + services.AddKeyedChatClient( + MeaiTargetKeys.LocalOnnx, + sp => (onnxInnerFactory ?? defaultOnnx)(sp)); + + return services; + } + + private static void BindSection(IConfiguration section, MeaiPipelineOptions options) + { + // Lightweight bind without Configuration.Binder package dependency. + 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; + } + } +} 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/Nexo.AI.Pipeline.csproj b/src/Nexo.AI.Pipeline/Nexo.AI.Pipeline.csproj new file mode 100644 index 00000000..8735d7cf --- /dev/null +++ b/src/Nexo.AI.Pipeline/Nexo.AI.Pipeline.csproj @@ -0,0 +1,31 @@ + + + + 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 + https://github.com/IanFrelinger/Nexo + git + + + + + + + + + + + + + + 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..6264d44c 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; @@ -347,6 +348,28 @@ private static void RegisterPhase13_ModelDecoratorChain(NexoKernelRegistrationCo } + /// + /// Phase 13b: optional Microsoft.Extensions.AI keyed chat pipeline (feature-flagged; default off). + /// Legacy IProviderFactory path remains the default until Phase 6. + /// + private static void RegisterPhase13b_MeaiPipeline(NexoKernelRegistrationContext ctx) + { + IServiceCollection services = ctx.Services; + NexoHostingOptions options = ctx.Options; + IConfiguration configuration = ctx.Configuration; + + 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..0c9ed1d5 100644 --- a/src/Nexo.Hosting/Sdk/Options/NexoHostingOptions.cs +++ b/src/Nexo.Hosting/Sdk/Options/NexoHostingOptions.cs @@ -28,6 +28,13 @@ public sealed class NexoHostingOptions /// public bool? TrustEnabled { get; set; } + /// + /// When true, registers the Microsoft.Extensions.AI governed chat pipeline + /// (keyed IChatClient targets). Default: from Nexo:UseMeaiPipeline + /// / NEXO_USE_MEAI_PIPELINE, or false (legacy IProviderFactory path). + /// + 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.Tests.AI.Pipeline/MeaiPipelineRegistrationTests.cs b/src/Nexo.Tests.AI.Pipeline/MeaiPipelineRegistrationTests.cs new file mode 100644 index 00000000..2447d78c --- /dev/null +++ b/src/Nexo.Tests.AI.Pipeline/MeaiPipelineRegistrationTests.cs @@ -0,0 +1,143 @@ +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_false() + { + var previous = Environment.GetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar); + try + { + Environment.SetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar, null); + 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 + + + + + + + + + + + + + + + + + + From 3aec58c24d0c4b4e7269c5bf12b7c45bd3ba5bed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Jul 2026 18:09:40 +0000 Subject: [PATCH 3/3] feat(ai): add MEAI governance middleware (policy, sanitize, audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce UseNexoGovernance with fixed PolicyGate → Sanitizing → Auditing order on every keyed IChatClient, plus unit tests for deny short-circuit, PII redaction, audit outcomes, and composition/architecture guarantees. Co-authored-by: IanFrelinger --- docs/meai-migration-notes.md | 19 +- src/Nexo.AI.Pipeline/Clients/SpyChatClient.cs | 64 ++++ .../Governance/AuditingChatClient.cs | 173 +++++++++++ .../Governance/DefaultChatMessageSanitizer.cs | 129 ++++++++ .../DefaultChatTargetAccessPolicy.cs | 47 +++ .../Governance/IChatInvocationAuditor.cs | 52 ++++ .../Governance/IChatMessageSanitizer.cs | 62 ++++ .../Governance/IChatTargetAccessPolicy.cs | 12 + .../Governance/ITargetSanitizePolicy.cs | 28 ++ .../InMemoryChatInvocationAuditor.cs | 21 ++ ...xoGovernanceChatClientBuilderExtensions.cs | 44 +++ .../Governance/PolicyGateChatClient.cs | 76 +++++ .../Governance/PolicyViolationException.cs | 34 +++ .../Governance/SanitizationCallContext.cs | 15 + .../Governance/SanitizeDisposition.cs | 19 ++ .../Governance/SanitizingChatClient.cs | 91 ++++++ ...MeaiPipelineServiceCollectionExtensions.cs | 42 +-- .../GovernanceMiddlewareTests.cs | 281 ++++++++++++++++++ 18 files changed, 1190 insertions(+), 19 deletions(-) create mode 100644 src/Nexo.AI.Pipeline/Clients/SpyChatClient.cs create mode 100644 src/Nexo.AI.Pipeline/Governance/AuditingChatClient.cs create mode 100644 src/Nexo.AI.Pipeline/Governance/DefaultChatMessageSanitizer.cs create mode 100644 src/Nexo.AI.Pipeline/Governance/DefaultChatTargetAccessPolicy.cs create mode 100644 src/Nexo.AI.Pipeline/Governance/IChatInvocationAuditor.cs create mode 100644 src/Nexo.AI.Pipeline/Governance/IChatMessageSanitizer.cs create mode 100644 src/Nexo.AI.Pipeline/Governance/IChatTargetAccessPolicy.cs create mode 100644 src/Nexo.AI.Pipeline/Governance/ITargetSanitizePolicy.cs create mode 100644 src/Nexo.AI.Pipeline/Governance/InMemoryChatInvocationAuditor.cs create mode 100644 src/Nexo.AI.Pipeline/Governance/NexoGovernanceChatClientBuilderExtensions.cs create mode 100644 src/Nexo.AI.Pipeline/Governance/PolicyGateChatClient.cs create mode 100644 src/Nexo.AI.Pipeline/Governance/PolicyViolationException.cs create mode 100644 src/Nexo.AI.Pipeline/Governance/SanitizationCallContext.cs create mode 100644 src/Nexo.AI.Pipeline/Governance/SanitizeDisposition.cs create mode 100644 src/Nexo.AI.Pipeline/Governance/SanitizingChatClient.cs create mode 100644 src/Nexo.Tests.AI.Pipeline/GovernanceMiddlewareTests.cs diff --git a/docs/meai-migration-notes.md b/docs/meai-migration-notes.md index c08698e2..70afb400 100644 --- a/docs/meai-migration-notes.md +++ b/docs/meai-migration-notes.md @@ -327,7 +327,7 @@ Router (Phase 3) sits **outside** per-target stacks and is itself wrapped in Aud |-------|-------------|--------| | 0 | This notes file | **Done** | | 1 | `Nexo.AI.Pipeline` + Ollama/LLamaSharp `IChatClient` + flag off | **Done** | -| 2 | PolicyGate / Sanitizing / Auditing middleware + DI architecture tests | Pending | +| 2 | PolicyGate / Sanitizing / Auditing middleware + DI architecture tests | **Done** | | 3 | `RoutingChatClient` + policy × availability matrix tests | Pending | | 4 | Bedrock tiered targets + env-gated integration test | Pending | | 5 | VectorData RAG + embedding middleware + reindex CLI | Pending | @@ -352,3 +352,20 @@ Landing branch: `cursor/meai-phase1-pipeline-5a04` 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 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/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/MeaiPipelineServiceCollectionExtensions.cs b/src/Nexo.AI.Pipeline/MeaiPipelineServiceCollectionExtensions.cs index 8ac13627..be368c25 100644 --- a/src/Nexo.AI.Pipeline/MeaiPipelineServiceCollectionExtensions.cs +++ b/src/Nexo.AI.Pipeline/MeaiPipelineServiceCollectionExtensions.cs @@ -1,8 +1,10 @@ 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.Governance; namespace Nexo.AI.Pipeline; @@ -44,19 +46,9 @@ public static bool IsMeaiPipelineEnabled(IConfiguration? configuration, bool? ex } /// - /// Registers keyed pipelines for local:ollama and local:onnx. - /// Raw provider clients (OllamaApiClient, LLamaSharp sessions) are never registered in DI. + /// Registers keyed pipelines for local:ollama and local:onnx + /// with the fixed Nexo governance stack. Raw provider clients are never registered in DI. /// - /// Service collection. - /// Optional configuration for options binding. - /// Optional options mutation. - /// - /// Optional replacement for the Ollama inner client (tests). When null, OllamaSharp is used. - /// - /// - /// Optional replacement for the local offline inner client (tests). When null, LLamaSharp is used. - /// - /// The service collection. public static IServiceCollection AddNexoMeaiPipeline( this IServiceCollection services, IConfiguration? configuration = null, @@ -73,26 +65,40 @@ public static IServiceCollection AddNexoMeaiPipeline( configure?.Invoke(options); services.AddSingleton(Options.Create(options)); - // Keyed pipelines via MEAI ChatClientBuilder — inner factories stay private. + RegisterGovernanceDefaults(services); + Func defaultOllama = sp => new OllamaHttpChatClient(sp.GetRequiredService>()); Func defaultOnnx = sp => new LlamaSharpChatClient(sp.GetRequiredService>()); services.AddKeyedChatClient( - MeaiTargetKeys.LocalOllama, - sp => (ollamaInnerFactory ?? defaultOllama)(sp)); + MeaiTargetKeys.LocalOllama, + sp => (ollamaInnerFactory ?? defaultOllama)(sp)) + .UseNexoGovernance(MeaiTargetKeys.LocalOllama); services.AddKeyedChatClient( - MeaiTargetKeys.LocalOnnx, - sp => (onnxInnerFactory ?? defaultOnnx)(sp)); + MeaiTargetKeys.LocalOnnx, + sp => (onnxInnerFactory ?? defaultOnnx)(sp)) + .UseNexoGovernance(MeaiTargetKeys.LocalOnnx); + + return services; + } + /// + /// Registers default governance services if not already present. + /// + public static IServiceCollection RegisterGovernanceDefaults(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); return services; } private static void BindSection(IConfiguration section, MeaiPipelineOptions options) { - // Lightweight bind without Configuration.Binder package dependency. var ollamaBase = section["OllamaBaseUrl"]; if (!string.IsNullOrWhiteSpace(ollamaBase)) { 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() + { + } + } +}