diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index 12391ca..a4938b6 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -73,7 +73,7 @@ permissions: jobs: build-chat-gateway: - name: Build provider-agnostic chat gateway + name: Test provider-agnostic chat gateway runs-on: ubuntu-latest steps: @@ -86,16 +86,23 @@ jobs: dotnet-version: "10.0.x" - name: Restore - run: | - dotnet restore \ - dotnet-ai/provider-agnostic-chat-gateway/ChatGatewayMinimal.csproj + run: > + dotnet restore + dotnet-ai/provider-agnostic-chat-gateway/ProviderAgnosticChatGateway.slnx - name: Build - run: | - dotnet build \ - dotnet-ai/provider-agnostic-chat-gateway/ChatGatewayMinimal.csproj \ - --configuration Release \ - --no-restore + run: > + dotnet build + dotnet-ai/provider-agnostic-chat-gateway/ProviderAgnosticChatGateway.slnx + --configuration Release + --no-restore + + - name: Test deterministic layers + run: > + dotnet test + dotnet-ai/provider-agnostic-chat-gateway/ProviderAgnosticChatGateway.slnx + --configuration Release + --no-build build-hybrid-search: name: Build hybrid search RRF sample diff --git a/README.md b/README.md index e224c44..fb222f2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The | Sample | What it demonstrates | Full tutorial | | --- | --- | --- | -| [`dotnet-ai/provider-agnostic-chat-gateway`](dotnet-ai/provider-agnostic-chat-gateway/) | One HTTP chat endpoint using Ollama and optional OpenAI providers through `Microsoft.Extensions.AI.IChatClient` | [Build a switchable multi-provider AI gateway with IChatClient](https://www.dotnet-guide.com/tutorials/dotnet-ai/provider-agnostic-chat-gateway/) | +| [`dotnet-ai/provider-agnostic-chat-gateway`](dotnet-ai/provider-agnostic-chat-gateway/) | .NET 10 `Microsoft.Extensions.AI` gateway with allow-listed `IChatClient` providers, bounded conversation state, SSE streaming, safe function calling, and deterministic tests | [Microsoft.Extensions.AI Tutorial: Build a Multi-Provider IChatClient Gateway](https://www.dotnet-guide.com/tutorials/dotnet-ai/provider-agnostic-chat-gateway/) | | [`dotnet-ai/hybrid-search-ef-core-pgvector`](dotnet-ai/hybrid-search-ef-core-pgvector/) | Minimal Reciprocal Rank Fusion demo combining pre-ranked keyword and vector results | [Hybrid Search in .NET with EF Core 10 and pgvector](https://www.dotnet-guide.com/tutorials/dotnet-ai/hybrid-search-ef-core-pgvector/) | | [`dotnet-ai/agent-framework-incident-triage`](dotnet-ai/agent-framework-incident-triage/) | Evidence-first Microsoft Agent Framework sample with read-only tools, deterministic incident evidence, `AgentSession` reuse, and correlation-vs-causation boundaries | [Microsoft Agent Framework Tutorial in C#: Build a Tool-Using AI Agent](https://www.dotnet-guide.com/tutorials/dotnet-ai/agent-framework-incident-triage/) | | [`dotnet-aspire/orchestrate-distributed-system`](dotnet-aspire/orchestrate-distributed-system/) | Minimal Aspire AppHost coordinating a web project and API with service discovery and startup ordering | [Aspire in .NET: Orchestrate, Run, and Deploy a Distributed System from One App Host](https://www.dotnet-guide.com/tutorials/dotnet-aspire/orchestrate-distributed-system/) | @@ -57,9 +57,10 @@ Each sample folder contains a focused implementation of one tutorial topic. The ```powershell cd dotnet-ai\provider-agnostic-chat-gateway -dotnet restore -dotnet run --urls http://localhost:5123 -```` +dotnet restore ProviderAgnosticChatGateway.slnx +dotnet test ProviderAgnosticChatGateway.slnx --configuration Release +dotnet run --project .\src\ProviderAgnosticChatGateway\ProviderAgnosticChatGateway.csproj +``` ### Hybrid search RRF sample @@ -83,9 +84,13 @@ dotnet test tutorials/ |-- dotnet-ai/ | |-- provider-agnostic-chat-gateway/ -| | |-- ChatGatewayMinimal.csproj -| | |-- Program.cs -| | `-- README.md +| | |-- ProviderAgnosticChatGateway.slnx +| | |-- README.md +| | |-- global.json +| | |-- verified-environment.json +| | |-- docs/ +| | |-- src/ +| | `-- tests/ | `-- hybrid-search-ef-core-pgvector/ | |-- HybridSearchMinimal.csproj | |-- Program.cs diff --git a/dotnet-ai/provider-agnostic-chat-gateway/.gitignore b/dotnet-ai/provider-agnostic-chat-gateway/.gitignore new file mode 100644 index 0000000..8e57beb --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/.gitignore @@ -0,0 +1,11 @@ +**/bin/ +**/obj/ +.vs/ +.vscode/ +.idea/ +*.user +*.suo +.env +.env.* +secrets.json +_automation-backup/ diff --git a/dotnet-ai/provider-agnostic-chat-gateway/Program.cs b/dotnet-ai/provider-agnostic-chat-gateway/Program.cs deleted file mode 100644 index 42058e2..0000000 --- a/dotnet-ai/provider-agnostic-chat-gateway/Program.cs +++ /dev/null @@ -1,126 +0,0 @@ -using Microsoft.Extensions.AI; -using OllamaSharp; - -var builder = WebApplication.CreateBuilder(args); - -var providers = new HashSet( - StringComparer.OrdinalIgnoreCase); - -var ollamaEndpoint = new Uri( - builder.Configuration["Ollama:Endpoint"] - ?? "http://localhost:11434/"); - -var ollamaModel = - builder.Configuration["Ollama:Model"] - ?? "qwen3.6:27b"; - -builder.Services.AddKeyedChatClient( - "ollama", - _ => new OllamaApiClient( - ollamaEndpoint, - ollamaModel)); - -providers.Add("ollama"); - -var openAiKey = - builder.Configuration["OpenAI:ApiKey"]; - -if (!string.IsNullOrWhiteSpace(openAiKey)) -{ - var openAiModel = - builder.Configuration["OpenAI:Model"] - ?? "gpt-4o-mini"; - - builder.Services.AddKeyedChatClient( - "openai", - _ => new OpenAI.Chat.ChatClient( - openAiModel, - openAiKey) - .AsIChatClient()); - - providers.Add("openai"); -} - -var app = builder.Build(); - -app.MapGet("/", () => - Results.Ok(new - { - sample = "Provider-agnostic chat gateway", - endpoints = new[] - { - "GET /providers", - "POST /chat" - } - })); - -app.MapGet("/providers", () => - Results.Ok(new - { - providers = providers.OrderBy(x => x) - })); - -app.MapPost( - "/chat", - async ( - ChatRequest request, - IServiceProvider services, - CancellationToken cancellationToken) => - { - if (string.IsNullOrWhiteSpace(request.Message)) - { - return Results.BadRequest(new - { - error = "Message is required." - }); - } - - var provider = - string.IsNullOrWhiteSpace(request.Provider) - ? "ollama" - : request.Provider.Trim(); - - if (!providers.Contains(provider)) - { - return Results.BadRequest(new - { - error = - $"Unknown provider '{provider}'.", - available = - providers.OrderBy(x => x) - }); - } - - IChatClient client = - services.GetRequiredKeyedService( - provider); - - ChatResponse response = - await client.GetResponseAsync( - [ - new ChatMessage( - ChatRole.User, - request.Message) - ], - cancellationToken: - cancellationToken); - - return Results.Ok(new - { - provider, - model = response.ModelId, - content = response.Text, - finishReason = - response.FinishReason?.ToString(), - inputTokens = - response.Usage?.InputTokenCount, - outputTokens = - response.Usage?.OutputTokenCount - }); - }); - -app.Run(); - -internal sealed record ChatRequest( - string Message, - string? Provider); \ No newline at end of file diff --git a/dotnet-ai/provider-agnostic-chat-gateway/ProviderAgnosticChatGateway.slnx b/dotnet-ai/provider-agnostic-chat-gateway/ProviderAgnosticChatGateway.slnx new file mode 100644 index 0000000..b98e8d9 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/ProviderAgnosticChatGateway.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dotnet-ai/provider-agnostic-chat-gateway/README.md b/dotnet-ai/provider-agnostic-chat-gateway/README.md index 2f80b47..540ed10 100644 --- a/dotnet-ai/provider-agnostic-chat-gateway/README.md +++ b/dotnet-ai/provider-agnostic-chat-gateway/README.md @@ -1,210 +1,266 @@ -# Provider-Agnostic Chat Gateway - Minimal Sample - -A small ASP.NET Core sample that registers a local Ollama model and an optional OpenAI model behind the same `Microsoft.Extensions.AI.IChatClient` abstraction. - -The HTTP endpoint selects a provider by key without depending on a provider-specific SDK. - -## Full tutorial - -[Build a switchable multi-provider AI gateway with IChatClient](https://www.dotnet-guide.com/tutorials/dotnet-ai/provider-agnostic-chat-gateway/) - -## Companion article - -[Common Microsoft.Extensions.AI mistakes](https://www.dotnet-guide.com/articles/dotnet-ai/microsoft-extensions-ai-common-mistakes/) - -## Prerequisites - -- .NET 10 SDK -- Ollama installed and running locally -- The `qwen3.6:27b` Ollama model -- An OpenAI API key only when testing the optional OpenAI provider - -Pull the Ollama model: - -```powershell -ollama pull qwen3.6:27b -``` - -Confirm that the model is installed: - -```powershell -ollama list -``` - -## Run the sample - -Open PowerShell in this sample folder and run: - -```powershell -dotnet restore -dotnet run --urls http://localhost:5123 -``` - -Keep that PowerShell window open while testing the endpoints. - -## Check the root endpoint - -Open a second PowerShell window and run: - -```powershell -Invoke-RestMethod ` - -Method Get ` - -Uri "http://localhost:5123/" -``` - -The response identifies the sample and lists the available endpoints. - -## List the registered providers - -```powershell -Invoke-RestMethod ` - -Method Get ` - -Uri "http://localhost:5123/providers" -``` - -Without an OpenAI API key, the response should list: - -```text -ollama -``` - -## Send an Ollama request - -```powershell -$body = @{ - message = "Explain dependency injection in one sentence." - provider = "ollama" -} | ConvertTo-Json - -Invoke-RestMethod ` - -Method Post ` - -Uri "http://localhost:5123/chat" ` - -ContentType "application/json" ` - -Body $body -``` - -A successful response includes fields such as: - -```text -provider -model -content -finishReason -inputTokens -outputTokens -``` - -## Optional OpenAI provider - -Set the API key only in the current PowerShell session: - -```powershell -$env:OpenAI__ApiKey = "YOUR_KEY" -$env:OpenAI__Model = "gpt-4o-mini" - -dotnet run --urls http://localhost:5123 -``` - -Then send an OpenAI request: - -```powershell -$body = @{ - message = "Explain dependency injection in one sentence." - provider = "openai" -} | ConvertTo-Json - -Invoke-RestMethod ` - -Method Post ` - -Uri "http://localhost:5123/chat" ` - -ContentType "application/json" ` - -Body $body -``` - -Remove the temporary API key afterward: - -```powershell -Remove-Item Env:\OpenAI__ApiKey -``` - -Never place an API key in: - -- `Program.cs` -- `appsettings.json` -- `appsettings.Development.json` -- a README -- a screenshot -- any file uploaded to GitHub - -## What this sample demonstrates - -- `IChatClient` as a provider-neutral contract -- keyed provider registration with `AddKeyedChatClient` -- local Ollama integration through `OllamaSharp` -- optional OpenAI integration through `.AsIChatClient()` -- provider selection without changing the HTTP endpoint -- non-streaming responses through `GetResponseAsync` -- basic request validation -- usage and finish-reason metadata in the JSON response - -## Available endpoints - -| Method | Endpoint | Purpose | -| --- | --- | --- | -| `GET` | `/` | Displays basic sample information | -| `GET` | `/providers` | Lists registered AI providers | -| `POST` | `/chat` | Sends a message through the selected provider | - -## Example request body - -```json -{ - "message": "Explain dependency injection in one sentence.", - "provider": "ollama" -} -``` - -When the `provider` value is omitted or empty, the sample uses `ollama` by default. - -## Deliberately omitted - -The full tutorial adds production-oriented features such as: - -- streaming responses -- bounded conversation history -- provider capability metadata -- safe tool calling -- middleware -- OpenTelemetry -- consistent error handling -- automated tests -- resilience and timeout policies - -This repository sample intentionally remains small so that the central provider-agnostic design is easy to inspect. - -## Project files - -```text -provider-agnostic-chat-gateway/ -|-- ChatGatewayMinimal.csproj -|-- Program.cs -`-- README.md -``` - -Generated folders such as `bin` and `obj` should not be committed. - -## Verification - -- Target framework: .NET 10 -- Tested SDK: .NET SDK 10.0.302 -- Default local model: `qwen3.6:27b` -- Package versions: see `ChatGatewayMinimal.csproj` -- Local restore: verified -- Release build: verified -- Ollama request: verified -- Last reviewed: 2026-07-24 - -## Security note - -This sample is educational and should be reviewed before production use. - -Use environment variables, user secrets, or a managed secrets service for credentials. Never commit API keys or production configuration to a public repository. +# Microsoft.Extensions.AI in .NET — Provider-Agnostic Chat Gateway + +Full tutorial: [Microsoft.Extensions.AI Tutorial: Build a Multi-Provider IChatClient Gateway](https://www.dotnet-guide.com/tutorials/dotnet-ai/provider-agnostic-chat-gateway/) + +A verified .NET 10 sample that puts multiple AI providers behind the common +`Microsoft.Extensions.AI.IChatClient` boundary without letting HTTP callers +choose arbitrary endpoints, models, or credentials. A caller can select only a +named provider that is registered and enabled in trusted server configuration. + +## What this sample demonstrates + +- `Microsoft.Extensions.AI.IChatClient` as the application-level provider boundary; +- named, allow-listed provider registration; +- provider-specific SDK construction kept at the application edge; +- Ollama, OpenAI, OpenAI-compatible/OpenRouter, and Azure OpenAI construction paths; +- capability metadata for streaming and tools; +- bounded in-memory, non-persistent conversation history; +- provider-bound conversations that never silently migrate to another provider; +- non-streaming chat; +- SSE streaming; +- a deterministic `convert_temperature` function tool built with `AIFunctionFactory.Create(...)`; +- automatic function invocation through the Microsoft.Extensions.AI pipeline; +- deterministic tests that need no cloud API key. + +## Architecture + +```text +HTTP caller + | + v +ASP.NET Core Minimal API (Program.cs) + | + +--> GET /api/providers (list registered providers) + +--> POST /api/chat (non-streaming reply) + +--> POST /api/chat/stream (SSE stream) + | + v +ChatGateway (gateway logic, tool invocation, temperature tool) + | + +--> ConversationStore (bounded in-memory, provider-bound conversations) + | + v +ProviderRegistry (allow-list of trusted, server-registered providers) + | + | ProviderClientFactory (constructs the provider-specific IChatClient) + v +IChatClient (Microsoft.Extensions.AI provider boundary) + | + +--> OllamaClient (default local provider) + +--> OpenAIClient + +--> OpenAI-compatible endpoint (OpenRouter) + +--> AzureOpenAIClient +``` + +See [docs/architecture.md](docs/architecture.md) for the full walkthrough. + +## Project structure + +```text +provider-agnostic-chat-gateway/ +|-- ProviderAgnosticChatGateway.slnx +|-- README.md +|-- global.json +|-- verified-environment.json +|-- docs/ +| |-- architecture.md +| |-- provider-matrix.md +| |-- sample-runs.md +| `-- verified-environment.md +|-- src/ +| `-- ProviderAgnosticChatGateway/ +| |-- ProviderAgnosticChatGateway.csproj +| |-- Program.cs +| |-- appsettings.json +| |-- appsettings.Development.json +| |-- Properties/launchSettings.json +| |-- Providers/ (ProviderCapabilities, ProviderDescriptor, ProviderRegistry, ProviderClientFactory) +| |-- Conversations/(ConversationState, ConversationStore) +| |-- Gateway/ (ChatGateway, ChatRequest, ChatResult, ChatStreamEvent) +| `-- Tools/ (TemperatureTool) +`-- tests/ + `-- ProviderAgnosticChatGateway.Tests/ + |-- ProviderAgnosticChatGateway.Tests.csproj + |-- FakeChatClient.cs + |-- ProviderRegistryTests.cs + |-- ConversationStoreTests.cs + |-- ProviderClientFactoryTests.cs + |-- GatewayTests.cs + `-- ToolTests.cs +``` + +## Prerequisites + +- .NET 10 SDK (baseline used: `10.0.401`, target framework `net10.0`). +- Ollama only if you want to run the default local live path. +- No cloud API key is required to restore, build, or test this sample. + +## Restore, build, and test + +```powershell +dotnet restore ProviderAgnosticChatGateway.slnx +dotnet build ProviderAgnosticChatGateway.slnx --configuration Release --no-restore +dotnet test ProviderAgnosticChatGateway.slnx --configuration Release --no-build +``` + +The verified baseline reports **25/25 deterministic tests passing** with 0 build +errors. See [docs/verified-environment.md](docs/verified-environment.md). + +## Run with Ollama + +The checked-in default configuration enables the local `ollama` provider: + +- endpoint: `http://localhost:11434` +- reference model: `qwen3.8:27b` + +If that model is not installed on your machine, change `model` in +`src/ProviderAgnosticChatGateway/appsettings.json`. Only providers enabled in +trusted server configuration are registered. + +```powershell +dotnet run --project .\src\ProviderAgnosticChatGateway\ProviderAgnosticChatGateway.csproj +``` + +## Inspect providers + +List the registered providers: + +```text +GET /api/providers +``` + +The response shows the provider names that are currently enabled in server +configuration together with their declared capabilities. + +## Non-streaming chat + +```text +POST /api/chat +Content-Type: application/json + +{ + "provider": "ollama", + "conversationId": null, + "message": "Explain IChatClient in one paragraph.", + "useTools": false +} +``` + +The reply is returned as a single JSON response. + +## Conversation continuity + +Reuse a `conversationId` to continue the same provider-bound conversation. +The gateway keeps a bounded, in-memory history and never migrates an existing +conversation to a different provider automatically. + +```text +POST /api/chat +Content-Type: application/json + +{ + "provider": "ollama", + "conversationId": "", + "message": "Which provider are we using, and what did I ask last?", + "useTools": false +} +``` + +## SSE streaming + +```text +POST /api/chat/stream +Content-Type: application/json +Accept: text/event-stream + +{ + "provider": "ollama", + "conversationId": null, + "message": "Count from one to five.", + "useTools": false +} +``` + +The stream emits typed events: + +- `meta` — session and provider metadata; +- `delta` — each incremental text chunk as it arrives; +- `done` — final event signaling stream completion. + +## Function tool + +The sample ships one harmless deterministic tool: + +```text +convert_temperature(value, from, to) +``` + +It converts between Celsius and Fahrenheit. Enable it per request with +`"useTools": true`. The Microsoft.Extensions.AI function-invocation pipeline +calls the tool automatically and feeds the result back to the model. The gateway +also logs each real invocation, for example: + +```text +TOOL CALLED: convert_temperature value=100 from=Celsius to=Fahrenheit result=212 +``` + +There are no write-capable or unrelated tools. + +## Optional cloud providers + +Cloud providers are disabled by default. You can enable OpenAI, OpenRouter, and +Azure OpenAI in server configuration. Secrets are read only from the configured +server-side environment-variable names: + +- OpenAI: `OPENAI_API_KEY` +- OpenRouter: `OPENROUTER_API_KEY` +- Azure OpenAI API-key mode: `AZURE_OPENAI_API_KEY` + +Azure OpenAI may alternatively use `DefaultAzureCredential`. Never place a real +API key in source, documentation, tests, or GitHub Actions. No `.env` file is +required. + +## Provider security boundary + +HTTP callers may select only provider names registered and enabled in trusted +server configuration. A request is **not** allowed to supply an arbitrary +provider endpoint, model, API key, or credential. Endpoints, models, and secrets +remain trusted server configuration. + +## Verification levels + +This repository deliberately distinguishes three evidence levels: + +- **COMPILE VERIFIED** — the provider construction/API path compiles successfully. +- **DETERMINISTICALLY TESTED** — tests validate gateway behavior without a live model/provider call. +- **LIVE VERIFIED** — a real provider/model request was executed successfully. + +See [docs/provider-matrix.md](docs/provider-matrix.md) for the per-provider matrix. + +## Verified sample run + +See [docs/sample-runs.md](docs/sample-runs.md) for the recorded local run. The +local live verification on **29 September 2026** used Ollama with `qwen3.8:27b` +and established provider registration, non-streaming chat, conversation +continuity, SSE streaming, real tool invocation, and unregistered-provider +rejection. Cloud providers were **not** live-verified in that publication run; +they were compile-verified and deterministically constructed/tested. + +## Deliberate exclusions + +This first provider-gateway sample deliberately does **not** implement: +Agent Framework/`AIAgent`, MCP, RAG/vector search, embeddings, persistent or +database conversation memory, Redis storage, automatic failover, load balancing, +model ranking/benchmarking, multi-agent orchestration, arbitrary user-supplied +provider endpoints, user-supplied API keys, write-capable business tools, +production authentication, or Azure/Kubernetes deployment infrastructure. + +## License + +Repository-owned sample code is MIT licensed. See [](../../LICENSE). + +> This sample is an independent educational reference and is not an official Microsoft sample or specification. + +Provider/model capabilities can vary; this sample does not claim identical +capabilities across all provider/model combinations, and it does not claim live +cloud-provider verification that did not occur. \ No newline at end of file diff --git a/dotnet-ai/provider-agnostic-chat-gateway/docs/architecture.md b/dotnet-ai/provider-agnostic-chat-gateway/docs/architecture.md new file mode 100644 index 0000000..4ea74e7 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/docs/architecture.md @@ -0,0 +1,36 @@ +# Architecture + +```text +HTTP client + | + | provider name only + v +Minimal API + | + v +ChatGateway + |---------------------> ConversationStore + | + v +ProviderRegistry + | + +--> ollama ----------- IChatClient + +--> openai ----------- IChatClient + +--> openrouter ------- IChatClient + +--> azure-openai ----- IChatClient + | + v + Function-invocation pipeline +``` + +## Security boundary + +The request may select only an already registered provider name. Provider endpoints, models and credentials remain trusted server configuration. + +## Conversation rule + +A conversation is bound to the provider that created it. The sample rejects attempts to continue that conversation with a different provider instead of silently migrating history. + +## Scope boundary + +This first sample deliberately excludes automatic failover, model ranking, RAG, embeddings, MCP, Agent Framework, persistent memory, database storage, multi-agent orchestration and write-capable business tools. diff --git a/dotnet-ai/provider-agnostic-chat-gateway/docs/provider-matrix.md b/dotnet-ai/provider-agnostic-chat-gateway/docs/provider-matrix.md new file mode 100644 index 0000000..35fb4e8 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/docs/provider-matrix.md @@ -0,0 +1,34 @@ +# Provider Matrix + +This sample distinguishes three verification levels per provider instead of a +single generic "verified" claim: + +- **Construction** — the provider/client-construction and API path compiles (COMPILE VERIFIED). +- **Deterministic** — the provider is constructed and tested without a live model/provider call (DETERMINISTICALLY TESTED). +- **Live** — a real provider/model request was executed successfully (LIVE VERIFIED). + +## Publication verification matrix (29 September 2026) + +| Provider | Construction | Deterministic | Live | +|---|---|---:|---:| +| Ollama | Verified | Verified | Verified with `qwen3.8:27b` | +| OpenAI | Verified | Verified construction | Not run | +| OpenRouter | Verified | Verified construction | Not run in this publication run | +| Azure OpenAI | Verified | Verified construction | Not run | + +Cloud providers (`openai`, `openrouter`, `azure-openai`) were compile-verified +and deterministically constructed/tested, but this local publication run did not +perform live cloud requests. Capability values are gateway configuration +metadata; actual model/provider behavior can still vary by selected model and +service configuration. + +## Adapter construction paths + +| Provider name | Adapter path | Credential source | Default | +|---|---|---:|---| +| `ollama` | `OllamaApiClient` -> `IChatClient` | none | enabled | +| `openai` | OpenAI `ChatClient` -> `AsIChatClient()` | `OPENAI_API_KEY` | disabled | +| `openrouter` | `OpenAIClient` with compatible endpoint -> `AsIChatClient()` | `OPENROUTER_API_KEY` | disabled | +| `azure-openai` | `AzureOpenAIClient` -> OpenAI chat client -> `AsIChatClient()` | `AZURE_OPENAI_API_KEY` or `DefaultAzureCredential` | disabled | + +Only providers enabled in trusted server configuration are registered. \ No newline at end of file diff --git a/dotnet-ai/provider-agnostic-chat-gateway/docs/sample-runs.md b/dotnet-ai/provider-agnostic-chat-gateway/docs/sample-runs.md new file mode 100644 index 0000000..8333d73 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/docs/sample-runs.md @@ -0,0 +1,93 @@ +# Sample Runs + +## Deterministic run + +The deterministic implementation script runs: + +```powershell +dotnet restore ProviderAgnosticChatGateway.slnx +dotnet build ProviderAgnosticChatGateway.slnx --configuration Release --no-restore +dotnet test ProviderAgnosticChatGateway.slnx --configuration Release --no-build +``` + +A successful run proves the application compiles and the deterministic gateway +tests pass without cloud API keys. + +Reference result: + +```text +.NET SDK: 10.0.401 +Target framework: net10.0 +Build: 0 errors +Tests: 25 total, 25 passed, 0 failed, 0 skipped +``` + +## Live Ollama verification (29 September 2026) + +The authoritative local live verification used the reference provider and model: + +- provider: `ollama` +- model: `qwen3.8:27b` +- endpoint: `http://localhost:11434` + +Live verification established: + +```text +Provider registration LIVE VERIFIED +Non-streaming chat LIVE VERIFIED +Conversation continuity LIVE VERIFIED +SSE streaming LIVE VERIFIED +Function/tool invocation LIVE VERIFIED +Unregistered provider rejection LIVE VERIFIED +``` + +### Non-streaming chat + +Observed non-streaming model output: + +```text +GATEWAY_OK +``` + +### Conversation continuity + +Reusing a `conversationId`, the conversation-continuity response correctly +recalled the earlier context: + +```text +GATEWAY_OK +``` + +### SSE streaming + +Observed SSE deltas reconstructed: + +```text +STREAM_OK +``` + +### Function/tool invocation + +Observed explicit tool-call log: + +```text +TOOL CALLED: convert_temperature value=100 from=Celsius to=Fahrenheit result=212 +``` + +Observed tool-enabled response: + +```text +100 °C = 212 °F +``` + +### Unregistered-provider rejection + +Observed HTTP result for an unregistered provider: + +```text +400 Bad Request +``` + +LLM-generated wording can vary between runs; the verification records the +observable gateway behavior (tool log, deltas, status codes) rather than exact +prose. \ No newline at end of file diff --git a/dotnet-ai/provider-agnostic-chat-gateway/docs/verified-environment.md b/dotnet-ai/provider-agnostic-chat-gateway/docs/verified-environment.md new file mode 100644 index 0000000..77cd527 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/docs/verified-environment.md @@ -0,0 +1,40 @@ +# Verified Environment + +## Deterministic baseline (29 September 2026) + +- .NET SDK: `10.0.401` +- Target framework: `net10.0` +- Solution format: `.slnx` +- Restore: PASS +- Release build: PASS (0 errors) +- Deterministic tests: 25/25 passed, 0 failed, 0 skipped +- Tool-logging patch applied and re-validated (build + all 25 tests passed again) + +## Direct package baseline + +- Microsoft.Extensions.AI: 10.10.0 +- Microsoft.Extensions.AI.OpenAI: 10.10.1 +- Azure.AI.OpenAI: 2.1.0 +- Azure.Identity: 1.21.0 +- OllamaSharp: 5.4.30 + +Relevant resolved dependencies recorded during verification: + +- Microsoft.Extensions.AI.Abstractions: 10.10.1 +- OpenAI: 2.14.0 +- System.ClientModel: 1.15.0 + +## Local live verification (29 September 2026) + +- Local live provider: Ollama +- Local live model: `qwen3.8:27b` +- Local live endpoint: `http://localhost:11434` +- Live verification date: 29 September 2026 + +Live checks (provider registration, non-streaming chat, conversation +continuity, SSE streaming, function/tool invocation, unregistered-provider +rejection) ran against the local Ollama model. Cloud providers were +compile-verified and deterministically constructed/tested but were **not** +live-verified in this publication run. + +Normal restore/build/test requires no cloud API key and no running model server. \ No newline at end of file diff --git a/dotnet-ai/provider-agnostic-chat-gateway/global.json b/dotnet-ai/provider-agnostic-chat-gateway/global.json new file mode 100644 index 0000000..2f6e963 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "rollForward": "latestFeature", + "version": "10.0.401" + } +} diff --git a/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Conversations/ConversationState.cs b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Conversations/ConversationState.cs new file mode 100644 index 0000000..47238ba --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Conversations/ConversationState.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.AI; + +namespace ProviderAgnosticChatGateway.Conversations; + +/// +/// In-memory state for one bounded conversation. A semaphore serializes turns +/// so two requests cannot mutate the same history concurrently. +/// +public sealed class ConversationState : IDisposable +{ + private readonly object _sync = new(); + private readonly List _messages = []; + + public ConversationState( + string id, + string providerName) + { + Id = id; + ProviderName = providerName; + CreatedAt = DateTimeOffset.UtcNow; + } + + public string Id { get; } + + public string ProviderName { get; } + + public DateTimeOffset CreatedAt { get; } + + public SemaphoreSlim Gate { get; } = new(1, 1); + + public int MessageCount + { + get + { + lock (_sync) + { + return _messages.Count; + } + } + } + + public IReadOnlyList SnapshotMessages() + { + lock (_sync) + { + return _messages.ToArray(); + } + } + + internal void AppendMessages( + IEnumerable messages, + int maxMessages) + { + ArgumentNullException.ThrowIfNull(messages); + + if (maxMessages < 1) + { + throw new ArgumentOutOfRangeException(nameof(maxMessages)); + } + + lock (_sync) + { + _messages.AddRange(messages); + + int removeCount = _messages.Count - maxMessages; + if (removeCount > 0) + { + _messages.RemoveRange(0, removeCount); + } + } + } + + public void Dispose() => Gate.Dispose(); +} diff --git a/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Conversations/ConversationStore.cs b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Conversations/ConversationStore.cs new file mode 100644 index 0000000..12dd0b5 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Conversations/ConversationStore.cs @@ -0,0 +1,86 @@ +using System.Collections.Concurrent; + +namespace ProviderAgnosticChatGateway.Conversations; + +/// +/// A deliberately small in-memory conversation store for the tutorial sample. +/// A conversation is permanently bound to the provider that created it. +/// +public sealed class ConversationStore : IDisposable +{ + private readonly ConcurrentDictionary _conversations = + new(StringComparer.OrdinalIgnoreCase); + + public ConversationStore(int maxMessages) + { + if (maxMessages < 2) + { + throw new ArgumentOutOfRangeException( + nameof(maxMessages), + "At least two messages must be retained."); + } + + MaxMessages = maxMessages; + } + + public int MaxMessages { get; } + + public ConversationState GetOrCreate( + string? conversationId, + string providerName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(providerName); + + if (string.IsNullOrWhiteSpace(conversationId)) + { + string id = Guid.NewGuid().ToString("N"); + ConversationState created = + new(id, providerName.Trim()); + + if (!_conversations.TryAdd(id, created)) + { + created.Dispose(); + throw new InvalidOperationException( + "Unable to create a conversation."); + } + + return created; + } + + string normalizedId = conversationId.Trim(); + if (!_conversations.TryGetValue( + normalizedId, + out ConversationState? existing)) + { + throw new KeyNotFoundException( + $"Conversation '{normalizedId}' was not found."); + } + + if (!existing.ProviderName.Equals( + providerName.Trim(), + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Conversation '{normalizedId}' is bound to provider " + + $"'{existing.ProviderName}' and cannot be continued with " + + $"'{providerName}'."); + } + + return existing; + } + + public bool TryGet( + string conversationId, + out ConversationState? state) => + _conversations.TryGetValue(conversationId, out state); + + public void Dispose() + { + foreach (ConversationState state in _conversations.Values) + { + state.Dispose(); + } + + _conversations.Clear(); + } +} diff --git a/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Gateway/ChatGateway.cs b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Gateway/ChatGateway.cs new file mode 100644 index 0000000..3ca0ef6 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Gateway/ChatGateway.cs @@ -0,0 +1,255 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; +using ProviderAgnosticChatGateway.Conversations; +using ProviderAgnosticChatGateway.Providers; +using ProviderAgnosticChatGateway.Tools; + +namespace ProviderAgnosticChatGateway.Gateway; + +/// +/// Provider-neutral chat orchestration. The class depends on IChatClient through +/// ProviderDescriptor and contains no provider SDK-specific request logic. +/// +public sealed class ChatGateway +{ + private readonly ProviderRegistry _providers; + private readonly ConversationStore _conversations; + private readonly int _maxInputCharacters; + private readonly ILogger _logger; + private readonly AIFunction _temperatureTool; + + public ChatGateway( + ProviderRegistry providers, + ConversationStore conversations, + int maxInputCharacters, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(providers); + ArgumentNullException.ThrowIfNull(conversations); + ArgumentNullException.ThrowIfNull(logger); + + if (maxInputCharacters < 1) + { + throw new ArgumentOutOfRangeException(nameof(maxInputCharacters)); + } + + _providers = providers; + _conversations = conversations; + _maxInputCharacters = maxInputCharacters; + _logger = logger; + + _temperatureTool = AIFunctionFactory.Create( + (Func)ConvertTemperatureWithLogging, + name: "convert_temperature", + description: + "Convert a temperature value between Celsius, Fahrenheit and Kelvin."); + } + + public async Task SendAsync( + ChatRequest request, + CancellationToken cancellationToken = default) + { + ValidateRequest(request); + + ProviderDescriptor provider = + ResolveProviderForRequest(request); + + ConversationState conversation = + _conversations.GetOrCreate( + request.ConversationId, + provider.Name); + + await conversation.Gate.WaitAsync(cancellationToken); + try + { + ChatMessage userMessage = + new(ChatRole.User, request.Message.Trim()); + + List messages = + [.. conversation.SnapshotMessages(), userMessage]; + + ChatOptions? options = + CreateOptions(request, provider); + + ChatResponse response = + await provider.Client.GetResponseAsync( + messages, + options, + cancellationToken); + + conversation.AppendMessages( + [userMessage, .. response.Messages], + _conversations.MaxMessages); + + _logger.LogInformation( + "Chat completed with provider {Provider} for conversation {ConversationId}", + provider.Name, + conversation.Id); + + return new ChatResult( + conversation.Id, + provider.Name, + response.Text); + } + finally + { + conversation.Gate.Release(); + } + } + + public async IAsyncEnumerable StreamAsync( + ChatRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ValidateRequest(request); + + ProviderDescriptor provider = + ResolveProviderForRequest(request); + + if (!provider.Capabilities.Streaming) + { + throw new InvalidOperationException( + $"Provider '{provider.Name}' is not configured for streaming."); + } + + ConversationState conversation = + _conversations.GetOrCreate( + request.ConversationId, + provider.Name); + + await conversation.Gate.WaitAsync(cancellationToken); + try + { + yield return new ChatStreamEvent( + Type: "meta", + ConversationId: conversation.Id, + Provider: provider.Name); + + ChatMessage userMessage = + new(ChatRole.User, request.Message.Trim()); + + List messages = + [.. conversation.SnapshotMessages(), userMessage]; + + ChatOptions? options = + CreateOptions(request, provider); + + List updates = []; + + await foreach ( + ChatResponseUpdate update + in provider.Client.GetStreamingResponseAsync( + messages, + options, + cancellationToken) + .WithCancellation(cancellationToken)) + { + updates.Add(update); + + if (!string.IsNullOrEmpty(update.Text)) + { + yield return new ChatStreamEvent( + Type: "delta", + ConversationId: conversation.Id, + Provider: provider.Name, + Text: update.Text); + } + } + + ChatResponse response = updates.ToChatResponse(); + + conversation.AppendMessages( + [userMessage, .. response.Messages], + _conversations.MaxMessages); + + _logger.LogInformation( + "Streaming chat completed with provider {Provider} for conversation {ConversationId}", + provider.Name, + conversation.Id); + + yield return new ChatStreamEvent( + Type: "done", + ConversationId: conversation.Id, + Provider: provider.Name); + } + finally + { + conversation.Gate.Release(); + } + } + + private ProviderDescriptor ResolveProviderForRequest( + ChatRequest request) + { + ProviderDescriptor provider = + _providers.Resolve(request.Provider); + + if (request.UseTools && !provider.Capabilities.Tools) + { + throw new InvalidOperationException( + $"Provider '{provider.Name}' is not configured for tools."); + } + + return provider; + } + + private ChatOptions? CreateOptions( + ChatRequest request, + ProviderDescriptor provider) + { + if (!request.UseTools) + { + return null; + } + + if (!provider.Capabilities.Tools) + { + throw new InvalidOperationException( + $"Provider '{provider.Name}' is not configured for tools."); + } + + return new ChatOptions + { + Tools = [_temperatureTool] + }; + } + + private double ConvertTemperatureWithLogging( + double value, + string fromUnit, + string toUnit) + { + double result = TemperatureTool.ConvertTemperature( + value, + fromUnit, + toUnit); + + _logger.LogInformation( + "TOOL CALLED: {ToolName} value={Value} from={FromUnit} to={ToUnit} result={Result}", + "convert_temperature", + value, + fromUnit, + toUnit, + result); + + return result; + } + private void ValidateRequest(ChatRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + if (string.IsNullOrWhiteSpace(request.Message)) + { + throw new ArgumentException( + "Message is required.", + nameof(request)); + } + + if (request.Message.Length > _maxInputCharacters) + { + throw new ArgumentException( + $"Message exceeds the {_maxInputCharacters}-character limit.", + nameof(request)); + } + } +} diff --git a/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Gateway/ChatRequest.cs b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Gateway/ChatRequest.cs new file mode 100644 index 0000000..cb2d926 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Gateway/ChatRequest.cs @@ -0,0 +1,7 @@ +namespace ProviderAgnosticChatGateway.Gateway; + +public sealed record ChatRequest( + string Message, + string? Provider = null, + string? ConversationId = null, + bool UseTools = false); diff --git a/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Gateway/ChatResult.cs b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Gateway/ChatResult.cs new file mode 100644 index 0000000..7de5474 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Gateway/ChatResult.cs @@ -0,0 +1,6 @@ +namespace ProviderAgnosticChatGateway.Gateway; + +public sealed record ChatResult( + string ConversationId, + string Provider, + string Text); diff --git a/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Gateway/ChatStreamEvent.cs b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Gateway/ChatStreamEvent.cs new file mode 100644 index 0000000..c2674e6 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Gateway/ChatStreamEvent.cs @@ -0,0 +1,7 @@ +namespace ProviderAgnosticChatGateway.Gateway; + +public sealed record ChatStreamEvent( + string Type, + string? ConversationId = null, + string? Provider = null, + string? Text = null); diff --git a/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Program.cs b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Program.cs new file mode 100644 index 0000000..2783f80 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Program.cs @@ -0,0 +1,392 @@ +using System.Text.Json; +using Microsoft.Extensions.AI; +using ProviderAgnosticChatGateway.Conversations; +using ProviderAgnosticChatGateway.Gateway; +using ProviderAgnosticChatGateway.Providers; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +string defaultProvider = + builder.Configuration["Gateway:DefaultProvider"] ?? "ollama"; + +bool allowProviderSelection = + GetBoolean( + builder.Configuration, + "Gateway:AllowProviderSelection", + defaultValue: true); + +int maxMessages = + GetInteger( + builder.Configuration, + "Gateway:MaxMessagesPerConversation", + defaultValue: 20); + +int maxInputCharacters = + GetInteger( + builder.Configuration, + "Gateway:MaxInputCharacters", + defaultValue: 8000); + +ProviderRegistry registry = + new( + defaultProvider, + allowProviderSelection); + +RegisterConfiguredProviders( + builder.Configuration, + registry); + +// Fail fast if the configured default provider is not enabled/registered. +_ = registry.Resolve(); + +ConversationStore conversationStore = + new(maxMessages); + +builder.Services.AddSingleton(_ => registry); +builder.Services.AddSingleton(_ => conversationStore); +builder.Services.AddSingleton( + serviceProvider => + new ChatGateway( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + maxInputCharacters, + serviceProvider.GetRequiredService>())); + +WebApplication app = builder.Build(); + +app.MapGet( + "/", + () => Results.Ok( + new + { + sample = "ProviderAgnosticChatGateway", + abstraction = nameof(IChatClient), + endpoints = new[] + { + "GET /api/providers", + "POST /api/chat", + "POST /api/chat/stream", + "GET /healthz" + } + })); + +app.MapGet( + "/healthz", + () => Results.Ok( + new + { + status = "ok" + })); + +app.MapGet( + "/api/providers", + (ProviderRegistry providers) => + Results.Ok( + providers.Providers + .OrderBy(p => p.Name) + .Select( + p => new + { + name = p.Name, + isDefault = p.Name.Equals( + providers.DefaultProvider, + StringComparison.OrdinalIgnoreCase), + p.Capabilities + }))); + +app.MapPost( + "/api/chat", + HandleChatAsync); + +app.MapPost( + "/api/chat/stream", + HandleStreamAsync); + +app.Logger.LogInformation( + "Registered AI providers: {Providers}. Default provider: {DefaultProvider}", + string.Join(", ", registry.ProviderNames), + registry.DefaultProvider); + +app.Run(); + +static async Task HandleChatAsync( + ChatRequest request, + ChatGateway gateway, + CancellationToken cancellationToken) +{ + try + { + ChatResult result = + await gateway.SendAsync( + request, + cancellationToken); + + return Results.Ok(result); + } + catch (Exception ex) when (IsClientError(ex)) + { + return Results.BadRequest( + new + { + error = ex.Message + }); + } +} + +static async Task HandleStreamAsync( + ChatRequest request, + ChatGateway gateway, + HttpContext context, + CancellationToken cancellationToken) +{ + context.Response.ContentType = "text/event-stream"; + context.Response.Headers["Cache-Control"] = "no-cache"; + context.Response.Headers["X-Accel-Buffering"] = "no"; + + try + { + await foreach ( + ChatStreamEvent item + in gateway.StreamAsync( + request, + cancellationToken)) + { + string payload = JsonSerializer.Serialize(item); + await context.Response.WriteAsync( + $"event: {item.Type}\ndata: {payload}\n\n", + cancellationToken); + await context.Response.Body.FlushAsync(cancellationToken); + } + } + catch (Exception ex) when (IsClientError(ex)) + { + if (!context.Response.HasStarted) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync( + new { error = ex.Message }, + cancellationToken); + return; + } + + string payload = JsonSerializer.Serialize( + new ChatStreamEvent( + Type: "error", + Text: ex.Message)); + + await context.Response.WriteAsync( + $"event: error\ndata: {payload}\n\n", + cancellationToken); + } +} + +static bool IsClientError(Exception exception) => + exception is ArgumentException or + KeyNotFoundException or + InvalidOperationException; + +static void RegisterConfiguredProviders( + IConfiguration configuration, + ProviderRegistry registry) +{ + RegisterOllama(configuration, registry); + RegisterOpenAI(configuration, registry); + RegisterOpenRouter(configuration, registry); + RegisterAzureOpenAI(configuration, registry); + + if (registry.ProviderNames.Count == 0) + { + throw new InvalidOperationException( + "No AI providers are enabled in configuration."); + } +} + +static void RegisterOllama( + IConfiguration configuration, + ProviderRegistry registry) +{ + const string root = "Providers:Ollama"; + if (!GetBoolean(configuration, $"{root}:Enabled", false)) + { + return; + } + + string endpoint = + RequireConfiguration(configuration, $"{root}:Endpoint"); + + string model = + RequireConfiguration(configuration, $"{root}:Model"); + + registry.Add( + new ProviderDescriptor( + "ollama", + ProviderClientFactory.CreateOllama(endpoint, model), + ReadCapabilities(configuration, root))); +} + +static void RegisterOpenAI( + IConfiguration configuration, + ProviderRegistry registry) +{ + const string root = "Providers:OpenAI"; + if (!GetBoolean(configuration, $"{root}:Enabled", false)) + { + return; + } + + string model = + RequireConfiguration(configuration, $"{root}:Model"); + + string apiKey = + RequireEnvironmentSecret(configuration, root); + + registry.Add( + new ProviderDescriptor( + "openai", + ProviderClientFactory.CreateOpenAI(apiKey, model), + ReadCapabilities(configuration, root))); +} + +static void RegisterOpenRouter( + IConfiguration configuration, + ProviderRegistry registry) +{ + const string root = "Providers:OpenRouter"; + if (!GetBoolean(configuration, $"{root}:Enabled", false)) + { + return; + } + + string endpoint = + RequireConfiguration(configuration, $"{root}:Endpoint"); + + string model = + RequireConfiguration(configuration, $"{root}:Model"); + + string apiKey = + RequireEnvironmentSecret(configuration, root); + + registry.Add( + new ProviderDescriptor( + "openrouter", + ProviderClientFactory.CreateOpenAICompatible( + endpoint, + apiKey, + model), + ReadCapabilities(configuration, root))); +} + +static void RegisterAzureOpenAI( + IConfiguration configuration, + ProviderRegistry registry) +{ + const string root = "Providers:AzureOpenAI"; + if (!GetBoolean(configuration, $"{root}:Enabled", false)) + { + return; + } + + string endpoint = + RequireConfiguration(configuration, $"{root}:Endpoint"); + + string deployment = + RequireConfiguration(configuration, $"{root}:Deployment"); + + bool useDefaultCredential = + GetBoolean( + configuration, + $"{root}:UseDefaultAzureCredential", + defaultValue: true); + + IChatClient client = + useDefaultCredential + ? ProviderClientFactory.CreateAzureOpenAIWithDefaultCredential( + endpoint, + deployment) + : ProviderClientFactory.CreateAzureOpenAIWithApiKey( + endpoint, + RequireEnvironmentSecret(configuration, root), + deployment); + + registry.Add( + new ProviderDescriptor( + "azure-openai", + client, + ReadCapabilities(configuration, root))); +} + +static ProviderCapabilities ReadCapabilities( + IConfiguration configuration, + string providerRoot) => + new( + Streaming: GetBoolean( + configuration, + $"{providerRoot}:Capabilities:Streaming", + defaultValue: true), + Tools: GetBoolean( + configuration, + $"{providerRoot}:Capabilities:Tools", + defaultValue: false), + ProviderManagedConversation: GetBoolean( + configuration, + $"{providerRoot}:Capabilities:ProviderManagedConversation", + defaultValue: false)); + +static string RequireEnvironmentSecret( + IConfiguration configuration, + string providerRoot) +{ + string variableName = + RequireConfiguration( + configuration, + $"{providerRoot}:ApiKeyEnvironmentVariable"); + + string? value = + Environment.GetEnvironmentVariable(variableName); + + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException( + $"Provider is enabled, but environment variable " + + $"'{variableName}' is not set."); + } + + return value; +} + +static string RequireConfiguration( + IConfiguration configuration, + string key) +{ + string? value = configuration[key]; + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException( + $"Required configuration value '{key}' is missing."); + } + + return value.Trim(); +} + +static bool GetBoolean( + IConfiguration configuration, + string key, + bool defaultValue) +{ + string? value = configuration[key]; + return bool.TryParse(value, out bool parsed) + ? parsed + : defaultValue; +} + +static int GetInteger( + IConfiguration configuration, + string key, + int defaultValue) +{ + string? value = configuration[key]; + return int.TryParse(value, out int parsed) + ? parsed + : defaultValue; +} diff --git a/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Properties/launchSettings.json b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Properties/launchSettings.json new file mode 100644 index 0000000..e7d275c --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5073", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7288;http://localhost:5073", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet-ai/provider-agnostic-chat-gateway/ChatGatewayMinimal.csproj b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/ProviderAgnosticChatGateway.csproj similarity index 70% rename from dotnet-ai/provider-agnostic-chat-gateway/ChatGatewayMinimal.csproj rename to dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/ProviderAgnosticChatGateway.csproj index 9100d27..9d4f0b4 100644 --- a/dotnet-ai/provider-agnostic-chat-gateway/ChatGatewayMinimal.csproj +++ b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/ProviderAgnosticChatGateway.csproj @@ -7,8 +7,10 @@ - - + + + + diff --git a/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Providers/ProviderCapabilities.cs b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Providers/ProviderCapabilities.cs new file mode 100644 index 0000000..fc230fc --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Providers/ProviderCapabilities.cs @@ -0,0 +1,11 @@ +namespace ProviderAgnosticChatGateway.Providers; + +/// +/// Describes capabilities that this gateway declares for a configured provider. +/// The gateway uses this metadata for request validation; it does not infer +/// capabilities from model output at runtime. +/// +public sealed record ProviderCapabilities( + bool Streaming, + bool Tools, + bool ProviderManagedConversation); diff --git a/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Providers/ProviderClientFactory.cs b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Providers/ProviderClientFactory.cs new file mode 100644 index 0000000..c70520a --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Providers/ProviderClientFactory.cs @@ -0,0 +1,121 @@ +using Azure; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Extensions.AI; +using OllamaSharp; +using OpenAI; + +namespace ProviderAgnosticChatGateway.Providers; + +/// +/// Keeps provider-specific SDK construction at the edge of the application. +/// Every returned client is exposed to the gateway as IChatClient and is +/// wrapped with the Microsoft.Extensions.AI function-invocation pipeline. +/// +public static class ProviderClientFactory +{ + public static IChatClient CreateOpenAI( + string apiKey, + string model) + { + ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); + ArgumentException.ThrowIfNullOrWhiteSpace(model); + + IChatClient client = + new OpenAI.Chat.ChatClient(model, apiKey) + .AsIChatClient(); + + return AddCommonPipeline(client); + } + + public static IChatClient CreateOpenAICompatible( + string endpoint, + string apiKey, + string model) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); + ArgumentException.ThrowIfNullOrWhiteSpace(model); + + OpenAIClientOptions options = + new() + { + Endpoint = new Uri(endpoint) + }; + + OpenAIClient client = + new( + new System.ClientModel.ApiKeyCredential(apiKey), + options); + + IChatClient chatClient = + client + .GetChatClient(model) + .AsIChatClient(); + + return AddCommonPipeline(chatClient); + } + + public static IChatClient CreateAzureOpenAIWithApiKey( + string endpoint, + string apiKey, + string deployment) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); + ArgumentException.ThrowIfNullOrWhiteSpace(deployment); + + AzureOpenAIClient client = + new( + new Uri(endpoint), + new AzureKeyCredential(apiKey)); + + IChatClient chatClient = + client + .GetChatClient(deployment) + .AsIChatClient(); + + return AddCommonPipeline(chatClient); + } + + public static IChatClient CreateAzureOpenAIWithDefaultCredential( + string endpoint, + string deployment) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(deployment); + + AzureOpenAIClient client = + new( + new Uri(endpoint), + new DefaultAzureCredential()); + + IChatClient chatClient = + client + .GetChatClient(deployment) + .AsIChatClient(); + + return AddCommonPipeline(chatClient); + } + + public static IChatClient CreateOllama( + string endpoint, + string model) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(model); + + IChatClient client = + new OllamaApiClient( + new Uri(endpoint), + model); + + return AddCommonPipeline(client); + } + + private static IChatClient AddCommonPipeline( + IChatClient client) => + new ChatClientBuilder(client) + .UseFunctionInvocation() + .Build(); +} diff --git a/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Providers/ProviderDescriptor.cs b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Providers/ProviderDescriptor.cs new file mode 100644 index 0000000..45297b0 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Providers/ProviderDescriptor.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.AI; + +namespace ProviderAgnosticChatGateway.Providers; + +/// +/// Represents one allow-listed AI provider exposed through the common +/// Microsoft.Extensions.AI IChatClient abstraction. +/// +public sealed record ProviderDescriptor( + string Name, + IChatClient Client, + ProviderCapabilities Capabilities); diff --git a/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Providers/ProviderRegistry.cs b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Providers/ProviderRegistry.cs new file mode 100644 index 0000000..6e96a09 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Providers/ProviderRegistry.cs @@ -0,0 +1,99 @@ +namespace ProviderAgnosticChatGateway.Providers; + +/// +/// Stores the AI providers that the gateway is explicitly configured to use. +/// Callers select a registered name only; they never supply provider endpoints, +/// models or credentials in an HTTP request. +/// +public sealed class ProviderRegistry : IDisposable +{ + private readonly Dictionary _providers = + new(StringComparer.OrdinalIgnoreCase); + + private readonly bool _allowProviderSelection; + + public ProviderRegistry( + string defaultProvider, + bool allowProviderSelection = true) + { + if (string.IsNullOrWhiteSpace(defaultProvider)) + { + throw new ArgumentException( + "A default provider name is required.", + nameof(defaultProvider)); + } + + DefaultProvider = defaultProvider.Trim(); + _allowProviderSelection = allowProviderSelection; + } + + public string DefaultProvider { get; } + + public bool AllowProviderSelection => _allowProviderSelection; + + public IReadOnlyCollection ProviderNames => + _providers.Keys.ToArray(); + + public IReadOnlyCollection Providers => + _providers.Values.ToArray(); + + public void Add(ProviderDescriptor provider) + { + ArgumentNullException.ThrowIfNull(provider); + + if (string.IsNullOrWhiteSpace(provider.Name)) + { + throw new ArgumentException( + "Provider name is required.", + nameof(provider)); + } + + string normalizedName = provider.Name.Trim(); + ProviderDescriptor normalizedProvider = + provider with { Name = normalizedName }; + + if (!_providers.TryAdd(normalizedName, normalizedProvider)) + { + throw new InvalidOperationException( + $"Provider '{normalizedName}' is already registered."); + } + } + + public ProviderDescriptor Resolve(string? requestedProvider = null) + { + string selectedProvider = + string.IsNullOrWhiteSpace(requestedProvider) + ? DefaultProvider + : requestedProvider.Trim(); + + if (!_allowProviderSelection && + !selectedProvider.Equals( + DefaultProvider, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Provider selection is disabled. " + + $"The gateway is configured to use '{DefaultProvider}'."); + } + + if (!_providers.TryGetValue( + selectedProvider, + out ProviderDescriptor? provider)) + { + throw new KeyNotFoundException( + $"Provider '{selectedProvider}' is not registered."); + } + + return provider; + } + + public void Dispose() + { + foreach (ProviderDescriptor provider in _providers.Values) + { + provider.Client.Dispose(); + } + + _providers.Clear(); + } +} diff --git a/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Tools/TemperatureTool.cs b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Tools/TemperatureTool.cs new file mode 100644 index 0000000..27a88f4 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/Tools/TemperatureTool.cs @@ -0,0 +1,55 @@ +namespace ProviderAgnosticChatGateway.Tools; + +/// +/// A harmless deterministic function used to demonstrate tool calling. +/// +public static class TemperatureTool +{ + public static double ConvertTemperature( + double value, + string fromUnit, + string toUnit) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fromUnit); + ArgumentException.ThrowIfNullOrWhiteSpace(toUnit); + + string from = Normalize(fromUnit); + string to = Normalize(toUnit); + + if (from == to) + { + return Math.Round(value, 2); + } + + double celsius = from switch + { + "c" => value, + "f" => (value - 32d) * 5d / 9d, + "k" => value - 273.15d, + _ => throw new ArgumentException( + $"Unsupported temperature unit '{fromUnit}'.", + nameof(fromUnit)) + }; + + double converted = to switch + { + "c" => celsius, + "f" => (celsius * 9d / 5d) + 32d, + "k" => celsius + 273.15d, + _ => throw new ArgumentException( + $"Unsupported temperature unit '{toUnit}'.", + nameof(toUnit)) + }; + + return Math.Round(converted, 2); + } + + private static string Normalize(string unit) => + unit.Trim().ToLowerInvariant() switch + { + "c" or "celsius" => "c", + "f" or "fahrenheit" => "f", + "k" or "kelvin" => "k", + _ => unit.Trim().ToLowerInvariant() + }; +} diff --git a/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/appsettings.json b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/appsettings.json new file mode 100644 index 0000000..f268360 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/src/ProviderAgnosticChatGateway/appsettings.json @@ -0,0 +1,60 @@ +{ + "Gateway": { + "DefaultProvider": "ollama", + "AllowProviderSelection": true, + "MaxMessagesPerConversation": 20, + "MaxInputCharacters": 8000 + }, + "Providers": { + "Ollama": { + "Enabled": true, + "Endpoint": "http://localhost:11434", + "Model": "qwen3.8:27b", + "Capabilities": { + "Streaming": true, + "Tools": true, + "ProviderManagedConversation": false + } + }, + "OpenAI": { + "Enabled": false, + "Model": "gpt-5-mini", + "ApiKeyEnvironmentVariable": "OPENAI_API_KEY", + "Capabilities": { + "Streaming": true, + "Tools": true, + "ProviderManagedConversation": false + } + }, + "OpenRouter": { + "Enabled": false, + "Endpoint": "https://openrouter.ai/api/v1", + "Model": "openai/gpt-5-mini", + "ApiKeyEnvironmentVariable": "OPENROUTER_API_KEY", + "Capabilities": { + "Streaming": true, + "Tools": true, + "ProviderManagedConversation": false + } + }, + "AzureOpenAI": { + "Enabled": false, + "Endpoint": "", + "Deployment": "", + "UseDefaultAzureCredential": true, + "ApiKeyEnvironmentVariable": "AZURE_OPENAI_API_KEY", + "Capabilities": { + "Streaming": true, + "Tools": true, + "ProviderManagedConversation": false + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/ConversationStoreTests.cs b/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/ConversationStoreTests.cs new file mode 100644 index 0000000..735e015 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/ConversationStoreTests.cs @@ -0,0 +1,45 @@ +using ProviderAgnosticChatGateway.Conversations; + +namespace ProviderAgnosticChatGateway.Tests; + +public sealed class ConversationStoreTests +{ + [Fact] + public void GetOrCreate_CreatesConversation_WhenIdIsMissing() + { + using ConversationStore store = new(10); + ConversationState state = store.GetOrCreate(null, "fake"); + + Assert.False(string.IsNullOrWhiteSpace(state.Id)); + Assert.Equal("fake", state.ProviderName); + } + + [Fact] + public void GetOrCreate_ReturnsExistingConversation() + { + using ConversationStore store = new(10); + ConversationState first = store.GetOrCreate(null, "fake"); + ConversationState second = store.GetOrCreate(first.Id, "fake"); + + Assert.Same(first, second); + } + + [Fact] + public void GetOrCreate_RejectsProviderChange() + { + using ConversationStore store = new(10); + ConversationState state = store.GetOrCreate(null, "fake"); + + Assert.Throws( + () => store.GetOrCreate(state.Id, "other")); + } + + [Fact] + public void GetOrCreate_RejectsUnknownConversationId() + { + using ConversationStore store = new(10); + + Assert.Throws( + () => store.GetOrCreate("not-created-here", "fake")); + } +} diff --git a/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/FakeChatClient.cs b/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/FakeChatClient.cs new file mode 100644 index 0000000..fa086d6 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/FakeChatClient.cs @@ -0,0 +1,67 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace ProviderAgnosticChatGateway.Tests; + +internal sealed class FakeChatClient : IChatClient +{ + public List> Requests { get; } = []; + + public ChatOptions? LastOptions { get; private set; } + + public string ResponseText { get; set; } = "FAKE_OK"; + + public IReadOnlyList StreamingParts { get; set; } = + ["STREAM_", "OK"]; + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + Requests.Add(messages.ToArray()); + LastOptions = options; + + return Task.FromResult( + new ChatResponse( + new ChatMessage( + ChatRole.Assistant, + ResponseText))); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Requests.Add(messages.ToArray()); + LastOptions = options; + + foreach (string part in StreamingParts) + { + cancellationToken.ThrowIfCancellationRequested(); + + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [new TextContent(part)]) + { + MessageId = "fake-message-1" + }; + + await Task.Yield(); + } + } + + public object? GetService( + Type serviceType, + object? serviceKey = null) => + serviceKey is null && serviceType.IsInstanceOfType(this) + ? this + : null; + + public void Dispose() + { + } +} diff --git a/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/GatewayTests.cs b/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/GatewayTests.cs new file mode 100644 index 0000000..911bfaa --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/GatewayTests.cs @@ -0,0 +1,180 @@ +using Microsoft.Extensions.Logging.Abstractions; +using ProviderAgnosticChatGateway.Conversations; +using ProviderAgnosticChatGateway.Gateway; +using ProviderAgnosticChatGateway.Providers; + +namespace ProviderAgnosticChatGateway.Tests; + +public sealed class GatewayTests +{ + [Fact] + public async Task SendAsync_ReturnsProviderNeutralResponse() + { + FakeChatClient fake = new() { ResponseText = "HELLO" }; + using ProviderRegistry registry = CreateRegistry(fake); + using ConversationStore store = new(10); + ChatGateway gateway = CreateGateway(registry, store); + + ChatResult result = + await gateway.SendAsync( + new ChatRequest("hello")); + + Assert.Equal("fake", result.Provider); + Assert.Equal("HELLO", result.Text); + Assert.False(string.IsNullOrWhiteSpace(result.ConversationId)); + Assert.Single(fake.Requests); + } + + [Fact] + public async Task SendAsync_CarriesHistoryIntoNextTurn() + { + FakeChatClient fake = new(); + using ProviderRegistry registry = CreateRegistry(fake); + using ConversationStore store = new(10); + ChatGateway gateway = CreateGateway(registry, store); + + ChatResult first = + await gateway.SendAsync( + new ChatRequest("first")); + + await gateway.SendAsync( + new ChatRequest( + "second", + ConversationId: first.ConversationId)); + + Assert.Equal(2, fake.Requests.Count); + Assert.Equal(3, fake.Requests[1].Count); + } + + [Fact] + public async Task SendAsync_AddsToolOnlyWhenRequested() + { + FakeChatClient fake = new(); + using ProviderRegistry registry = CreateRegistry(fake); + using ConversationStore store = new(10); + ChatGateway gateway = CreateGateway(registry, store); + + await gateway.SendAsync( + new ChatRequest( + "convert 0 C to F", + UseTools: true)); + + Assert.NotNull(fake.LastOptions); + Assert.NotNull(fake.LastOptions!.Tools); + Assert.Single(fake.LastOptions.Tools); + } + + [Fact] + public async Task SendAsync_KeepsConversationHistoryBounded() + { + FakeChatClient fake = new(); + using ProviderRegistry registry = CreateRegistry(fake); + using ConversationStore store = new(2); + ChatGateway gateway = CreateGateway(registry, store); + + ChatResult first = + await gateway.SendAsync( + new ChatRequest("first")); + + await gateway.SendAsync( + new ChatRequest( + "second", + ConversationId: first.ConversationId)); + + Assert.True( + store.TryGet( + first.ConversationId, + out ConversationState? state)); + + Assert.NotNull(state); + Assert.Equal(2, state!.MessageCount); + } + + [Fact] + public async Task StreamAsync_EmitsMetadataDeltasAndDone_AndPersistsHistory() + { + FakeChatClient fake = new() + { + StreamingParts = ["A", "B"] + }; + + using ProviderRegistry registry = CreateRegistry(fake); + using ConversationStore store = new(10); + ChatGateway gateway = CreateGateway(registry, store); + + List events = []; + await foreach ( + ChatStreamEvent item + in gateway.StreamAsync( + new ChatRequest("stream this"))) + { + events.Add(item); + } + + Assert.Equal("meta", events.First().Type); + Assert.Equal("done", events.Last().Type); + Assert.Equal( + "AB", + string.Concat( + events + .Where(e => e.Type == "delta") + .Select(e => e.Text))); + + string conversationId = events.First().ConversationId!; + Assert.True(store.TryGet(conversationId, out ConversationState? state)); + Assert.Equal(2, state!.MessageCount); + } + + [Fact] + public async Task SendAsync_RejectsProviderChangeForExistingConversation() + { + FakeChatClient fake = new(); + using ProviderRegistry registry = CreateRegistry(fake); + registry.Add( + new ProviderDescriptor( + "other", + new FakeChatClient(), + Capabilities())); + + using ConversationStore store = new(10); + ChatGateway gateway = CreateGateway(registry, store); + + ChatResult first = + await gateway.SendAsync( + new ChatRequest("first")); + + await Assert.ThrowsAsync( + () => gateway.SendAsync( + new ChatRequest( + "second", + Provider: "other", + ConversationId: first.ConversationId))); + } + + private static ProviderRegistry CreateRegistry( + FakeChatClient client) + { + ProviderRegistry registry = new("fake"); + registry.Add( + new ProviderDescriptor( + "fake", + client, + Capabilities())); + return registry; + } + + private static ProviderCapabilities Capabilities() => + new( + Streaming: true, + Tools: true, + ProviderManagedConversation: false); + + private static ChatGateway CreateGateway( + ProviderRegistry registry, + ConversationStore store) => + new( + registry, + store, + maxInputCharacters: 8000, + NullLogger.Instance); +} diff --git a/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/ProviderAgnosticChatGateway.Tests.csproj b/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/ProviderAgnosticChatGateway.Tests.csproj new file mode 100644 index 0000000..8196267 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/ProviderAgnosticChatGateway.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/ProviderClientFactoryTests.cs b/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/ProviderClientFactoryTests.cs new file mode 100644 index 0000000..eb07e54 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/ProviderClientFactoryTests.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.AI; +using ProviderAgnosticChatGateway.Providers; + +namespace ProviderAgnosticChatGateway.Tests; + +public sealed class ProviderClientFactoryTests +{ + [Fact] + public void CreateOllama_ConstructsIChatClientWithoutNetworkCall() + { + using IChatClient client = + ProviderClientFactory.CreateOllama( + "http://localhost:11434", + "verification-placeholder"); + + Assert.NotNull(client); + } + + [Fact] + public void CreateOpenAI_ConstructsIChatClientWithoutNetworkCall() + { + using IChatClient client = + ProviderClientFactory.CreateOpenAI( + "test-api-key", + "test-model"); + + Assert.NotNull(client); + } + + [Fact] + public void CreateOpenAICompatible_ConstructsIChatClientWithoutNetworkCall() + { + using IChatClient client = + ProviderClientFactory.CreateOpenAICompatible( + "https://example.invalid/v1", + "test-api-key", + "test-model"); + + Assert.NotNull(client); + } + + [Fact] + public void CreateAzureOpenAIWithApiKey_ConstructsIChatClientWithoutNetworkCall() + { + using IChatClient client = + ProviderClientFactory.CreateAzureOpenAIWithApiKey( + "https://example.openai.azure.com/", + "test-api-key", + "test-deployment"); + + Assert.NotNull(client); + } +} diff --git a/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/ProviderRegistryTests.cs b/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/ProviderRegistryTests.cs new file mode 100644 index 0000000..6c9fa09 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/ProviderRegistryTests.cs @@ -0,0 +1,99 @@ +using ProviderAgnosticChatGateway.Providers; + +namespace ProviderAgnosticChatGateway.Tests; + +public sealed class ProviderRegistryTests +{ + [Fact] + public void Resolve_UsesDefaultProvider_WhenNoProviderIsRequested() + { + using ProviderRegistry registry = CreateRegistry(); + ProviderDescriptor provider = registry.Resolve(); + Assert.Equal("fake", provider.Name); + } + + [Fact] + public void Resolve_IsCaseInsensitive() + { + using ProviderRegistry registry = CreateRegistry(); + ProviderDescriptor provider = registry.Resolve("FAKE"); + Assert.Equal("fake", provider.Name); + } + + [Fact] + public void Resolve_ThrowsForUnknownProvider() + { + using ProviderRegistry registry = CreateRegistry(); + Assert.Throws( + () => registry.Resolve("missing-provider")); + } + + [Fact] + public void Add_RejectsDuplicateProviderNames() + { + using ProviderRegistry registry = CreateRegistry(); + FakeChatClient duplicateClient = new(); + + ProviderDescriptor duplicate = + new( + "FAKE", + duplicateClient, + Capabilities()); + + Assert.Throws( + () => registry.Add(duplicate)); + + duplicateClient.Dispose(); + } + + [Fact] + public void Resolve_CanDisableProviderSelection() + { + using ProviderRegistry registry = + new( + defaultProvider: "fake", + allowProviderSelection: false); + + registry.Add( + new ProviderDescriptor( + "fake", + new FakeChatClient(), + Capabilities())); + + registry.Add( + new ProviderDescriptor( + "other", + new FakeChatClient(), + Capabilities())); + + Assert.Throws( + () => registry.Resolve("other")); + + Assert.Equal("fake", registry.Resolve().Name); + } + + [Fact] + public void Providers_ReturnsRegisteredDescriptors() + { + using ProviderRegistry registry = CreateRegistry(); + Assert.Single(registry.Providers); + Assert.Single(registry.ProviderNames); + } + + private static ProviderRegistry CreateRegistry() + { + ProviderRegistry registry = new("fake"); + registry.Add( + new ProviderDescriptor( + "fake", + new FakeChatClient(), + Capabilities())); + return registry; + } + + private static ProviderCapabilities Capabilities() => + new( + Streaming: true, + Tools: true, + ProviderManagedConversation: false); +} diff --git a/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/ToolTests.cs b/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/ToolTests.cs new file mode 100644 index 0000000..1667359 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/tests/ProviderAgnosticChatGateway.Tests/ToolTests.cs @@ -0,0 +1,36 @@ +using ProviderAgnosticChatGateway.Tools; + +namespace ProviderAgnosticChatGateway.Tests; + +public sealed class ToolTests +{ + [Theory] + [InlineData(0, "c", "f", 32)] + [InlineData(100, "celsius", "fahrenheit", 212)] + [InlineData(32, "f", "c", 0)] + [InlineData(273.15, "k", "c", 0)] + public void ConvertTemperature_ReturnsExpectedValue( + double value, + string from, + string to, + double expected) + { + double actual = + TemperatureTool.ConvertTemperature( + value, + from, + to); + + Assert.Equal(expected, actual, precision: 2); + } + + [Fact] + public void ConvertTemperature_RejectsUnsupportedUnit() + { + Assert.Throws( + () => TemperatureTool.ConvertTemperature( + 10, + "rankine", + "c")); + } +} diff --git a/dotnet-ai/provider-agnostic-chat-gateway/verified-environment.json b/dotnet-ai/provider-agnostic-chat-gateway/verified-environment.json new file mode 100644 index 0000000..8593552 --- /dev/null +++ b/dotnet-ai/provider-agnostic-chat-gateway/verified-environment.json @@ -0,0 +1,35 @@ +{ + "generatedUtc": "2026-09-29T10:08:51.1157255Z", + "sdk": "10.0.401", + "targetFramework": "net10.0", + "directPackages": { + "Microsoft.Extensions.AI": "10.10.0", + "Microsoft.Extensions.AI.OpenAI": "10.10.1", + "Azure.AI.OpenAI": "2.1.0", + "Azure.Identity": "1.21.0", + "OllamaSharp": "5.4.30" + }, + "deterministicVerification": { + "restore": "PASS", + "releaseBuild": "PASS", + "tests": "PASS" + }, + "liveVerification": { + "ollama": "NOT RUN BY THIS SCRIPT", + "openai": "NOT RUN BY THIS SCRIPT", + "openrouter": "NOT RUN BY THIS SCRIPT", + "azureOpenAI": "NOT RUN BY THIS SCRIPT" + }, + "runtimes": [ + "Microsoft.AspNetCore.App 10.0.10 [C:\\Program Files\\dotnet\\shared\\Microsoft.AspNetCore.App]", + "Microsoft.AspNetCore.App 10.0.12 [C:\\Program Files\\dotnet\\shared\\Microsoft.AspNetCore.App]", + "Microsoft.NETCore.App 8.0.3 [C:\\Program Files\\dotnet\\shared\\Microsoft.NETCore.App]", + "Microsoft.NETCore.App 9.0.7 [C:\\Program Files\\dotnet\\shared\\Microsoft.NETCore.App]", + "Microsoft.NETCore.App 10.0.10 [C:\\Program Files\\dotnet\\shared\\Microsoft.NETCore.App]", + "Microsoft.NETCore.App 10.0.12 [C:\\Program Files\\dotnet\\shared\\Microsoft.NETCore.App]", + "Microsoft.WindowsDesktop.App 8.0.3 [C:\\Program Files\\dotnet\\shared\\Microsoft.WindowsDesktop.App]", + "Microsoft.WindowsDesktop.App 9.0.7 [C:\\Program Files\\dotnet\\shared\\Microsoft.WindowsDesktop.App]", + "Microsoft.WindowsDesktop.App 10.0.10 [C:\\Program Files\\dotnet\\shared\\Microsoft.WindowsDesktop.App]", + "Microsoft.WindowsDesktop.App 10.0.12 [C:\\Program Files\\dotnet\\shared\\Microsoft.WindowsDesktop.App]" + ] +}